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: after a game update, the Cheats menu lists only
Cheats for build … (another version of the game), and nothing runs. Or a cheat file converted to the new build by hand writes into the wrong place: a table instead of a variable, or code that moved. -
Cause: Atmosphère cheats name fixed offsets into the main NSO, valid for the one build the file is named after. An update moves code and data by different amounts in different parts of the image; a single shift is a guess.
-
Fix:
scripts/cheat_anchor.pyanchors each address to the game's code and compiles the cheats for any build (Cheats that survive game updates):- data through the adrp pairs (and relocated
.dataslots) that reference the nearest address at or below it; - code sites through
nso_match.py's function fingerprints, then the tool's own fallbacks (the body digest, then a unique masked window), with a code site's original words always checked; - ASM code caves through the padding before the read-only data.
The result is plain Atmosphère text, so it runs on any engine.
- data through the adrp pairs (and relocated
-
Found on:
- FINAL FANTASY CRYSTAL CHRONICLES Remastered Edition: the 1.0.1 cheatslips file, anchored on the cartridge's
main, compiles to exactly the hand-verified +0x43680 on 1.0.3 (12 of 12 cheats, 4 of 4 references each). The 1.0.3 file (5 ASM cheats with a code cave, 7 stat cheats, Mog's fatigue) compiles back to identical writes on 1.0.3 and to all 18 entries on the cartridge's build (code sites: 4 exact, 3 by body digest, 2 by window). - 13 Sentinels: Aegis Rim: a 1.10 Ryujinx list compiles back to identical writes and to 1.00, where every site lies 0x330 lower and the hook site was found by its body digest.
- FINAL FANTASY CRYSTAL CHRONICLES Remastered Edition: the 1.0.1 cheatslips file, anchored on the cartridge's
- 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 inpatch_nso.pywhere the index is made with a 64-bitandby0xffffffffafter the check (cmp w8, #4; b.hi; and x18, x8, #0xffffffff; ldrsw x18, [x16, x18, lsl #2]; add x18, x18, x16; br x18). - Cause:
jump_offsetstook theandas a mask and looked for the same mask on the checked register, which has none, so no form matched. - Fix: a 64-bit
andwith0xffffffffzero-extends the source's low 32 bits.jump_zero_extendedrecognizes a 32-bitcmpof that source followed byb.hiout of the dispatch. The source stays unchanged up to the mask, the index up to the load, the loaded entry and base up to the add, andx18up to the branch. Calls, system calls, traps, other intervening control flow, undecodable gaps, and known entries past the check are rejected; the table must fit in the image and every target must be a decoded instruction outside the guarded run (or its check). - Check: this is a separate fallback after every earlier recognizer fails. Adding the rule inside
jump_offsetsbefore its existing searches could change a previously recognized table's extent by selecting a nearer compare. The fallback preserves those earlier results; synthetic full-rewriter tests cover both the new five-entry form and an existing eight-entry match with a nearer five-entry check. - Found on: Mario Tennis Aces (0100BDE00862A000)
main+0x35837c(1.0.0), a five-entry table whose targets all lie in the function (PR #778). The contributor reported thatmain,subsdk0andsdkrewrite with zero-extension recognition; gameplay validation remains open.
- 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:
AssertionError: jump table 30ee50cwhile rewriting an ADR-relative dispatch withcmp x9,#4. - Cause:
jump_adr_offsetsrecognized only 32-bit bounds comparisons, although a comparison of the full 64-bit index also bounds the table. Its backward copy tracking could also retain a destination whose old value had been overwritten. - Fix: accept either comparison width for the tracked index. When following a 32-bit index copy backward, replace the destination with its source, so an earlier check of the overwritten destination cannot justify the load. Other writes still invalidate the match; table and target validation are unchanged.
- Found on: DRAGON QUEST I & II HD-2D Remake,
main+0x30ee50c(five targets). - Check:
dm/tools/tests/test_jump_copies.pyexercises both comparison widths, unrelated checks, overwritten indices and a bounds check on the copied source with real AArch64 instructions. - Open: as in the existing 32-bit form, this recognizer assumes the comparison has an effective unsigned bounds guard; it does not prove guard/flag validity or that every incoming path passes the check. This game's actual dispatch has
b.hiwith unchanged flags. Validating those branches remains separate from this fix.
- Symptom:
AssertionError: jump table 3ce6cwhile rewritingmain, on an ADR-relative dispatch that the existing form describes:ldrb w0, [x18, #5]; cmp w0, #5; b.hi; ldr s0, [sp, #8]; ldr s1, [x18, #0xc]; adr x18, #L; ldrsw x1, [x17, x0, lsl #2]; add x18, x18, x1; br x18. - Cause: the backward walk from the load to the bounds check refuses the site when an instruction writes the index. It named each written register
'w' + name[1:], soldr s0became a write ofw0, the index, andjump_adr_offsetsgave up.half_count,check_before_copyand the masked form ofjump_offsetshad the same test. It also failed the other way: capstone'sreg_namecalls x30lrand x29fp, so a real write of an index in either went unseen.jump_masked_notmissed those writes too. - Fix: all of them ask
written_w, which names only general-purpose registers, withlrandfpas w30 and w29. A vector or floating-point register (s,d,q,b,h,v) never changes the index. - Found on: Tomodachi Life: Living the Dream (
010051F0207B2000) 1.0.0,main+0x3ce6c(six targets, table atmain+0x2a5967c), by the pull request's author: with the fix the game rewrites, packs as a Mac app from a provisional profile and boots to its first screen. The other forms were found by reading the code and shown on synthetic sites. - Check:
dm/tools/tests/test_jump_copies.pyputs vector loads and x29 / x30 writes between the check and the load in each form, includinghalf_count.
- Symptom: no failure seen on a game. The rewriter could read a jump table with the wrong size or base, so a case's target or landing pad would be missing or wrong. In the other direction, a halfword table whose base is in x30 got all 65,536 landing pads (
Jrows with 65536 inmain.sites.tsv), which only makes the patch area larger. - Cause:
jump_offsetsandhalf_countfollowedmov w9,w0as a copy into the index but keptw9as the index too, socmp w9,#4before the copy counted as its check. The table-base writer chains andjump_adr_offsets' search for theadrcompared written names withxN, so a write ofwN, which replaces the register as well, went unseen, as didlrandfp. A compare ofxNcounted as a write there. - Fix: a copy replaces the index, as
jump_adr_offsetsalready did. Those searches askwrites_reg, which sees either width and thelr/fpnames and leaves compares out.jump_offsetsalso refuses a site whose index is written after its check, such asadd w18,w18,#1, or a caller-saved index across a call. A write that keeps the value, such assxtw x0,w0, is refused too: no game on the drive has one, and a refusal stops the rewrite with ajump tableassertion instead of reading the table wrongly. - Found on: reading the code while fixing Vector registers read as writes of the jump-table index. Of the 51
mains that rewrote in a comparison of old and new, only Absolum's changed: its four tables atmain+0x1ed2dc,+0x1ed4fc,+0x1ed71cand+0x1ed8cc, based in x30, went from 65,536 pads to 445, 373, 301 and 257, each its table's largest entry + 1, and the patch area from 3.7 MB to 570 KB. - Check:
dm/tools/tests/test_jump_copies.pyCopyAfterCheck,IndexChangedAfterCheck,HalfCount,test_table_base_sees_every_write_of_the_registerandtest_a_32_bit_write_after_the_adr_replaces_its_value.
- 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, orjump table 108bb70on Ori and the Blind Forest: Definitive Edition. - Cause: a framed IL2CPP method loads a method pointer into
x18(ldr, orldpwith the next field), moves its arguments, restores its frame and executesbr x18, with code after thebr: error blocks in a small thunk, the function's other paths in a larger one. The terminal-tail recognizer requires the jump to end the function. - Fix: keep the terminal rule, which proves no frame, unchanged. For a
br x18with code after it,tail_callproves that the frame is gone, because a jump inside a function needs that function's frame. A wrong accept turns a jump table into a call without any error, so what is not proven stays atjump table <addr>. It holds for both frames:- Pointer: the last write of
x18isldr x18, [xN, #imm]orldp x18, xM, [xN, #imm]. - The path to the
br: no call or jump after that load or the frame restore, whichever is first, no branch or store after the restore, and no branch from anywhere lands past the restore. A guard may land before it: real code skips a reload of the pointer that way. - Frame registers: nothing else on the way writes
sp,x29orx30, except thatx29may be set once to the saved pair's address, and no branch re-enters the frame's setup. - Stack stores: a fixed offset from
spor the setx29, whose bytes stay clear of the saved pair; a register offset or writeback is rejected, and so is any exclusive or atomic access, whatever its base, which the rule does not model. - After the
br: no branch comes back to thebror ahead of it, and no direct call lands in the function. - A 16-byte thunk (
stp x29, x30, [sp, #-0x10]!; mov x29, sp, restored by the instruction before thebr) proves more, as Warm Snow's does: no calls, no stores through other bases, every guard goes to the code after thebr, at least one does, and that code is error blocks that hold a call out of the function, end in a call, a trap or a branch among themselves, and hold noret,brorblr. - A function (
sub sp, sp, #Nfirst,add sp, sp, #Nright before thebr,x29, x30saved to and restored from one slot inside the N bytes) is ordinary compiled code: its body may branch, loop and call, as IL2CPP's lazy init does, its stores through other bases cannot be proven apart from its callees' and are allowed, and any of its other paths may follow thebr. A jump through another register in its body has unknown targets and is rejected.
- Pointer: the last write of
- Found on: Warm Snow 2.1.4,
mainbuild306fc054203275803dcbceba7fb7c392, at+0x53e9c. Ori and the Blind Forest: Definitive Edition,mainbuild228b944fcb08ae56180369be66727558, at+0x108bb70,+0x108ca70,+0x13ff8f0,+0x14008a0. - Check:
dm/tools/tests/test_tail_call.pycovers both forms and rejects each broken proof with synthetic AArch64 instructions; no game assets are required by the tests. The function form was measured on real framed tail calls through other registers in Unity mains, whose shapes it must accept.
- 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 rewriter assumed the host may change x9–x17 in a gate. So a gate was placed only where they carry nothing: a function entry, after a
bl, or (from 2026-10-22,scratch_dead) where the straight-line code ahead writes them before reading them. The assumption never held. Since the first host, the gate frame (dm/host/entry.S,_dm_gate) has saved x0–x28, the vector registers and the flags and restored them, and the gate block borrows only x29 and x30, which it restores from the stack. - Fix: since 2026-10-31 there is no placement rule: any instruction
site_obscan relocate takes a gate. A register changes only when the callback writes it (OBS_STDALLOC_INIT's x2,OBS_UE_REGION's size register and x1). - Found on:
- DRAGON QUEST VII Reimagined: the gate on
mov x0, x19atmain+0x32758e4, after a cutbland anadrp. - Pikmin 4: the gate on
ldp x29, x30, [sp, #0x10]right after the cut allocator constructor call,main+0x353f78c. - Both gates were removed on 2026-10-30: the engine-wide allocator rule puts its gate on the cut call itself (Unreal's FMallocBinned3 replaced by the host allocator).
- The engine region's gate sits on a store that reads x10 (
str x1, [x10, #0x280], Octopath Traveler 0main+0x12e20), which the old rule refused: Unreal's engine address region and its options.
- DRAGON QUEST VII Reimagined: the gate on
- 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.
- Symptom: packing spends most of the rewrite step on the x18 scan and the spin-wait search, using one core per module. Cancelling a parallel rewrite can leave its workers running;
DM_REWRITE_J=0or a negative value can silently omit spin gates, and the NSP script can ignore an explicit worker limit. - Cause: both searches analyse an unchanged image, but ran sequentially. A default SIGTERM exits without unwinding the pool's context manager. The spin search's job count assumed a positive worker count, and the NSP script always replaced the caller's setting with its automatic budget.
- Fix:
patch_nso.pyscans code ranges in 1 MB pieces and distributes spin candidates across forked workers. Each pool inherits the image and analysis state at that pass; workers return addresses, and the parent rebuilds accepted spin regions in address order before emitting patches. SIGTERM exits through Python's cleanup so the pool terminates and joins its children.DM_REWRITE_Jmust be a positive integer (1runs serially), checked before loading modules; the NSP script honours an explicit value and otherwise chooses its budget from the core and module counts. These changes apply to every NSO and NRO; patch selection and runtime semantics stay the same. - Found on: Breath of the Wild.
- Check: synthetic tests cover SIGTERM during active pool jobs, invalid counts and explicit worker limits through the packing script. The original speed measurements and module comparisons are in PR #761; their timings are contributor measurements, not local regression results. Local game validation is recorded in Breath of the Wild's note; other games and NROs have no new local runtime validation here.
- Symptom: a rewrite never ends, using no CPU:
patch_nso.pywaits inwait4and one forked worker of the same command insem_wait, inmultiprocessing/queues.pyget, having run no job. It is rare and needs small jobs (about 1 in 6,000 pools of tiny jobs under load). Separately, a SIGTERM could be ignored until the rewrite ended, or leave workers behind. - Cause: workers inherited the Python SIGTERM handler that lets the parent exit through the pool's context manager.
Pool.terminatetakes the job queue's lock for good, sends each worker one SIGTERM and joins it with no timeout, so it needs that signal to end the worker. A handler only sets a flag, and Python clears flags in a new child, so a worker just forked lost the signal, then waited for that lock forever. Resetting SIGTERM in the pool's initializer alone does not help: the signal can land before the initializer runs. In the parent, the handler'ssys.exitcould also be raised during the pool's teardown, where it stopped a terminate halfway or was discarded in a destructor. - Fix:
in_parallelblocks SIGTERM across the fork, which keeps it pending in the kernel, and each worker restores the default action before unblocking it. In the parent SIGTERM acts only while waiting for results. A finished pool leaves bycloseandjoin, so no SIGTERM is sent. - Open: cancelling a rewrite still ends in
Pool.terminate, and a worker it kills while sending a result never releases the result queue's lock, so the parent can hang (Python's own limit; 2 in about 25,000 cancellations of a process doing nothing but pools, far rarer in a real rewrite, which spends its time in analysis). A rewrite that finishes never reaches it. - Found on: the Python tool tests (
discover -s dm/tools/tests), which hung for 2 hours in a test's small NRO; no game was seen to hang. - Check:
test_rewrite_parallel.pychecks that SIGTERM is blocked from the fork on and that a worker has the default action unblocked before any job, and sends SIGTERM during pool start and during teardown. The race itself is too rare and too slow to repeat in a test.
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.