The NSO rewriter (dm/tools/patch_nso.py) and its per-build tables (dm/tools/sdk_patches.py, a profile's patches.py). Each entry: symptom, cause, fix, the games it was found on. The symptom index lists every symptom.
- Symptom:
NameError: name 'CS_ARCH_ARM64' is not definedat the start ofpatch_nso.py; now[RED] Capstone 6.… is unsupportedfrom the NSP script orpatch_nso.py. - Cause: Capstone 6 renames the architecture to
CS_ARCH_AARCH64and the operand constants (ARM64_OP_*); the rewriter uses the Capstone 5 API. PyPI's Capstone 6 releases are pre-releases (6.0.0a…), so a plainpip install capstonegets 5.x; a--preinstall or another package's requirement brings in 6. - Fix: install
capstone>=5,<6(README and CI).patch_nso.pychecks the major version (cs_version()) before it disassembles anything, and the NSP script checks it before building or surveying, so the extraction is not wasted; both print the install command for the Python that runs them. Capstone 6 is not supported. - Found on: Celeste.
- 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 ... skippedwhen that match cannot be established, and the NSP script ends with[RED] main: no function patch carries the registered builds' fixes to this buildor[RED] main: function patches not applied(Super Smash Bros. Ultimate 13.0.1: a static-initialiser abort innninitStartup, GitHub issue #375). - Cause: fixed-address
CUTScorrectly 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_CUTSin itspatches.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. Every locator fails closed this way, and a new one must too: exactly one match with its assumptions checked, or no change (dm_ue_cvarin the host as well: a console variable whose name is registered twice is not written). That is what makes a looser binding safe on builds nobody has seen (Binding). Overlapping rules stop the rewrite. Every accepted cut is recorded in<module>.cuts.tsvwith[function match]in its reason. Rules with the samenameare 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.- A patch of several functions (
{'name', 'functions': [{'size', 'fingerprints', 'cuts'}, ...]}) applies whole or not at all: every function must match exactly once, at distinct places, or none of its cuts applies, and the red line names each function that did not match. Use it when the cuts depend on each other, as a constant used in several functions does (a region size, the place it derives, the bound checks of its end): applying some of them leaves code that disagrees about the layout, which fails later and less clearly than the original abort. Before this, rules were independent, so a constant split over several functions could not be enrolled safely. - What an unregistered build kept: for a module whose build is not registered, the rewriter writes
<module>.match.tsv: the cuts of the registered builds of that module (the repository profile'sbuildrows) and each patch's result. The NSP script reports it with the build: a red line when registered cuts have no function patch at all, or when a patch did not apply; the build itself goes on, since a derived profile is best-effort.
- A patch of several functions (
- SDK-wide patches:
dm/tools/sdk_patches.py'sMATCH_CUTS({'sdk': [rule, ...]}) apply to every game'ssdkof a build with noCUTS, whatever the profile; a build that must keep Nintendo's code registers an emptyCUTSlist. Before, they skipped a build the selected profile'sbuildrows name, which kept a registered row for every such build even where the rules gave the same cuts (18 builds' rows were removed when that changed). A patch that finds no reviewed implementation is logged as a plain line and listed by the NSP script without red, since a module may not need it (The SDK's address-region node area). The NSP script's rewrite step keys its reuse on the profile'sbuildrows as well. A callee that is one of the SDK's system call wrappers (svc #n; ret, assembly with no unwind entry) is identified by its call number. - 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 registeredCUTSuses 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, 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, whose frame-limiter cut has one implementation for 1.1.1 and one for the base game, where the function is shorter. A patch of several functions: Super Smash Bros. Ultimate, whose 11 engine-region cuts lie in four functions. SDK-wide: the AddressRegionManager node area and the 2 MB alignment of large regions (SDK 20's), from the SDK builds of 20 games (The SDK's address-region node area).
To enroll a cut for any game's own module:
- 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 containingname,size,fingerprintsand function-relativecuts, and rejects an ambiguous reference. For cuts that must apply together,python3 dm/tools/nso_match.py <module.nso> --registered <name>makes one patch of every cut registered for that NSO's build ID, grouped by function. Keep game files and scratch outputs in the game's NSP work folder. - Check the function and callees in another known build using
nso_fnmap.pyand disassembly. Check that the cut still has the same effect.Image.match_patch(rule)must resolve the reviewed locations. 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 exactCUTSfor known builds. - Add the rule under
MATCH_CUTS = {'main': [rule]}(or the appropriate non-SDK module) in that game'spatches.py. Record the evidence and remaining runtime validation in the game's note. Do not transplant a workaround into unrelated games. - 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.pyandtest_nro_pack.py. The matching tests use synthetic modules from two profiles to check relocation, rejection and isolation without game files.
- Symptom:
jump table <addr>assertion inpatch_nso.py, in a Unity / IL2CPPmain. - Cause: the compiler used
x18for jump-table shapes other than the ones the rewriter knew. One of them is an offset table:ldrsw x18, [xB, x18, lsl #2]; add x18, x18, xB; br x18. (The samemainalso usesx18for an indirect call and analloca: see Indirect calls through x18 and Stack pointer writes through x18.) - Fix: the rewriter reads the offset table's targets at rewrite time (
site_br_tabinpatch_nso.py). - Found on: Fire Emblem Engage
main: 7 jump tables (beside 1 call and 1spwrite). - Check: Super Mario Bros. Wonder's output stays byte-identical.
- Symptom:
jump table <addr>assertion inpatch_nso.py, in asubsdk. - Cause: the offset-table form
ldrsw x18, [xB, xI, lsl #2]; add x18, x18, xB; br x18with the bounds check on another register: the index inw0(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). The entry may also be loaded into another register, with only theaddwritingx18(Cuphead, Unity 2017.4:cmp w17, #7; ldrsw x17, [x15, x17, lsl #2]; add x18, x17, x15). - Fix:
jump_offsetsfollowsmovcopies back to thecmpand accepts a check on the sameandof the same, unchanged source. - Found on: The Legend of Zelda: Link's Awakening (GLSLC and movie modules); Cuphead (the entry loaded into another register).
- Check: Fire Emblem Engage's output stayed byte-identical.
- Symptom:
jump table <addr>assertion where the index is a copy made before the check (mov w0,w18, then 41 instructions holding two other jump tables onx0, thencmp w18,#4; b.hi; ldrsw x18,[x11,x0,lsl #2]; add x18,x18,x11; br x18). - Cause: every other form follows copies made after the bounds check.
- Fix:
check_before_copyaccepts thecmpandb.hiright before the load when amov wI, wSis the last writer of both registers; capstone lists acmp's first operand as written, which the search skips. The search follows the function boundary rather than an arbitrary instruction count, stopping at undecodable gaps and calls that can clobber either register. It runs only when every other form has failed. - Found on: DRAGON QUEST XI S: Echoes of an Elusive Age – Definitive Edition
main+0x2dd8cd0; Mario + Rabbids Kingdom Battlesubsdk0+0x7340b8, whose copy is 71 instructions before the load, across two switches preserving both registers (beyond the former 64-instruction limit). - Note: before fixing one site, a census of all of them saves a run per site: a copy of
patch_nso.pywhose assert prints and continues found this one alone among the 25,508x18sites. Thatmainhas no unwind entries for its first 70 MB, so the rewriter disassembles them as one function and takes 6 minutes (see Long functions analysed in pieces).
- Symptom:
jump table <addr>assertion inpatch_nso.pyon an offset table whose index is checked with a 64-bit compare (cmp x22, #0x10; …; b.hi; adrp x18 + add x18; mov x0, x18; ldrsw x18, [x0, x22, lsl #2]; add x18, x18, x0; br x18). - Cause:
jump_offsetstook only a 32-bitcmp wI, #Nas the bounds check. A 64-bit compare bounds the index as well (the load uses the wholexI); here the table base also reaches the load through a copy, so only the far-base pass reads it. - Fix: in its last pass (
far_base),jump_offsetsacceptscmp xI, #Non the index too. The earlier passes are unchanged, so modules that rewrote before come out the same. - Found on: OCTOPATH TRAVELER
main+0x1676f58(1.0.5), the only such site in thatmain.
- Symptom:
jump table <addr>assertion in a Unity / IL2CPPmainwhose dispatch checks a fresh mask of another register than the one it indexes with. - Cause: the dispatch block masks the source again for its bounds check, while the index register was masked from the same source in the block that branches to it:
and x6,x17,#0x7ff; ubfx w18,w17,#0xb,#2; cbz w18,L, thenL: and w18,w17,#0x7ff; cmp w18,#3; b.hi; ldrsw x18,[x27,x6,lsl #2]; add x18,x18,x27; br x18, with the basex27from anadrp+addabout 1,500 instructions back. A sibling builds its base inside the block and moves registers before thebr(adrp x24+add, amov, the load and the add, then 10 register moves). No copy links the two registers, sojump_offsetsandcheck_before_copyfind no check. - Fix:
jump_masked_predaccepts the site when the block (at most 32 instructions) isand wC,wS,#M; cmp wC,#N; b.hifollowed by straight-line code holding the load and the add; it is entered by branches alone (the instruction before it isb/br/ret, no branch lands inside it after itsand, no jump table already resolved in the function reaches it); every branch into it follows, on a straight-line path,and xI,xS,#Mwith the check's source and mask; and the index, the base andx18are unchanged where the form needs them. The base is found asfar_basefinds it and every target must be an instruction of the function. It runs only after every other form has failed. - Guards (resolved): direct calls and resolved jump tables must not enter between a predecessor's mask and its branch, since that bypasses the index assignment; direct calls into the dispatch are refused too. Undecodable words break either straight-line run, and table targets must be decoded instruction addresses, not just aligned addresses inside the function. The base uses
copied_base, which followsadrp+addand register copies while rejecting 32-bit writes that destroy a 64-bit base. - Found on: FINAL FANTASY CRYSTAL CHRONICLES Remastered Edition 1.0.0 (Unity 2018.4.2f1, SDK 8.2.1)
main+0x2d103ccand+0x2d10590, in one function; the same form in its update 1.0.3. - Check: it runs only where the rewriter asserted before, so modules that rewrote before come out the same; other games' modules were not on hand to compare.
- Symptom:
jump table 6eea88orjump table 6eeac0assertion inpatch_nso.pyon Return of the Obra Dinn 1.0.1. - Cause: Unity's IL2CPP output uses
tst w15,#7; b.eq exit; mvn w15,w15; and x15,x15,#7; ldrsw x15,[xB,x15,lsl #2]; add x18,x15,xB; br x18. The zero test excludes one masked value before complementing it: the reachable indices are 0–6, so the table holds seven entries. Treating thetstimmediate as acmpbound reads an eighth word from the next table, introducing a target the guest cannot reach through this dispatch. - Fix: the last fallback,
jump_masked_not, accepts this contiguous guard / complement / low-bit-mask form, reads exactly the mask's number of entries, follows the table base throughcopied_base, and requires decoded instruction targets in the function. It rejects intervening register writes or control flow, direct branches / calls and already resolved tables entering after the guard, and its own table targets bypassing the guard. Earlier recognizers are unchanged. - Found on: Return of the Obra Dinn 1.0.1 (
mainbuild9e93a6566aaa27418dd2435d8d5dffe5), two dispatches in the function atmain+0x6ee8c0. - Check:
dm/tools/tests/test_jump_copies.pyexercises the exact seven-entry extent with an invalid next word, mismatched guards / masks / sources, register clobbers, incoming edges, undecodable words and invalid targets. The extracted update's two sites resolve to seven targets each; this is static verification, with gameplay validation recorded in the game note.
- Symptom:
jump table <addr>assertion on an offset table whose base was set far back (cmp w18,#5; b.hi; ldrsw x18,[x24,x18,lsl #2]; add x18,x18,x24; br x18). - Cause: the function's first block loads six table bases with
adrp+addinto registers that its loop keeps (x24441 instructions before this load), and a later block one more (x3, 549 before the load at+0x6db70c);jump_offsetsfollows the base's writer chain 256 instructions back. The function-wide search thatjump_adr_offsetsuses for a hoisted base (the oneadrp+addpair that fits) found two forx3, the other a local table of abr x17further on. - Fix: where nothing writes the base in those 256, its last attempt (
far_base) follows the chain to the function's start (table_basefar), through no call when the base is caller-saved, and accepts the table only when every target is an instruction of the function. It runs only where every other form has failed. - Found on: ASTRAL CHAIN 1.0.1 (SDK 6)
main+0x6db4a0: three of thatmain's fivebr x18sites need it. - Check: modules that rewrote before come out the same (Tears of the Kingdom, Age of Calamity, Cuphead, DARK SOULS: REMASTERED, Luigi's Mansion 3 and Metroid Dread
mains: byte-identical).
- Symptom:
jump table <addr>assertion on an offset table whose base was set far back and moved through other registers (ldrb w18,[x14]; sub w18,w18,#1; cmp w18,#6; b.hi; ldrsw x18,[x7,x18,lsl #2]; add x18,x18,x7; br x18). - Cause:
x7is built byadrp+add688 instructions before, then parked inx30around a loop (mov x30,x7…mov x7,x30), so the far chain meets amovand stops; the function-wide search finds a secondadrp x7+addpair (another table further on) whose seven targets also lie in the function. - Fix: the last attempt now follows register copies back to the
adrp+add(copied_base, through no call while the register followed is caller-saved; the same target check). It runs only where the rewriter asserted before. - Found on: Super Smash Bros. Ultimate 13.0.5 (SDK 8.2.1)
main+0x2b74458: thatmain's only unhandled site. - Check: Mario Kart 8 Deluxe's three modules and Smash's
sdkand movie module come out byte-identical.
- Symptom:
jump table ae95f8assertion inpatch_nso.pywhile rewriting Blasphemous 1.0.8. - Cause: the function enters an initialization block later in address order, then branches backward to
cmp w18,#9; b.hi; ldrsw x18,[x17,x18,lsl #2]; add x18,x18,x17; br x18. Theadrp x17/add x17,x17,#lopair is after the dispatch, so the backward writer searches find no base. - Fix: a final
jump_offsetsfallback usesforward_table_baseonly when that adjacent full-width pair is the base register's sole initialization in the function. It rejects other writes, calls that can clobber the base, known branches or table targets entering the add without its adrp, out-of-image tables and targets that are not decoded instructions of the function. Earlier recognizers are unchanged. - Found on: Blasphemous,
main+0xae95f8, ten table-relative targets. - Check:
dm/tools/tests/test_jump_copies.pycovers the exact table extent, base clobbers, incoming edges, undecodable targets and out-of-image tables. The extracted function resolves to the ten targets read directly from its table.
- Symptom:
jump table <addr>assertion, entries relative to anadr. - Cause:
cmp wI, #N; b.hi; …; adr x18, #L; ldrsw xE, [xT, xI, lsl #2]; add x18, x18, xE; br x18: target = L + entry, not table + entry. - Fix:
jump_adr_offsets, tried only where the table-relative form is not found. The bounds check can be on a register copied into the index (mov w16, w15), and the table base can be built far from the dispatch; then everyadrp+addpair in the function that builds that register is tried, and a table is accepted only if exactly one makes every target an instruction of the function. - Found on: The Legend of Zelda: Tears of the Kingdom
main+0x393bb0and others.
- Symptom:
jump table <addr>assertion on a byte table indexed by x30 (adr x18, #L; ldrb w30, [x14, x12]; add x18, x18, x30, lsl #2; br x18). - Cause: capstone names x30
lr(and x29fp), so the index register looked like no register and theldrbnever matched. - Fix: the byte / halfword table check maps those names back to 30 / 29.
- Found on: Hyrule Warriors: Age of Calamity
main+0x138216c.
- Symptom:
jump table <addr>assertion on a byte table whoseaddis far from thebr(adr x18,#L; ldrb w0,[x17,x15]; add x18,x18,x0,lsl #2, then 17 register moves beforebr x18). - Cause: the byte / halfword search looked 16 instructions back.
- Fix:
jump_bytes_farlooks 32 back, requires that nothing writes x18 between theaddand thebr, and runs only when every other form has failed, so sites that rewrote before come out the same. - Found on: Octopath Traveler 0
main+0x37f9bc8: one of the 12br x18sites in thismain. - Note: a census of all the
br x18sites first (each shape within 16 / 32 / 48 instructions) saves a 12-minute rewrite per site.
- Symptom:
jump table <addr>assertion inpatch_nso.py, in a Unity / IL2CPPmain, at ablr x18. - Cause: the compiler made an indirect call through
x18(blr x18). - Fix: the rewriter calls through
x16with the original return address, so C++ unwinding still finds its frames (site_blr). - Found on: Fire Emblem Engage
main: 1 call (beside 7 jump tables and 1spwrite). - Check: Super Mario Bros. Wonder's output stays byte-identical.
- Symptom:
jump table <addr>assertion on abr x18with no table in front of it. - Cause: not a jump table but an indirect tail call: an IL2CPP thunk loads the method pointer with
ldr x18, [x0, #0x18], moves its stack arguments with every other scratch register (ldr x16, [sp],ldp x17, x15, [sp, #8], …) and ends withbr x18. - Fix: when neither table form is found, the last write of
x18is a plain load and thebrends the function, it becomesx16 = guest x18; br x16(tail_call/site_br_call; at a callx16/x17are free, as forblr x18). It is tried only where the rewriter used to stop. - Found on: Ace Attorney Investigations Collection (Unity 2022.3 IL2CPP):
main+0x1d271bcand+0x204ebec. - Check: every module the rewriter accepted before comes out byte-identical.
- Symptom:
jump table 53e9cwhile rewriting Warm Snow 2.1.4's Unity IL2CPPmain. - Cause: a framed thunk loads a method pointer into
x18, moves arguments, restores its frame and executesbr x18. A separate null-error call follows it. The terminal-tail recognizer requires the jump to end the function. - Fix: keep the existing terminal-tail rule unchanged. Only for nonterminal jumps, recognize a contiguous decoded thunk with the standard 16-byte FP/LR frame, a single forward
cbzto the trailing error call, plain argument moves/transfers, one pointer load intox18, and the matching frame restore. Reject additional control flow, stack-pointer/frame-register/link-register writes, pointer clobbers and undecoded gaps. Stores must use a fixed offset of at least 16 from the unchangedspor frame pointer, so they write caller stack arguments above the saved FP/LR. Checking register writes alone does not protect that saved pair; stores through other bases or dynamic offsets may alias it and are rejected. An unframed loaded-pointer jump followed by other blocks remains unsupported: ending a basic block alone does not prove a tail call. - Found on: Warm Snow 2.1.4,
mainbuild306fc054203275803dcbceba7fb7c392, at+0x53e9c. Its game profile is deliberately outside this change. - Check:
dm/tools/tests/test_tail_call.pycovers acceptance and rejection with synthetic AArch64 instructions, including saved-frame stores, safe caller argument stores, unproven store addresses, 32-bit and 64-bit SP/FP/LR clobbers (includingwsp), the unframed guarded block and the previously accepted terminal tail with an intervening conditional branch. Actual game rewrite/build results and remaining coverage are recorded in the PR; no game assets are required by the tests.
- Symptom: job system hangs; a queue claims thousands of tasks but every slot is empty.
- Cause: in an
ldxr … cmp … b.ne out … stxr … cbnzwindow, the rewritten stub emitted its exits in the wrong order, so successful stores took the failure path. - Fix: emit the fall-through exit first (
patch_nso.pysite_window). - Found on: Super Mario Bros. Wonder: 82 such windows. D2R had no such windows.
- Symptom:
branch into window <addr>assertion. - Cause: the compiler moved an atomic update's retry path out of line: the failed
stxrbranches to a blockldxr; cmp; b.eq <the stxr>that re-enters the window at its store. That block cannot jump into the window's patch copy (thex18stand-in would be loaded between its exclusive load and the store). - Fix: the retry block gets a copy of its own: the retry block and the window's tail from the store on, under one prologue (
retry_block/site_retry_window). - Found on: The Legend of Zelda: Tears of the Kingdom 1.4.3
main, 8 sites.
- Symptom: branches to an
ldxr … stxrloop's head landed mid-window. - Cause: before this rule, a retry block's
ldxropened a window that ran on into the next, unrelatedldxr … stxrloop (main+0x5a4674). - Fix: a window never spans a second exclusive load.
- Found on: The Legend of Zelda: Tears of the Kingdom 1.4.3
main(with Out-of-line retry blocks of atomic updates).
- Symptom:
AssertionError: 0x…insite_windowon anadrpinside an exclusive window (ldaxr x9,[x9]; cmp x9,x25; b.ne out; adrp x10 + add x10(the same address again, for the store);mov x18,x26; stlxr w9,x8,[x10], then loads andcbnz w9back). - Cause: a window that uses x18 is copied into the patch area, and an
adrpthere would build another page. - Fix: it is re-encoded for the copy's address with the same target page (x18 as its stand-in), in both window copiers (
window_adrp). - Found on: Pikmin 4 1.1.1 (Unreal Engine 4.26.2)
main+0x43da14c. - Check: only windows that stopped the rewrite change, so every other module rewrites byte for byte as before.
- Symptom:
B()assertion at a spin wait: a branch into the patch area beyond the ±128 MB abreaches. - Cause: each halfword jump table (
ldrh wE, [xT, xI, lsl #1]; …; adr x18, #L; add x18, x18, xE, lsl #2; br x18) got a landing pad for every one of the 65,536 values an entry can hold (768 KB a table), whatever the switch's size: thatmainhas 51 of them, 40 MB, and the spin-wait stubs placed after them were out of reach. - Fix:
half_countreads the table asjump_adr_offsetsdoes (the index's bounds check, following register copies, then the base fromadrp+add, or the one candidate in the function whose every target is an instruction of it) and pads only up to its largest entry; a table it cannot read keeps all 65,536 (7 of the 51). The patch area went from 42 MB to 7.9 MB. - Found on: Animal Crossing: New Horizons 3.0.3
main, 63 MB of text. - Check: in the other titles' modules only such tables changed: one in the
sdkof several SDK 11–20 builds (936 to 1,320 pads), 1 to 6 in somemains (Tears of the Kingdom, Breath of the Wild, Silksong, Monster Hunter Rise, Absolum, STEINS;GATE RE:BOOT) and one in some movie and middlewaresubsdks.
- Symptom:
B()range assertion inpatch_nso.py(site_plain), only for one module. - Cause: 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. - Fix: when an appended patch area would start beyond 112 MB, the rewriter puts it 32 MB before the module base (negative
PATCH_OFF).patched.binthen 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) andQueryMemoryhandle both layouts. - Found on: Metroid Dread
main. - Check: Mario's and Engage's output is byte-identical to before.
- Symptom:
frame step state pageassertion when aFRAME_STEPprofile is added to a module with a large BSS. - Cause: frame-state allocation required a patch area after the image, although the stubs can address the state page through ADRP with either layout.
- Fix: for a front patch area, reserve a zero-filled 16 KB state page immediately after the key page by extending
IMAGE_SIZE. The existing Mac and iOS loaders map it with the BSS; no padding is added to the game binary. Ordinary modules retain their existing layout. - Found on: ASTRAL CHAIN.
- Symptom:
B()range assertion, thenpatch area of … bytes does not fit in the … before the base. - Cause: with the patch area 32 MB before the base, sites near the end of the text are more than 128 MB away.
- Fix: when the text ends within 32 MB of the reach (
BIG_TEXT), the patch area goes right before the base: the stubs follow the sites in address order, so each site's stub is about as far from it as the text is long. The first pass tries the gap that keeps every site in reach (2.6 MB here); when the patch area is larger (3.6 MB, 34,322 sites), the rewriter runs itself again with exactly that size (DM_PATCH_FRONT, set only by the rerun) andB()checks every branch. - Found on: Monster Hunter Rise
main: 125.3 MB of text.
- Symptom:
main: patch area of 0x6b9d38 bytes does not fit in -0x39c0000 before the base; rewriting again with 0x6c0000, then aB()range assertion insite_window: the gap before the base came out negative. - Cause: a text longer than the ±128 MB a
breaches has no place for one patch area from which every site is in reach: before the base the last sites are too far, after the image the first ones. - Fix: such a module (
TWOinpatch_nso.py: the text alone ends past the reach) gets two patch areas. The front one, 16 MB before the base, takes the sites belowSPLIT(128 MB less the front area and 1 MB of margin for a stub's exits past its site); the back one, right after bss, the key page and the frame step page (BACK_OFF, 64 KB aligned), takes the others. A site's stubs and their branches back stay in its own area (use_area); when the front sites need more room, the rewriter runs again with exactly that size, andB()checks every branch. The file is the image, the front area padded to 16 KB, then the back area;fixups.tsvaddsBACK_OFFandBACK_LEN, andIMAGE_SIZEcovers the back area. The Mac loader copies it toBACK_OFFand seals it read-only and executable, the iOS embedding gives it a fourth section (gen_embed.py:__TEXT,__dm<name>x2) that the loader remaps there, andsvcQueryMemoryreports it as CodeData+RX like a patch area after the image (data and bss end at it). A module that does not need it is rewritten byte for byte as before. - Found on: EA SPORTS FC 27
main(1.8d.310c): 185.6 MB of text, 177,058 sites; 3.9 MB of stubs in front and 3.2 MB atBACK_OFF0xdec0000.
- Symptom:
observation gate neither at a function entry nor after a bl;observation gate neither at a function entry, after a bl nor before a callon an epilogue. - Cause: the host may change x9–x17 in a gate, so a gate sat only where they carry nothing (a function entry, after a
bl). - Fix: a site is also accepted when the straight-line code from it to the next
blreads none of x9–x17 before writing it (scratch_dead); it is tried only after the other two rules fail.retends the code ahead as abldoes: under AAPCS64 none of x9–x17 carries a result, so the straight-line scan (scratch_dead) accepts arettoo. - Found on: DRAGON QUEST VII Reimagined: the gate on
mov x0, x19atmain+0x32758e4, after a cutbland anadrp. Pikmin 4: the gate onldp x29, x30, [sp, #0x10]right after the cut allocator constructor call,main+0x353f78c; the rule checks the image after the cuts, so theblbefore it is anopby then (theretcase). - Note: the gate table takes the original instruction as a word value (
aa1303e0), the cut table as bytes (e00313aa).
- Symptom:
jump table <addr>assertion on an ordinary byte table near the start of the text (adr x18; ldrb w0,[x17,x16]; add x18,x18,x0,lsl #2; br x18). - Cause: the module has no unwind entry before
main+0x6a75d4, andfunc_ofmade every site before the first FDE a function of one instruction, so no form saw theadrin front of thebr. - Fix: the text before the first FDE is now one function, from the end of the data block before it (or the text's start), as a gap between two FDEs already was (
func_of). - Found on: FINAL FANTASY RESONANCE 1.0.1
main(Unreal Engine 5.6.1):main+0x219234. - Check: of the 120 modules of 33 games on hand, only DRAGON QUEST VII Reimagined's
mainhas otherx18sites there (110 before+0xb310, none in a window or abr x18), and its rewrite is byte-identical.
- Symptom: the rewrite never finishes, the Mac swaps, the rewriter's footprint reaches 100 GB.
- Cause: a stretch without FDEs is one function to
func_of, and its detailed disassembly costs about 880 bytes an instruction once built (2.6 KB while capstone builds it): 21.6 million instructions. In FINAL FANTASY RESONANCE'smain, 82 MB atmain+0x6c4f88–+0x59220c0and the 6.65 MB before the first FDE have no unwind entries. Octopath Traveler 0 (71 MB), DRAGON QUEST XI S (68 MB) and DRAGON QUEST VII Reimagined (two of 23 MB) have such stretches too; they got through on a quieter Mac with the compressor and swap. - Fix: a function longer than 256 KB is now analysed in pieces (
piecesinpatch_nso.py), cut only right after aret/br/bwhere no conditional branch and nobwithin 64 KB crosses, so a window, a jump table's bounds check and table base, and a retry block stay in one piece. After all pieces, every fartherb/bland every jump table target is checked against the windows and retry blocks of all pieces. Countingbup to 1 MB left only 21,385 cut points in the 82 MB (tail calls between neighbours) and pieces of up to 6.5 MB; with 64 KB there are 185,228, and no piece is over 256 KB (the two stretches: 28 and 344 pieces). The rewrite then takes 13 minutes at about 2.7 GB resident on a loaded Mac. - Found on: FINAL FANTASY RESONANCE 1.0.1
main; also Octopath Traveler 0, DRAGON QUEST XI S: Echoes of an Elusive Age – Definitive Edition, DRAGON QUEST VII Reimagined. - Check: DRAGON QUEST VII Reimagined's
maincame out byte-identical to its rewrite by the previouspatch_nso.py. FINAL FANTASY RESONANCE's came out the same as withbcounted up to 1 MB (3 and 136 pieces), and DRAGON QUEST XI S's (292 pieces) and Octopath Traveler 0's the same as with 1 MB pieces andbwithin 256 KB (75 pieces for DQ XI S), so the analysis does not depend on where the cuts fall.
- Symptom:
KeyErroratsite_brfor abr x18that is an ordinary byte jump table. - Cause: capstone stops at the first word it cannot decode, and the function held a trap word (
0xe7ffdefe,main+0x6b34540) before the jump table, so the table was never analysed. - Fix: function disassembly now goes on after undecodable words (
disasm_fn). - Found on: Monster Hunter Rise
main+0x6b346d0.
- Symptom:
RuntimeError: 788 ldr x18, #0xd1eb0inpatch_nso.py, or any rewrite site in data. - Cause: the text segment starts with 1.7 MB of data (
main+0x0–+0x1b5000, high-entropy, no FDE covers it, and noadrpor relocation points into it): 62% of its words do not decode, and 6,858 of the rest read as instructions that usex18, plus three "svc" words with numbers such as#0x91e7. The linear sweep rewrote them all. - Fix:
dm/tools/nso_code.pyfinds such data blocks: a stretch of at least 4 KB between the.eh_frameFDE ranges in which more than a quarter of the words do not decode. The sweep skips them (DATA_BLOCKS, logged asdata blocks left alone), and so doesinsn_census.py, whose counts then match the rewriter's again (11,744x18sites in Absolum'smain). Code without unwind information is still swept: it decodes (Absolum's zlib atmain+0x75261c, libunwind's register restore atmain+0x28dbd4). - Found on: Absolum
main(.NET NativeAOT). - Check: no module of the 19 other games on hand has a data block, and their rewrites are byte-identical.
- Symptom:
RuntimeError: 44 ldr x18, #0xfffffffffff40370while rewriting an NRO. - Cause: an NRO's text segment includes its metadata header. The arbitrary build ID at
+0x40can decode as instructions;magic_0898_2015.nrocontains a literal load intox18at+0x44. The ordinary 4 KB data-gap heuristic cannot exclude this 120-byte header. - Fix:
nso_code.data_blocksexplicitly excludes+8..+0x80when the image has theNRO0header. The initial entry branch and MOD0 pointer remain intact, and scanning resumes at the first possible function. NSO scanning is unchanged. - Found on: FINAL FANTASY X/X-2 HD Remaster, 1,432 registered NROs.
- Check:
test_nro_header.pyruns the real rewriter on synthetic NROs whose build IDs decode as anx18load orbr x18, checks the header byte-for-byte, and checks that an actualx18instruction immediately after it is rewritten. Code before the first unwind entry is covered too: per-function analysis must not reenter the header even when the linear scans exclude it.
- Symptom:
jump table <addr>assertion inpatch_nso.py, in a Unity / IL2CPPmain, at analloca(mov sp, x18). - Cause: the compiler wrote
spfromx18for analloca(mov sp, x18). - Fix: the rewriter restores its scratch registers relative to the old
sp(site_sp). - Found on: Fire Emblem Engage
main: 1spwrite (beside 7 jump tables and 1 call). - Check: Super Mario Bros. Wonder's output stays byte-identical.
- Symptom:
CRASH sig=10 addr=0on anstprelative tosp, withspnot a multiple of 16 (main+0x2bbba4,sub sp, sp, #0xc0thenstp d15, d14, [sp, #0x60]with sp ending in…9dc). - Cause: Horizon leaves stack alignment checking off for user code; Apple's kernel faults on an sp-based access while sp is misaligned. The game's script engine calls native functions through six hand-written bridges (
main+0x17c140and five more beside it): each reserves the callee's stack arguments withsub sp, sp, x19, x19 being their sizes added up (4 for one int,[x0 + 0x238]), and gives them back withadd sp, sp, x19after the call, so the callee ran with sp 4 bytes off. - Fix: the rewriter now sends every
sub sp, sp, xN(extended-register form, no shift) through a stub that rounds xN up to 16 first (site_sp_align); theaddthen gives back the same amount. Compiled code keeps such sizes 16-aligned, so there it changes nothing.insn_census.pycounts the sites assub sp, sp, xN. - Found on: Paper Mario: The Thousand-Year Door
main(its AngelScript call bridges). - Check: of the 22 games' modules on hand only this
mainhas the form (6 sites), so every other rewrite is byte-identical.
- Symptom: SIGILL / trap on a system register read.
- Cause: a newer SDK reads a register macOS traps (
CTR_EL0in SDK 16'sFlushDataCache). - Fix: rewrite to a constant (
patch_nso.py, thectrsite kind).
- Symptom: every pointer in
.data/ the GOT is a module offset, crashes everywhere. - Cause: SDK 20 modules pack their relative relocations as
DT_RELR(tag 36, size tag 35):mainhas 223,994 of them and itsDT_RELAonly symbol relocations. Each 8-byte entry is either an even offset (relocate it, move to the next slot) or an odd bitmap whose bits 1–63 stand for the following 63 slots; the addend is the slot's own value. - Fix:
relr_applyinloader.c;DM_LOADHASHandnvn_slots_got.pyread it too. The loader's log line counts them asrelr=. - Found on: The Legend of Zelda: Breath of the Wild 1.9.0 (SDK 20).
- Symptom:
regress.py checkstops before gameplay withCUTS of build ... in both ..., naming a revision profile and its copied harness profile. - Cause: shader builds use a writable profile copy for generated tables. Passing that copy to the rewriter exposes its
patches.pyalongside the revision's original, registering every cut twice. - Fix: registered games rewrite with the revision's original profile; provisional games retain their external profile path. Shader builds continue using their writable copies. The harness regression covers both selections.
- Found on: NieR:Automata geometry review, when comparing rewrites after reconciling PRs #336 and #416.
Rewrite every extracted module of every title with the old and the new patch_nso.py and compare all outputs; only build_src.sha256 (the rewriter's own hash) may differ for titles the change is not meant for.