Outdoors in Breath of the Wild (the user's Great Plateau save, Mac M4 Pro) a frame had about 386
render passes and loaded and stored about 900 MB of attachments, which an iPhone at 60 fps can
hardly move (about 55 GB/s). 220 of those passes ended only because the game updates the first
64 bytes of a 64 KB vertex uniform block before each draw group (nvnCommandBufferUpdateUniformBuffer
inside the pass) while its shaders read up to 544 bytes of it. A deferred update that covers only
part of what a draw reads used to end the pass.
- Partly covered blocks (nvn_mtl.m ubp_get): the shadow starts from memory and takes every inline
update queued in the submission, newest last, incrementally per range. It is refused, and the
pass ends as before, when something else may have written the range by the time the pass runs:
a copy, fill or feedback write queued in the submission, a storage buffer a shader may write or
a texture->buffer copy over it (sbw, by 4 KB page), or a GPU write or counter report of an
earlier submission still running (gw). A counter report of the same submission does not count:
the host writes reports on the CPU after the whole submission completes (Fire Emblem Engage
writes its reports into a uniform block's page). DM_NVN_UB_PARTIAL=0 turns it off;
DM_NVN_UBP_CHECK=1 lands the updates, waits for the GPU and compares every shadow with memory.
- cb_extent.tsv (new tool dm/gfx/tools/cb_extent.py, c1_guard.py's interval arithmetic over the
translated MSL): per shader, how far into each uniform slot it can read, and the storage slots
it never writes. Like c1_full.tsv, the host loads it only for its chain version; build_ipa.sh
copies it, the NSP script writes it for provisional profiles, and the chain procedure
regenerates it. Without it bindings are copied whole (up to 64 KB). Ten profiles have one.
- Route B's reflection never says whether a storage buffer is written, so every one read as
read-only and its writes went unnoticed: a storage buffer now counts as written unless
SPIRV-Cross typed it const (nvn_refl.m sbuf_rw) or cb_extent.tsv proves the slot unwritten.
- Lazy clears (lc_*): a set of targets that gets only clears before the game sets other targets
no longer opens a pass just to store them; the next pass that attaches the same texture level
and layer through the same view clears it as its load action, and the rest land as before
before anything else could see them. Breath of the Wild's four clear-only passes before the
G-buffer fold into its first pass. Off in TBDR mode; DM_NVN_LAZY_CLEAR=0 turns it off.
- The residency set line counts shadows from memory, refusals by reason, and lazy clears.
On the Mac, old (DM_NVN_UB_PARTIAL=0 DM_NVN_LAZY_CLEAR=0) and new side by side on each game's
route; attachment loads + stores per frame:
- Fire Emblem Engage battles: about 1,850 -> 360 MB, 88 -> 38 passes.
- Tears of the Kingdom: about 1,100 -> 700 MB, 124 -> 116 passes.
- Breath of the Wild outdoors: 386 -> about 158 passes, GPU p50 about 9.3 -> 6.5 ms, submit
5.7 -> 4.2-4.9 ms; where the old host fell to 30 or 20 fps under load, the new held 60.
- Link's Awakening, 13 Sentinels: about 15% less. Super Mario 3D World: 5% less (lazy clears).
- Odyssey, Three Houses: 2-8% less. Mario Wonder, Metroid Dread: unchanged.
No new red lines in any of them. The shadow check found every shadow equal to the GPU's memory
(BotW 346,000, Engage 284,000, TOTK 86,000), and t_nvn passes 68/68. Screenshots differ only by
timing. Tried and dropped: DM_NVN_TBDR=1 (it turns the deferred updates off), DM_NVN_BARRIER=game
(no gain), and same-target continuation outside TBDR mode (BotW's decals end the pass anyway).
What still splits BotW's big passes: the GPU particles' compute dispatches (about 190 MB a frame)
and decals that sample the G-buffer. Mac shader libraries were built for the current chain of
TOTK, Engage, Wonder, Dread, Odyssey, Link's Awakening, Three Houses and 13 Sentinels (c1_full.tsv
headers already matched). The IPA ZeldaBreathOfTheWild-2026-10-21-passes.ipa (an earlier state of
these changes plus the uncommitted frame-step probes) awaits an iPhone test.
The game steps a fixed amount per update, from three 30 fps values, and its updates are not held
by the presents:
- A KIDS task (main+0x166d980) waits until 1 / fps has passed and steps the game clock by a
frame-rate object's 1/30 s (the measured path runs only without the object).
- The battle steps 1 / the game's frame-rate block (main+0x269e150: {30, interval 2, 30, 30.0}),
read by the scene update's caller (main+0x8c3610) and its jobs; found with a sample profile.
- The event scripts step by the KTGL device's frame rate, 59.94 / the interval in its config.
The 60 fps cuts (d975e2e) changed only the task's wait and the interval, and were checked only on
cutscenes. DM_PROBE showed the game clock at 2.00 x real time, and battles ran twice as fast on an
iPhone. Fixing the clock alone was not enough: Link still covered twice the ground in a 3 s stick
hold, and the enemy waves came early.
AoC 1.3.0 main joins the rewriter's frame step (patch_nso.py FRAME_STEP), with three stubs. The
updates run at the cap: the task's fps and the frame-rate block become the cap; the object's step
is scaled by its fps x vsyncs per update / 60; the device frame rate becomes 59.94 / vsyncs; and
nnMain's device config gets interval 1 above a 30 fps cap. They replace the two CUTS entries.
Every step stays per update, as on the Switch. The host paces the presents: 60 fps, or 30 when 60
does not hold. Profile fps_target 60; DM_FPS_TARGET=30 runs at 30, and 0 is the Switch original.
On the Mac:
- Game time follows real time at 60 and 30.
- After the same stick hold at the same wall time, Link stops on the same spot at 60 fps as in
the 30 fps original.
- The battle loads at about 133 s in every run.
- The forced 30 matches the original.
Two variants were tried and dropped. Stepping by the measured time still ran battles at twice the
speed. A task wait that follows the step locked the hold at 3 vsyncs.
AoC's shader libraries were rebuilt for chain a9687d01ebfa (Mac and iOS); c1_full.tsv is
regenerated with the same list. The IPA HyruleWarriorsAgeOfCalamity-2026-10-21-speed.ipa, built
from 89b608d plus these changes, awaits an iPhone test.
nn::fs::Commit(const char* const*, int) had no takeover, so the SDK's own
code opened fsp-srv and aborted with the save writes left uncommitted
(Animal Crossing: New Horizons). Both overloads now share one transaction
engine. A list commits once when every name is the same host save; cache
and temporary storage are skipped as the single-name Commit does, and a
list of two separate saves is refused without committing either.
t_fs_commit_list covers rollback, remount, aliases, clone failure, cache
storage beside the save and the refused multi-save list.
Co-authored-by: BeeeeeMo <14192706+BeeeeeMo@users.noreply.github.com>
The app shows the frames put on screen each second in the top row (front_ios.m, from mtl_present_count, so NVN and Vulkan games alike).
build_ipa.sh's DM_SHOW_FPS=1 sets DMShowFPS in Info.plist; DM_SHOW_FPS=0 / 1 in Documents/env.txt overrides it without a rebuild.
- Asked for 60 Hz, the system fired the display link on every third refresh of a 165 Hz screen (55 Hz), so games paced by it ran at 55 fps. On screens whose rate is not a multiple of the link's, link_range (nvn_mtl.m) now runs the link at the screen's rate and counts as ticks only the refreshes nearest a steady 1/60 s clock; DM_LINK_VTICK=0 keeps the system's choice. front_screen_max_fps (front.h, front_mac.m) gives the window's screen's rate.
- A tick lies up to half a refresh off its slot, so there the hold of frames over 25 ms tolerates one refresh more. Without it, 30 fps frames kept a third tick: 22.5 fps at 165 Hz and 24 at 144 in a simulation of the callback. This part is not in the PR.
- The frame step (nvn_fstep.h shown_slack, hle_nvn.c fstep_tick) lets a frame on time show one refresh longer than its hold on such screens: a cheap 50 ms frame shows 54.5 ms at 165 Hz, and counted as late it kept the hold at 20 fps for good (Breath of the Wild, Tears of the Kingdom). Three new t_fstep checks.
- Not taken from the PR: the responsive pacing mode, its uniform-buffer shadows (redone separately) and the Breath of the Wild resource-size-table overlay. docs/development.md records what the PR found about the world-load crash (the Obj_TreeConiferous resource heap) and the overlay runs; docs/agents/04-graphics.md describes the ticks.
- Not yet run on a 144 or 165 Hz screen here (this Mac's is 120 Hz, where nothing changes); iOS screens are not affected.
Co-authored-by: Yuecheng Liao <128268702+lyccccy@users.noreply.github.com>
No host change was needed. The profile reports 84 MB of used memory (mem_used): the rewritten
modules take 80 MB, and with the default 64 MB SetHeapSize fails and the game aborts at its
first allocation. 3,118 shaders, every binding decoded; prewarm list recorded on the Mac
(boot, title, storybook, Elder Kettle's house, Inkwell Isle One, Forest Follies).
README, AGENTS.md and the game notes list it as playable on an iPhone.
A touch that lands in the right third of the screen on no control holds the
right stick, centred where it lands; past the stick radius the centre is
dragged along, so turning back responds at once. Controls in that third
(A/B/X/Y, ZR, R, R3) keep their touches; in Super Mario Galaxy the pointer
keeps the other unclaimed touches. Only the left stick is drawn now, so a
right-stick swipe redraws nothing.
The NVN driver's constant segments have the same layout in every game, so the
nvn_* profile keys are gone. Combined textures are at c2 0x20 + 8 r (a slot past
31 shares the image table; Hades II binds 34), images at 0x120, separate textures
at 0x168 (now always the host's slots 128 + n, as Monster Hunter Rise had them),
samplers at 0x568, and the storage buffer tables at c0 0x110 / 0x310 / 0x510.
dmsh_build.py decodes with that layout, the replay fills both texture tables,
and the translator numbers slots the same way (rebuilt; its SPIR-V and
reflection are byte-identical on 1,491 sample shaders, lock updated).
The shader_* switches and graphics are gone too; the build reads them from the
files (profile_guess.py shader_sources):
- Vulkan when main imports Vulkan and not NVN; its capture when the RomFS has
no SPIR-V.
- A GLSLC capture when main holds GLSL sources and imports glslcCompile.
- An Oodle decode for Unreal paks, and the tool-mode decoder for Trinity packs.
- GLSLC outputs from every main.
- A cover pairing for each single-stage vertex shader (GLSLC, Unreal).
build_ipa.sh takes GFXAPI from the NSP script.
Also removed: the last D2R pieces (the embedded-shader path in dmsh_build.py,
dmsh_facts.py --freeze / --check, maxwell_refs.py, ref/hle_hid_d2r.c).
Chain: every profile's dmsh_chain.h is regenerated. The c1_full lists are
carried over: the change touches no c1 read, and the rebuilt Octopath
Traveler 0, Hades II, Unicorn Overlord and Rhythm Heaven Groove gave the same
lists, identical tables and the same reflection apart from separate texture
slots + 128.
Still to do:
- Run the chain procedure for the other NVN profiles.
- Engage's and Three Houses' shader tables need a new split: Engage gains
Unity's 11 GLSLC shaders in main, and Three Houses' 17 BNSH vertex shaders
lose their cover pairs.
- Ace Attorney's new tables are in, but its libraries are not rebuilt.
- 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).
- The loader reads the SDK version from the sdk module's NintendoSDK_nnSdk string
(dm_sdk_major). SDK 10 and older report NVN API 53.313 and point the shared-font
address at the TTF itself; later SDKs 55.15 and the BFTTF header. That is what
every profile set, so device_api and shared_font leave the five that had them
(Odyssey, 3D World, Link's Awakening, Age of Calamity, Three Houses) and
profile_guess.py no longer guesses them; a profile can still override.
- DM_OODLE_JOB finds OodleLZ_Decompress as the one function in main that takes
the address of the library's own name string, with its start from
.eh_frame_hdr, instead of an address per main build: Octopath Traveler 0
main+0x4f77e10 and Pokemon Legends: Z-A main+0x1a9c9e0. Octopath's 10,596
shaders decode byte-identical to before.
- Rows that only restated defaults are gone (pad_layout simple, graphics nvn,
present_interval game), and the NSP script writes no default rows into
provisional profiles.
No chain version changes. All 19 profiles build, the host tests pass; Mac boots
of 3D World (SDK 10.4.0: API 53.313) and Engage (SDK 12.3.7) read the version.
D2R (Diablo II Resurrected) has no profile and its code could not run here: the
host-call svcs its cuts emitted only died, the touch-run-v4 rule set needed a
missing dm/PRODUCT, and the host files its hooks called were never in this
repository. Removed with it:
- rewriter: D2R cuts, observation gates and parameter slots, the touch-run-v4
rule set and private gates, the boot list, the cut scope column and the
KRES / KASPECT key slots (fixups.tsv keeps the four thread slots; no
params.tsv any more); dm/tools/product.sh
- host: D2R save edits, add-on content, render-scale controller, time()
takeover and Terror Zone clock, crash decoding at D2R offsets, host-call
registry and stubs, i18n, UI-screen, shell-app loader, zero-stripped
archives, boot profiling hooks with no implementation; D2R headers, tests
and scripts; dm/ios/Sources/dm_touch_controls; dm/third_party/libdeflate
- graphics: runtime extrapolation, world freeze, iPad UI shift, shop drag
image, brightness LUT, visual ruler, bloom / FXAA shader hashes, DM_GFXUSE,
the eco-fps setting, the loading-screen gate; shader_map.py,
nometal_run.py, nvn_slots.py, nvn_slots.tsv and the PC-texture samples
The legacy frame step (frame_step legacy, DM_FRAME_STEP) is retired: the
Breath of the Wild and Tears of the Kingdom stubs lose their mode test.
Breath of the Wild's interval-controller cuts stay: the framework keeps the
controller's interval while the game's VFR override holds.
patch_nso.py output is byte-identical for all 15 games on this machine except
the two frame-step stubs; all 18 profiles build; the host tests pass (their
Makefile now builds the general tests only).
On an iPhone it plays at 30 fps through character creation, name entry and the story opening, with no audio underruns and three saves (a footprint of about 2.35 GB after 3.4 minutes). It joins the table with a screenshot of the castle in the snow; AGENTS.md lists it with the nineteen games and a sentence on its Unreal Engine 5 port; the game notes give the iPhone status.
The previous commit let the guest clock run while swkbd::ShowKeyboard is up, but the iOS front end's input box froze
it a second time around its own wait (front_ios.m front_text_input), so nothing changed on the phone: the game's
frame loop stopped for as long as the box was up (4,977 ms and 2,577 ms frames, 5,181 wakeups a second). That freeze
is gone too; the inline keyboard keeps the freeze it takes around its own call (hle_swkbd.c, Monster Hunter Rise).
The Mac front end never froze, so the Mac runs with the keyboard held up (DM_TEST_SWKBD_HOLD_MS) were already the
behaviour the phone now has: 30 fps and the usual wakeups.
While swkbd::ShowKeyboard was up the host froze the guest clock (d2r-mobile's choice, so that monsters did not attack
while the player typed). Unreal calls it off its game thread, so the game kept running, and every thread that waits
on the clock spun while its audio mixing starved: on an iPhone the sound stuttered and the input box lagged (one
4.7 s frame, 5,035 wakeups a second while the box was up instead of about 1,800, no audio underruns). On the Switch
the applet blocks only the caller while the clock runs, which the host now does too. The test switch
DM_TEST_SWKBD_HOLD_MS (with DM_KBD) holds the keyboard up on the Mac: frozen, 8.8 fps and 7,936 wakeups a second in
that window; running, 30 fps and 1,788. The inline keyboard keeps the freeze: its game (Monster Hunter Rise) does
not block on hardware, and the host's input box blocks it.
The IPA built from the Rhythm Heaven Groove profile (base game 1.0.0, handheld with Boost) works on an iPhone.
README lists it (~3 GB) with a screenshot from the Mac (the title screen) and takes it off the Mac-only line; AGENTS.md
counts eighteen games and notes its engine and SDK 21, and the handheld / Boost profile keys that skip the TV latency
calibration.
New profile 0100D9F01D474000 (base game 1.0.0, SDK 21.4.0, Nintendo's own 2D engine; one BNSH of 35 shaders,
Mario's binding layout). On the Mac it plays from a first boot through the setup, the main menu and Play Solo into
Hoop Trundling at 60 fps with sound and no red line. Not yet run on an iPhone.
- SDK 21 calls: nn::pl::GetSharedFont (every shared font at once; the SDK opened the pl service and aborted in
sm::Initialize) returns the stand-in TTFs past the BFTTF header, as the service's offsets point there and the game
hands them straight to ScalableFontEngine::LoadFont; vi::GetDefaultDisplayPanelIdentifier (SIGSEGV through the vi
service object) answers an all-zero panel.
- Audio: SDK 21's DSP command header has no magic (+0 enabled, +1 command ID, +2 size) and DeviceSink is 0x140
bytes with its fields 8 bytes earlier. capture() recognizes that layout on the first non-empty command list (the
walk bounded by the generated list's length); the game was silent.
- Pool initial content (all games): a new texture in a CPU_NO_ACCESS pool whose storage holds data is filled from it
once, as NVN keeps a pool's storage content at nvnMemoryPoolInitialize (tex_init_fill); it stays a GPU texture
that can be rendered into. Linear textures are made on the pool buffer in any pool. The game reads each texture
file into memory and makes a CPU_NO_ACCESS pool on it; every texture read 0 and the screen stayed black.
COMPRESSIBLE, multisample, depth / stencil and sparse textures and storage of zeros are left out.
- NVN block height (all games): the automatic block height truncates height in blocks x 1.5 / 8 (MacNativeGames'
finding, confirmed on this game's 40-block-row BC7 textures and by Touchable on 16 of its files); rounding up
scrambled the calibration screen's beat numbers. Only data a game lays out itself without a packaged layout
changes (Tears of the Kingdom's flushed and Monster Hunter Rise's sparse textures were not re-checked).
- Profile key perf_mode (follow by default): boost reports Boost whatever the operation mode. This game plays
handheld with Boost: docked means a TV to it, which asks for a latency calibration, and Boost alone picks its
1920x1080 draw size.
- The NSP script's survey no longer calls nn::ro missing (hle_ro.c answers it; it now counts the .nro files);
sdis.py --callers takes imported names and scans to the end of the PLT.
The dm/gfx unit tests give the same results as before; Unicorn Overlord renders the same with the old and new host
(no texture takes the initial-content path there).
A 26-minute run from a fresh save goes through character creation and the story opening into free play with no red
line; the game saves four times and a later boot continues from the saves. Not yet run on an iPhone.
- Dynamic texture slots (profile key tex_dyn_slots, on for this game only): 61 vertex shaders (particles) pick one of
several c2 table slots at run time, which the translator cannot trace to one slot (Ryujinx's 32-entry fallback
arrays, <stage>_<t|tb|s>_cb0_40000000). The reflection reads them as NVN_RB_TEXD / NVN_RB_SAMPD arrays, the replay
writes slot indices into that shader's c2 tables and binds slot k at element k. They used not to load; the castle
scene's snow is back. Host only: no chain version changes.
- Image atomics: SPIRV-Cross's MSL 2.3 emulation binds a device atomic pointer (<image>_atomic) beside the image, which
the build reads as a second image of the slot, so the replay put a texture resource ID where an address belongs.
NVN_RB_IMGA binds the storage of a buffer or linear image there (a buffer texture is a view of its pool); a
block-linear image's atomics go to the dummy buffer (logged). 2,084 shaders in this game, at least ten run.
- Buffer textures at an offset below Metal's reported 16-byte alignment are created when the offset is a multiple of
the element size (measured on an M6: views at offsets 4, 8 and 12 read the right elements); they were dummies.
- The first occlusion-query reset inside a pass without a visibility buffer splits the pass, so it is counted.
- A buffer texture in a slot a shader reads as another type (a cube) reads 0 instead of undefined data.
- Service calls: nn::fs::GetSaveDataSizeMax (SIGSEGV at the first save, about 13 minutes in), GetCacheStorageSize,
GetFreeSpaceSize, account::PushOpenUsers, web::ShowOfflineHtmlPage, oe::ExecuteProgram, found by listing main's
unhooked imports.
- The engine is Unreal Engine 5 (its paged global distance field shaders and LowLevelTasks), not 4.26 / 4.27; the 16
compute shaders with 3D image atomics are that global distance field, which the mobile renderer does not dispatch.
Tears of the Kingdom boots the same with the new host (no shader libraries there, so the new bindings are not
exercised); the dm/gfx unit tests give the same results as before the change.
Octopath Traveler 0 1.0.5 (Unreal Engine 4.26 / 4.27 mobile renderer, SDK 19.3.13) plays from boot through character
creation into the story on the Mac (15 minutes with the iPhone memory layout, no crash); not yet run on an iPhone.
- Shaders: the 10,324 are in the two shader code archives of the game's pak, each one Oodle-compressed. uepak.py reads
the version 11 pak (AES-256 index; the key is the window of main that decrypts it, never printed or written; AES
through CommonCrypto) and the archives; the Mac host decodes them with the game's own OodleLZ_Decompress
(DM_OODLE_JOB, hle_oodle.c, build-gated) and shader_split.py reads the decoded LSLG records (profile key
shader_ue_decode; the NSP script's decode_ue_shaders stage). Bindings measured: Engage's separate texture layout.
- Memory: Unreal's nninitStartup reserves a 62 GB address region and its FMallocBinned3 37 GB of it. A cut skips the
allocator's constructor and the host puts its own FMalloc vtable on a malloc zone in the object (hle_uemalloc.c);
the region is capped at 2 GB with the game's own -SwitchVirtualAddressSpaceSize (new profile key game_args, also
for the iOS app) and SDK 19's node area is cut to 62 MB as Engage's, for a 3.75 GB guest block.
- Host: game arguments are set before nninitStartup, as on hardware; an undefined weak import nothing defines binds
to 0, as rtld binds it (Tears of the Kingdom boots the same with the old and new hosts: its weak glslc* imports
now resolve to 0); nvnCommandBufferFenceSync (OP_FENCE_SYNC), the texture / sampler pool getters,
vi::GetLatestFrameNumber / ListFrameHistory; DM_ALIAS_WINDOWS for sizing fixed-window allocators.
- Rewriter: a byte jump table whose add sits 17 instructions before its br (jump_bytes_far, tried last).
- build_ipa.sh quotes FONT_PY (a python path with spaces failed the font step).
Open: 61 vertex shaders pick a texture slot at run time (dynamic handle), 16 compute shaders with 3D image atomics
do not build. The profile (dm/games/01005270232F2000) holds the shader tables, translation chain b-ba76ba138e78.
The IPA built from the Unicorn Overlord profile plays on an iPhone (A20 Pro: 60 fps, sound, a 2.88 GB footprint).
README lists it (~4 GB) with a screenshot from the Mac (the opening scene in the castle garden); AGENTS.md counts
seventeen games and notes the engine it shares with 13 Sentinels and their alloc_big_align key.
Unicorn Overlord 1.05 (SDK 16.2.4, Vanillaware's engine as in 13 Sentinels) plays from boot through the title screen
into the opening story scene. Three boots, three fixes:
- the hardware multi-stream Opus decoder's C interface (CRI's Opus streams; the size query takes the SDK 12+ option
argument) goes to the sdk's software OpusMultiStreamDecoder, as the single-stream one does (hle_codec.c);
- nn::crypto::GenerateCryptographicallyRandomBytes, which the sdk's own getentropy calls under std::random_device,
fills from the host's entropy stream instead of opening csrng (hle_os.c);
- 13 Sentinels' 64 KB alignment of large StandardAllocator blocks becomes the profile key alloc_big_align. The engine
cuts ring heaps out of a memory pool at 64 KB GPU alignment; with 4 KB aligned pool storage one kept base 0, and the
game wrote to 0x268 right after the title screen. 13 Sentinels sets the key (its profile lists only the 1.10 build the
old check named), so its behaviour is unchanged.
The profile (dm/games/010069401ADB8000) holds 1,725 shaders from 2,188 GLSLC outputs in main, Mario's binding layout
with every binding decoded, translation chain b-239c170b7616.
The IPA built from the Hades II profile plays on an iPhone. README lists it (~4 GB) with a screenshot from the Mac
(Erebus, the first room of a run); AGENTS.md counts sixteen games and notes its texture slots up to 34 and the staff's
Omega aim lines that look like a rendering bug; the game notes drop "not tested on an iPhone".
New profile 0100A00019DE0000 (base game v0, display version 0.0.126424, SDK 19.3.9), from the NSP script's provisional
profile. Unlike Hades it renders with NVN: 88 BNSH shaders in Shaders/NVN/bin; the ExeFS holds only main and sdk.
- Shaders bind textures at fixed, sparse slots (0, 1, 3, …, 31 and 34): SceneFade.frag reads c2 0x130 = 0x20 + 8 x 34,
past Mario's 32 slots, so dmsh_build left that binding undecoded. nvn_tex_slots 35; the game has no image bindings.
- hle_ro.c: SDK 19's LoadModule takes one more bool (…mib), which the older hook did not catch; it and ro::Finalize
are hooked. The Epic Online Services NRO stays offline as in Hades (RegisterModuleInfo fails).
- Memory: no system resource; the 3.10 GB heap fits the 4 GB layout; main's 82 MB bss gives mem_used 108 MB.
- nvn_mtl.m: DM_NVN_HILITE set its keys and library up lazily on whichever thread built a pipeline first; with several
keys two threads raced and one used a library the other had replaced (objc_msgSend crash in pso_desc). Set up once.
On the Mac: title, a new save, the opening and Erebus with combat at 60 fps, resident memory flat at about 3.4 GB,
one red line (the NRR). The two long lines through Melinoë while the staff's Omega attack charges are the game's own
aim lines (WeaponStaffSwing5: AimLineCountOverride 2, AimLineAngleOffsetOverride 180), traced with vertex dumps and
probes; docs/development.md says so. IPA HadesII.ipa (4.0 GB) built; awaits an iPhone test.
The IPA built from b1cd440 plays on an iPhone. README lists it (~7 GB) with a screenshot from the Mac
(Edgeworth's office, the first investigation); AGENTS.md counts fifteen games and says where its Unity 2022 shader and
file-API handling live; the game notes and the profile header drop "not tested on an iPhone".
New profile 010005501E68C000 (update 1.0.0.2, Unity 2022.3.17f1 IL2CPP, SDK 17.5.3; the base game an XCI):
- Rewriter: two IL2CPP thunks end in an indirect tail call, ldr x18, [x0, #0x18]; ...; br x18 (main+0x1d271bc,
+0x204ebec), which asserted as an unknown jump table. When neither table form is found, the last write of x18 is a
plain load and the br ends the function, it becomes x16 = guest x18; br x16 (tail_call / site_br_call). Tried only
where the rewriter used to stop, so modules it accepted before come out byte-identical.
- sdk cut: the AddressRegionManager node area 1 GB -> 62 MB on this build (sdk+0x139468 / +0x1394fc), as for Engage;
StandardAllocator::Initialize aborted in nninitStartup without it.
- Shaders (unityfs.py, shader_split.py): Unity 2022 keeps each program's parameters as blob entries of their own
(m_ParameterBlobIndices; the programs are m_PlayerSubPrograms[tier][].m_BlobIndex), and subprogram version
202012090 (from 2021.2) has no local keyword list. The player's files borrow the unitybuiltinshaders bundle's Shader
type tree. 4,951 shaders, 4,950 build (one geometry shader); Engage's layout of driver segments decodes every binding.
UnityPy's type trees for 2020.3.18 and 2021.3 have neither field, so Engage reads every entry as before.
- hle_fs.c: nnmusl (open / stat / opendir) checks every path with nn::fs::IsPathMounted, GetPathLengthMax,
GetUtf16PathLengthMax, GetEntryNameLengthMax and GetUtf16EntryNameLengthMax, which the SDK answered from its empty
mount table: File.Exists failed with no nn::fs call, Addressables never found settings.json and the game crashed on
a null asset (main+0x31100ec). Answered from the host's mount table with Horizon's 0x300-byte limits.
- hle_time.c: clock_gettime(CLOCK_REALTIME) reads the source nntimeStartup selects (sdk+0x8e414),
nn::time::GetStandardUserSystemClockCurrentTimespec, which asked the time service; system_clock::now() then called
std::terminate after the first dialog. It and the adjustable clock's getter are the host clock now.
- Memory: Engage's 5 GB block with a 1 GB application pool. StandardAllocator commits 0.19 GB; with a 2.5 GB pool
its reservation and Unity's own pools reached the end of the 4.375 GB alias region after 140 s (probes on
VammManager::AllocateAddressRegion / FreeAddressRegion). 25 minutes now peak at +2.81 GB.
On the Mac: first-boot settings, title, Investigations 1's Episode 1 into the first investigation, 60 fps, FMOD sound,
25 minutes with no red line, 1.55 GB resident and 1.25 GB of Metal allocations, flat.
IPA AceAttorneyInvestigations.ipa (7.0 GB) built from HEAD + these changes; awaits an iPhone test.
Also: UNIMPL stubs log once (the seen counter was a byte and wrapped every 256 calls); build_ipa.sh quotes FONT_PY
(a Python path with spaces broke the font step); profile_guess.py marks IL2CPP by global-metadata.dat (Unity 2022's
main has no il2cpp_ names). Not needed in the end: pull requests #15 (this main's string table is intact) and #16
(nninitStartup sizes no heap with virtual address memory).
Much of the bring-up was found first by armyja (GitHub issues #17, #18, #19, pull requests #15, #16).
Co-authored-by: armyja <546323405@qq.com>
60 fps (30 on the Switch), two host-only cuts on 1.3.0 main (patch_nso.py):
- nnMain sets KTGL's device up with present interval 2 (main+0x8d94b0), and the device's frame rate, 59.94 / interval,
is what its event scripts step by (main+0x7802c4). The config interval becomes 1, the engine's own 60 fps mode.
- A KIDS task waits until 1 / fps has passed since the last frame (fps 30 from engine data) and then steps the game by
the time that really passed. Its wait becomes 1/60 s (main+0x166db80).
On the Mac the opening scenes at frame 2N match the original's frame N (same subtitle, same poses) and hold 60.
Battles ran at 25.8 fps at a 30 or a 60 fps target: the replay converted the soldiers' Half2 / Short2 attributes (fed
to integer shader inputs) on the CPU over whole bound mesh pools, 4.4 million vertices per submission at about twelve
submissions a frame, 24 of the 32 ms the replay took. In nvn_mtl.m:
- Profile key vconv_range (Age of Calamity on): only the vertices each draw reads (first / count, or the indices'
smallest and largest, kept while the index pages stay unwritten and dropped once a GPU write is queued on them),
bound as many vertices before the conversion as the range starts at. 219,000 vertices per submission;
DM_NVN_VCONV_CHECK=1 found no stale index range in 983,000 reuses. Keeping the conversions across frames did not
pay: the soldiers' attributes change every frame.
- vconv_fill: loops of their own for the 8 / 16-bit integer and the half formats (byte-identical to vconv_elem for
every format in an offline test; affects every game that converts).
- The visibility buffer ring is as deep as the command allocator pool (8, was 4): the submit thread waited for the
previous frame's GPU work.
First battle on the Mac (M4 Pro): 58.6 fps, frame p50 16.9 ms, replay CPU 10 ms, GPU 11 ms. On an iPhone the GPU will
likely decide in battle; the game steps by measured time, so it keeps normal speed below 60.
IPA HyruleWarriorsAgeOfCalamity-2026-10-20-60fps.ipa built from HEAD + these changes; awaits an iPhone test.
Two red lines from an iPhone log of the PR #11 build:
- agl's BC4 block encoder has a twin for a 2D source (93aa5e22a8202409, agl_common BAHS@0x44918; 8e2cc2671cae6020 reads
a 3D one). Now that the BC4 target is attached, its pipeline was refused (float4 output, RGBA16Uint attachment) and its
draws were lost. The profile's shader_facts_manual.tsv declares its output uint: chain d3a07e79011d (Mac and iOS
libraries and c1_full.tsv rebuilt). The Mac route does not reach it; a prewarm row for the pair fails on the old chain
and builds on the new one.
- NVN's integer-read-as-float vertex formats (*_UI2F / *_I2F, 0x51-0x68) had no Metal format, so the attribute read 0.
They now go through the replay's CPU conversion (vconv_i2f in nvn_mtl.m): the values as floats for a float input,
their float32 bits for an integer one. BotW's grass (uking_grass variants 3 and 7) reads attribute 2 as RG8_UI2F;
with it the grass parts around Link's feet again (Mac A/B from the same outdoor save). No other game's Mac logs show
these formats; Mario Wonder runs as before.
With the frame step at 2 vsyncs every frame stays on screen 33.3 ms, and the on-screen summary counted all of
them as dropped against the 60 fps period ("300 frames, dropped 300" in an iPhone BotW log). Each present's hold now
goes into a ring by present number, and the interval read 8 presents later is judged against its own presents'
holds: dropped = on screen longer than 1.5 x max(target period, hold x 16.7 ms). The [slowframe] diagnostic uses
the same limit. Checked with a scratch test built like t_nvn: step 2 at 33.3 ms 0 dropped, step changes 1 -> 3 -> 2
0, five doubled frames at step 1 5, discarded presents still counted.
The D2R 3D render-scale line ("main build ... has no known addresses") printed for every other game, where it read
like a failure (BotW and TOTK scale through DM_RENDER_SCALE); it now prints only when DM_3D_SCALE is set.
Tears of the Kingdom's three remaining red lines, with the fixes PR #11 found:
- A BC4 texture written through an RGBA16UI view (BC4 encoded on the GPU; TOTK a 32x32x32 3D texture one depth
plane per pass, BotW a 32x32 one) could not be attached, so its draws were lost. Such a pass now draws into an
uncompressed shadow of blocks x blocks; the blocks are copied in through a buffer before the pass and back after it
(nvn_mtl.m bcrt_attach / bcrt_flush). Shadows stay resident and are reused, and they are kept out of the
load-skip / store-elision bookkeeping.
- An agl fragment shader that only stores to a buffer draws into a 1x1 viewport with no target set: the pass is now
attachment-less at the viewport size (bare_dims) instead of skipped. The opening cave gets the ambient light it
lacked.
- nvnDeviceRegisterFastClearDepth returns success; nvnCommandBufferSetAlphaRef is logged once.
Neither game has a PC version, so the translator declared every fragment output float and Metal refused the
encoders' integer targets. The profiles' shader_facts_manual.tsv declare them (TOTK 0544282c162305db and the
Ultrahand mask e3fb0bb58a691e10, BotW 8e2cc2671cae6020), which changes only those two chains (743f7a102a72,
f5259a6c6fb6; Mac and iOS libraries and c1_full.tsv rebuilt). A pipeline red line now prints the row to add when a
float output meets an integer attachment.
Mac: the 3D BC4 texture was all zeros before and holds encoded blocks in all 32 planes after (raw dump, frame 1500);
TOTK's three red lines are gone; BotW (two runs) and Mario Wonder show no new red lines.
Co-authored-by: digitalhivehub <141404180+digitalhivehub@users.noreply.github.com>
A stub sets the scale where the game's dynamic resolution applies its own (main+0x1266880), so the scene buffers, the
shader's resolution ratio and the LOD check all follow it; a second stub takes that apply block every frame when the
factor is not 1.0 (the game only runs it after recomputing its scale). 1.0, the default, keeps the game's dynamic
resolution. Mac: at 0.5 every scene pass halves while the shadow maps, the 1920x1080 output and the HUD keep their
size; handheld works too (960x540 at 0.75). The Mac's GPU time does not change (about 650 passes a frame, fixed cost
first); the iPhone test decides whether it helps there.
The host decides each frame's step (1 to 3 vsyncs) from its own measurements (nvn_fstep.h, hle_nvn.c fstep_tick),
keeps the frame on screen that long and pays back real time lost to late frames one vsync per frame: even motion,
game time = real time, and 30 fps (not 40) when 60 does not hold. Tears of the Kingdom's frame-step stub writes
60 / step; Breath of the Wild gets a stub at the framework's setWaitVsyncInterval, so its VFR and every reader of the
framework's interval take the host's step, and its VFR override is published so the host holds it.
DM_FRAME_STEP=legacy (profile key frame_step) keeps each game's earlier method for the iPhone A/B test.
Mac: both games at 1.000 of real time at 60, and at 30 with 12 ms added to every frame. The docs record a
pre-existing intermittent Breath of the Wild crash at the world load (legacy too).
- hle_fpslock.c: on every present the host writes the profile's fps_target into RE Engine's frame limit at [main+0x1295b3f0]+0x434 (FPSLocker's address for build c9a3dd77; the game sets 30 after boot). Its frame loop (main+0x6b106c8) waits until [+0x430]/[+0x434] sixtieths of a second have passed and steps by the time that really passed.
- The game also asks for present interval 2: the profile's present_interval display ignores it. Both are needed; either alone stays at 30.
- Profile: fps_target 60, present_interval display; DM_FPS_TARGET=30 gives back 30. Mac: frame time p50 16.67 ms.
- MessageSuperviseThread (main+0x6bc8fc4) waits on oe::GetNotificationMessageEvent with WaitAny, which does not clear it, then pops until the queue is empty. The host only ever signalled the event, so after the focus change at iOS launch it stayed set and the thread looped: 995 ms/s, 5-6 W on an iPhone.
- hle_oe.c mq_ev_sync: the event is set while a notification is queued and cleared when the last one is popped (as the am service does), decided under the queue lock; hle_thread.c hle_clear_system_event.
- DM_OE_SCRIPT also starts at the game's first GetNotificationMessageEvent, for games that pop only once the event fires. Mac with focus=2@5,focus=1@6: before 995 ms/s, CPU 109%, 6.97 W; after the thread is idle, 0.05 W. Hades still pops its notifications.
- t_oe: stubs for the system event calls (it no longer linked) and 6 checks of the event state; 53/53.
- patch_nso.py FRAME_STEP_BUILD (main 277178b7, 1.4.3): stubs step the game by the measured frame time instead of its own interval (2 = 30 fps), capped at the target (30 while a prerendered movie plays), with game time that ran ahead paid back by the next frames. Also the two readers of the fps, the D-pad menu fade and the swapchain argument. Sites from masagrator's FPSLocker config for this build; the pay-back from OJZen/totk-mobile; stubs written and assembled here.
- State page after the key page (fixups key FPS_STATE_OFF): the loader writes the target (profile key fps_target, DM_FPS_TARGET; 0 = every stub runs the original instructions) and the render scale (DM_RENDER_SCALE 0.5-1.0; any factor but 1 fixes the size and drops the game's dynamic resolution).
- hle_nvn.c paces presents by the cap instead of the game's interval and logs game time against real time every 1,800 presents.
- TOTK profile: fps_target 60. Mac (M4 Pro): 60 presents a second in the opening cave, game time 1.000 of real time; at 30 and 0 as before.
- The idle timer is off while the app is active: only touches reset it, so with a controller or in a cutscene the screen dimmed and locked.
- Open file limit raised from 256 to 4096 at host start; Documents/romfs and temp are kept out of device backups.
- Audio: observers for audio session interruptions (Siri, alarms, calls) and media services resets (dm_audio_reset recreates the AudioUnits); becoming active ends an interruption whose "ended" never came.
- A watchdog switches a started output whose device has not called back for 200 ms to the silent sink and retries the AudioUnit after 1, 2, 4, 8 s; a start that fails falls back the same way instead of aborting. t_audio scenario 3 stops a playing AudioUnit behind the host's back.
- GetCurrentProcessorNumber: a thread with no ideal core reports the lowest core of its mask, not core 0.
- dm/ios/tools/pack_ipa.py replaces the Payload/ copy and Info-ZIP: it packs the app, the shader
libraries, the add-on content and the RomFS straight from their folders, reading and deflating on
all cores (zip64, modes, symlinks, the profile's zip_store as before). Metroid Dread 156 s -> 12 s,
Breath of the Wild 171 s -> 57 s (the T7 writes at about 310 MB/s).
- The shader libraries no longer go through Xcode (Gen/msl left the project): no 1.5 GB detour
through the home disk.
- NSP script: the rewrite, the shader split and the iOS / Vulkan shaders are reused when a sha256
of their inputs matches the one stored beside their output; the modules are rewritten in
parallel; J = the core count; the extraction is kept by default (--no-keep-extracted).
A Metroid Dread repeat build: about 8 min -> 19 s.
- Profile 0100B04011742000 (update 16.0.2, RE Engine): KPKA PAK shaders (shader_split.py kpka_jobs), XCI base game in nx_extract and the NSP script, BIG_TEXT patch area in patch_nso.py.
- Sparse virtual pool: a 4 KB page table (vpt_*) that the replay reads textures through, dirty tracking per mapped page, texture views from the first mapped level.
- MultiDraw*IndirectCount with a CPU wait for the count; pending GPU writes indexed by page; bindless handles read from queued inline updates.
- sbuf_ubo binding kind, integer vertex formats into float inputs, clears on textures that are not render targets.
- Inline keyboard text, state and finished callback; ngc::ProfanityFilter host vtable.
- Ryujinx BindlessElimination follows phi webs (218 MHR pixel shaders); dmsh_xlate_bin and dmsh_chain.lock updated; dmsh_chain.h and c1_full.tsv rebuilt for every Maxwell profile.
- README: Monster Hunter Rise under being ported, gallery image, XCI base games, Ryujinx local fixes.
dm/games/profile_guess.py decides the profile keys a game's files show, each written with its reason:
graphics vulkan and the modules to load (a subsdk only when main takes something from it the host does
not supply), shader_vk_capture / shader_main_glslc / shader_separable / shader_glslc_capture, the memory
partition (giant bss, no heap, virtual address memory), the SDK 8 / 10 font and NVN API, Hagi's handheld
pointer. --check compares the guess with every profile in dm/games: all 13 agree on these keys.
After the rewrite the script sets mem_used from the rewritten modules (Age of Calamity's 72 MB), writes
the empty shader tables and chain header a provisional Vulkan game lacked, and boots a game with virtual
address memory that sets a heap until its heap request to size the heap region (about 7 s; Tears of the
Kingdom gets its 0x98000000 alias region). A minute's boot could not tell which games need render_cond,
tex_flush_upload or core_preempt, so they stay with the developer.
--provisional builds a game that has a profile from a guessed one (DM_GAMES_FIRST in game.py and the host
Makefile, whose profile header is now rewritten only when it changes); --no-heap-check skips the check.
A capture run whose host stops early now says so. hle_svc.c logs a heap request the pool check refuses.
The route from a new game into the prologue battle counted presented frames at 30 fps. At 60 fps its
frame numbers double, and the repeating confirm step becomes Left, A, Left, A: A, Left, A loops forever
once it starts on an open dialog (A answers No, Left changes the selection, A reopens it), which
happened at character creation because A presses during the opening movie pause it and shift the
scenes after it. The new route reaches the battle in about 7 minutes. The DM_KEYS example in
05-debugging uses the same cycle.
The render thread forced present interval 2 while either of two flags was set: one for the full-screen
movie image (the opening movie), one from the scene object (the character creation screen after it, and
similar scenes). The engine's own 60 fps modes dropped to 30 there to keep 1080p; with the per-mode
tables at 1 it would also run the render thread's own steps at half speed. A cut at main+0x50fb7c
ignores both. Mac, new game: 59.9 fps through the movie and character creation; the movie lasts
210.6 s in both the original and the 60 fps build.
KTGL already updates the game 60 times a second on its main thread (a fixed 1/60 s step); the render
thread presents the latest update with the render mode's interval, 2 in the docked and handheld modes.
Per-build cuts on 1.2.0 main (patch_nso.py) set the per-mode interval tables to 1, as in the engine's
own unused 60 fps modes, so every update is shown at the same game speed. Two more cuts make the waits
cheap on the host: the frame limiter's yield-and-poll loop sleeps the time left (it spun 2 ms a frame
and ran at 59.5 updates a second), and the render thread's wait for the next update gets one frame
instead of 16 µs (a millisecond count passed to a microsecond wait; about 50,000 wakeups a second).
Mac, prologue battle: 59.9 fps, 55% of a core, 1.8 W of CPU energy (the original at 30 fps: 44%,
1.7 W). Not yet tested on an iPhone.
Being ported: Tears of the Kingdom runs on an iPhone into the opening cave (the 5 GB guest layout fits),
frame rate still being worked on; Super Mario Galaxy plays on the Mac into the Star Festival, not yet
tested on an iPhone. The gallery gets Mac captures of both (TOTK after the stone-block fix). The touch
controls line names the 2D games so it covers every profile, and says Galaxy's pointer follows touches.
AGENTS.md drops the stale note that TOTK's memory layout is too large for an iPhone.
- Log messages, script output, test labels, Makefile errors and comments are English. Red log lines start with "[RED] " (was 【红】).
- Shader map stage column: vertex / pixel / compute / geometry / hull / domain (generators, dmsh_build / dmsh_facts and nvn_mtl.m stage_of follow).
- The NSP script's regexes match the new shader_split.py / dmsh_build.py summary lines; patch_nso cut scopes are all / host-only.
- Host UI text table (dm_i18n_tab.h) is English only; dm_lang_zh() removed.
- Text that matches game data (t_swkbd.c, t_textbox.c) is written as \x escapes.
- Shader chain: the chain files lose their bilingual comments, so every profile has a new chain version. dmsh_xlate.dll rebuilt from the translated Program.cs (SPIR-V byte-identical on 4,500 shaders of Wonder, TOTK and Age of Calamity), lock and dmsh_chain.h headers regenerated. c1_full.tsv restamped for Dread, 13 Sentinels and Wonder; the other profiles follow when their rebuild finishes.
- AGENTS.md and docs/agents: the language rule now covers logs; quoted log lines updated.
The Wii game on NERD's Hagi layer (PowerPC code recompiled into main), rendering
with Vulkan through Hades' layer. Title screen, file creation and the storybook
prologue into the Star Festival at about 58 fps on the Mac; the IPA awaits an
iPhone test.
- Vulkan layer (hle_vk.m): push constants, descriptor arrays, dual-source
blending variants, copies inside a render pass (cut and resume with deferred
store actions), linear images over their memory, timestamp and occlusion
queries, pipeline caches, vkCmdDraw, image copies and read-backs, USCALED /
SSCALED and three-component vertex formats, viewport and scissor kept across
passes, the INHERIT swapchain transform, the vi layer crop at present.
- vk_shaders.py: ArrayStride on struct members, push constants, array counts,
`_d` dual-source variants, extra SPIR-V input dirs; non-vert/frag stages
skipped with a red line.
- Run-time SPIR-V capture: DM_VK_CAPTURE in the layer, profile key
shader_vk_capture and vk_capture.tsv (3,089 keys), and the NSP script's
Vulkan shader capture stage.
- Handheld pointer: profile key op_mode, touch hooks fill touches only in
handheld mode, InitializeTouchScreen(int,int), DM_KEYS taps, iOS gamepad
forwards untaken touches to the game.
- SleepThread under 20 us takes the yield back-off (Hagi's 100 ns poll kept a
core at 100%).
- nx_extract / NSP script: --allow-damaged for NSPs whose files fail the NCA
hash check.
The blocks and steps kept their texture and ambient light but stayed black beside the torch, on the Mac and the iPhone. Their shader (54e8c08998050e79) takes roughness from a 5120x5120 BC4 atlas that was all zero; roughness 0 gives no point light. TOTK fills that atlas on the CPU: it fills 1024x1024 BC4 material textures from the CPU with no NVN flush, reads them back with nvnTextureReadTexels on its own threads before any draw uses them, and copies the blocks into the atlas. tex_dirty_upload only marked such a texture CPU-written when the submit thread first resolved it, so ReadTexels returned the never-uploaded host texture and the atlas got zeros.
- mtl_read_texels (every game): a texture whose content is in guest memory (packaged layout, CPU-written, or with tex_dirty_upload a registered texture written since registration) is read there, on any thread, without resolving or uploading the host texture.
- nvn_ft_poll (games with tex_dirty_upload, now TOTK): at each submission after guest memory changed, registered textures written after their first use become CPU-written and are uploaded at their next use; the atlas itself is filled after it was first bound. At most 128 MB of textures per look, going on where it stopped, through each entry's cached dirty-range handle (searching every pool per check cost 0.4 ms a frame). Textures made as render targets or shader-writable (mtl_tex_gpu_owned) leave the list without an upload.
Found by running MacNativeGames' build of the same game on the same save (their atlas held data and their blocks were lit), then reading the atlas memory inside their process. Mac A/B against the previous commit on the TOTK route, side by side: 30 fps, no drops to interval 3, submit CPU 4.46 vs 4.79 ms. Notes in docs/agents/04-graphics.md, with the still-open block-height rounding bug MacNativeGames reported.
The blocks keep their texture and ambient light but stay black beside the torch (Mac and iPhone; lit on the Switch). Drawn by 54e8c08998050e79, they write roughness 0 into colour target 3, sampled from a 5120x5120 BC4 atlas that nothing tracked ever writes; filling it by hand did not light them, so that is not the cause. Still open: the torch's shadow map, the light volumes' half-resolution depth test, the normal map.
MacNativeGames (a TOTK port on an older copy of this replay layer, MIT) reports that NVN truncates height_in_blocks * 1.5 / 8 when choosing the automatic block height, where nvn_tex.c rounds up: textures of some sizes are read with the wrong layout. The fix touches every game and waits for a check across the profiles. Fast loop and session patch location recorded.
The back-off (each yield sleeps an eighth of the run so far, 10-250 µs) started after 200 µs of consecutive yields. That also caught TOTK's main thread waiting for its ModuleSystem workers, about 350 yields a frame in runs of a few hundred µs on the frame's critical path: a sleep that overran the workers' finish pushed frames past 33 ms, and the game then drops itself to present interval 3 (20 fps) for a while. Mac, two runs side by side: 9 drops in 300 s and frame p95 41.7 ms with the back-off, 0 drops and 34.4 ms without, at the same CPU time. On the iPhone frame p95 was 87-91 ms and the threads stayed on efficiency cores at 1.5 GHz.
Waits under 2 ms are handoffs between working threads; limiters and idle polls run far longer. With the 2 ms start TOTK's main thread and busiest worker never back off (0 drops, 30.00 fps, as with the back-off off), and Three Houses' 23 ms limiter wait still does: CPU 78% of a core without the back-off, 19% with it, 14% with the old start, same frame times. DM_YIELD_BACKOFF=<cap µs>[:<start µs>] sets both (default 250:2000).