Files
seedds 3a993fa3fe Subsystem references and a symptom index: the failure catalogue of docs/agents/03-bringup.md and the per-title material of docs/agents/04-graphics.md (section 1's container list, section 6 and its "Seen in <game>" subsections) move into docs/reference/, one heading per mechanism: rewriter.md, memory.md, threads.md, services.md, movies.md, audio.md, frame-rate.md, nvn-objects.md, rendering.md (with the Vulkan and OpenGL layers), shaders.md and game-state.md, indexed by docs/reference/README.md. Each entry gives the symptom, the cause, the fix (file and function), the games it was found on (linked to their notes in dm/games/<id>/README.md) and any check, caveat or later change; one mechanism met on several games is one entry. docs/agents/symptoms.md lists every entry's symptom (an assertion, a [RED] line, an abort or crash site, a visible result) with a link to it, 350 lines, generated from the Symptom bullets. 03 keeps the loop and reading the log, and its Failure catalogue section now points to the references, the index and where a new finding goes; 04 keeps sections 1-5 and 7 (the container list is shaders.md's Containers by engine), and section 6 says where each kind of finding is. Facts in the old sections about one game only were checked against that game's note, where they already were. Statements the code no longer matches are marked as history beside the current value (movie output slots: MD_NOUT 64, MD_NOUT_AAC 32, MD_NOUT_VIDEO 16; APP_POOL is the profile key app_pool; Engage's layout is 5 / 4.375 GB; inline uniform updates are deferred by default; scaled copies are drawn), and Tears of the Kingdom joins the node-area entry's build list (issue #70). The game notes, docs/agents/README.md, 02 and 05 link to the exact entries, and so do the comments in nvn_mtl.m (nvnTextureCompare) and hle_audio.c (the RemoteIO buffer) and the provisional-profile note in profile_guess.py. 03 goes from 29,600 to 470 words, 04 from 34,400 to 3,500. Checked: every backticked span, module address, issue / PR number and 16-digit hash of AGENTS.md, development.md, dm/games/README.md and docs/agents/01-05 at 6a06877 (4,600 items) still appears in the repository's Markdown (the same five file patterns as before appear as the full names they stood for), every relative link and heading anchor in the repository's Markdown resolves, and the tool tests pass (the four that need capstone or lz4 in the T7 venv)
2026-10-25 12:31:58 +08:00

17 KiB
Raw Permalink Blame History

DRAGON QUEST XI S: Echoes of an Elusive Age – Definitive Edition (01006C300E9F0000)

Update 1.0.4 (v262144) over the base game, SDK 7.3.2. Square Enix, Unreal Engine 4.18 (legacy version 4 paks, LZ5-compressed) on NVN, with Square Enix's own sound driver (SQEX::Sd, "SEAD") on the audio renderer. Modules main, subsdk0 (the movie module, driven by Unreal's Switch movie player: WebM, VP9 with two 6-channel Vorbis tracks) and sdk; the sdk is the same build as Super Mario Odyssey 1.4.1's (0100000000010000), and Raging Loop (010020400E1C2000) runs this game's sdk and subsdk0 builds. Plays on the iPhone; 60 fps (30 on the Switch) by the host's writes of Unreal's frame limits.

Status: brought up on the Mac through the opening movie, the title (name entry, system settings), the prologue movie (skipped with +) and into free play at Cobblestone Tor (the hero, Gemma, the villagers, the HUD and the minimap), at 30 fps with sound and saves; no red line, about 2.1 GB resident (1.1 GB of it Metal allocations), 1.5 GB mapped in the guest. Playable on an iPhone.

What it needs

Setting Where Why
fps_target 60 game.tsv, hle_fpslock.c the host writes 60 into t.MaxFPS and 1 into nvn.SyncInterval; see Frame rate
game_args -SwitchVirtualAddressSpaceSize=4064 game.tsv caps Unreal's one engine region (16 GB by default) at 4064 MB; the value is MB up to 4095, bytes above
Node area cut 1 GB → 62 MB (sdk+0x32c460 / +0x32c500) and 32 MB alignment for regions of 1 GB and more (sdk+0x32c66c) dm/tools/sdk_patches.py, sdk build fcf93a41… together they put the engine region 64 MB into the alias region; the 1 GB alignment would have put it 1 GB in; see Memory
Engage's 5 GB block: mem_reserve 0x140000000, mem_alias 0x118000000, mem_stack 0x10000000, app_pool 0xCD500000 game.tsv 256 MB in front + 4.375 GB alias region + 256 MB stack region + 128 MB heap region (the game sets no heap)
pad_layout full game.tsv a 3D game: D-pad, right stick (camera) and L3 / R3 on the iPhone's virtual gamepad

Frame rate

  • Current: 60 fps (30 on the Switch). The game's config caps Unreal at 30: FrameRateLimit=30 in DefaultGameUserSettings.ini, which sets t.MaxFPS, and nvn.SyncInterval=2 in SwitchEngine.ini, the present interval 2 it asks for. The profile's fps_target writes 60 into this build's t.MaxFPS and 1 into nvn.SyncInterval (hle_fpslock.c, both threads' copies), so the game asks for present interval 1 itself.
  • Unreal steps the game by the time that really passed. On the Mac (M4 Pro) the field holds 60 fps (frame time p95 17 ms, GPU 6.6 ms p50 / 9.7 ms p95) at normal speed, with sound intact.
  • The game's dynamic resolution budgets are for 30 fps (r.DynamicResolution.*TimeBudget 28–35 ms in SwitchEngine.ini), so it does not lower the resolution to reach 60: where a frame needs more than 16.7 ms, frames are dropped instead.
  • DM_FPS_TARGET=30 (Documents/env.txt on the iPhone) keeps the game's 30 and saves power.

Memory

  • Unreal Engine 4.18's nninitStartup takes one address region for the whole engine (16 GB by default). It reads -SwitchVirtualAddressSpaceSize= and the other options from argv[1] on (main+0x177b5a4; main+0x177c440 parses each one: atoi, MB up to 4095, bytes above). The region needs no allocator cut: the SDK's NumberLineAllocator hands it out in 64 KB units (main+0x177c4d0), and the engine maps only what it uses.
  • Resolved: a crash in NumberLineAllocator::Compaction at boot. A value of 4224 was taken as bytes: a 4 KB region, and the first 2 MB request crashed. The profile gives 4064 (MB).
  • Current: the node area cut and 32 MB alignment for large regions on the SDK 7.3.2 build put a 4064 MB region 64 MB into Engage's 4.375 GB alias region. The AddressRegionManager constructor is at sdk+0x32c450 (the limit at +0x28 and the occupied length both from orr w9,wzr,#0x3fe00000); VammManager::AllocateAddressRegion's placement at sdk+0x32c600 aligns a region by its size as SDK 18 does: 64 KB under 2 MB, 2 MB under 4 MB, 4 MB under 32 MB, 32 MB under 1 GB, else 1 GB (+0x32c66c). The same cuts serve Super Smash Bros. Ultimate's SDK 8.2.1 build and Advance Wars 1+2's SDK 10.4.1 build at other offsets.
  • On the Mac the mapped total stays at 1.5 GB from the title to the first field (Cobblestone Tor), used up to +1.75 GB of the alias region; the process holds about 2.1 GB, 1.1 GB of it Metal allocations.

Rendering

  • Resolved: the screen came out a flat blue. Unreal Engine 4.18 builds its 32×32×32 colour-grading LUT with WriteToSliceMainVS + FWriteToSliceGS, one slice per instance; Metal has no geometry stage, so the tone mapper read an empty LUT. The slice-writing geometry shader is now folded into its vertex shader's layer entry points (dmsh_build.py gs_slice, vs_layer_entry; nvn_mtl.m prog_fold_gs). The other 22 geometry shaders (one-pass cube shadows that emit transformed triangles) do not build.
  • Resolved: an abort in MTLTextureDescriptor validation when the prologue movie ends (arrayLength (510) … greater than the max allowed size (341)). Its cube-map array of 510 faces (85 cubes) needed NVN's layer-face count: the host took the depth as a cube count in the Metal texture, the upload's layer count and the guest layout (nvn_tex.c shape). A cube array is now created as a 2D array of all its faces, and tex_fit makes the cube-array view a shader asks for.
  • The flushed-before-first-use texture fill (tex_init_fill) filled no texture in this game (title, free play, menus; 300 s).
  • NVN calls new with Unreal Engine 4.18: nvnTextureGetTextureAddress (pool address + offset, as nvnMemoryPoolGetBufferAddress) and nvnCommandBufferGetCommandMemorySize (used + free, as in NVN; GetCommandMemoryFree is what is left in the current segment).

Files and shaders

  • Legacy version 4 paks: JackGame/Content/Paks/pakchunk0-Switch.pak (6.6 GB, 264,844 files) and pakchunk1-Switch.pak (7.2 GB, 147,824 files; the profile's romfs_marker). The update adds *_0_P.pak patch paks, which the engine mounts over the others (a path in a _P pak wins).
  • The footer is the last 0x2d bytes (encrypted index flag, magic, version, index offset and size, SHA-1), and the index is not encrypted: the mount point, the file count, then each file's name and its FPakEntry (offset, stored and raw size, compression flags, SHA-1, the blocks' start and end, which are file offsets before version 5, the encrypted flag and the block size, 64 KB).
  • The compression flags are no standard value: 3, which is LZ5 2.0 (LZ5 v2, later renamed Lizard; main exports LZ5_*, LZ5_versionNumber = 20001, and FCompression::UncompressMemory calls LZ5_decompress_safe for flags 3, main+0x16f5c70). Every block starts with its level, 0x2d = 45 (LIZv1 with Huffman). The repository has no LZ5 decoder: the game's own runs in the Mac host's tool mode (hle_oodle.c, DM_OODLE_SERVE), which uepak.py (Pak.read with a decode function) and the shader split use. It takes 512 KB of work memory from the game's malloc on every call, so the host serves it only after nninitStartup. Scanning both paks (13.8 GB) with 8 workers takes 28 s.
  • There is no shader code archive. Each material's shader map is serialized in its .uexp (38 to 87 shaders in the ones looked at) and the engine's global shaders in Engine/GlobalShaderCache-GLSL_SWITCH.bin (945: 597 pixel, 104 vertex, 241 compute, 3 geometry). Each shader's code is a byte array (i32 length, then the bytes) holding one zlib stream that inflates to an LSLG record. shader_split.py (ue_legacy_jobs, ue_inline_shaders) decodes every file of the newest copy of each path with the game's own LZ5 and takes each zlib stream that inflates whole from exactly the length in front of it and starts with LSLG (every such stream in the files looked at); records stored uncompressed are taken too, but none were seen.
  • 38,892 unique shaders (4,654 vertex, 33,981 pixel, 234 compute, 23 geometry) from 1,740 files, grouped as global, engine and game (shader_groups). Vertex and pixel shaders are paired as single-stage programs, as for Octopath Traveler 0 (01005270232F2000).

Sound and movies

  • Resolved: the sound ran about 50 times faster than the output. SEAD (SQEX::Sd::Driver::Core::CoreAudioOut, its loop at main+0x442c5c0) appends a wave buffer, mixes the next block on the same thread, calls RequestUpdate and takes the next released buffer; it waits on the renderer event only when no buffer is released. Every update finds no frame rendered since the last one and renders a catch-up frame, which releases the buffer just appended, so the loop never waits; when a full catch-up queue discarded the frame, the loop ran 11,000 updates a second on one core. An update with a full queue now waits (at most 50 ms) for the render thread to play one queued frame, so catch-up frames keep real time (hle_audren.c).
  • The audio renderer's update lock on this SDK is MutexType at +0x1d8; checked with sound on this game (update_lock in hle_audren.c).
  • Resolved: a crash in InternalCriticalSectionImplByHorizon::Enter at the first deferred movie signal. The movie player registers its event struct right after constructing the decoder (main+0x39edab0, through the decoder's vtable) and fills it later, so the host's copy held leftovers (0xe00000010). The SDK keeps only the pointer (DecoderImpl::RegisterClientEvents stores it at +0xd0) and reads the entry each time it signals, skipping a null one (subsdk0+0x1feb0); the host now does the same (md_event in hle_movie.c).
  • Resolved: the opening movie never started in about half the boots (a black screen and no sound from boot on). Square Enix's UMediaPlayer has two natives of its own, StartPreDecoding and IsPreDecodingCompleted. The opening's Blueprint polls the second once a frame and only then plays its Matinee, whose event key calls Play. Its Switch player (vtable main+0x77921b8, IsPreDecodingCompleted at main+0x42d4d20) is complete when every selected track has at least one decoded output queued. The player starts its clock and its decoders on the first TickInput after StartPreDecoding, and the render thread's first fetch (main+0x39f07d0, the +0x5c7 flag) takes every decoded picture and shows the newest. The host decoded all eight video outputs (to 236 ms) within 36 ms; each Vorbis decoder's 8 outputs were full by then, because nothing consumes audio before Play, so the extractor waited for an audio input and no picture followed the ones the render thread took. Audio decoders then kept 32 outputs (MD_NOUT, 64 since Monster Train 2; video keeps 8): 8 boots out of 8 start. Found by probing the player's SetRate (DM_PROBE=main:<off>) to see whether the game ever asked to play, then following the Blueprint native that calls it (the FNameNativePtrPair tables give the names).
  • Resolved: 2.25 GB mapped within seconds after the prologue movie was skipped, then a crash in the game's allocator. Once its extractor is done, the movie player sends an end-of-stream packet (length 0, time 0) for every input it gets, until the game stops the movie; each became an end-of-stream output that the player turned into an audio sample. An input marked end-of-stream is now the last until Flush / Stop (in_eos in hle_movie.c).
  • Resolved: distorted movie sound, with peaks far past full scale. The 6-channel Vorbis tracks decode right only since stb_vorbis's fix for type-2 residues with more than two channels (stb_vorbis README, GitHub PR #60).

Services

  • Resolved: an abort from ec::InitializeForShopServiceAccessors, called from Unreal's FOnlineSubsystemSwitch::Init at the start of nnMain (SVC 0x15 / 0x1f / 0x21 placeholders, then Break with sm::Finalize under ShopServiceAccessServer::Proxy::Initialize). The library init succeeds with nothing to set up, an accessor never initializes, and its async response is complete at once with no data (hle_misc.c); the accessor and response are stubbed whole because the SDK's destructor aborts unless Finalize succeeds.
  • Listing the rest of main's imports with no hook gave oe::Begin / EndAutoSleepDisabledSection and IsAutoSleepDisabled (counted, hle_oe.c), pctl::CheckFreeCommunicationPermission (never), SDK 7's album::SaveScreenshot without an orientation (fails), friends::GetBlockedUserList, DeclareOpen / DeclareCloseOnlinePlaySession (unavailable) and fs::DisableGlobalFileDataCache. The game then runs its opening movie at 30 fps. The friends::GetBlockedUserList overload with a Uid reports a failure, which this game takes as it is.

Rewriter

  • Resolved: a jump table <addr> assertion where the index is a copy made before the check (main+0x2dd8cd0: mov w0,w18, then 41 instructions holding two other jump tables on x0, then cmp w18,#4; b.hi; ldrsw x18,[x11,x0,lsl #2]; add x18,x18,x11; br x18). check_before_copy accepts it. A copy of patch_nso.py whose assert prints and continues found this one alone among the 25,508 x18 sites.
  • main has no unwind entries for its first 70 MB (a 68 MB stretch), which the rewriter disassembled as one function (6 minutes); it got through on a quieter Mac with the compressor and swap. With functions analysed in pieces it takes 292 pieces, and the result is the same as with 1 MB pieces and b within 256 KB (75 pieces).

Mac routes

  • With the save of an earlier run (the name entered), DM_KEYS="20:A*150/3,250:Plus*4/10,480:B,483:LUp~4" skips the prologue movie at about 270 s and walks on Cobblestone Tor at about 480 s.

Found on this game

General host changes first needed here; each applies to every game:

Open

  • The bring-up status (development.md) says 30 fps on the Mac; its frame-rate entry says 60 fps on the Mac with fps_target 60. No source records a 60 fps run on the iPhone, while the player-facing README lists the game as 60 fps with 1.0.4.
  • 22 geometry shaders (one-pass cube shadows) do not build.