Skip to content
Merged
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
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,10 @@ python3 shared/tools/dump.py # process all missing versions
python3 shared/tools/dump.py --force # re-dump even if dump.cs exists
```

Requires `dotnet` 8.x on PATH (`apt install dotnet-sdk-8.0` on Ubuntu 24.04).
Requires `dotnet` 8.x on PATH. Il2CppDumper only needs the runtime, so
`dotnet-runtime-8.0` is enough (`ghcr.io/devcontainers/features/dotnet:2` with
`{"version": "none", "dotnetRuntimeVersions": "8.0"}` in a devcontainer, or
`apt install dotnet-runtime-8.0` on Ubuntu 24.04).

## Checking for RVA drift after a target update

Expand All @@ -199,6 +202,41 @@ Any mismatch is reported with the expected vs. found prologue bytes and the
dump line number. Create a new `recipes/v<maj>_<min>_<patch>.py` with the
updated addresses and register it in `recipes/__init__.py`.

### Porting the SITES table automatically

`tools/port_recipe.py` does the mechanical part of that: it looks every row's
label up in the new dump index, rewrites the RVA and prologue columns from the
new binary, and leaves row order, comments, and every other column untouched.

```sh
PYTHONPATH=shared python3 -m tools.port_recipe \
--base-recipe recipes/v1_0_2.py \
--index assets/1.1.0/dump.cs.index.json \
--ipa assets/1.1.0/AppName-1.1.0.ipa \
--out recipes/v1_1_0.py
```

Rows whose label no longer resolves keep their old values, gain a
`# TODO(port_recipe): unresolved` marker, and make the tool exit non-zero —
those are the ones that need a human to look at the dump. Row order is never
changed: cave payloads are allocated in declaration order while the runtime
dispatcher indexes by hook id, so a reordered table mispoints every
orig-call trampoline.

### Field offsets

Prologue checks catch a moved method but not a moved *field* — a class that
gains a member above the one a hook reads shifts everything below it.
`tools/verify_offsets.py` diffs the field offsets of named types between two
dumps:

```sh
PYTHONPATH=shared python3 -m tools.verify_offsets \
--old assets/1.0.2/dump.cs --old-index assets/1.0.2/dump.cs.index.json \
--new assets/1.1.0/dump.cs --new-index assets/1.1.0/dump.cs.index.json \
--type SomeReply --type SomeStatus
```

## Development

