Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,79 @@ See [SETUP.md](./SETUP.md).

To apply patcher on patch files run this command:

`python main.py [path to folder with patches] [path to clang++ executable] [path to linker executable (ld)] [path to g++ executable]`
`python main.py [path to config file]`

config structure:
```json
{
// path to target folder. Can be either relative or absolute.
// if relative path then it will be {config path}/{target_folder_path}
"target_folder_path": "FA-Binary-Patches",
// path to build folder. Defaults to "{target_folder_path}/build"
"build_folder_path": null,
// paths of input and output files
"input_exe_path": "ForgedAlliance_base.exe",
"output_exe_path": "C:\\ProgramData\\FAForever\\bin\\ForgedAlliance_exxt.exe",
// path to clang++ compiler. Defaults to "clang++"
"clang": "clang++.exe",
// path to g++ compiler. Defaults to "g++"
"gcc": "g++.exe",
// path to linker. Defaults to "ld"
"linker": "ld.exe",
// flags for compilers
"clang_flags": [
"-pipe",
"-m32",
"-O3",
"-nostdlib",
"-Werror",
"-masm=intel",
"-std=c++20",
"-march=core2"
],
"gcc_flags": [
"-pipe",
"-m32",
"-Os",
"-fno-exceptions",
"-nostdlib",
"-nostartfiles",
"-fpermissive",
"-masm=intel",
"-std=c++20",
"-march=core2",
"-mfpmath=both"
],
"asm_flags": [
"-pipe",
"-m32",
"-Os",
"-fno-exceptions",
"-nostdlib",
"-nostartfiles",
"-w",
"-fpermissive",
"-masm=intel",
"-std=c++20",
"-march=core2",
"-mfpmath=both"
]
}
```
Comment on lines 17 to 72
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSON example contains invalid syntax that will cause parse errors.

The JSON example includes // comments and a trailing comma (line 71), both of which are invalid JSON syntax. Users copying this example will encounter parse errors.

