Function-matched cuts: same-name rules are one patch's implementations; Taiko Risshiden V DX's frame-limiter cut reaches the base game and later builds. nso_match.resolve groups rules by name: exactly one implementation must match (the log names it), a red line only when none or more than one does. Before, each implementation of a changed function logged a red line on every build it did not fit. Taiko's MATCH_CUTS has two implementations of the frame end under one name: 1.1.1's (main+0x443e8c, 364 bytes, cut at +288) and the base game's (v0 main 47d7baf6..., main+0x43e140, 172 bytes: 1.1.1 added the frame-skip branch; the same 20 bytes at +104 = main+0x43e1a8). In v0 N is likewise a global (GOT 0xa1be28, data 0x9ec55c, 1) set to 2 only by CPrivateBattleWindow / CPrivateBattleTutorialWindow, and the start tick is read after the update; nso_fnmap.py finds no exact match for 1.1.1's function in v0, found by disassembly. Checked through patch_nso.py: v0 main takes the cut at 0x43e1a8 (implementation 2, no red line); a 1.1.1 main copy with another build ID takes implementation 1, output byte-identical to the registered cut; 1.1.1 keeps its exact CUTS. A scan of the 42 distinct mains on the drives found this limiter only in Taiko. The base game has not been run. Docs: rewriter.md (same-name implementations, procedure step 2, Found on), the game note. Checked: test_nso_match.py (new test fails on the old matcher), all 19 tool test files, docs tests

This commit is contained in:
seedds committed 2026-10-26 13:24:01 +08:00
1 parent 7753b7b685
commit dac7ba6338
5 files changed
+56 -12

No files matched your search