```sh
Expand Down
136 changes: 136 additions & 0 deletions tests/test_port_recipe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Tests for ``tools.port_recipe``.

The tool rewrites a recipe's SITES table against a new dump. Three
properties are load-bearing and easy to break:

* row ORDER, comments, and every non-RVA column survive the rewrite
(cave payloads are allocated in declaration order, so a reordered
table silently mispoints every orig-call trampoline),
* an unresolved label is never guessed — the row keeps its old values,
gains a TODO marker, and the exit status is non-zero, and
* the RVA and prologue columns actually get the new values.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from tools.port_recipe import PortError, find_sites_node, main, rewrite_row

BASE_RECIPE = '''\
"""Recipe for the old build."""

from recipes.common import CAVE_ENTRY

BUILD = 12

# fmt: off
SITES = [
# --- a section comment that must survive ---
(0x1000, "aaaaaaaa", "HOOK_A", CAVE_ENTRY, "Foo.Alpha"),
(0x2000, "bbbbbbbb", "HOOK_B", CAVE_ENTRY, "Foo.Beta"),
(0x3000, "cccccccc", "HOOK_C", CAVE_ENTRY, "Gone.Vanished"),
]
# fmt: on
'''


def _index(entries: dict[str, list[tuple[str, str]]]) -> dict:
"""Build a minimal dump index: {type: [(method_name, rva_hex)]}."""
return {
"images": [],
"types": [
{
"name": name,
"namespace": "",
"kind": "class",
"tdi": i,
"line": 1,
"methods": [
{"sig": f"public void {m}()", "rva": rva, "line": 2}
for m, rva in methods
],
}
for i, (name, methods) in enumerate(entries.items())
],
}


@pytest.fixture
def workspace(tmp_path: Path) -> dict:
recipe = tmp_path / "v_old.py"
recipe.write_text(BASE_RECIPE)

index = tmp_path / "dump.cs.index.json"
index.write_text(
json.dumps(_index({"Foo": [("Alpha", "0x9000"), ("Beta", "0xA000")]}))
)

# A stand-in Mach-O: the tool reads 4 bytes at each resolved RVA.
macho = tmp_path / "UnityFramework"
blob = bytearray(0xB000)
blob[0x9000:0x9004] = b"\x11\x22\x33\x44"
blob[0xA000:0xA004] = b"\x55\x66\x77\x88"
macho.write_bytes(bytes(blob))

return {"recipe": recipe, "index": index, "macho": macho, "out": tmp_path / "v_new.py"}


def _run(ws: dict, monkeypatch) -> int:
monkeypatch.setattr(
"sys.argv",
[
"port_recipe",
"--base-recipe", str(ws["recipe"]),
"--index", str(ws["index"]),
"--macho", str(ws["macho"]),
"--out", str(ws["out"]),
],
)
return main()


def test_resolved_rows_get_new_rva_and_prologue(workspace, monkeypatch):
_run(workspace, monkeypatch)
out = workspace["out"].read_text()
assert '(0x9000, "11223344", "HOOK_A"' in out
assert '(0xA000, "55667788", "HOOK_B"' in out


def test_unresolved_row_is_marked_and_left_alone(workspace, monkeypatch):
rc = _run(workspace, monkeypatch)
out = workspace["out"].read_text()
assert rc == 1
assert '(0x3000, "cccccccc", "HOOK_C"' in out
assert "TODO(port_recipe): unresolved" in out


def test_row_order_and_comments_survive(workspace, monkeypatch):
_run(workspace, monkeypatch)
out = workspace["out"].read_text()
assert "# --- a section comment that must survive ---" in out
assert "# fmt: off" in out and "# fmt: on" in out
assert out.index("HOOK_A") < out.index("HOOK_B") < out.index("HOOK_C")
assert 'BUILD = 12' in out


def test_missing_sites_table_is_an_error(tmp_path):
import ast

module = ast.parse("PATCHES = []\n")
with pytest.raises(PortError):
find_sites_node(module)


def test_rewrite_row_replaces_only_the_first_two_columns():
line = ' (0x5C3C29C, "fc6fbaa9", "HOOK_X", CAVE_ENTRY, "Foo.Bar"),\n'
got = rewrite_row(line, 0x5C3C29C, 0x6B58E3C, "deadbeef")
assert got == ' (0x6B58E3C, "deadbeef", "HOOK_X", CAVE_ENTRY, "Foo.Bar"),\n'


def test_rewrite_row_rejects_a_row_whose_rva_it_cannot_find():
with pytest.raises(PortError):
rewrite_row(' (0x1, "aa", "H", CAVE_ENTRY, "F.B"),\n', 0xDEAD, 0x1, "bb")
121 changes: 121 additions & 0 deletions tests/test_verify_sites.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Tests for the label resolver in ``tools.verify_sites``.

Recipe labels are the only durable anchor a hook site has: RVAs move on
every target build, so a label that resolves to the wrong overload silently
patches the wrong function. Google.Protobuf alone ships 15 ``MergeFrom``
overloads, which is why labels may pin a parameter list.
"""

from __future__ import annotations

from tools.verify_sites import (
find_method,
param_type,
split_label,
split_params,
types_by_name,
)

INDEX = {
"types": [
{
"name": "MessageExtensions",
"namespace": "Google.Protobuf",
"kind": "class",
"tdi": 1,
"line": 10,
"methods": [
{
"sig": "public static void MergeFrom(IMessage message, byte[] data)",
"rva": "0x1000",
"line": 11,
},
{
"sig": (
"internal static void MergeFrom(IMessage message, "
"ReadOnlySequence<byte> data, bool discardUnknownFields, "
"ExtensionRegistry registry)"
),
"rva": "0x2000",
"line": 12,
},
],
},
{
"name": "TitleScene.<OnActivateAsync>d__10",
"namespace": "",
"kind": "struct",
"tdi": 2,
"line": 20,
"methods": [
{"sig": "private void MoveNext()", "rva": "0x3000", "line": 21},
],
},
{
"name": "Evaluator",
"namespace": "Game",
"kind": "class",
"tdi": 3,
"line": 30,
"methods": [
{
"sig": "public void .ctor(string path, Settings settings)",
"rva": "0x4000",
"line": 31,
},
],
},
]
}


def _resolve(label: str):
by_name = types_by_name(INDEX)
type_name, method_name, params = split_label(label)
hit = find_method(by_name, type_name, method_name, param_types=params)
return None if hit is None else hit[1]["rva"]


def test_param_list_selects_one_overload():
assert _resolve("MessageExtensions.MergeFrom(IMessage, byte[])") == "0x1000"
assert (
_resolve(
"MessageExtensions.MergeFrom(IMessage, ReadOnlySequence<byte>, "
"bool, ExtensionRegistry)"
)
== "0x2000"
)


def test_label_without_params_matches_any_overload():
assert _resolve("MessageExtensions.MergeFrom") == "0x1000"


def test_wrong_arity_does_not_resolve():
assert _resolve("MessageExtensions.MergeFrom(IMessage)") is None


def test_namespace_qualified_type_resolves():
assert _resolve("Google.Protobuf.MessageExtensions.MergeFrom(IMessage, byte[])") == "0x1000"


def test_nested_type_plus_notation_resolves():
assert _resolve("TitleScene+<OnActivateAsync>d__10.MoveNext") == "0x3000"


def test_ctor_shorthand_resolves():
assert _resolve("Evaluator.ctor") == "0x4000"


def test_split_params_respects_generic_commas():
assert split_params("List<ValueTuple<string, float>> ranked, int topN") == (
"List<ValueTuple<string, float>> ranked",
"int topN",
)


def test_param_type_strips_names_modifiers_and_defaults():
assert param_type("ref ParseContext input") == "ParseContext"
assert param_type("int maxLength = 180") == "int"
assert param_type("ExtensionRegistry") == "ExtensionRegistry"
assert param_type("List<ValueTuple<string, float>> ranked") == "List<ValueTuple<string, float>>"
15 changes: 15 additions & 0 deletions tools/caves.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ def apply_patches(
# path can match both the site and the cave content byte-for-byte.
cave_cursor = cave_start
for site_off, expected, build_payload, label in cave_patches:
# A row with no site is a placeholder: the method it used to
# patch is gone from this build, but the cave slot it occupied
# must stay reserved because consumers address caves by index.
# Nothing is written — the payload isn't even built, since it
# would need a branch back to a site that doesn't exist. The
# slot is sized from `expected`, which the recipe fills with a
# zero block of one payload's length for exactly this purpose.
if site_off is None:
print(
f" RESERVE {label} "
f"(cave @ 0x{cave_cursor:X}, {len(expected)} B)"
)
cave_cursor += len(expected)
continue

if len(expected) != 4:
raise AssertionError(f"cave-patch site must be one 4B insn: {label}")

Expand Down
Loading
Loading