Consider either:

  1. Adding a note that comments must be removed before use
  2. Using JSONC format indicator (```jsonc) to clarify it's annotated JSON
  3. Moving comments outside the code block
Suggested fix - use jsonc identifier
-```json
+```jsonc
 {
     // path to target folder...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 17 - 72, The README's JSON example contains inline //
comments and a trailing comma after the "asm_flags" block which makes it invalid
JSON; update the code fence from ```json to ```jsonc to indicate JSON with
comments and remove the trailing comma after the final "asm_flags" array (or
alternatively move all explanatory comments outside the code block) so consumers
can copy/paste without parse errors—look for the example object containing
"target_folder_path", "clang", "clang_flags", "gcc_flags", and "asm_flags".


## Patches folder structure

- **/build**: Here happens build for all source files. Patcher leaves address maps after build for debug purposes.
- **/hooks**: All files with asm that is injected by specified addresses.
- **/include**: Header files.
- **/section**: All files with patches that involve logic written with C/C++.
- `*.cpp` files are built with *g++*
- `*.cxx` files are built with *clang++*
- Can contain nested folders
- Can contain header files
- **/hooks**: All files with asm that is injected by specified addresses.
- ***define.h***: Generated file for hooks to use.
- Can contain `.hook` files
- ***config.json***: Config file for patcher.
- ***section.ld***: Main linker script.
- ***SigPatches.txt***: File with signature patches. Replaces one binary sequence with another. Applied after build.
- ***ForgedAlliance_base.exe***: Base executable of the game for patching.
- ***ForgedAlliance_exxt.exe***: Result executable, run with [debugger](https://github.com/FAForever/FADeepProbe) for more information in case of crashes.
Expand All @@ -33,18 +93,18 @@ To apply patcher on patch files run this command:
Versions of compilers and linkers used.

clang++ compiler:
* version 18.1.8
* clang version 21.1.0
* Target: x86_64-pc-windows-msvc
* Thread model: posix

ld linker:
* GNU ld (GNU Binutils) 2.40
* GNU ld (GNU Binutils) 2.39

g++ compiler:
* g++ (Rev6, Built by MSYS2 project) 13.1.0
* g++ (i686-posix-dwarf-rev0, Built by MinGW-Builds project) 13.2.0

python:
* 3.12.4
* 3.14.2

# HumanUserCalls.py

Expand Down
45 changes: 45 additions & 0 deletions config_example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"target_folder_path": "FA-Binary-Patches",
"input_exe_path": "ForgedAlliance_base.exe",
"output_exe_path": "C:\\ProgramData\\FAForever\\bin\\ForgedAlliance_exxt.exe",
"clang": "clang++.exe",
"gcc": "g++.exe",
"linker": "ld.exe",
"clang_flags": [
"-pipe",
"-m32",
"-O3",
"-nostdlib",
"-Werror",
"-masm=intel",
"-std=c++20",
"-march=core2"
],
"gcc_flags": [
"-pipe",
"-m32",
"-Os",
"-fno-exceptions",
"-nostdlib",
"-nostartfiles",
"-fpermissive",
"-masm=intel",
"-std=c++20",
"-march=core2",
"-mfpmath=both"
],
"asm_flags": [
"-pipe",
"-m32",
"-Os",
"-fno-exceptions",
"-nostdlib",
"-nostartfiles",
"-w",
"-fpermissive",
"-masm=intel",
"-std=c++20",
"-march=core2",
"-mfpmath=both"
]
}
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

if __name__ == "__main__":
start = time.time()
patcher.patch(*sys.argv)
patcher.patch(*sys.argv[1:])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Argument unpacking may cause errors with the new single-argument signature.

The patch function now expects a single config_path argument, but *sys.argv[1:] will unpack all CLI arguments. If a user passes extra arguments (as shown in the pipeline failure), this will raise a TypeError.

The pipeline failure shows the old invocation style being used: python main.py "$(pwd)" clang++ ld g++ — this passes 4 arguments to a function expecting 1.

Proposed fix - accept only the first argument
 if __name__ == "__main__":
     start = time.time()
-    patcher.patch(*sys.argv[1:])
+    if len(sys.argv) < 2:
+        print("Usage: python main.py <config_path>")
+        sys.exit(1)
+    patcher.patch(sys.argv[1])
     end = time.time()
     print(f"Patched in {end-start:.2f}s")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@main.py` at line 7, The current invocation uses argument unpacking
patcher.patch(*sys.argv[1:]) which will pass multiple CLI args to patcher.patch
(now expecting a single config_path) and causes a TypeError; change it to pass
only the first CLI argument (e.g., use sys.argv[1] with a sensible default or
validate arg count) so that patcher.patch receives a single config_path string;
update any related error handling to surface a clear message when no argument is
provided.

end = time.time()
print(f"Patched in {end-start:.2f}s")
74 changes: 74 additions & 0 deletions patcher/Config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import json
from pathlib import Path
from dataclasses import dataclass, field
from typing import Self


@dataclass
class Config:
path: Path
target_folder_path: Path
build_folder_path: Path
input_exe_path: Path = "ForgedAlliance_base.exe"
output_exe_path: Path = "ForgedAlliance_exxt.exe"

clang_path: Path = "clang++"
gcc_path: Path = "g++"
linker_path: Path = "ld"

clang_flags: tuple[str] = ()
gcc_flags: tuple[str] = ()
asm_flags: tuple[str] = ()

@classmethod
def load_from_json(cls, path: Path) -> Self:
path = Path(path).resolve()
with open(path, 'r') as f:
config = json.load(f)

return cls(
path=path,
target_folder_path=config.get("target_folder_path", path.parent),
build_folder_path=config.get("build_folder_path"),
input_exe_path=config.get("input_exe_path", Config.input_exe_path),
output_exe_path=config.get(
"output_exe_path", Config.output_exe_path),
clang_path=config.get("clang", Config.clang_path),
gcc_path=config.get("gcc", Config.gcc_path),
linker_path=config.get("linker", Config.linker_path),
clang_flags=config.get("clang_flags", Config.clang_flags),
gcc_flags=config.get("gcc_flags", Config.gcc_flags),
asm_flags=config.get("asm_flags", Config.asm_flags),
)

def __post_init__(self):
self.target_folder_path = Path(self.target_folder_path)
self.input_exe_path = Path(self.input_exe_path)
self.output_exe_path = Path(self.output_exe_path)

if not self.target_folder_path.is_absolute():
self.target_folder_path = self.path.parent / self.target_folder_path

self.build_folder_path = Path(self.build_folder_path)\
if self.build_folder_path \
else self.target_folder_path / "build"

self.clang_path = Path(self.clang_path)
self.gcc_path = Path(self.gcc_path)
self.linker_path = Path(self.linker_path)

if not self.build_folder_path.is_absolute():
raise ValueError(
"build_folder_path must be an absolute path to folder")

@property
def input_path(self) -> Path:
if self.input_exe_path.is_absolute():
return self.input_exe_path
return self.target_folder_path / self.input_exe_path

@property
def output_path(self) -> Path:
if self.output_exe_path.is_absolute():
return self.output_exe_path
return self.build_folder_path / self.output_exe_path
40 changes: 26 additions & 14 deletions patcher/Hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,57 @@

ADDRESS_RE = re.compile(r"^(0[xX][0-9A-Fa-f]{6,8})\:$")
FUNCTION_NAME_RE = re.compile(r"@([a-zA-Z\_][a-zA-Z0-9\_]+)")
ESCAPE_TRANSLATION = str.maketrans({"\"": r"\"", "\\": r"\\", })


class Section:

def __init__(self, address: str, lines: list[str]) -> None:
def __init__(self, address: str, lines: list[str], addresses: dict[str, str]) -> None:
self._address: str = address
self._lines: list[str] = lines
self._addresses: dict[str, str] = addresses

def lines_to_cpp(self):
s = ""
s = []
for line in self._lines:
line = line.translate(str.maketrans({"\"": r"\"", "\\": r"\\", }))
# line = line.translate(ESCAPE_TRANSLATION)

def replace_address(match: re.Match[str]) -> str:
func_name = match.group(1)
if func_name in self._addresses:
return f'{match.group(1)} /* {self._addresses[func_name]} */'
return match.group(0)

if FUNCTION_NAME_RE.findall(line):
line = FUNCTION_NAME_RE.subn(r'"QU(\1)"', line)[0]
line = FUNCTION_NAME_RE.subn(replace_address, line)[0]

s += f'"{line};"\n'
return s
s.append(f'{line};')
return "\n".join(s)

def to_cpp(self, index: int) -> str:
def header(self, index: int) -> str:
if self._address is None:
return self.lines_to_cpp()
return f'SECTION({index:X}, {self._address})\n{self.lines_to_cpp()}'
return ""
return f'.section h{index:X}; .set h{index:X},{self._address};'

def to_cpp(self, index: int) -> str:
return f'{self.header(index)}\n{self.lines_to_cpp()}'


class Hook:
def __init__(self, sections: list[Section]) -> None:
self._sections: list[Section] = sections

def to_cpp(self):
s = '#include "../asm.h"\n#include "../define.h"\n'
s = ''
if len(self._sections) > 0:
sections_lines = (section.to_cpp(i).split("\n")
for i, section in enumerate(self._sections))
s += f"asm(\n{''.join((f" {line}\n" for lines in sections_lines for line in lines))});"
s += f"asm(R\"(\n{''.join((f" {line}\n" if not line.startswith(".section") else f'\n{line}\n'
for lines in sections_lines for line in lines))})\");"
return s


def load_hook(file_path: Path) -> Hook:
def load_hook(file_path: Path, addresses: dict[str, str]) -> Hook:
sections: list[Section] = []
lines = []
address = None
Expand All @@ -56,11 +68,11 @@ def load_hook(file_path: Path) -> Hook:

if match := ADDRESS_RE.match(line):
if len(lines) > 0:
sections.append(Section(address, lines))
sections.append(Section(address, lines, addresses))
lines = []
address = match.group(1)
continue
lines.append(line)
if len(lines) > 0:
sections.append(Section(address, lines))
sections.append(Section(address, lines, addresses))
return Hook(sections)
2 changes: 1 addition & 1 deletion patcher/PEData.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,4 @@ def find_sect(self, name: str) -> Optional[PESect]:
for sect in self.sects:
if sect.name == name:
return sect
return None
raise Exception(f"Couldn't find section {name}")
Loading
Loading