Files
nxnative-easy/dm/tools/patch_nso.py
T
seedds d34e0446aa Pokémon Legends: Z-A plays on the Mac: Trinity packs through the game's Oodle, transform feedback, stencil views, the base instance in the driver segment
- Profile 0100F43008C44000 (2.0.2, SDK 19.3.11): 5 GB layout with two cuts (StandardAllocator 12 → 4 GB, node area), mem_map_limit (the pool enforced as on Horizon), shader_oodle.
- Shaders: Trinity packs (arc/data.trpfs, FlatBuffers, Oodle Mermaid) decoded with the game's own OodleLZ_Decompress in the Mac host; 28,603 shaders.
- SDK 19: GetAudioOutBufferCount, crypto random bytes, ToPosixTime with a rule, NVN state getters, CommandBufferFenceSync, the 64-bit WaitForAddress type.
- Graphics: transform feedback (vertex capture entry point, rasterizer discard), layered passes (the colour grading LUT), point size dropped beside a layer output,
  function constants specialized, a stencil-only view for STENCIL-mode texture views (striped lighting), sampler min / max reduction (Metal 26),
  the draw's base instance at c0 + 0x644 (occlusion boxes; characters' legs folded back from the hips), the zero cube for a 2D texture in a cube slot.
- Shader chain changed (dmsh_build.py): every profile's dmsh_chain.h rewritten; the other profiles' c1_full lists carried over (that change touches no c1 read).
2026-10-21 03:47:16 +08:00