+3 -1
View File
@@ -6,7 +6,7 @@ Update 1.1.1 (v131072) over the base game, SDK 12.3.3 (no other game's `sdk` bui
| Setting | Where | Why |
|---|---|---|
| Frame limiter threshold 0 when the game's vsyncs per frame N = 1 (`main+0x443fac`) | [`patches.py`](patches.py) `CUTS`, `main` build `f5ab74b9…` | its wait from the frame's start tick made every frame 18.2 ms (55 fps); the present pacing holds one vsync, and N = 2 scenes keep the wait; see [Frame rate](#frame-rate) |
| Frame limiter threshold 0 when the game's vsyncs per frame N = 1 (`main+0x443fac`) | [`patches.py`](patches.py) `CUTS`, `main` build `f5ab74b9…`; other builds through `MATCH_CUTS` (1.1.1's function and the base game's) | its wait from the frame's start tick made every frame 18.2 ms (55 fps); the present pacing holds one vsync, and N = 2 scenes keep the wait; see [Frame rate](#frame-rate) |
| `op_mode handheld` | [`game.tsv`](game.tsv) | handheld, the Switch build plays by touch; docked it reads only the pads |
| `pad_layout touch` | `game.tsv` | the Tap layout sends every touch to the game (the pads' corners would cover the cards, the menu icons at the top right and the on-screen B button) |
| 4 GB layout: `mem_reserve 0x100000000`, `mem_alias 0x10000000`, `mem_stack 0x10000000`, `app_pool 0xCD500000`, `sys_resource 0x0` | `game.tsv` | the NPDM declares no system resource (META +0x14 = 0): no virtual address memory; `nninitStartup` sets a heap of the pool − used memory (3.14 GB), which the game maps only as it touches it. The default 64 MB of used memory fits its small modules |
@@ -18,6 +18,7 @@ Update 1.1.1 (v131072) over the base game, SDK 12.3.3 (no other game's `sdk` bui
- **N:** the int at GOT 0xa1c088 (data `0x9ec764`, initially 1) is the game's vsyncs per frame. `CPrivateBattleWindow` and `CPrivateBattleTutorialWindow` set it to 2 when they start (`main+0xf02d0`, `main+0xec4cc`), and `main+0xf04f4` puts it back to 1. The scenes' code counts 60 / N frames a second (`main+0x2f29c8`, `main+0x3106dc`). The game presents at interval 1 in every scene, so in those scenes only this wait holds a frame for two vsyncs. N stayed 1 in probe runs through the menus, the town, the field battle tutorial, a council, the castle training ground and the world map. The `CPrivateBattle` scenes, small unit battles by their assets (`PBBATTLE/PBUNIT_*`), have not been reached on the Mac yet (open). `main+0xea8b0` starts one and is called from about 50 event sites; the tutorial version comes from an event-script command (`main+0x163dd8`).
- How it was found: sampling the host (`sample <pid>`: 84% of the main thread in `dm_guest_sleep_ns` under `main+0x443d74`, 15% in `nvnWindowAcquireTexture` under the present wrapper), the per-frame times (`DM_NVN_TSV`), and probes on N's writers.
- **Current:** a cut at `main+0x443fac` computes the threshold N × tick frequency / 60 with `udiv` (the compiler's multiply by 1/60 took one more instruction), then sets it to 0 when N = 1. The wait then returns after its first tick, which it still stores, and the host's present pacing (the acquire waits) holds the frame to one vsync. With N = 2 the wait stays the game's own. The first cut (`b.hs` → `b` at `main+0x443fd4`) skipped the wait for every N: with N forced to 2 at the title it presented 60 frames a second against the game's 28.8, for scenes that count 30. At the title with the current cut: N = 1 gives 60.0 fps (frame time p50 16.67 ms, wakeups 2,050 → 300 a second); N forced to 2 gives 28.9 fps, the game's own pace. In the town: 60.0 fps, frame time p50 16.66 / p95 17.08 ms, about 300 wakeups a second, no red line.
- **Other builds (current):** `MATCH_CUTS` carries the cut to builds without registered cuts, with two implementations of the frame end under one rule ([function-matched cuts](../../../docs/reference/rewriter.md#function-matched-cuts-for-unregistered-builds)). One is 1.1.1's function (364 bytes, the cut at +288). The other is the base game's (v0, `main` `47d7baf6…`): `main+0x43e140`, 172 bytes, because 1.1.1 added the frame-skip branch at `main+0x443ed4`. It has the same 20 bytes at +104 (`main+0x43e1a8`), and in v0 too N is a global (GOT 0xa1be28, data `0x9ec55c`, initially 1) set to 2 only by the same two windows (`main+0xefe90`, `main+0xec0bc`), and the start tick is read after the update (`main+0x43e128`). `nso_fnmap.py` finds no exact match for 1.1.1's function in v0; v0's was found by disassembly. Through the rewriter, v0's `main` takes the cut at `main+0x43e1a8` (implementation 2), and a copy of 1.1.1's `main` with another build ID takes implementation 1 with output byte-identical to the registered cut. The base game has not been run (its `sdk` and `subsdk0` are 1.1.1's builds).
## Input
@@ -51,3 +52,4 @@ General host changes first needed here; each applies to every game:
- **`oe::SetCpuOverclockEnabled`** (`hle_oe.c`) and **gestures for `hid::GetGestureStates`** recognized from the touch points (`gesture_sample`: yuzu's rules plus an 8 px slop, `t_gesture`, `DM_TOUCH_LOG`, `DM_CTL` `drag`): [CPU overclock calls without an apm session](../../../docs/reference/services.md#cpu-overclock-calls-without-an-apm-session), [Touch gestures recognized from the touch points](../../../docs/reference/services.md#touch-gestures-recognized-from-the-touch-points).
- **KTGL's frame-end wait** (a game-code cause; this profile's cut, no host change): [A frame-end wait polled in 2 ms sleeps](../../../docs/reference/frame-rate.md#a-frame-end-wait-polled-in-2-ms-sleeps).
- **Same-name implementations of a function-matched cut** (`nso_match.py`: exactly one must match, no red line for the others): [Function-matched cuts for unregistered builds](../../../docs/reference/rewriter.md#function-matched-cuts-for-unregistered-builds).
+14
View File
@@ -17,3 +17,17 @@ CUTS = {
'frame limiter threshold: N × tick frequency / 60 (umulh by 1/60 → udiv by 60), then 0 when N = 1 (cmp w20, #1; csel x20, xzr, x8, eq): one vsync a frame is the present pacing\'s; N = 2 keeps the 2 ms rounds'),
],
}
# The same cut for builds without registered CUTS (dm/tools/nso_match.py; one patch, two reviewed implementations of the frame end, exactly one of which must match). Each is
# found by its fingerprint and its direct callees' (the present wrapper, GetSystemTick / GetSystemTickFrequency, SleepThread). The function reads N through its GOT entry, so the
# cut's assumption (N = 1 is held by the present pacing, N = 2 by the wait) moves with the code.
# 1.1.1: main+0x443e8c, 364 bytes, the cut at +288 (the CUTS above).
# The base game (v0, main 47d7baf6…): main+0x43e140, 172 bytes (1.1.1 added the frame-skip branch at main+0x443ed4), the same 20 bytes at +104 (main+0x43e1a8) with N in w20
# (GOT 0xa1be28, data 0x9ec55c, 1; 2 in the same two windows, main+0xefe90 / main+0xec0bc) and the start tick read after the update (main+0x43e128). Found by disassembly
# (nso_fnmap.py: the 1.1.1 function has no exact match in v0); not run
limiter = 'frame limiter threshold: 0 when N = 1'
MATCH_CUTS = {'main': [
{'name': limiter, 'size': 364, 'fingerprints': ['3191958fa93fa496f5b57c61cf4a08fd6154d947f0b16f481d119ba38c28c48d'],
'cuts': [(288, '087c149be9e301b2291191f2087dc99b14fd45d3', '087c149b890780520809c99a9f060071f403889a', limiter + ' (as 1.1.1 main+0x443fac)')]},
{'name': limiter, 'size': 172, 'fingerprints': ['8fda590c2f6df29ae21230a600452979e26c0972dcb738cec24130239aef1346'],
'cuts': [(104, '087c149be9e301b2291191f2087dc99b14fd45d3', '087c149b890780520809c99a9f060071f403889a', limiter + ' (as v0 main+0x43e1a8)')]},
]}
+13 -7
View File
@@ -190,14 +190,20 @@ def resolve(image, text_end, rules, report=print):
except MatchError as e:
report(f'[RED] function patches skipped: {e}')
return []
cuts = []
for rule in rules:
try: found = module.match(rule)
except MatchError as e:
report(f'[RED] function patch {rule["name"]!r} skipped: {e}')
cuts, groups = [], {}
# Rules with the same name are one patch's reviewed implementations (a function another build changed, with its own size or cut offsets): exactly one of them must match
for rule in rules: groups.setdefault(rule['name'], []).append(rule)
for name, alternatives in groups.items():
found, misses = [], []
for i, rule in enumerate(alternatives, 1):
try: found.append((i, module.match(rule)))
except MatchError as e: misses.append(str(e))
if len(found) != 1:
report(f'[RED] function patch {name!r} skipped: ' + (f'{len(found)} implementations match (need exactly one)' if found else '; '.join(misses)))
continue
cuts.extend(found)
report(f'function patch {rule["name"]!r}: matched at {found[0][0]:#x}')
(i, got), = found
cuts.extend(got)
report(f'function patch {name!r}: matched at {got[0][0]:#x}' + (f' (implementation {i} of {len(alternatives)})' if len(alternatives) > 1 else ''))
occupied = set()
for off, old, _, _ in cuts:
span = set(range(off, off + len(bytes.fromhex(old))))
+23 -1
View File
@@ -129,8 +129,30 @@ class Matching(unittest.TestCase):
self.assertTrue(messages[0].startswith('[RED] '))
def test_overlapping_rules_are_rejected(self):
other = dict(self.rule, name='another patch')
with self.assertRaisesRegex(MatchError, 'overlap'):
resolve(self.original, 0x2000, [self.rule, self.rule], lambda _: None)
resolve(self.original, 0x2000, [self.rule, other], lambda _: None)
def test_same_name_rules_are_alternative_implementations(self):
# A later build changed the function (another size, the cut elsewhere in it): one rule per reviewed implementation, under one name
def changed(start): # caller() with one more instruction before the cut
return [ADRP(8, start, 0x2200), ADDI(8, 8, 0x200), NOP, MOV, B(start + 16, 0x500) | 0x80000000, 0x54000040, NOP, RET]
later = image({0x100: changed(0x100), 0x500: [0xd2800020, RET]})
alt = Image(later, 0x2000).recipe(0x10c, 'e00317aa', 'e0124091', 'heap')
self.assertNotEqual(alt['size'], self.rule['size'])
for img, off in ((self.original, 0x108), (later, 0x10c)):
with self.subTest(off=hex(off)):
messages = []
cuts = resolve(img, 0x2000, [self.rule, alt], messages.append)
self.assertEqual([c[0] for c in cuts], [off])
self.assertFalse([m for m in messages if m.startswith('[RED]')], messages)
messages = []
self.assertEqual(resolve(image({0x100: [RET]}), 0x2000, [self.rule, alt], messages.append), [])
self.assertEqual(len(messages), 1); self.assertTrue(messages[0].startswith('[RED] '))
both = image({0x100: caller(0x100, 0x500), 0x800: changed(0x800), 0x500: [0xd2800020, RET]})
messages = []
self.assertEqual(resolve(both, 0x2000, [self.rule, alt], messages.append), [])
self.assertIn('2 implementations match', messages[0])
def test_registry_scopes_rules_and_preserves_known_builds(self):
source = TOOLS / 'patch_nso.py'; tree = ast.parse(source.read_text())
+3 -3
View File
@@ -8,15 +8,15 @@ The NSO rewriter (`dm/tools/patch_nso.py`) and its per-build tables (`dm/tools/s
- **Symptom:** an updated game loses a needed cut because its NSO build ID changed, although the relevant code still matches a verified version; the rewriter reports `[RED] function patch ... skipped` when that match cannot be established.
- **Cause:** fixed-address `CUTS` correctly apply only to their registered builds. Copying an old address, or matching a short instruction sequence, does not identify the updated function.
- **Fix:** a profile can opt individual cuts into `MATCH_CUTS` in its `patches.py`. The shared matcher, `dm/tools/nso_match.py`, selects a unique complete unwind function by length and SHA-256 fingerprint. It retains constants, registers and internal branch destinations; normalizes relocation addresses; and checks the normalized bodies of direct callees or their imported symbol names. Original bytes are checked again before a cut is applied. Missing boundaries, unknown callees, changed code and ambiguous matches produce a red diagnostic and no cut. Overlapping rules stop the rewrite. Every accepted cut is recorded in `<module>.cuts.tsv` with `[function match]` in its reason.
- **Fix:** a profile can opt individual cuts into `MATCH_CUTS` in its `patches.py`. The shared matcher, `dm/tools/nso_match.py`, selects a unique complete unwind function by length and SHA-256 fingerprint. It retains constants, registers and internal branch destinations; normalizes relocation addresses; and checks the normalized bodies of direct callees or their imported symbol names. Original bytes are checked again before a cut is applied. Missing boundaries, unknown callees, changed code and ambiguous matches produce a red diagnostic and no cut. Overlapping rules stop the rewrite. Every accepted cut is recorded in `<module>.cuts.tsv` with `[function match]` in its reason. Rules with the same `name` are one patch's reviewed implementations, for a function a build changed (another size, or the cut at another offset in it). Exactly one of them must match: the log names the implementation that did, and a red line appears only when none matches or more than one does. Before this, each implementation logged a red line on every build it did not match.
- **Scope:** the profile selected by `DM_GAME`, keyed by module name. A derived profile still uses that repository profile's rules, even when the repository has no matching rules or patch file; an external-only provisional profile can supply its own. External files cannot replace or enroll a repository profile's matching rules. Other games' rules never participate. A build with registered `CUTS` uses those instead, including an explicitly empty list. SDK patches, observation gates, frame-step stubs and fixed host addresses retain their existing build-ID gates. The rewrite cache includes the selected profile's patch file even for unknown builds, the selected game ID and the matching tool. Repeated search roots or symlinks to the same patch file are loaded and counted once; distinct files claiming the same build still stop the rewrite.
- **Limits:** this is an opt-in code-identity check, not automatic compatibility for every game update. Direct callee bodies are checked one level deep; their callees, indirect-call targets and external data layouts are not proven unchanged. Enroll only a cut whose assumptions remain valid under those changes. Prefer fixing the general host behavior when possible. A replacement must contain no PC-relative instruction; relocated branch replacements need a different mechanism. Failure leaves the game potentially without its workaround, and stays visible in the rewrite log.
- **Found on:** [Super Mario Bros. Wonder](../../dm/games/010015100B514000/README.md#memory), whose former resource-heap allowance was missing on 1.0.1. The shared matcher remains, but Wonder's allowance and matching rule were later removed after the general command-record fix described in its game note.
- **Found on:** [Super Mario Bros. Wonder](../../dm/games/010015100B514000/README.md#memory), whose former resource-heap allowance was missing on 1.0.1. The shared matcher remains, but Wonder's allowance and matching rule were later removed after the general command-record fix described in its game note. Same-name implementations: [Taiko Risshiden V DX](../../dm/games/0100346017304000/README.md#frame-rate), whose frame-limiter cut has one implementation for 1.1.1 and one for the base game, where the function is shorter.
To enroll a cut for any game's own module:
1. Start from a verified fixed cut and its original NSO. Run `python3 dm/tools/nso_match.py <module.nso> <offset> <old hex> <new hex> <reason>` with the tools' Python environment. It emits a rule containing `name`, `size`, `fingerprints` and function-relative `cuts`, and rejects an ambiguous reference. Keep game files and scratch outputs in the game's NSP work folder.
2. Check the function and callees in another known build using `nso_fnmap.py` and disassembly. Check that the cut still has the same effect. `Image.match(rule)` must resolve the reviewed location. If a reviewed implementation differs, generate its fingerprint separately; combine fingerprints only when function size and relative cuts agree. Retain exact `CUTS` for known builds.
2. Check the function and callees in another known build using `nso_fnmap.py` and disassembly. Check that the cut still has the same effect. `Image.match(rule)` must resolve the reviewed location. If a reviewed implementation differs, generate its fingerprint separately; combine fingerprints only when function size and relative cuts agree, and otherwise add the implementation as another rule under the same name. Retain exact `CUTS` for known builds.
3. Add the rule under `MATCH_CUTS = {'main': [rule]}` (or the appropriate non-SDK module) in that game's `patches.py`. Record the evidence and remaining runtime validation in the game's note. Do not transplant a workaround into unrelated games.
4. Verify an unregistered build-ID copy through the rewriter, comparing its output with the known fixed cut, and run `test_nso_match.py`, `test_patches.py` and `test_nro_pack.py`. The matching tests use synthetic modules from two profiles to check relocation, rejection and isolation without game files.