patch_nso: offset-table jumps whose entry is loaded into another register

Cuphead (Unity 2017.4) main+0x1bb872c loads the table entry into x17 and only
the add writes x18: cmp w17,#7; ldrsw x17,[x15,x17,lsl #2]; add x18,x17,x15;
br x18. jump_offsets now takes the loaded register from the add; the walk
from the index back to the bounds check is unchanged.

Ported from GitHub pull request #1 onto the current jump_offsets (the PR's
other two shapes already pass since Link's Awakening's subsdk work). All 35
rewritable modules of the 9 extracted titles give byte-identical output.

Co-authored-by: Jeffrey Wang <11900676+armyja@users.noreply.github.com>
This commit is contained in:
seeddsandJeffrey Wang committed 2026-10-18 08:59:19 +08:00
1 parent 9b1d22d81f
commit 64c17c26d4
2 files changed
+7 -5

No files matched your search

+6 -4
View File
@@ -591,15 +591,17 @@ def jump_offsets(insl, j):
"""The br x18 is preceded by the offset-table form (Engage main+0x12eec94): cmp w18,#N; b.hi; ldrsw x18,[xB,x18,lsl #2]; add x18,x18,xB.
The index may be another register, and the bounds check may be on a register copied into it: Link's Awakening's GLSLC (subsdk0+0x1be6e8) has
sub w0,w18,#0xd1; cmp w0,#0x31; b.hi; ldrsw x18,[x17,x0,lsl #2], its movie module (subsdk1+0xae9a4) cmp w21,#7; b.hi; ...; mov w18,w21; ldrsw x18,[x4,x18,lsl #2].
The entry may also be loaded into another register and only the add writes x18: Cuphead (Unity 2017.4, main+0x1bb872c) has cmp w17,#7; ldrsw x17,[x15,x17,lsl #2]; add x18,x17,x15
(found by armyja, GitHub pull request #1).
For the table base xB, search back for the nearest writer: it must be add xB,xB,#lo, and the writer before it must be adrp xB. Returns all targets, or None if not recognized"""
ctx = insl[max(0, j - 16):j]
add = next((p for p in reversed(ctx) if p.mnemonic == 'add' and re.fullmatch(r'x18, x18, x\d+', p.op_str)), None)
add = next((p for p in reversed(ctx) if p.mnemonic == 'add' and re.fullmatch(r'x18, x\d+, x\d+', p.op_str)), None)
if not add: return None
rb = add.op_str.split(', ')[2]
ld = next((p for p in reversed(ctx) if p.mnemonic == 'ldrsw' and re.fullmatch(rf'x18, \[{rb}, x(\d+), lsl #2\]', p.op_str)), None)
_, t, rb = add.op_str.split(', ') # t = the loaded entry, rb = the table base
ld = next((p for p in reversed(ctx) if p.mnemonic == 'ldrsw' and re.fullmatch(rf'{t}, \[{rb}, x(\d+), lsl #2\]', p.op_str)), None)
if not ld: return None
# GLSLC subsdk0+0x2f403c checks a masked copy instead: and w16,w18,#0x3f; cmp w16,#0xe; b.hi; ...; and x18,x18,#0x3f; ldrsw x18,[x15,x18,lsl #2]
idx = {'w' + re.fullmatch(rf'x18, \[{rb}, x(\d+), lsl #2\]', ld.op_str).group(1)}
idx = {'w' + re.fullmatch(rf'{t}, \[{rb}, x(\d+), lsl #2\]', ld.op_str).group(1)}
masked = None # (source, mask, position) when the index is "source & mask"
k0 = ctx.index(ld); cmp = None
def wname(r): return 'w' + r[1:]
+1 -1
View File
@@ -32,7 +32,7 @@ Each entry: symptom → root cause → fix (where the fix lives). Check these fi
### Rewriter
- **`jump table <addr>` assertion in `patch_nso.py`** (Unity / IL2CPP `main`) → the compiler used `x18` for other jump-table shapes: an offset table (`ldrsw x18, [xB, x18, lsl #2]; add x18, x18, xB; br x18`), an indirect call (`blr x18`) and an `alloca` (`mov sp, x18`). The rewriter reads the offset table's targets at rewrite time (`site_br_tab`), calls through `x16` with the original return address so C++ unwinding still finds its frames (`site_blr`), and restores its scratch registers relative to the old `sp` (`site_sp`). Engage `main`: 7 jump tables, 1 call, 1 `sp` write; Mario's output stays byte-identical.
- **`jump table <addr>` assertion in a `subsdk`** (Link's Awakening's GLSLC and movie modules) → the offset-table form `ldrsw x18, [xB, xI, lsl #2]; add x18, x18, xB; br x18` with the bounds check on another register: the index in `w0` (`sub w0, w18, #0xd1; cmp w0, #0x31`), a copy (`cmp w21, #7; …; mov w18, w21`) or a masked copy (`and w16, w18, #0x3f; cmp w16, #0xe; …; and x18, x18, #0x3f`). `jump_offsets` follows `mov` copies back to the `cmp` and accepts a check on the same `and` of the same, unchanged source. Engage's output stayed byte-identical.
- **`jump table <addr>` assertion in a `subsdk`** (Link's Awakening's GLSLC and movie modules) → the offset-table form `ldrsw x18, [xB, xI, lsl #2]; add x18, x18, xB; br x18` with the bounds check on another register: the index in `w0` (`sub w0, w18, #0xd1; cmp w0, #0x31`), a copy (`cmp w21, #7; …; mov w18, w21`) or a masked copy (`and w16, w18, #0x3f; cmp w16, #0xe; …; and x18, x18, #0x3f`). `jump_offsets` follows `mov` copies back to the `cmp` and accepts a check on the same `and` of the same, unchanged source. Engage's output stayed byte-identical. The entry may also be loaded into another register, with only the `add` writing `x18` (Cuphead, Unity 2017.4: `cmp w17, #7; ldrsw x17, [x15, x17, lsl #2]; add x18, x17, x15`).
- **Job system hangs; a queue claims thousands of tasks but every slot is empty** → in an `ldxr … cmp … b.ne out … stxr … cbnz` window, the rewritten stub emitted its exits in the wrong order, so successful stores took the failure path → emit the fall-through exit first (`patch_nso.py`). D2R had no such windows; Mario had 82.
- **SIGILL / trap on a system register read** → newer SDK reads a register macOS traps (`CTR_EL0` in SDK 16's `FlushDataCache`) → rewrite to a constant.
- **`B()` range assertion in `patch_nso.py` (`site_plain`), only for one module** (Metroid Dread `main`) → its bss is 1.8 GB, the engine's static heap, so a patch area appended after the image starts 1.9 GB from the code and no `B` (±128 MB) reaches it. When an appended patch area would start beyond 112 MB, the rewriter puts it 32 MB *before* the module base (negative `PATCH_OFF`). `patched.bin` then holds only text + ro + data (`FILE_IMAGE_LEN`) followed by the patch bytes, and the loader maps the bss and the key page as zero pages (`IMAGE_SIZE`). The Mac loader, the iOS embedding (`gen_embed.py`: no bss in the executable) and `QueryMemory` handle both layouts; Mario's and Engage's output is byte-identical to before.