1078 lines
83 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Generic NSO rewriter: Switch aarch64 module → an image that can execute natively on Apple systems.
# Usage: patch_nso.py <NSO file> <output dir> <module name>
# Rewrites (the Apple kernel modifies x18 and tpidr_el0; Horizon's thread area, svc and timers have no counterpart on Apple):
# x18 read/write → thread slot K18 (TSD, via tpidrro_el0)
# mrs tpidr_el0 → read thread slot KTLS; msr tpidr_el0 → write KTLS
# mrs tpidrro_el0 → read thread slot KTLR (Horizon ThreadLocalRegion pointer)
# svc #n, mrs cntpct_el0 → host gate (gate address taken from thread slot KGATE; once in C, gates.tsv is looked up by return point)
# Registered cuts (CUTS, restricted by NSO build ID): before rewriting, the original bytes are asserted one by one and any mismatch aborts; results are listed in <name>.cuts.tsv
# Registered observation gates (OBS, restricted by build ID): one instruction at the entry is replaced with "enter the host observation callback → execute the original instruction → jump back", so diagnostics modify zero code at runtime; listed in <name>.obs.tsv
# The key page only stores "actual key number × 8" at the end of the data segment, and patch code fetches thread slots indirectly through the key page: the loader may use any key number.
# Output: <name>.patched.bin (key page and patch area appended after the image at PATCH_OFF; when that would start beyond 112 MB the patch area sits before the base, see FAR), <name>.fixups.tsv (layout and key page index header), <name>.gates.tsv, <name>.sites.tsv, <name>.cuts.tsv, <name>.obs.tsv
import struct, re, sys, os
from itertools import combinations
import lz4.block
from capstone import *
src, outdir, NAME = sys.argv[1], sys.argv[2], sys.argv[3]
d = open(src, 'rb').read()
assert d[:4] == b'NSO0'
flags = struct.unpack_from('<I', d, 0xc)[0]
segs = [struct.unpack_from('<III', d, 0x10 + i * 0x10) for i in range(3)]
bss = struct.unpack_from('<I', d, 0x3c)[0]
csz = struct.unpack_from('<III', d, 0x60)
# Every jump between the patch area and the code is a B (±128 MB). A patch area appended after the image (with bss) cannot reach the code when it would start beyond 112 MB (leaving 16 MB for the patch area): Metroid Dread's main has about 1.8 GB of bss (the engine's static heap); Engage's main starts its patch area at about 103 MB and keeps it after the image.
# The patch area then sits FRONT bytes before the module base (PATCH_OFF is negative), and the output file holds only text + ro + data (FILE_IMAGE_LEN) followed by the patch area; the loader supplies zero pages for bss and the key page (IMAGE_SIZE is the whole image).
# The key page stays after bss (the patch area reaches it through ADRP, ±4 GB). Modules below the limit produce output byte-identical to before
NEAR_PATCH_OFF = ((((segs[2][1] + segs[2][2] + bss + 0x3FFF) & ~0x3FFF) + 0x4000 + 0xFFFF) & ~0xFFFF) # where an appended patch area would start (as in the else branch below)
FAR, FRONT = NEAR_PATCH_OFF > 0x7000000, 0x2000000
# A text segment that ends within 32 MB of the ±128 MB reach (Monster Hunter Rise: 125.3 MB of text) puts the patch area right before the base instead. Its stubs follow the sites in address
# order, so the last site's stub ends up about as far from it as the text is long. The first pass tries the gap that keeps every site in reach; when the patch area does not fit there
# (Monster Hunter Rise: 3.6 MB), the rewriter runs again with exactly the size it needs (DM_PATCH_FRONT, set only by that rerun) and B() checks every branch
BIG_TEXT = FAR and segs[0][2] + FRONT > 0x8000000 - 0x10000
if BIG_TEXT: FRONT = int(os.environ.get('DM_PATCH_FRONT', '0'), 0) or (0x8000000 - 0x10000 - segs[0][2]) & ~0xFFFF
IMG = bytearray(segs[2][1] + segs[2][2] + (0 if FAR else bss))
for i, (fo, mo, sz) in enumerate(segs):
raw = d[fo:fo + csz[i]]
if flags >> i & 1: raw = lz4.block.decompress(raw, uncompressed_size=sz)
IMG[mo:mo + sz] = raw
TEXT_END = segs[0][2]
RO_OFF, DATA_OFF = segs[1][1], segs[2][1]
if FAR:
FILE_IMAGE_LEN = (len(IMG) + 0x3FFF) & ~0x3FFF
KEY_PAGE_OFF = (len(IMG) + bss + 0x3FFF) & ~0x3FFF
IMAGE_SIZE, PATCH_OFF = KEY_PAGE_OFF + 0x4000, -FRONT
else:
KEY_PAGE_OFF = (len(IMG) + 0x3FFF) & ~0x3FFF
IMG.extend(bytes(0x4000))
PATCH_OFF = (len(IMG) + 0xFFFF) & ~0xFFFF
# ---- Registered cuts: {NSO build ID: [(offset, original bytes, new bytes, reason)]} ----
CUTS = {
# Super Mario Bros. Wonder v0 main
'cd6e42aee7934f4d8393cd43893ae25e': [
# Resource heap slack: each resource unit's ExpHeap is created with the resource size table value + 0x1008 (main+0x1e603c → ExpHeap::create main+0x4aac38); the table holds Nintendo's measured peaks with almost no margin
# (PlayerYoshiAnm uses 99%). On the host, Model/NpcCommonerA.bfres in the opening course Course900 is about 0x70 bytes short: after the model resource's last GPU chunk (MatBlock,
# 0x9000 bytes, 4 KB aligned, main+0x4290c0) is placed, its 0x598-byte descriptor does not fit, the GPU allocation fails → a buffer is built on a null memory pool → null read crash at main+0x106f5c8.
# Where this differs from hardware is not known (every allocation size before this block is determined by game code and data); for now every resource heap gets +16 KB.
(0x1e62fc, 'e00317aa', 'e0124091', 'resource heap size: mov x0,x23 → add x0,x23,#0x4000 (ExpHeap::create size argument +16 KB)'),
],
# Super Mario Bros. Wonder 1.2.1 (v327680) main: same as above. Location carried over from v0 by dm/tools/nso_fnmap.py (containing function 0x1e603c → 0x1cf6f0 identical after address masking, ExpHeap::create 0x4aac38 → 0x4c4a18)
'ff773e90972d544eb79406eaa65396d5': [
(0x1cf9b0, 'e00317aa', 'e0124091', 'resource heap size: mov x0,x23 → add x0,x23,#0x4000 (ExpHeap::create size argument +16 KB)'),
],
# Fire Emblem Engage 2.0.0 (v327680) main: run the game's own 60 fps path (vertical sync count always 1).
# The game's time class App.GameTime picks a vertical sync count per scene (Slow = 2, i.e. 30 fps: system, movies, demos, combat cutscenes, the hub; Normal is 1 only when GameConfig.IsPerformance)
# and hands it to Unity through GameTime.SetVsyncCount(int) (QualitySettings.vSyncCount → NVN present interval). About 96 pieces of per-frame logic (cameras, map cursor, unit actors, menu scrolling and cursors, hub camera, minigames)
# step by GameTime.VsyncFixedCount (= vSyncCount, how many 60 Hz ticks each frame advances): with a count of 2 and 60 frames on screen they all run twice as fast, with a count of 1 at 60 fps they are right.
# The real-time VsyncDeltaCount / VsyncDeltaTime (hub character movement, fishing, dragon ride and so on, about 120 places) do not depend on the frame rate.
# The cut is in Unity's managed wrapper QualitySettings.set_vSyncCount (main+0x32f92c0): both paths use w19 as the argument, mov w19,w0 → mov w19,#1,
# so every caller (GameTime, QualitySettingsStack.PopImpl, XRSystem) can only set 1; the read side (get_vSyncCount) follows at 1 and the game stays consistent. No code calls SetQualityLevel.
'8c08b9719e085f91847b5e0f935d9488': [
(0x32f92d4, 'f303002a', '33008052', 'QualitySettings.set_vSyncCount wrapper: argument mov w19,w0 → mov w19,#1 (the 60 fps path of the game itself)'),
],
# The Legend of Zelda: Breath of the Wild 1.9.0 (v1114112) main: 60 fps. The main loop of sead::GameFrameworkNx (main+0x112660c) runs a dynamic interval (framework +0x267 = 1):
# every frame it computes how many vsyncs the last frame needed (rounded up), clamped to [2, max 3] (the max comes from the interval controller +0xc, constructed at main+0x1126c04); it steps up after 3 overrunning frames in a row,
# steps down one level every 6 frames until 2, and hands the result through setWaitVsyncInterval (vtable +0x130, main+0x11269b8) to presentation (nvnWindowSetPresentInterval).
# The game logic time step follows it: ksys::VFR is initialized with a base interval of 2 (constant 2 at main+0x127e740 → VFR::init main+0x151d2d0), and every frame VFR::updateInterval (main+0x151d3d0,
# called at main+0xcf9ad4 / main+0x1281044) takes the framework's current interval (vtable +0x138); the frame ratio = current interval / 2 -- at interval 1 each frame advances half a 30 fps tick and the game speed stays the same;
# when it struggles and steps back to 2 / 3 the ratio is 1 / 1.5, again at the same speed. So only the floor changes from 2 to 1: the lower bound of the candidate interval (two places), the step-down target recorded when stepping up, max(candidate, 3) - 1 → max(candidate, 2) - 1,
# and stepping down stops at < 3 → < 2. On hardware the same logic starting from 1 would also be 60 fps; it falls back to 30 by itself only when its input shows a slow frame (the last two cuts make that input include the GPU time).
# With the frame step (FRAME_STEP below) the host's step replaces the controller's interval, except while the game's own VFR override is set (and at fps_target 0): the framework then keeps the controller's
'cd57b23fa4bbad65803d9788c01821ee': [
(0x112670c, '6041201e', '4041201e', 'lower bound of the candidate interval: fmov s0,s11 (2.0) → fmov s0,s10 (1.0)'),
(0x1126710, '7f0b0071', '7f070071', 'lower bound of the candidate interval: cmp w27,#2 → cmp w27,#1'),
(0x112665c, '78008052', '58008052', 'step-down target recorded when stepping up, max(candidate, w24) − 1: mov w24,#3 → mov w24,#2'),
(0x1126748, '1f0d0071', '1f090071', 'step-down target recorded when stepping up: cmp w8,#3 → cmp w8,#2'),
(0x1126794, '3f0d0071', '3f090071', 'stepping down stops at < 3 → at < 2: cmp w9,#3 → cmp w9,#2'),
# The controller's input (framework +0x250, controller main+0x1126cec..) is the main thread's CPU span up to the draw step (about 2.7 ms in the first shrine), so GPU-bound frames stayed at interval 1 and the game ran slow
# (iPhone 16 Pro Max: 25 ms frames, two thirds of the speed). The game has a GPU-time path that uses max(CPU span, GPU time between the frame's two timestamp reports, framework +0x220, read after the fence wait)
# but takes it only while [main+0x1e3a638]+0x1d2b8 is set (64 of 6,046 shrine frames); both of its checks become branches into it. The GPU time needs the profile's gpu_timestamps segment
(0x112691c, 'c00000b4', '03000014', 'interval controller always takes the GPU time path (CPU span timing): cbz x0,#0x1126934 → b #0x1126928'),
(0x1126974, 'c00000b4', '03000014', 'interval controller always takes the GPU time path (takes max(CPU span, GPU time)): cbz x0,#0x112698c → b #0x1126980'),
],
# Fire Emblem Engage 2.0.0 (v327680) sdk: AddressRegionManager node area 0x3fe00000 (about 1 GB) → 0x3e00000 (62 MB).
# The constructor (sdk+0x346420) carves the node area out of the start of the alias region (a unit heap of 0x30-byte nodes, mapped only as used) before AllocateAddressRegion hands out regions.
# The hardware alias region is 64 GB, so the 1 GB does not matter there; the host's alias region is only 5.375 GB (iOS caps a single reservation at 6 GB), and after StandardAllocator's 3.14 GB block (1 GB aligned)
# Unity's low-level virtual allocator (main+0x9aa0f4 → AllocateAddressRegion) had only about 1.2 GB left; about 357 s into the prologue battle it got no region → returned 0 → null write crash at main+0x9a4664.
# 62 MB holds about 1.35 million nodes (a few hundred regions are live in measurements). Both movs must change together: +0x346430 sets the node area limit, +0x3464cc registers it as an occupied region.
'56ec24747ec9e7f19b3efb062483a19d': [
(0x346430, '08fca752', '087ca052', 'AddressRegionManager constructor: node area limit mov w8,#0x3fe00000 → mov w8,#0x3e00000'),
(0x3464cc, '09fca752', '097ca052', 'AddressRegionManager constructor: length of the node area registered as an occupied region mov w9,#0x3fe00000 → mov w9,#0x3e00000'),
],
# Ace Attorney Investigations Collection 1.0.0.2 (v131072) sdk (SDK 17.5.3): the same node area cut as Engage's. Without it nninitStartup's StandardAllocator::Initialize(nullptr, pool − used)
# (main+0x874b6c, virtual address memory) aborts at its first CentralHeap::Init: the 1 GB-aligned region of 3.14 GB did not fit in the 4.375 GB alias region after the node area.
# The constructor is at sdk+0x139458 (limit = alias start + 0x3fe00000 at +0x28, a unit heap of 0x30-byte nodes, the area then registered as occupied)
'311deab1f062b504f3d646a49ddcd4e8': [
(0x139468, '08fca752', '087ca052', 'AddressRegionManager constructor: node area limit mov w8,#0x3fe00000 → mov w8,#0x3e00000'),
(0x1394fc, '09fca752', '097ca052', 'AddressRegionManager constructor: length of the node area registered as an occupied region mov w9,#0x3fe00000 → mov w9,#0x3e00000'),
],
# Pokémon Legends: Z-A 2.0.2 (v393216) sdk (SDK 19.3.11): the same node area cut. The constructor is at sdk+0x1423ec (limit = alias start + 0x3fe00000 at +0x28, a unit heap of 0x30-byte
# nodes, the area then registered as occupied at +0x142468). With main's allocator region below, the guest block fits the 5 GB proven on a phone
'df7a3960c74c3a4803f826c587153252': [
(0x1423fc, '08fca752', '087ca052', 'AddressRegionManager constructor: node area limit mov w8,#0x3fe00000 → mov w8,#0x3e00000'),
(0x142468, '09fca752', '097ca052', 'AddressRegionManager constructor: length of the node area registered as an occupied region mov w9,#0x3fe00000 → mov w9,#0x3e00000'),
],
# Pokémon Legends: Z-A 2.0.2 (v393216) main: nninitStartup (main+0x1b2e650) initializes its StandardAllocator with virtual address memory over 12 GB of address space (main+0x1b3e3e4), which
# the Switch's 64 GB alias region holds and an iPhone's single 6 GB reservation cannot. The allocator commits pages as it goes and the pool (3.2 GB) bounds what it maps (profile mem_map_limit);
# its largest block is nninitStartup's own probe of pool − used (3.15 GB, allocated and freed at once), so 4 GB of address space holds it
'b1f12fd919eae86ab8a978317677e64b': [
(0x1b3e3e4, '6200c0d2', '2200c0d2', 'StandardAllocator::Initialize(nullptr, size): address space mov x2,#0x300000000 → mov x2,#0x100000000 (12 GB → 4 GB)'),
],
# The Legend of Zelda: Link's Awakening 1.1.0 (v131072) main: the physics step (main+0x15730, main thread) rebuilds each world's PhysX completion task in place
# (a PxLightCpuTask at world+0xc8) and passes it to simulate. A worker runs it (main+0x10ccf80: fetchResults, world+0x1d8 done = 1, notify, unlock) and then calls its
# release (main+0x10cd070), which calls the task's own destructor: vtable back to the PxLightCpuTask base, mTm = 0 (main+0x10cced0). At 30 fps the game steps twice
# per frame (step, wait, step, wait); the unlock hands the world's mutex to the woken main thread, and when the worker is descheduled there the main thread's
# next step rebuilds the task before the late destructor runs, which then wipes the new task. The next simulation's NpSceneCompletion::release reads its mTm = 0:
# crash at main+0x10ccf28 (lr main+0x13c236c; seen on the iPhone and in 2 of 5 Mac runs, in every Mac run under CPU load). On the Switch the release follows the
# unlock within nanoseconds. The release becomes a plain ret: the destructor only resets the vtable and mTm (no resources), the step rebuilds every field, and this
# release is used by this one vtable (main+0x1c51308 slot 7) only
'909e904af78ac1b8deefe97ab2ccdb51': [
(0x10cd070, '080040f9', 'c0035fd6', 'physics completion task release (calls its own destructor): ldr x8,[x0] → ret (no longer destroyed; the step function rebuilds it in place every step)'),
],
# Fire Emblem: Three Houses 1.2.0 (v327680) main: 60 fps. KTGL runs the game on the main thread at 60 updates a second whatever it shows: each update steps by
# 1/fps with fps = 60.0 (main+0x1a38f08, set at main+0x50e114; dt at main+0x510010), and the frame end (main+0x50f680) waits out 60/fps ticks of 1/59.94 s in a yield loop
# (main+0x5be430). The render thread (main+0x50f8c0) draws the latest update and presents with the interval of the render mode (frame +0xbb84, set at main+0x50f3d0:
# 0 = 1080p docked, 2 = 720p handheld; 3 / 5 their two-view variants; 1 and 4 are 720p / 1080p modes at interval 1 that nothing selects). Measured on the Mac: 59.2 updates
# and 29.8 presents a second. The per-mode interval table (int at main+0xd13de0, read by main+0x4b0420 for the present and by the 6-tick fade counter in main+0x4b7530;
# float at main+0xd13e40, the per-frame scroll step in main+0x4c2b30) becomes 1 for every mode, as in modes 1 and 4: every update is shown and the render thread's
# own steps stay at the same speed. Resolutions stay per mode
'89048449ba238c8cf565518b83bf02d3': [
(0xd13de0, '020000000100000002000000020000000100000002000000', '010000000100000001000000010000000100000001000000', 'per-render-mode present interval table {2,1,2,2,1,2} → all 1 (60 fps; the game logic already runs at 60 updates a second)'),
(0xd13e40, '000000400000803f00000040000000400000803f00000040', '0000803f0000803f0000803f0000803f0000803f0000803f', 'per-render-mode interval as float {2.0,1.0,2.0,2.0,1.0,2.0} → all 1.0 (render-thread per-frame steps)'),
# The render thread still presents with interval 2 while either of two flags is set: main+0x21c9a03, a full-screen image drawn before the frame (main+0x4b4908: the opening movie),
# and frame +0xc55f / +0xc560 (from the object at app +0x40: the character creation screen after the movie, and so on). In the engine's own 60 fps modes this traded 60 fps for
# resolution in those scenes (main+0x50f7d0 drops 720p mode 1 to 1080p mode 0); here it would show them at 30 with the render thread's own steps at half speed. Both are ignored
(0x50fb7c, '2801082a', 'e8031f2a', 'render thread forced interval 2: orr w8,w9,w8 (either flag) → mov w8,wzr (the mode\'s interval, 1)'),
# The frame limiter's wait (main+0x5be430: yield, read the tick, until 60/fps ticks of 1/59.94 s have passed since the last frame end; x8 = time passed and x23 = target, in 100 ns)
# is a busy loop on the host: the yield back-off spins its first 2 ms on a performance core and then oversleeps by up to 250 µs, so frames took 16.8 ms (59.5 fps, a repeated
# display frame every 2 s) and CPU energy went from 0.16 W at 30 fps to 1.5 W on the Mac. The yield becomes nn::os::SleepThread of 96% of the time left, and the loop goes back to
# the limiter's own tick read and comparison at main+0x5be44c (the instructions after the b are no longer reached); the host's sleep never oversleeps, so the frames take 16.68 ms
(0x5be4c0, '20fa1494', 'e00208cb', 'frame limiter wait: bl YieldThread → sub x0,x23,x8 (time left, 100 ns units)'),
(0x5be4c4, 'bbf71494', '01e87bd3', 'frame limiter wait: bl GetSystemTick → lsl x1,x0,#5'),
(0x5be4c8, '680240f9', '2018008b', 'frame limiter wait: ldr x8,[x19] → add x0,x1,x0,lsl #6 (96 ns per 100 ns left)'),
(0x5be4cc, '080008cb', '8df71494', 'frame limiter wait: sub x8,x0,x8 → bl nn::os::SleepThread (PLT main+0xafc300)'),
(0x5be4d0, '087d149b', 'dfffff17', 'frame limiter wait: mul x8,x8,x20 → b main+0x5be44c (read the tick and compare again)'),
# The render thread waits for the next update with KTGL's event wait (main+0xaa90e0, timeout in µs: × 1000 → nn::os::TimedWaitEvent) and passes 1/fps × 1000 = 16, one frame in ms,
# so it polls every 16 µs: at 60 fps it draws in about 3 ms and then polled for the rest of the frame (42,000–53,000 wakeups a second on the Mac, against 1,200 at 30 fps, where
# it waited in the present instead). The other four callers wait without a timeout. The timeout becomes 1000/fps × 1000 = 16,666 µs (s8 = 1000.0, loaded first)
(0x50f8e8, '01102e1e', '483d0090', 'render thread wait timeout: fmov s1,#1.0 → adrp x8,#0xcb7000'),
(0x50f8ec, '2018201e', '08514bbd', 'render thread wait timeout: fdiv s0,s1,s0 → ldr s8,[x8,#0xb50] (1000.0)'),
(0x50f8f0, '483d0090', '0019201e', 'render thread wait timeout: adrp x8,#0xcb7000 → fdiv s0,s8,s0 (1000/fps; the fmul by s8 that follows makes it µs)'),
(0x50f8f4, '08514bbd', '1f2003d5', 'render thread wait timeout: ldr s8,[x8,#0xb50] → nop (moved up)'),
],
# Hyrule Warriors: Age of Calamity 1.3.0 (v327680) main: the init (main+0x8d9610) hands the KTSL sound engine three heaps carved from one block: 15 MB engine memory, 2 MB, and
# 142 MB sound data (sizes built by mov x8,#0xf00000; movk x8,#0x20,lsl #48 and mov w9,#0x8e00000; constructor main+0x12647a0, heaps created at main+0x1a2ba58). The audio renderer
# work buffer in the 15 MB heap is 0x760000 (the SDK's AudioRenderSystem::GetWorkBufferSize, 0x40a9c8 of it command buffer), and the engine's setup asks for about 16.9 MB in
# all: the fourth 0x1b5860-byte player block (main+0x1a32f70; three of the four players mix 5.1) came back null, the setup released its memory pools and gave up (-51),
# the first sound archive (ASRS) never loaded and nnMain's resource wait (main+0x8d9bc8) spun forever. Where hardware differs is not known. The engine heap gets 4 MB from the
# sound data heap, so the block the three come from keeps its size
'c3cf52bf2b05d7310884971b50bcccb8': [
(0x8d9610, '081ea0d2', '0826a0d2', 'sound engine heap: mov x8,#0xf00000 → mov x8,#0x1300000 (+4 MB)'),
(0x8d9618, '091ca152', '0914a152', 'sound data heap: mov w9,#0x8e00000 → mov w9,#0x8a00000 (-4 MB; the block the three heaps come from keeps its total size)'),
# 60 fps. nnMain builds KTGL's device config on its stack and inits the device once (main+0x8d94c4 → main+0x17c34f0): {1920, 1080, 3, 9, 5, interval 2, 9, …}. The device
# keeps the interval (main+0x17c4ba8 → device +0x2aa4, passed to every present at main+0x17ced78, which sets it on the window when it changes) and its frame rate,
# 59.94 / interval (main+0x17c37c8 → device +0x32d0), which the game steps its time by (the event scripts at main+0x7802c4: timescale / frame rate). Forcing interval 1 at the
# present alone would keep the 1/29.97 step at 60 frames a second. With interval 1 in the config the device runs the engine's own 60 fps mode: frame rate 59.94, interval 1
(0x8d94b0, '4900c0f2', '2900c0f2', 'KTGL device config present interval: movk x9,#0x2,lsl #32 → #0x1 (60 fps; the device frame rate becomes 59.94 / 1)'),
# The frame itself is paced by a KIDS task (main+0x166d9c0, on a KIDSTaskScheduler thread): it waits until 1 / fps has passed since the last frame (tick frequency / fps,
# SleepThread and a poll), then steps the game by the time that really passed (+0x24, seconds). fps (+0x30) is 30, from engine data (main+0x166daa4)
(0x166db80, '14159f1a', '94078052', 'KIDS frame task wait: csinc w20,w8,wzr,ne (fps, at least 1) → mov w20,#60 (wait 1/60 s; the step stays the measured time)'),
],
# Octopath Traveler 0 1.0.5 (v327680) main: Unreal's nninitStartup (main+0x12d74) constructs the engine allocator, an FMallocBinned3, in place at main+0x68022c8 (main+0x12e40 → constructor
# main+0x9f480). Its constructor reserves 37 GB of address space (main+0x9f748: 74 pools of 512 MB, pool index = offset >> 29), which only the Mac can give: an iPhone has about 5 GB without the
# extended virtual addressing entitlement, and the pools are too uneven to shrink (at character creation the largest holds 68 MB, the 74 together 732 MB). The constructor is not called;
# the host puts its own allocator's vtable in the object instead (dm/host/hle_uemalloc.c: Malloc, Realloc, Free and the size queries on a host malloc zone), and every allocation goes through it
# (FMemory reaches the object through FPlatformMemory::BaseAllocator, main+0x132b4, and the thread-local storage allocator nninitStartup registers, main+0x12e80 / main+0x12ec0, through its vtable)
'9fe60a212cff9b9a3656ab63d4f882e5': [
(0x12e40, '90310294', '1f2003d5', 'nninitStartup: bl 0x9f480 (FMallocBinned3 constructor, 37 GB of address space) → nop; the host allocator is in the object already (hle_uemalloc.c)'),
],
# Octopath Traveler 0 1.0.5 (v327680) sdk (SDK 19.3.13): the AddressRegionManager node area 0x3fe00000 → 0x3e00000 (62 MB), as Engage's SDK 12 (above). The same constructor (sdk+0x1423ec): +0x1423fc
# sets the node area limit, +0x142468 registers it as an occupied region. Unreal's region (-SwitchVirtualAddressSpaceSize, the profile's game_args) then starts 62 MB into the alias region, and the guest
# block stays small enough for an iPhone next to the Unreal heap (hle_uemalloc.c), which lives outside it
'7bba185a35ee9b4d0af66a4df910a823': [
(0x1423fc, '08fca752', '087ca052', 'AddressRegionManager constructor: node area limit mov w8,#0x3fe00000 → mov w8,#0x3e00000'),
(0x142468, '09fca752', '097ca052', 'AddressRegionManager constructor: length of the node area registered as an occupied region mov w9,#0x3fe00000 → mov w9,#0x3e00000'),
],
}
# ---- Registered observation gates: {NSO build ID: [(offset, number, original instruction word, reason)]}. Numbers match OBS_* in the host's hle.h;
# the original instruction must be position independent (no PC-relative / branch / anything involving x18 or tpidr), asserted offline; when the host has no callback attached the gate block goes in and out once and returns ----
OBS = {
# Metroid Dread 2.1.0 main: the movie player posts "link / unlink holders" requests into the dispatcher thread's request ring (main+0x222cc8) and goes on without waiting for them (after an unlink it frees the wrapper object and builds a new one in place).
# On hardware the dispatcher thread (priority 30, core 0 only) preempts the main thread (priority 44, core 0) at once and finishes first; the host waits for the ring to empty after each post (hle_movie.c movie_ring_wait). The gates sit on the add x22, x22, #8 after the bl
'646761f643afebb379edd5e6a5151af2': [
(0x2247a4, 19, '910022d6', 'link request posted (loop at main+0x224728, x19 = request ring): wait for the dispatcher; orig add x22, x22, #8'),
(0x2248e4, 20, '910022d6', 'unlink request posted (loop at main+0x224868, x19 = request ring): wait for the dispatcher before the object is freed; orig add x22, x22, #8'),
],
}
BUILD_ID = d[0x40:0x50].hex()
cuts = list(CUTS.get(BUILD_ID, []))
for off, ob, nb, why in cuts:
ob, nb = bytes.fromhex(ob), bytes.fromhex(nb)
assert len(ob) == len(nb) and IMG[off:off + len(ob)] == ob, f'cut original bytes mismatch {off:#x}: {bytes(IMG[off:off + len(ob)]).hex()}'
IMG[off:off + len(nb)] = nb
# Key page indices are fixed; the key page content is written by the loader as "actual key number × 8".
KEYS = {'K18': 508, 'KTLS': 509, 'KTLR': 510, 'KGATE': 511}
KEY_INDEX = {k: i for i, k in enumerate(('K18', 'KTLS', 'KTLR', 'KGATE'))}
md = Cs(CS_ARCH_ARM64, CS_MODE_ARM); md.detail = True
patch = bytearray(); fix = []; gates = []; stats = {}
def stat(k): stats[k] = stats.get(k, 0) + 1
def here(): return PATCH_OFF + len(patch)
def emit(w, kind=None):
if kind: fix.append((len(patch), kind))
patch.extend(struct.pack('<I', w & 0xFFFFFFFF))
def emit_key_addr(reg, kind):
fix.append((len(patch), kind)); emit(ADRP(reg, here(), KEY_PAGE_OFF)); emit(LDRX(reg, reg, KEY_INDEX[kind]))
def emit_slot_addr(base, key_reg, kind):
emit_key_addr(key_reg, kind); emit(ADDR(base, base, key_reg))
# ---- Encoders ----
def B(frm, to):
off = to - frm; assert -(1 << 27) <= off < (1 << 27) and off % 4 == 0
return 0x14000000 | ((off >> 2) & 0x3FFFFFF)
def STP(t, t2, n, off): return 0xA9000000 | (((off // 8) & 0x7F) << 15) | (t2 << 10) | (n << 5) | t
def LDP(t, t2, n, off): return 0xA9400000 | (((off // 8) & 0x7F) << 15) | (t2 << 10) | (n << 5) | t
def STP_PRE(t, t2, n, off): return 0xA9800000 | (((off // 8) & 0x7F) << 15) | (t2 << 10) | (n << 5) | t
def LDP_POST(t, t2, n, off): return 0xA8C00000 | (((off // 8) & 0x7F) << 15) | (t2 << 10) | (n << 5) | t
def STUR(t, n, off): return 0xF8000000 | ((off & 0x1FF) << 12) | (n << 5) | t # stur xt,[xn,#off] (off may be negative)
def LDUR(t, n, off): return 0xF8400000 | ((off & 0x1FF) << 12) | (n << 5) | t # ldur xt,[xn,#off]
def LDRX(t, n, imm12=0): return 0xF9400000 | (imm12 << 10) | (n << 5) | t
def STRX(t, n, imm12=0): return 0xF9000000 | (imm12 << 10) | (n << 5) | t
def MRS_TPIDRRO(t): return 0xD53BD060 | t
def ADRP(dd, pc, target):
imm = ((target & ~0xFFF) - (pc & ~0xFFF)) >> 12; assert -(1 << 20) <= imm < (1 << 20)
return 0x90000000 | ((imm & 3) << 29) | (((imm >> 2) & 0x7FFFF) << 5) | dd
def ADDI(dd, n, imm12, sh=0): return 0x91000000 | (sh << 22) | (imm12 << 10) | (n << 5) | dd
def ADDR(dd, n, m): return 0x8B000000 | (m << 16) | (n << 5) | dd
def SUBI(dd, n, imm12, sh=0): return 0xD1000000 | (sh << 22) | (imm12 << 10) | (n << 5) | dd
def BR(n): return 0xD61F0000 | (n << 5)
def CMPR(n, m): return 0xEB00001F | (m << 16) | (n << 5) # cmp xn, xm (subs xzr)
def BEQ(frm, to):
off = to - frm; assert -(1 << 20) <= off < (1 << 20) and off % 4 == 0
return 0x54000000 | (((off >> 2) & 0x7FFFF) << 5)
def BRK(imm): return 0xD4200000 | ((imm & 0xFFFF) << 5)
def BLR(n): return 0xD63F0000 | (n << 5)
NOP = 0xD503201F
def dis(w, addr):
l = list(md.disasm(struct.pack('<I', w), addr)); assert len(l) == 1, hex(w)
return l[0]
def regset(ins):
r, w = ins.regs_access()
return {ins.reg_name(x) for x in r}, {ins.reg_name(x) for x in w}
def num(name):
m = re.fullmatch(r'[xw](\d+)', name or ''); return int(m.group(1)) if m else None
def has18(ins): return re.search(r'\b[xw]18\b', ins.op_str) is not None
def regs_in(ins):
s = {int(n) for n in re.findall(r'\b[xw](\d+)\b', ins.op_str)}
try:
rd, wr = regset(ins); s |= {num(n) for n in rd | wr} - {None}
except Exception: pass
return s
def pick(avoid, n=2):
out = [r for r in range(0, 16) if r not in avoid and r != 18][:n]; assert len(out) == n; return out
def subst(ins, S):
"""x18/w18 → xS/wS: only the 4 register field positions are changed, verified against the disassembly text."""
w = struct.unpack('<I', ins.bytes)[0]
want = re.sub(r'\b([xw])18\b', lambda m: f'{m.group(1)}{S}', ins.op_str)
pos = [p for p in (0, 5, 10, 16) if (w >> p) & 31 == 18]
for k in range(len(pos), 0, -1):
for c in combinations(pos, k):
nw = w
for p in c: nw = (nw & ~(31 << p)) | (S << p)
try: dd = dis(nw, ins.address)
except AssertionError: continue
if dd.mnemonic == ins.mnemonic and dd.op_str == want: return nw
raise RuntimeError(f'subst fail {ins.address:x} {ins.mnemonic} {ins.op_str}')
# ---- Rewrite blocks of each kind ----
def sp_delta(ins):
"""The amount an instruction writes back to sp (pre-index [sp, #i]! / post-index [sp], #i); returns 0 if sp is not written back."""
m = re.search(r'\[sp, #(-?(?:0x)?[0-9a-f]+)\]!', ins.op_str)
if m: return int(m.group(1), 0)
m = re.search(r'\[sp\], #(-?(?:0x)?[0-9a-f]+)$', ins.op_str)
if m: return int(m.group(1), 0)
assert not re.match(r'(sp|wsp),', ins.op_str), f'writes sp: {ins.address:x} {ins.op_str}'
return 0
def site_plain(ins):
S, T = pick(regs_in(ins))
dlt = sp_delta(ins)
save = -16 if dlt == 0 else -112 # Instructions that modify sp write below sp, so the scratch save is placed deep in the reserved area
assert dlt == 0 or dlt >= -96, f'sp delta {dlt} at {ins.address:x}'
start = here()
emit(STP(S, T, 31, save)); emit(MRS_TPIDRRO(T)); emit_slot_addr(T, S, 'K18'); emit(LDRX(S, T))
emit(subst(ins, S))
emit(STRX(S, T)) # Always write back: capstone can miss reporting a write (the adds w18 instance)
emit(LDP(S, T, 31, save - dlt)); emit(B(here(), ins.address + 4))
return start
def site_sp(ins):
"""Destination is sp and a source is x18 (Engage's alloca: mov x18, sp; sub x18, x18, x0; mov sp, x18). site_plain saves its scratch relative to the old sp and would restore it relative to the new one:
copy the old sp into T first, and after the instruction restore S / T relative to T (without writeback, ldp may use the base register as a destination). x18 is not written, so the slot is not written back"""
S, T = pick(regs_in(ins))
start = here()
emit(STP(S, T, 31, -16)); emit(MRS_TPIDRRO(T)); emit_slot_addr(T, S, 'K18'); emit(LDRX(S, T))
emit(ADDI(T, 31, 0)) # mov T, sp (in add immediate, 31 is sp)
emit(subst(ins, S))
emit(LDP(S, T, T, -16)); emit(B(here(), ins.address + 4))
return start
def site_cond(ins):
w = struct.unpack('<I', ins.bytes)[0]
S, T = pick(regs_in(ins))
tgt = ins.operands[-1].imm
start = here()
emit(STP(S, T, 31, -16)); emit(MRS_TPIDRRO(T)); emit_slot_addr(T, S, 'K18'); emit(LDRX(S, T))
m = ins.mnemonic
taken = here() + 4 * 3
off = (taken - here()) >> 2
nw = ((w & 0xFF000000) | ((off & 0x7FFFF) << 5) | S) if m in ('cbz', 'cbnz') else ((w & 0xFFF80000) | ((off & 0x3FFF) << 5) | S)
pc = here(); emit(nw)
dd = dis(nw, pc); assert dd.mnemonic == m and dd.operands[-1].imm == taken
emit(LDP(S, T, 31, -16)); emit(B(here(), ins.address + 4))
assert here() == taken
emit(LDP(S, T, 31, -16)); emit(B(here(), tgt))
return start
def site_pcrel(ins):
tgt = ins.operands[1].imm
S, T = pick(set())
start = here()
emit(STP(S, T, 31, -16)); emit(MRS_TPIDRRO(T)); emit_slot_addr(T, S, 'K18') # S first serves as a temporary for the key page offset; the next instruction then loads the target value
pc = here(); emit(ADRP(S, pc, tgt))
if ins.mnemonic == 'adr': emit(ADDI(S, S, tgt & 0xFFF))
emit(STRX(S, T))
emit(LDP(S, T, 31, -16)); emit(B(here(), ins.address + 4))
return start
def site_br(ins, base, count):
"""Jump table br x18: target = base + 4i. Stash x16 in the red zone → equally spaced landing pads → restore x16 → original target."""
start = here()
emit(STUR(16, 31, -16)); emit(STUR(17, 31, -24)) # x16 / x17 are temporary registers that can stay live within a function; save them to the red zone before borrowing
emit(MRS_TPIDRRO(16)); emit_slot_addr(16, 17, 'K18'); emit(LDRX(16, 16)); emit(LDUR(17, 31, -24))
padbase = start + 4 * 24
delta = padbase - base
op = ADDI if delta >= 0 else SUBI; dl = abs(delta)
ops = []; hi, lo = dl >> 12, dl & 0xFFF
while hi: h = min(hi, 0xFFF); ops.append(op(16, 16, h, 1)); hi -= h
if lo: ops.append(op(16, 16, lo, 0))
assert len(ops) <= 12
for o in ops: emit(o)
emit(BR(16))
while here() < padbase: emit(NOP)
tails = padbase + 4 * count
for i in range(count): emit(B(here(), tails + 8 * i))
for i in range(count):
emit(0xF85F0000 | (31 << 5) | 16) # ldur x16,[sp,#-16]
emit(B(here(), base + 4 * i))
return start
def site_br_tab(ins, targets):
"""Offset-table br x18 (ldrsw x18,[xB,x18,lsl #2]; add x18,x18,xB): target = table base + signed entry; the table is constant, so all targets are read at rewrite time.
x16 = guest x18, compared with each target in turn (adrp + add put the target address in x17); on a match restore x16 / x17 and jump to the original target; no match → brk (the original bounds check already stops out-of-range indices)"""
start = here()
emit(STUR(16, 31, -16)); emit(STUR(17, 31, -24)) # as in site_br: save x16 / x17 to the red zone before borrowing
emit(MRS_TPIDRRO(16)); emit_slot_addr(16, 17, 'K18'); emit(LDRX(16, 16))
uniq = sorted(set(targets))
pads = here() + 4 * (4 * len(uniq) + 1)
for k, t in enumerate(uniq):
emit(ADRP(17, here(), t)); emit(ADDI(17, 17, t & 0xFFF)); emit(CMPR(16, 17)); emit(BEQ(here(), pads + 12 * k))
emit(BRK(0x18))
for t in uniq:
emit(LDUR(16, 31, -16)); emit(LDUR(17, 31, -24)); emit(B(here(), t))
return start
def site_blr(ins):
"""blr x18 (Engage main+0x9ff324): x16 = guest x18, x30 = the instruction after the original blr, br x16. At a call site x16 / x17 are intra-procedure-call temporaries under the calling convention, so they need no saving;
the return address is the original one, so the callee returns straight there (IL2CPP's C++ exceptions look up unwind tables by return address and must not stop in the patch area)"""
start = here()
emit(MRS_TPIDRRO(16)); emit_slot_addr(16, 17, 'K18'); emit(LDRX(16, 16))
ret = ins.address + 4
emit(ADRP(30, here(), ret)); emit(ADDI(30, 30, ret & 0xFFF)); emit(BR(16))
return start
def site_br_call(ins):
"""br x18 as an indirect tail call (tail_call): x16 = guest x18, br x16. As at site_blr, x16 / x17 are intra-procedure-call temporaries at a call, and x30 keeps the caller's return address"""
start = here()
emit(MRS_TPIDRRO(16)); emit_slot_addr(16, 17, 'K18'); emit(LDRX(16, 16)); emit(BR(16))
return start
def site_sysreg_read(ins, kind):
"""mrs xN, tpidr_el0 / tpidrro_el0 → xN = thread slot value."""
N = ins.bytes[0] & 31
tmp = 16 if N != 16 else 17
start = here()
emit(STUR(tmp, 31, -16)); emit(MRS_TPIDRRO(N)); emit_slot_addr(N, tmp, kind); emit(LDRX(N, N)); emit(LDUR(tmp, 31, -16)) # tmp is saved by borrowing the red zone
emit(B(here(), ins.address + 4))
return start
def site_tpidr_write(ins):
N = struct.unpack('<I', ins.bytes)[0] & 31
(T,) = pick({N}, 1)
start = here()
emit(0xF81F0000 | (31 << 5) | T) # stur xT,[sp,#-16]
tmp = next(x for x in (16, 17) if x not in (N, T))
emit(STUR(tmp, 31, -24)); emit(MRS_TPIDRRO(T)); emit_slot_addr(T, tmp, 'KTLS'); emit(STRX(N, T)); emit(LDUR(tmp, 31, -24))
emit(0xF85F0000 | (31 << 5) | T) # ldur xT,[sp,#-16]
emit(B(here(), ins.address + 4))
return start
def emit_gate_call():
"""Common section of a gate block (right after stp x29,x30,[sp,#-16]! and before blr x30): x30 = the gate address in thread slot KGATE.
Fetching the slot through the key page needs a second register to be borrowed; x29 is borrowed and then immediately restored from the stack: the x29 saved by _dm_gate must be
the guest's original value, since the crash backtrace walks the x29 chain."""
emit(MRS_TPIDRRO(30)); emit_slot_addr(30, 29, 'KGATE'); emit(LDRX(30, 30)); emit(LDRX(29, 31))
def site_gate(ins, kind, arg):
"""svc / mrs cntpct_el0 → host gate. The gate address is taken from thread slot KGATE; the gate looks up gates.tsv by return point (the address of this block's ldp)."""
start = here()
emit(STP_PRE(29, 30, 31, -16))
emit_gate_call()
emit(BLR(30))
ret = here()
emit(LDP_POST(29, 30, 31, 16))
emit(B(here(), ins.address + 4))
gates.append((ret, kind, arg, ins.address))
return start
def site_obs(off, oid, word):
"""Observation gate: original entry instruction → enter the host gate (arg = observation number) → restore x29/x30 → execute the original instruction → jump back to off+4.
The first half of the gate block is word-for-word isomorphic with site_gate; the host only reads registers and does not modify them (the x0–x8 written back by dm_gate are the original values)."""
ins = dis(word, off)
assert ins.mnemonic not in ('adr', 'adrp', 'b', 'bl', 'br', 'blr', 'ret', 'cbz', 'cbnz', 'tbz', 'tbnz') and not ins.mnemonic.startswith('b.') \
and not (ins.mnemonic.startswith('ldr') and '[' not in ins.op_str) and not has18(ins), f'observation gate original instruction cannot be relocated {off:#x} {ins.mnemonic} {ins.op_str}'
start = here()
emit(STP_PRE(29, 30, 31, -16))
emit_gate_call()
emit(BLR(30))
ret = here()
emit(LDP_POST(29, 30, 31, 16))
emit(word)
emit(B(here(), off + 4))
gates.append((ret, 'obs', oid, off))
return start
# ---- Exclusive windows ----
EXCL_LD = ('ldxr', 'ldaxr', 'ldxp', 'ldaxp', 'ldxrb', 'ldaxrb', 'ldxrh', 'ldaxrh')
EXCL_ST = ('stxr', 'stlxr', 'stxp', 'stlxp', 'stxrb', 'stlxrb', 'stxrh', 'stlxrh')
def find_windows(insl):
out = []
for i, ins in enumerate(insl):
if ins.mnemonic not in EXCL_LD: continue
j = i
while j < len(insl) and insl[j].mnemonic not in EXCL_ST: j += 1
if j >= len(insl) or j - i >= 16: continue
# Another exclusive load before the store: this one has no store of its own (a retry block, see retry_block); spanning both would merge two sequences
# (Tears of the Kingdom main+0x5a4674: ldxr w18; cmp; b.eq <stxr of 0x5a427c>; ...; 0x5a469c: ldxr w13; and; stxr; cbnz, a loop other code branches to)
if any(x.mnemonic in EXCL_LD for x in insl[i + 1:j]): continue
if j + 1 < len(insl) and insl[j + 1].mnemonic in ('cbz', 'cbnz'): j += 1
seg = insl[i:j + 1]
if any(has18(x) for x in seg): out.append(seg)
return out
def site_window(seg):
used = set()
for x in seg: used |= regs_in(x)
S, T = pick(used)
lo, hi = seg[0].address, seg[-1].address + 4
start = here()
emit(STP(S, T, 31, -16)); emit(MRS_TPIDRRO(T)); emit_slot_addr(T, S, 'K18'); emit(LDRX(S, T))
copy0 = here(); n = len(seg)
# Exit stubs: the first stub right after the window must be the fall-through one (after the last window instruction, hi); targets of branches leaving the window come after it.
# Previously the fall-through stub came last: as soon as a window contains a conditional branch out (e.g. ldxr; cmp; b.ne out; stxr; cbnz), a successful stxr fell into the
# first branch-out stub, i.e. took the "compare not equal" path (SMBW main 0x81c40: the enqueue retried forever, the tail index filled up without storing a single task, and the job system hung). D2R's windows had no branch out, so it never hit this.
exits = copy0 + 4 * n + 16
ex_targets = []; words = []
for k, x in enumerate(seg):
w = struct.unpack('<I', x.bytes)[0]
if has18(x): w = subst(x, S)
m = x.mnemonic
if m in ('cbz', 'cbnz', 'tbz', 'tbnz') or m.startswith('b.'):
tgt = x.operands[-1].imm
if lo <= tgt < hi: newt = copy0 + (tgt - lo)
else: ex_targets.append(tgt); newt = exits + 16 * (len(ex_targets) - 1)
pc = copy0 + 4 * k; off = (newt - pc) >> 2
if m in ('tbz', 'tbnz'): w = (w & 0xFFF8001F) | ((off & 0x3FFF) << 5)
else: w = (w & 0xFF00001F) | ((off & 0x7FFFF) << 5)
dd = dis(w, pc); assert dd.operands[-1].imm == newt and dd.mnemonic == m
else:
assert m not in ('adr', 'adrp', 'b', 'bl', 'br', 'blr', 'ret') and not (m == 'ldr' and '[' not in x.op_str), hex(x.address)
words.append(w)
for w in words: emit(w)
for t in [hi] + ex_targets: # fall-through first
emit(STRX(S, T)); emit(LDP(S, T, 31, -16)); emit(B(here(), t)); emit(NOP)
return start, lo, hi
def retry_block(insl, k, wseg):
"""The branch insl[k] lands in the middle of window wseg. Accepted only when it ends an out-of-line retry block that re-enters the window at its store-exclusive:
an exclusive load, then instructions that do not branch, then this branch. Tears of the Kingdom main+0x18598: ldxr w17,[x10]; cmp w15,w17; b.eq 0x181fc,
where 0x181fc is the stxr w17,w18,[x10] of the window 0x181ec..0x18200, whose failed stxr branches back to 0x18598 (same shape at 0x185e8 → 0x18244).
Returns (the retry block, the window from that store on), or None"""
t = insl[k].operands[-1].imm
tail = [y for y in wseg if y.address >= t]
if not tail or tail[0].mnemonic not in EXCL_ST: return None
for p in range(k - 1, max(-1, k - 8), -1):
q = insl[p]
if q.mnemonic in EXCL_LD: return insl[p:k + 1], tail
if q.mnemonic in ('b', 'bl', 'br', 'blr', 'ret', 'cbz', 'cbnz', 'tbz', 'tbnz') or q.mnemonic.startswith('b.') or q.mnemonic in EXCL_ST: return None
return None
def site_retry_window(parts):
"""A retry block (see retry_block) gets a patch copy of its own: it cannot jump into the window's copy, because the x18 stand-in would have to be loaded
between its exclusive load and the store. parts = [(retry block, its fall-through), (window from the store on, the window's fall-through)] are copied one
after the other under one prologue, each followed by its fall-through stub; branches to any copied instruction (between the parts, and the failed store's
branch back to the retry block's head) stay inside the copy, every other target leaves through an exit stub as in site_window"""
used = set()
for seg, _ in parts:
for x in seg: used |= regs_in(x)
S, T = pick(used)
start = here()
emit(STP(S, T, 31, -16)); emit(MRS_TPIDRRO(T)); emit_slot_addr(T, S, 'K18'); emit(LDRX(S, T))
pos, at = {}, here()
for seg, _ in parts:
for x in seg: pos[x.address] = at; at += 4
at += 16
exits, ex_targets = at, []
for seg, ft in parts:
for x in seg:
w = struct.unpack('<I', x.bytes)[0]
if has18(x): w = subst(x, S)
m, pc = x.mnemonic, here()
if m in ('cbz', 'cbnz', 'tbz', 'tbnz') or m.startswith('b.'):
tgt = x.operands[-1].imm
if tgt not in pos and tgt not in ex_targets: ex_targets.append(tgt)
newt = pos[tgt] if tgt in pos else exits + 16 * ex_targets.index(tgt)
off = (newt - pc) >> 2
if m in ('tbz', 'tbnz'): w = (w & 0xFFF8001F) | ((off & 0x3FFF) << 5)
else: w = (w & 0xFF00001F) | ((off & 0x7FFFF) << 5)
dd = dis(w, pc); assert dd.operands[-1].imm == newt and dd.mnemonic == m
else:
assert m not in ('adr', 'adrp', 'b', 'bl', 'br', 'blr', 'ret') and not (m == 'ldr' and '[' not in x.op_str), hex(x.address)
emit(w)
emit(STRX(S, T)); emit(LDP(S, T, 31, -16)); emit(B(here(), ft)); emit(NOP)
for t in ex_targets:
emit(STRX(S, T)); emit(LDP(S, T, 31, -16)); emit(B(here(), t)); emit(NOP)
return start
# ---- Function boundaries (eh_frame_hdr) ----
m0 = struct.unpack_from('<I', IMG, 4)[0]
assert IMG[m0:m0 + 4] == b'MOD0'
ehs = m0 + struct.unpack_from('<i', IMG, m0 + 16)[0]
def rd(o, enc):
fmt, app = enc & 0xF, enc & 0x70
v, sz = {0x3: (struct.unpack_from('<I', IMG, o)[0], 4), 0xB: (struct.unpack_from('<i', IMG, o)[0], 4),
0x4: (struct.unpack_from('<Q', IMG, o)[0], 8)}[fmt]
if app == 0x10: v += o
if app == 0x30: v += ehs
return v, sz
_, penc, cenc, tenc = IMG[ehs:ehs + 4]
o = ehs + 4
_, s = rd(o, penc); o += s
nfde, s = rd(o, cenc); o += s
starts = set()
for _ in range(nfde):
a, s = rd(o, tenc); o += s
_, s = rd(o, tenc); o += s
starts.add(a)
starts = sorted(x for x in starts if x < TEXT_END)
# ---- Linear scan to find all rewrite points (not relying on register read/write reports) ----
kinds = {}
for o in range(0, TEXT_END, 4):
w = struct.unpack_from('<I', IMG, o)[0]
if w & 0xFFE0001F == 0xD4000001: kinds[o] = ('svc', (w >> 5) & 0xFFFF); continue
if w & 0xFFFFFFE0 == 0xD53BD040: kinds[o] = ('tpidr_r', None); continue
if w & 0xFFFFFFE0 == 0xD51BD040: kinds[o] = ('tpidr_w', None); continue
if w & 0xFFFFFFE0 == 0xD53BD060: kinds[o] = ('tpidrro_r', None); continue
if w & 0xFFFFFFE0 == 0xD53BE020: kinds[o] = ('cntpct', w & 31); continue
if w & 0xFFFFFFE0 == 0xD53BE040: kinds[o] = ('cntvct', w & 31); continue
# mrs xN, ctr_el0 (cache type): the Apple kernel does not expose it to user mode (SIGILL); SDK 16's nn::os::FlushDataCache reads it for the cache line size
if w & 0xFFFFFFE0 == 0xD53B0020: kinds[o] = ('ctr', w & 31); continue
for ins in md.disasm(IMG[o:o + 4], o):
if has18(ins): kinds[o] = ('x18', None)
# Take the instruction table per function, find the windows and jump tables
import bisect
def func_of(a):
i = bisect.bisect_right(starts, a) - 1
return (starts[i], starts[i + 1] if i + 1 < len(starts) else TEXT_END) if i >= 0 else (a, a + 4)
done = set(); windows = []; jumps = {}; tabjumps = {}; tailcalls = set(); retries = []
def jump_offsets(insl, j):
"""The br x18 is preceded by the offset-table form (Engage main+0x12eec94): cmp w18,#N; b.hi; ldrsw x18,[xB,x18,lsl #2]; add x18,x18,xB.
The index may be another register, and the bounds check may be on a register copied into it: Link's Awakening's GLSLC (subsdk0+0x1be6e8) has
sub w0,w18,#0xd1; cmp w0,#0x31; b.hi; ldrsw x18,[x17,x0,lsl #2], its movie module (subsdk1+0xae9a4) cmp w21,#7; b.hi; ...; mov w18,w21; ldrsw x18,[x4,x18,lsl #2].
The entry may also be loaded into another register and only the add writes x18: Cuphead (Unity 2017.4, main+0x1bb872c) has cmp w17,#7; ldrsw x17,[x15,x17,lsl #2]; add x18,x17,x15
(found by armyja, GitHub pull request #1).
For the table base xB, search back for the nearest writer: it must be add xB,xB,#lo, and the writer before it must be adrp xB. Returns all targets, or None if not recognized"""
ctx = insl[max(0, j - 16):j]
add = next((p for p in reversed(ctx) if p.mnemonic == 'add' and re.fullmatch(r'x18, x\d+, x\d+', p.op_str)), None)
if not add: return None
_, t, rb = add.op_str.split(', ') # t = the loaded entry, rb = the table base
ld = next((p for p in reversed(ctx) if p.mnemonic == 'ldrsw' and re.fullmatch(rf'{t}, \[{rb}, x(\d+), lsl #2\]', p.op_str)), None)
if not ld: return None
# GLSLC subsdk0+0x2f403c checks a masked copy instead: and w16,w18,#0x3f; cmp w16,#0xe; b.hi; ...; and x18,x18,#0x3f; ldrsw x18,[x15,x18,lsl #2]
idx = {'w' + re.fullmatch(rf'{t}, \[{rb}, x(\d+), lsl #2\]', ld.op_str).group(1)}
masked = None # (source, mask, position) when the index is "source & mask"
k0 = ctx.index(ld); cmp = None
def wname(r): return 'w' + r[1:]
for i in range(k0 - 1, -1, -1): # walk back from the load: follow register copies into the index, stop at the bounds check
p = ctx[i]
m = re.fullmatch(r'(w\d+), (w\d+)', p.op_str) if p.mnemonic == 'mov' else None
if m and m.group(1) in idx: idx.add(m.group(2)); continue
m = re.fullmatch(r'([wx]\d+), ([wx]\d+), #(0x[0-9a-f]+|\d+)', p.op_str) if p.mnemonic == 'and' else None
if m and wname(m.group(1)) in idx and masked is None: masked = (wname(m.group(2)), int(m.group(3), 0), i); continue
m = re.fullmatch(r'(w\d+), #(0x[0-9a-f]+|\d+)', p.op_str) if p.mnemonic == 'cmp' else None
if not m: continue
if m.group(1) in idx: cmp = p; break
if masked: # the checked register must be "the same source & the same mask", with the source unchanged up to the index's and
for q in range(i - 1, -1, -1):
w = [wname(ctx[q].reg_name(r)) for r in ctx[q].regs_access()[1]]
if m.group(1) not in w: continue
mm = re.fullmatch(r'([wx]\d+), ([wx]\d+), #(0x[0-9a-f]+|\d+)', ctx[q].op_str) if ctx[q].mnemonic == 'and' else None
if mm and wname(mm.group(2)) == masked[0] and int(mm.group(3), 0) == masked[1] and \
not any(masked[0] in [wname(ctx[z].reg_name(r)) for r in ctx[z].regs_access()[1]] for z in range(q + 1, masked[2])):
cmp = p
break
if cmp: break
if not cmp: return None
n = int(cmp.op_str.split('#')[1], 0) + 1
k = insl.index(ld); lo = page = None
for p in reversed(insl[max(0, k - 256):k]): # the writer chain of the table base
_, w = p.regs_access()
if not any(p.reg_name(r) == rb for r in w): continue
if lo is None and p.mnemonic == 'add' and re.fullmatch(rf'{rb}, {rb}, #(0x[0-9a-f]+|\d+)', p.op_str): lo = int(p.op_str.split('#')[1], 0); continue
if lo is not None and p.mnemonic == 'adrp' and p.op_str.startswith(rb + ','): page = p.operands[1].imm
break
if page is None: return None
tb = page + lo
return [tb + struct.unpack_from('<i', IMG, tb + 4 * i)[0] for i in range(n)]
def table_base(insl, k, rb):
"""Address the table-base register rb holds at insl[k]: its writer chain before k must be add rb,rb,#lo after adrp rb (as in jump_offsets); None otherwise"""
lo = None
for p in reversed(insl[max(0, k - 256):k]):
_, w = p.regs_access()
if not any(p.reg_name(r) == rb for r in w): continue
if lo is None and p.mnemonic == 'add' and re.fullmatch(rf'{rb}, {rb}, #(0x[0-9a-f]+|\d+)', p.op_str): lo = int(p.op_str.split('#')[1], 0); continue
if lo is not None and p.mnemonic == 'adrp' and p.op_str.startswith(rb + ','): return p.operands[1].imm + lo
return None
return None
def jump_adr_offsets(insl, j):
"""Entries relative to an adr rather than to the table (Tears of the Kingdom main+0x393bb0): cmp w0,#5; b.hi; ...; adr x18,#L; ldrsw x1,[x17,x0,lsl #2];
add x18,x18,x1; br x18 → target = L + entry, with the table base x17 from adrp + add. Tried only where jump_offsets finds nothing. Returns all targets, or None if not recognized"""
ctx = insl[max(0, j - 16):j]
add = next((p for p in reversed(ctx) if p.mnemonic == 'add' and re.fullmatch(r'x18, x\d+, x\d+', p.op_str)), None)
if not add: return None
ka = ctx.index(add); ops = add.op_str.split(', ')[1:]
for e, a in (ops, ops[::-1]): # e = the loaded entry, a = the adr base
ld = next((p for p in reversed(ctx[:ka]) if p.mnemonic == 'ldrsw' and re.fullmatch(rf'{e}, \[x\d+, x\d+, lsl #2\]', p.op_str)), None)
wa = next((p for p in reversed(ctx[:ka]) if any(p.reg_name(r) == a for r in p.regs_access()[1])), None) # the nearest writer of a
if not ld or not wa or wa.mnemonic != 'adr': continue
tb_reg, ix = re.fullmatch(rf'{e}, \[(x\d+), x(\d+), lsl #2\]', ld.op_str).groups()
idx, cmp = {'w' + ix}, None # the index and the registers copied into it (main+0x596d30: cmp w15,#8; b.hi; mov w16,w15; ...; ldrsw x0,[x17,x16,lsl #2])
for p in reversed(ctx[:ctx.index(ld)]): # walk back from the load to the bounds check
m = re.fullmatch(r'(w\d+), (w\d+)', p.op_str) if p.mnemonic == 'mov' else None
if m and m.group(1) in idx: idx.add(m.group(2)); continue
m = re.fullmatch(r'(w\d+), #(0x[0-9a-f]+|\d+)', p.op_str) if p.mnemonic == 'cmp' else None
if m and m.group(1) in idx: cmp = p; break
if any('w' + p.reg_name(r)[1:] in idx for r in p.regs_access()[1]): return None # the index changes after the check
if cmp is None: return None
n, at = int(cmp.op_str.split('#')[1], 0) + 1, wa.operands[1].imm
def targets(tb): return [at + struct.unpack_from('<i', IMG, tb + 4 * i)[0] for i in range(n)] if 0 <= tb and tb + 4 * n <= len(IMG) else None
tb = table_base(insl, insl.index(ld), tb_reg)
if tb is not None: return targets(tb)
# The base register was loaded further away (main+0x5f2a9c: hoisted out of the dispatch block): take every adrp + add pair in the function that builds it,
# and accept the table only if exactly one candidate makes every target an instruction of this function
fa, fb = insl[0].address, insl[-1].address + 4
cands = set()
for k, p in enumerate(insl):
if p.mnemonic != 'adrp' or not p.op_str.startswith(tb_reg + ','): continue
q = next((x for x in insl[k + 1:k + 9] if any(x.reg_name(r) == tb_reg for r in x.regs_access()[1])), None)
m = re.fullmatch(rf'{tb_reg}, {tb_reg}, #(0x[0-9a-f]+|\d+)', q.op_str) if q is not None and q.mnemonic == 'add' else None
if m: cands.add(p.operands[1].imm + int(m.group(1), 0))
ok = [tb for tb in sorted(cands) if (t := targets(tb)) and all(fa <= x < fb and x % 4 == 0 for x in t)]
return targets(ok[0]) if len(ok) == 1 else None
return None
def tail_call(insl, j):
"""The br x18 is an indirect tail call, not a jump table: the last write of x18 before it is a plain pointer load and the br ends the function. Ace Attorney Investigations Collection
(Unity 2022.3 IL2CPP) main+0x1d271bc: ldr x18,[x0,#0x18]; ...; ldr x16,[sp]; ...; br x18, a thunk that moves stack arguments and jumps to the method pointer at +0x18"""
if j + 1 != len(insl): return False
for p in reversed(insl[:j]):
if 'x18' not in [p.reg_name(r) for r in p.regs_access()[1]]: continue
return p.mnemonic == 'ldr' and bool(re.fullmatch(r'x18, \[(x\d+|sp)(, #(0x[0-9a-f]+|\d+))?\]', p.op_str))
return False
def jump_bytes_far(insl, j):
"""The byte / halfword table form with its add further back (Octopath Traveler 0 main+0x37f9bc8: adr x18,#L; ldrb w0,[x17,x15]; add x18,x18,x0,lsl #2, then 17 register moves before br x18).
Up to 32 instructions back, and x18 must not change between the add and the br. Tried only where the 16-instruction search and the offset forms find nothing. Returns (base, width) or None"""
ctx = insl[max(0, j - 32):j]
for k in range(len(ctx) - 1, -1, -1):
p = ctx[k]
if p.mnemonic == 'add' and p.op_str.startswith('x18, x18,') and 'lsl #2' in p.op_str: break
if any(p.reg_name(r) == 'x18' for r in p.regs_access()[1]): return None
else: return None
rn = p.reg_name(p.operands[2].reg); idx = {'lr': 30, 'fp': 29}.get(rn, num(rn))
base = next((q.operands[1].imm for q in reversed(ctx[:k]) if q.mnemonic == 'adr' and q.op_str.startswith('x18,')), None)
ld = next((q for q in reversed(ctx[:k]) if q.mnemonic in ('ldrb', 'ldrsb', 'ldrh', 'ldrsh') and num(q.reg_name(q.operands[0].reg)) == idx), None)
if base is None or ld is None: return None
return base, 256 if ld.mnemonic in ('ldrb', 'ldrsb') else 65536
def disasm_fn(fa, fb):
"""A function's instructions. Capstone stops at the first word it cannot decode, so go on after it: Monster Hunter Rise main+0x6b34540 is a trap word (0xe7ffdefe)
in the middle of a function, and the br x18 jump table after it (main+0x6b346d0) was never seen"""
out, a = [], fa
while a < fb:
l = list(md.disasm(bytes(IMG[a:fb]), a))
out += l; a = (l[-1].address + 4 if l else a) + 4
return out
funcs = sorted({func_of(a) for a, (k, _) in kinds.items() if k == 'x18'})
for fa, fb in funcs:
insl = disasm_fn(fa, fb)
ws = find_windows(insl)
for wseg in ws:
windows.append(wseg); done |= {x.address for x in wseg}
for j, ins in enumerate(insl):
if ins.mnemonic == 'br' and ins.op_str == 'x18':
base = width = None
# 16 instructions back, as for the offset-table form: Hades main+0x1135d0 has six vector moves between the add and the br, which put its adr x18 nine back
ctx = insl[max(0, j - 16):j]
for p in reversed(ctx):
if p.mnemonic == 'adr' and p.op_str.startswith('x18,'): base = p.operands[1].imm; break
for p in reversed(ctx):
if p.mnemonic == 'add' and p.op_str.startswith('x18, x18,') and 'lsl #2' in p.op_str:
# capstone names x30 'lr' (and x29 'fp'): Age of Calamity main+0x1382160 indexes with add x18,x18,x30,lsl #2 after ldrb w30,[x14,x12]
rn = p.reg_name(p.operands[2].reg); idx = {'lr': 30, 'fp': 29}.get(rn, num(rn))
for q in reversed(ctx):
if q.mnemonic in ('ldrb', 'ldrsb') and num(q.reg_name(q.operands[0].reg)) == idx: width = 256
if q.mnemonic in ('ldrh', 'ldrsh') and num(q.reg_name(q.operands[0].reg)) == idx: width = 65536
if width: break
break
if not (base and width):
tgt = jump_offsets(insl, j) or jump_adr_offsets(insl, j)
if not tgt and tail_call(insl, j): tailcalls.add(ins.address); continue
if not tgt and (far := jump_bytes_far(insl, j)): jumps[ins.address] = far; continue
assert tgt, f'jump table {ins.address:x}'
tabjumps[ins.address] = tgt; continue
jumps[ins.address] = (base, width)
# No jump may land in the middle of a window, except a retry block's branch onto the window's store-exclusive (retry_block), which gets a copy of its own
inwin = {x.address for wseg in ws for x in wseg}; heads = {wseg[0].address for wseg in ws}
rmid = set()
for k, x in enumerate(insl):
if x.mnemonic in ('b', 'bl', 'cbz', 'cbnz', 'tbz', 'tbnz') or x.mnemonic.startswith('b.'):
t = x.operands[-1].imm
if t in inwin and t not in heads:
wseg = next(w for w in ws if any(y.address == t for y in w))
rb = retry_block(insl, k, wseg) if x.mnemonic not in ('b', 'bl') else None
assert rb, f'branch into window {x.address:x}'
assert not any(y.address in done for y in rb[0]), f'retry block overlaps a window {rb[0][0].address:x}'
retries.append([(rb[0], x.address + 4), (rb[1], wseg[-1].address + 4)])
done |= {y.address for y in rb[0]}; rmid |= {y.address for y in rb[0][1:]}
for x in insl:
if x.mnemonic in ('b', 'bl', 'cbz', 'cbnz', 'tbz', 'tbnz') or x.mnemonic.startswith('b.'):
assert x.operands[-1].imm not in rmid, f'branch into retry block {x.address:x}'
for a_, tgt in tabjumps.items():
if fa <= a_ < fb: assert not any(t in inwin and t not in heads for t in tgt), f'jump table target into window {a_:x}'
def put_branch(at, to): struct.pack_into('<I', IMG, at, B(at, to))
for wseg in windows:
st, lo, hi = site_window(wseg); stat('window'); put_branch(lo, st)
for parts in retries:
st = site_retry_window(parts); stat('retry_window'); put_branch(parts[0][0][0].address, st)
for a in sorted(kinds):
if a in done: continue
k, arg = kinds[a]
ins = dis(struct.unpack_from('<I', IMG, a)[0], a)
if k == 'x18':
m = ins.mnemonic
if m == 'br' and a in tailcalls: st = site_br_call(ins); stat('br_call')
elif m == 'br': st = site_br_tab(ins, tabjumps[a]) if a in tabjumps else site_br(ins, *jumps[a]); stat('br')
elif m in ('cbz', 'cbnz', 'tbz', 'tbnz'): st = site_cond(ins); stat('cond')
elif m in ('adr', 'adrp'): st = site_pcrel(ins); stat('pcrel')
elif m == 'blr': st = site_blr(ins); stat('blr')
elif re.match(r'(sp|wsp),', ins.op_str): st = site_sp(ins); stat('sp')
elif m in ('ret', 'b', 'bl') or (m == 'ldr' and '[' not in ins.op_str): raise RuntimeError(f'{a:x} {m} {ins.op_str}')
else: st = site_plain(ins); stat('plain')
elif k == 'tpidr_r': st = site_sysreg_read(ins, 'KTLS'); stat(k)
elif k == 'tpidrro_r': st = site_sysreg_read(ins, 'KTLR'); stat(k)
elif k == 'tpidr_w': st = site_tpidr_write(ins); stat(k)
elif k == 'svc': st = site_gate(ins, 'svc', arg); stat(k)
elif k in ('cntpct', 'cntvct'): st = site_gate(ins, k, arg); stat(k)
elif k == 'ctr': # replaced in place by a constant: DminLine = 4 (64-byte lines); movz xN, #4, lsl #16
assert arg != 18, f'{a:x} mrs x18, ctr_el0'
struct.pack_into('<I', IMG, a, 0xD2A00080 | arg); stat(k); continue
put_branch(a, st)
obs = list(OBS.get(BUILD_ID, []))
for off, oid, ow, why in obs:
w = struct.unpack_from('<I', IMG, off)[0]
assert f'{w:08x}' == ow, f'observation gate original instruction mismatch {off:#x}: {w:08x}'
# Function entries, or the instruction right after a bl, only: dm_gate writes back only x0–x8 / x30, and host C code may modify x9–x17; at entry these carry no values under AAPCS64 (arguments are only in x0–x7 / x8),
# and right after a call returns the callee was free to clobber them anyway (x16 / x17 are the veneer registers)
after_bl = off >= 4 and struct.unpack_from('<I', IMG, off - 4)[0] >> 26 == 0x25
assert off in set(starts) or after_bl, f'observation gate neither at a function entry nor after a bl {off:#x}'
assert off < TEXT_END and off not in kinds and not any(lo <= off < hi for wseg in windows for lo, hi in [(wseg[0].address, wseg[-1].address + 4)]), f'observation gate conflicts with a rewrite site {off:#x}'
put_branch(off, site_obs(off, oid, w)); stat('obs')
# ---- Frame step in whole vsyncs (The Legend of Zelda: Tears of the Kingdom 1.4.3 and Breath of the Wild 1.9.0 main, host only) ----
# Both games step their game time by a whole number of vsyncs (1/60 s) per frame that their own interval logic picks. With the stubs the host picks it (hle_nvn.c fstep_tick,
# nvn_fstep.h): the step is the number of vsyncs the host keeps the frame on screen (1 = 60 fps, 2 = 30 when 60 does not hold), plus one while the game is behind real time after
# a late frame, so motion is even and game time = real time. The steps stay values the games' own logic uses (1 to 3).
# State page = the page after the key page (zero in the file, read-write in both loaders): +0 float target fps written by the loader (fps_target, DM_FPS_TARGET; 0 = every stub
# runs the original instructions), +4 TOTK movie flag, +8 double lead (TOTK, below), +0x10 float render scale (TOTK, below), +0x18 double game seconds (TOTK, below),
# +0x20 u32 step in vsyncs (host; 0 = not decided yet), +0x24 u32 a step the game fixes itself (BotW's VFR override; 0 = none), +0x30 u64 vsyncs stepped (the stubs; logged
# by the host to check the game's speed). Stub bodies assembled with clang
FRAME_STEP = {'277178b7dba1b6d4949a84778d4abc58', 'cd57b23fa4bbad65803d9788c01821ee'}
FS_STATE_OFF = 0
if BUILD_ID in FRAME_STEP and NAME == 'main':
FS_STATE_OFF = KEY_PAGE_OFF + 0x4000
assert not FAR and FS_STATE_OFF % 0x1000 == 0 and FS_STATE_OFF + 0x40 <= PATCH_OFF and not any(IMG[FS_STATE_OFF:]), 'frame step state page'
def fs_site(off, orig):
ob = bytes.fromhex(orig)
assert IMG[off:off + len(ob)] == ob, f'frame step original bytes mismatch {off:#x}: {bytes(IMG[off:off + len(ob)]).hex()}'
for o in range(off, off + len(ob), 4):
assert o not in kinds and not any(seg[0].address <= o < seg[-1].address + 4 for seg in windows), f'frame step site is a rewrite site {o:#x}'
def fs_put(off, words, why):
old = bytes(IMG[off:off + 4 * len(words)])
for i, w in enumerate(words): struct.pack_into('<I', IMG, off + 4 * i, w)
cuts.append((off, old.hex(), bytes(IMG[off:off + 4 * len(words)]).hex(), why)); stat('frame_step')
# Tears of the Kingdom picks a present interval each frame (main+0x2db174: 2 = 30 fps, 3 under load) and steps its game time by 60 / fps (int fps at timing +0x50, read at main+0x2daa54
# and main+0x3e562c). The stub replaces that logic: int fps = 60 / step, and the float fps (timing +0x24, unused, per masagrator's FPSLocker config for this build, which found the
# sites) the same, which the consumers read. Until the host has decided a step (the first frames) the step is the measured frame time (timing frame +0xd8, 19.2 MHz
# ticks), clamped to [1 / cap, 1 / 15 s]; game time that ran ahead after a short frame (the "lead", at most 0.25 s; totk-mobile) is paid back by later frames. Prerendered movies set a 30 cap (flag set and cleared around main+0x2b36100 /
# main+0x2b33918), the D-pad menu fade starts at full rate (FPSLocker's fix at main+0x1fbc80) and the swapchain argument at main+0xbe14a8 becomes 1 as in FPSLocker
if FS_STATE_OFF and BUILD_ID == '277178b7dba1b6d4949a84778d4abc58':
# frame step (replaces ldrb w9,[x0,#0x58], the first instruction after the prologue)
fs_site(0x2db18c, '09604139')
start = here(); emit(ADRP(16, here(), FS_STATE_OFF))
for w in (0xbd400200, # ldr s0,[x16] target fps
0x1e202008, # fcmp s0,#0.0
0x54000640, # b.eq off (+50) target 0: original path
0xb9402202, # ldr w2,[x16,#0x20] step in vsyncs
0x340001a2, # cbz w2,elapsed (+13) not decided yet: by the measured frame time
0x1e230041, # ucvtf s1,w2
0x1e27d003, # fmov s3,#30.0
0x1e211872, # fdiv s18,s3,s1
0x1e322a52, # fadd s18,s18,s18 fps = 60 / step
0xbd002412, # str s18,[x0,#0x24] float fps
0x52800785, # mov w5,#60
0x1ac208a5, # udiv w5,w5,w2
0xb9005005, # str w5,[x0,#0x50] integer fps
0xf9401a04, # ldr x4,[x16,#0x30]
0x8b224084, # add x4,x4,w2,uxtw
0xf9001a04): # str x4,[x16,#0x30] vsyncs stepped
emit(w)
emit(B(here(), 0x2db284)) # the original epilogue
for w in (0xf9406c22, # elapsed: ldr x2,[x1,#0xd8] last frame time in 19.2 MHz ticks
0x9e630041, # ucvtf d1,x2
0x529f0003, # mov w3,#0xf800
0x72a02483, # movk w3,#0x124,lsl #16 (19,200,000)
0x1e630062, # ucvtf d2,w3
0x1e621821, # fdiv d1,d1,d2 elapsed seconds
0xb9400604, # ldr w4,[x16,#4] movie flag
0x1e27d003, # fmov s3,#30.0
0x7100009f, # cmp w4,#0
0x1e201c60, # fcsel s0,s3,s0,ne cap = movie ? 30 : target
0x1e22c000, # fcvt d0,s0
0x1e6e1003, # fmov d3,#1.0
0x1e601864, # fdiv d4,d3,d0 shortest step 1 / cap
0x1e65d005, # fmov d5,#15.0
0x1e651865, # fdiv d5,d3,d5 longest step 1 / 15
0xfd400606, # ldr d6,[x16,#8] lead
0x1e663827, # fsub d7,d1,d6
0x1e6448e7, # fmax d7,d7,d4
0x1e6558e7, # fmin d7,d7,d5 step = clamp(elapsed - lead)
0x1e6728c6, # fadd d6,d6,d7
0x1e6138c6, # fsub d6,d6,d1 lead += step - elapsed
0x2f00e410, # movi d16,#0
0x1e7048c6, # fmax d6,d6,d16
0x1e6a1011, # fmov d17,#0.25
0x1e7158c6, # fmin d6,d6,d17 lead in [0, 0.25]
0xfd000606, # str d6,[x16,#8]
0x1e671872, # fdiv d18,d3,d7 fps = 1 / step
0x1e624252, # fcvt s18,d18
0xbd002412, # str s18,[x0,#0x24] float fps
0x1e380245, # fcvtzs w5,s18
0xb9005005, # str w5,[x0,#0x50] integer fps
0xfd400e13, # ldr d19,[x16,#0x18]
0x1e672a73, # fadd d19,d19,d7
0xfd000e13): # str d19,[x16,#0x18] game seconds stepped
emit(w)
emit(B(here(), 0x2db284)) # the original epilogue
emit(0x39416009); emit(B(here(), 0x2db190)) # off: ldrb w9,[x0,#0x58]; back
fs_put(0x2db18c, [B(0x2db18c, start)], 'frame step from the host in whole vsyncs, by elapsed time until it has decided one (state page target fps; 0 = original interval logic)')
# the two readers of the integer fps: float fps when on; the scvtf a few instructions later moves into the stub (s0 is not touched in between)
for site, cvt, r in ((0x2daa54, 0x2daa6c, 19), (0x3e562c, 0x3e5638, 22)):
fs_site(site, struct.pack('<I', 0xbd425000 | r << 5).hex()); fs_site(cvt, '00d8215e')
start = here(); emit(STP_PRE(16, 17, 31, -16)); emit(ADRP(16, here(), FS_STATE_OFF))
for w in (0xb9400211, # ldr w17,[x16]
0x34000071, # cbz w17,off (+12)
0xbd422400 | r << 5, # ldr s0,[xR,#0x224] float fps
0x14000003, # b done (+12)
0xbd425000 | r << 5, # off: ldr s0,[xR,#0x250] integer fps
0x5e21d800, # scvtf s0,s0
0xa8c147f0): # done: ldp x16,x17,[sp],#16
emit(w)
emit(B(here(), site + 4))
fs_put(site, [B(site, start)], 'game step reads the float fps (original integer fps and scvtf when off)')
fs_put(cvt, [NOP], 'scvtf moved into the stub at the load')
# D-pad menu fade (w8 is overwritten right after the site)
fs_site(0x1fbc80, '0820201e0a04081f')
start = here(); emit(ADRP(8, here(), FS_STATE_OFF))
for w in (0xb9400108, 0x340001a8, # ldr w8,[x8]; cbz w8,off (+52)
0x1e202028, 0x54000101, # fcmp s1,#0.0; b.ne go
0x785fa308, 0x7100151f, 0x540000a1, # ldurh w8,[x24,#-6]; cmp w8,#5; b.ne go
0x785f8308, 0x7100151f, 0x54000041, # ldurh w8,[x24,#-8]; cmp w8,#5; b.ne go
0x1e2e1008, # fmov s8,#1.0
0x1f08040a, 0x1e202008, 0x14000003, # go: fmadd s10,s0,s8,s1; fcmp s0,#0.0; b end
0x1e202008, 0x1f08040a): # off: the original fcmp s0,#0.0; fmadd s10,s0,s8,s1
emit(w)
emit(B(here(), 0x1fbc88))
fs_put(0x1fbc80, [B(0x1fbc80, start), NOP], 'D-pad menu fade starts at full rate under fractional steps (FPSLocker)')
# swapchain argument (x16 is dead before the bl that follows)
fs_site(0xbe14a8, '61a24039')
start = here(); emit(ADRP(16, here(), FS_STATE_OFF))
for w in (0xb9400210, 0x34000070, 0x52800021, 0x14000002, 0x3940a261): # ldr w16,[x16]; cbz w16,off; mov w1,#1; b end; off: ldrb w1,[x19,#0x28]
emit(w)
emit(B(here(), 0xbe14ac))
fs_put(0xbe14a8, [B(0xbe14a8, start)], 'swapchain argument 1 when on (FPSLocker)')
# prerendered movie start and end set and clear the flag (x16 / x17 are free at a call)
fs_site(0x2b36100, 'b6f37097'); fs_site(0x2b33918, 'de170214')
start = here(); emit(ADRP(16, here(), FS_STATE_OFF)); emit(0x52800031); emit(0xb9000611); emit(B(here(), 0x772fd8)) # mov w17,#1; str w17,[x16,#4]; tail call
fs_put(0x2b36100, [B(0x2b36100, start) | 0x80000000], 'movie starts: 30 fps cap')
start = here(); emit(ADRP(16, here(), FS_STATE_OFF)); emit(0xb900061f); emit(B(here(), 0x2bb9890)) # str wzr,[x16,#4]; tail call
fs_put(0x2b33918, [B(0x2b33918, start)], 'movie ends')
# Render scale (state +0x10, float written by the loader: DM_RENDER_SCALE, 1.0 by default). main+0xd5c24 sizes each view: screen size x the view's scale (5/6 for the scene: 1600x896
# docked) x the dynamic-resolution multiplier, clamped to 8..16384 and floored to the view's alignment. The stub multiplies both extents by the factor (s6, w8 and the flags are dead
# at main+0xd5cc0: s6 is set to 8.0 right after); any factor but 1.0 also replaces the dynamic multiplier (main+0xd5d80) with 1, so the size is fixed. 1.0 = bit-exact original
fs_site(0xd5cc0, '08d0a8524008251e6108241e0401271e'); fs_site(0xd5d80, '062940bd')
start = here(); emit(ADRP(8, here(), FS_STATE_OFF))
for w in (0xbd401106, # ldr s6,[x8,#0x10] render scale
0x1e250840, # fmul s0,s2,s5 (original)
0x1e240861, # fmul s1,s3,s4 (original)
0x1e2020c8, # fcmp s6,#0.0
0x5400006d, # b.le skip (+12) not set
0x1e260800, # fmul s0,s0,s6
0x1e260821, # fmul s1,s1,s6
0x52a8d008, # skip: mov w8,#0x46800000 (original)
0x1e270104): # fmov s4,w8 (original)
emit(w)
emit(B(here(), 0xd5cd0))
fs_put(0xd5cc0, [B(0xd5cc0, start)], 'view render size x the host render scale (1.0 = original)')
start = here(); emit(0xbd402906); emit(ADRP(8, here(), FS_STATE_OFF)) # ldr s6,[x8,#0x28] (original: the dynamic multiplier); x8 is dead after it
for w in (0xbd401107, # ldr s7,[x8,#0x10]
0x1e2e1010, # fmov s16,#1.0
0x1e3020e0, # fcmp s7,s16
0x54000080, # b.eq done (+16) scale 1: the game's dynamic resolution
0x1e2020e8, # fcmp s7,#0.0
0x5400004d, # b.le done (+8) not set
0x1e204206): # fmov s6,s16 a fixed scale: no dynamic multiplier
emit(w)
emit(B(here(), 0xd5d84))
fs_put(0xd5d80, [B(0xd5d80, start)], 'dynamic resolution only at render scale 1.0')
# Breath of the Wild: the framework's interval controller (the 60 fps cuts above) hands its interval to setWaitVsyncInterval (main+0x11269b8), which the main loop calls once a frame
# (main+0x11267c0); the framework keeps it at fw+0x244 (getter vtable +0x138, read by both callers of VFR::updateInterval and directly in 43 places) and passes it on to
# nvnWindowSetPresentInterval. The stub at the setter's entry replaces it with the host's step, read once a frame: ksys::VFR::updateInterval (main+0x151d3d0, x0 = the VFR at
# [main+0x1e93890]; called from GameScene::calc and the per-frame delegate main+0x1280f80) takes the framework's interval in the next frame (ratio = step / 2, base interval 2, and the
# int interval that 16 pieces of logic read), so the time step and everything else that reads the interval agree. The step is not written into the VFR call itself: the game presents
# on its own thread, so the host's step can change between the two VFR calls of a frame. The VFR's own override (+0x29 flag, +0x2c interval: 2 at main+0xc3b588 and main+0xc5ebf8,
# 1 at main+0xc609d8, set and cleared by game scenes) wins in the VFR:
# a stub there publishes it at state +0x24 so the host holds frames that long, and the setter stub leaves the framework's interval to the game while it is set. x16 / x17 are free
# at both entries
if FS_STATE_OFF and BUILD_ID == 'cd57b23fa4bbad65803d9788c01821ee':
fs_site(0x11269b8, 'fd7bbea9'); fs_site(0x151d3d0, 'fd7bbea9')
start = here(); emit(ADRP(16, here(), FS_STATE_OFF))
for w in (0xb9400211, # ldr w17,[x16] target fps (0 = off)
0x34000191): # cbz w17,orig (+12)
emit(w)
emit(ADRP(17, here(), 0x1e93890)); emit(LDRX(17, 17, 0x890 // 8)) # the VFR
for w in (0xb4000131, # cbz x17,orig (+9)
0x3940a631, # ldrb w17,[x17,#0x29] the VFR's override
0x350000f1, # cbnz w17,orig (+7) the framework keeps the game's interval
0xb9402211, # ldr w17,[x16,#0x20] step in vsyncs
0x340000b1, # cbz w17,orig (+5) not decided yet
0x2a1103e1, # mov w1,w17
0xf9401a11, # ldr x17,[x16,#0x30]
0x8b214231, # add x17,x17,w1,uxtw
0xf9001a11, # str x17,[x16,#0x30] vsyncs stepped
0xa9be7bfd): # orig: stp x29,x30,[sp,#-0x20]!
emit(w)
emit(B(here(), 0x11269bc))
fs_put(0x11269b8, [B(0x11269b8, start)], 'setWaitVsyncInterval: the host\'s step unless the VFR override is set (state page target fps; 0 = the game\'s interval)')
start = here(); emit(ADRP(16, here(), FS_STATE_OFF))
for w in (0xb9400211, # ldr w17,[x16] target fps (0 = off)
0x340000b1, # cbz w17,orig (+5)
0x3940a411, # ldrb w17,[x0,#0x29] the VFR's override
0x34000051, # cbz w17,pub (+2) none: 0
0xb9402c11, # ldr w17,[x0,#0x2c]
0xb9002611, # pub: str w17,[x16,#0x24] the game's own step: the host holds frames that long
0xa9be7bfd): # orig: stp x29,x30,[sp,#-0x20]!
emit(w)
emit(B(here(), 0x151d3d4))
fs_put(0x151d3d0, [B(0x151d3d0, start)], 'VFR::updateInterval: its own override published for the host (state page target fps; 0 = original)')
# Render scale (state +0x10, float written by the loader: DM_RENDER_SCALE, 1.0 by default; independent of the frame step). The dynamic-resolution method (main+0x12666e4) keeps a
# scale in 0.8–1.0 rounded to 0.05 steps (+0x1218, main+0x1266818), or the game's forced 0.75 / 0.5 (+0x284 & 0x1800), in s8; from main+0x1266880 it applies s8: each view's
# scale (+0x28a0 / +0x28a4 = s8 × base width (+0x1214: 1600 docked, 1280 handheld) / output width), from which the scene buffers are sized each frame (main+0x106340c), the
# shader ratio uking_dynamic_resolution_ratio and the other readers (main+0xc3c3f4, an LOD check at main+0x14126b4). Setting s8 there keeps them all consistent (a multiply in the
# size function, TOTK's way, would leave the ratio and those readers at the old scale). The apply block runs only when the dynamic path has just recomputed the scale (+0x280 bit 4,
# set at main+0x1266830) or in a forced mode, else main+0x126686c skips it; with a factor it is taken every frame, as in the dynamic mode, so the factor holds with dynamic
# resolution off too. Factors above 1.0 are refused by the loader: the per-frame texture pool's size is unknown. x8, s0, s1 and the flags are dead at both sites
fs_site(0x1266880, '88ae40f9'); fs_site(0x126686c, struct.pack('<I', B(0x126686c, 0x1266950)).hex())
apply = here(); emit(ADRP(8, here(), FS_STATE_OFF))
for w in (0xbd401100, # ldr s0,[x8,#0x10] render scale
0x1e202008, # fcmp s0,#0.0
0x540000ad, # b.le orig (+5) not set
0x1e2e1001, # fmov s1,#1.0
0x1e212000, # fcmp s0,s1
0x54000040, # b.eq orig (+2) 1.0: the game's own scale
0x1e204008, # fmov s8,s0 the host's scale
0xf940ae88): # orig: ldr x8,[x20,#0x158]
emit(w)
emit(B(here(), 0x1266884))
fs_put(0x1266880, [B(0x1266880, apply)], '3D view scale = the host render scale (1.0 = the game\'s dynamic resolution)')
start = here(); emit(ADRP(8, here(), FS_STATE_OFF))
for w in (0xb9401108, # ldr w8,[x8,#0x10] render scale (float bits)
0x34000088, # cbz w8,skip (+4) not set
0x52091908, # eor w8,w8,#0x3f800000
0x34000048): # cbz w8,skip (+2) 1.0
emit(w)
emit(B(here(), 0x1266880)); emit(B(here(), 0x1266950)) # apply the host's scale; skip: the original branch
fs_put(0x126686c, [B(0x126686c, start)], 'scale apply block taken every frame when the host render scale is not 1.0')
os.makedirs(outdir, exist_ok=True)
P = lambda ext: os.path.join(outdir, f'{NAME}.{ext}')
if FAR: # patch area before the base: file = text + ro + data (padded to 16K) + patch area
if BIG_TEXT and len(patch) > FRONT - 0x4000 and 'DM_PATCH_FRONT' not in os.environ:
need = (len(patch) + 0x4000 + 0xFFFF) & ~0xFFFF
print(f'{NAME}: patch area of {len(patch):#x} bytes does not fit in {FRONT:#x} before the base; rewriting again with {need:#x}', flush=True)
os.execve(sys.executable, [sys.executable] + sys.argv, dict(os.environ, DM_PATCH_FRONT=hex(need)))
assert len(patch) <= FRONT - 0x4000, f'patch area of {len(patch):#x} bytes does not fit in the {FRONT:#x} before the base'
open(P('patched.bin'), 'wb').write(IMG + bytearray(FILE_IMAGE_LEN - len(IMG)) + patch)
else:
open(P('patched.bin'), 'wb').write(IMG + bytearray(PATCH_OFF - len(IMG)) + patch)
import hashlib
with open(os.path.join(outdir, 'build_src.sha256'), 'w') as f:
f.write(hashlib.sha256(open(os.path.abspath(__file__), 'rb').read()).hexdigest() + ' patch_nso.py\n')
with open(P('fixups.tsv'), 'w') as f:
f.write(f'# PATCH_OFF\t{PATCH_OFF:#x}\n# KEY_PAGE_OFF\t{KEY_PAGE_OFF:#x}\n# KEY_PAGE_SIZE\t0x4000\n# TEXT_END\t{TEXT_END:#x}\n# RO_OFF\t{RO_OFF:#x}\n# DATA_OFF\t{DATA_OFF:#x}\n')
if FAR: f.write(f'# IMAGE_SIZE\t{IMAGE_SIZE:#x}\n# FILE_IMAGE_LEN\t{FILE_IMAGE_LEN:#x}\n') # patch area before the base (negative PATCH_OFF): whole image size, length of the image part of the file
for k in KEY_INDEX: f.write(f'# KEY_{k}_IDX\t{KEY_INDEX[k]}\n')
if FS_STATE_OFF: f.write(f'# FPS_STATE_OFF\t{FS_STATE_OFF:#x}\n') # frame step state page (FRAME_STEP): the loader writes the target fps there
f.write(f'# BUILD_ID\t{BUILD_ID}\n') # the host identifies the build by it: features with hard-coded addresses only run on the build they belong to
with open(P('gates.tsv'), 'w') as f:
for ret, k, arg, orig in gates: f.write(f'{ret:#x}\t{k}\t{arg}\t{orig:#x}\n')
with open(P('sites.tsv'), 'w') as f:
for a in sorted(kinds):
if a not in done: f.write(f'{a:#x}\t{kinds[a][0]}\n')
for wseg in windows: f.write(f'{wseg[0].address:#x}\tW\t{wseg[-1].address + 4:#x}\n')
for a, (b, c) in jumps.items(): f.write(f'{a:#x}\tJ\t{b:#x}\t{c}\n')
for a, tgt in tabjumps.items(): f.write(f'{a:#x}\tT\t' + ' '.join(f'{t:#x}' for t in tgt) + '\n') # offset-table jump and all its targets
with open(P('cuts.tsv'), 'w') as f:
f.write(f'# BUILD_ID\t{BUILD_ID}\n')
for off, ob, nb, why in cuts: f.write(f'{off:#x}\t{ob}\t{nb}\t{why}\n') # Columns: offset, original bytes, new bytes, reason
with open(P('obs.tsv'), 'w') as f:
f.write(f'# BUILD_ID\t{BUILD_ID}\n')
for off, oid, ow, why in obs: f.write(f'{off:#x}\t{oid}\t{ow}\t{why}\n')
print(NAME, stats, 'cuts', len(cuts), 'obs', len(obs), 'patch', len(patch), 'PATCH_OFF', hex(PATCH_OFF), 'slot_refs', len(fix), 'gates', len(gates))