# Luigi's Mansion 3 (`0100DCA0064A6000`) Update 1.4.0 (v327680) over the base game, SDK 7.3.2 (NVN 53). Next Level Games' own engine on NVN, with Havok physics and cloth, Wwise sound (a 6-channel AudioOut) and NEX / Pia online. Modules `main`, `subsdk0` (`nnSdkEn`, a second copy of the SDK), `subsdk1` (NintendoSDK_movie) and `sdk`; `sdk` is the build of Super Mario Odyssey ([0100000000010000](../0100000000010000/README.md)) and DRAGON QUEST XI S ([01006C300E9F0000](../01006C300E9F0000/README.md)). Playable on the iPhone at 60 fps (30 on the Switch). On the Mac: the boot notices, the title (the hotel in daylight), a new game and the opening cutscene (the bus ride, the arrival at the hotel, the lobby), then the lobby in play at 60 fps, about 3.7 GB phys_footprint (3.0 GB of Metal allocations). ## What it needs | Setting | Where | Why | |---|---|---| | `fps_target 60` | [`game.tsv`](game.tsv), `hle_fpslock.c` | the host writes the frame limiter's 33.333 ms as a frame time; see [Frame rate](#frame-rate) | | `present_interval display` | `game.tsv` | ignores the present interval 2 the game also asks for | | `mem_used 0x4600000` | `game.tsv` | the rewritten modules are larger than the default 64 MB of used memory | | 4 GB layout: `mem_reserve 0x100000000`, `mem_alias 0x10000000`, `mem_stack 0x10000000`, `app_pool 0xCD500000`, `sys_resource 0x0` | `game.tsv` | no virtual address memory (the NPDM declares no system resource): `main` sets a 2.94 GB heap | | `pad_layout full` | `game.tsv` | Luigi moves with the left stick and aims with the right; the D-pad calls up the Virtual Boo: the iPhone pad starts in the 3D layout, which has both | | Node area cut and 2 MB alignment of large regions (SDK 20's) | `dm/tools/sdk_patches.py` SDK-wide rules, `sdk` build `fcf93a41…` | DRAGON QUEST XI S's build, cut by the SDK-wide rules; this game has no virtual address memory | No `patches.py`. ## Frame rate - **Current:** 60 fps (30 on the Switch). At the end of each frame the main thread calls its frame callbacks (`main+0xb7c308`) with the milliseconds since the frame began; the frame limiter among them (`main+0x7a3e70`) runs jobs or sleeps until 33.333 ms have passed, a float in read-only data (`main+0x13c94dc`, read only there), and the game steps by the time that really passed. The host writes the profile's `fps_target` there as a frame time (`hle_fpslock.c`): 16.167 ms at 60, half a millisecond under the vsync so the presents set the pace. At exactly 16.667 the work after the limiter made it 59.6 fps with a frame shown twice every 2.5 s. - On the Mac the lobby holds 60.0 fps (display interval p99 16.67 ms); the same 2 s walk and 1.5 s turn from the same save end at the same spot at 30 and 60 fps, and the title's animation matches at time scale 1.00 (`DM_NVN_REGLUM`). `DM_FPS_TARGET=30` keeps the game's 33.333 ms. - The limiter also has a phase lock, a nudge of at most 0.05 ms a frame from the layer's frame history, which stays off: the host's `nn::vi::ListFrameHistory` returns no entries. ## Modules and memory - `subsdk0` (`nnSdkEn`) is `sdk`'s code byte for byte, with another module name and build ID; it comes first in the load order, so `main`'s imports bind to it. - No virtual address memory: `main` sets a 2.94 GB heap. ## Command buffers - **Resolved: the title showed one stretched 64×64 icon over the 3D scene, a new game a white screen.** The engine records a command buffer, then rewrites texture handles in it before submitting, as the hardware lays the command out: before `nvnCommandBufferBindTexture` it takes the command memory base + `GetCommandMemoryUsed` (`main+0x7c8964`, the only reader of that NVN entry point), and every frame it stores a 32-bit handle at that address + 0x10 (`main+0xb7d0d4`, on a job worker). In a host bind record that word was the count, and the replay read 535 million handles from whatever followed, so the post-process passes kept stale texture handles in their slots. Found with a check that reports a bind record holding more than 32 handles (`[RED] bind record … overwritten after recording`), then `DM_PROBE_WATCH` on that word. **Current:** a single texture bind is recorded as `OP_BIND_TEX1`, its handle 16 bytes in (`nvn_obj.h` `p_bind_tex1`, read through `bind_rec`). ## Rendering A deferred renderer: the scene draws into a G-buffer of two integer targets (R32Uint, RGBA8Uint) and a D32S8 depth, light volumes and a full-screen resolve light it into RG11B10F targets, and a chain of 960×540 to 60×33 bloom passes ends in the window. Most textures are ASTC. - **Resolved: the bundled pipeline archive was refused** (`pipeline archive bundled with the app: made for … depth near=-w …, this run needs … depth near=0`): the game sets `nvnDeviceSetDepthMode` near = 0, so on its first start the device built its own 3,852 bases before the first frame (16 s), and again after each change of the chain or the pair lists. **Current:** the profile declares `depth_mode zero`, so the archive holds the `main0_z` vertex entries (PR #580). - **Resolved: every scene draw skipped** (`failed to create render pipeline … output of type float4 is not compatible with a MTLPixelFormatR32Uint color attachment`, about 1,400 pixel shaders). With no PC version the translator types every fragment output float, and the G-buffer shaders write bit-packed integers through `as_type`. A hand-written row per shader (`shader_facts_manual.tsv`, Breath of the Wild's way) does not scale to that. **Current:** 1,396 pixel shaders get a second entry point, `main0_u`, that writes the G-buffer as integers (`fs_uint`); the host takes it for a pipeline whose written colour attachments are all unsigned integer formats (`pso_all_uint`). - **Current: shadow cascades from an array of driver-table handles** (586 pixel shaders: `array, 32> fp_t2das_cb2_0`, `shaders with a binding whose slot was not decoded 588`), indexed by (0x80 + a cascade offset) ÷ 8 over the driver table c2 (`c2_array`). - **Resolved: the boot aborted in Metal's pipeline validation** (`MTLPixelFormatDepth16Unorm is not color renderable`): a 1024×1024 D16 texture (a shadow) is drawn through an R16 view. Such a pass now draws into a colour shadow of the same pixel size, copied in before and back after the pass (`shrt_block`; D16 and D32F; not D24S8). - **Resolved: a stencil-mode texture view aborted under the API validation layer:** Metal allows such a view only of a texture with the pixel-format-view usage; every depth-stencil texture has that usage now (PR #106). - Host calls added at bring-up: `nvnBlendStateSetAdvancedMode` and `nvnCommandBufferSetDebugLabel`. - **Not done: tessellation and geometry shaders.** The tessellation stages (15 hull, 18 domain shaders; `primitive type 14 not implemented`) do not build and their patch draws are skipped; 10 geometry shaders (one expands a box from two corners) do not build either. Neither is drawn much. Two tessellated programs (vertex `28a39709d0adfcfa` / hull `51d2d156bb12c260` / domain `0b95742e863bec50`, and `d4d89931b5a9a514` / `8aa6feeb7d33e9f6` / `f254b4dce2d6c012`) draw twice a frame for about 30 frames, in the title scene behind the file select as it slides out before a new game. From the title through a new game, the opening cutscene and the lobby, no draw used a geometry shader. A program whose geometry shader has no library used to draw silently without that stage, with the vertex shader's positions taken as clip coordinates; **Current:** `draw with geometry shader … which has no library` names it. - SPIRV-Cross already writes the hull shaders as kernels and the domain shaders as post-tessellation vertex functions, but Metal 4, which the host draws with, has no tessellation stage (no `drawPatches`, no factor buffer): the draws would need a tessellator of the host's own in a compute kernel and the domain shaders rewritten as plain vertex functions. The hull kernels also write quad factors (`MTLQuadTessellationFactorsHalf`) for triangle patches. The vertex shaders that feed a hull or geometry shader can be found from the shader headers (SPH): the generic attribute map at +0x18 of the consumer's header (inputs) equals the one at +0x36 of the vertex shader's (outputs). 40 vertex shaders match a hull shader and 49 a geometry shader. - A few pixel shaders declare a 3D texture in a slot the game fills with a 32×32 2D texture (`texture type mismatch`), on paths not seen taken. The host now binds the zero 3D texture there ([A 2D texture in a 3D slot binds the zero 3D texture](../../../docs/reference/rendering.md#a-2d-texture-in-a-3d-slot-binds-the-zero-3d-texture)); not run on this game since. ### Texture memory (2026-10-27) - **Confirmed:** [#257](https://github.com/seedds/nxnative/issues/257) and [#100](https://github.com/seedds/nxnative/issues/100) identify a real storage-duplication mechanism. An instrumented Mac run at frame 3,300 retained 101 live descriptions at one depth-image address, consuming 1,013.5 MiB across private Metal textures although its largest description needed 10.3 MiB. Several colour-image addresses held 91–99 live descriptions each, using 724–788.7 MiB per address for images whose largest allocation was 8.1 MiB. These are live allocation-table entries, not cumulative allocation events. The descriptions vary gradually from 1769×995 to 1920×1080 (half/quarter-size families too), use `COMPRESSIBLE|IMAGE`, and share their NVN pool and offset. Almost none of this memory was an unreferenced cache entry. - **Resolved for these families:** [physical image sharing](../../../docs/reference/rendering.md#live-resolution-variants-share-physical-image-storage) preserves each descriptor and its dimensions while backing compatible variants with one allocation. It is general host behavior; the game profile and shader chain are unchanged. - **Mac validation:** M6, 1.4.0, Docked/60, chain `5cddc47253ab`, separate fresh saves, silent output, direct drawable presentation (`DM_PRESENT_LINK=0`), matching shader libraries and frame-number inputs. At frame 3,300 on the same opening route, baseline resident/GPU allocation was 6,095/5,507 MiB; with sharing, 2,749/2,125 MiB. The fixed run continued through the bus ride, arrival and hotel cutscene: 3,067/2,297 MiB at frame 15,000, with the picture checked in captures. A separate seconds-driven opening also passed at approximately 2.9 GiB resident. These are bounded Mac observations, not an iOS process-limit or full-campaign stability claim. - **Final build after integration with main:** all 5,317 Mac libraries rebuilt on chain `a4e2088c2e69`; a separate headless hotel-save replay with `DM_NVN_ENC_THREAD=1` reached the playable lobby and frame 4,200, with the lobby picture checked. Its last memory sample (frame 3,600) was 2,780/2,108 MiB resident/GPU. The 318-check image-sharing regression (including a real atomic write through shared integer-image backing) and six existing NVN/clear/copy/ordering/upgrade regressions pass under Metal validation with each encoding mode (1,432 checks total); documentation and shader-chain checks pass too. - **Open:** later room-to-room transitions, the reported Floor 4 black floor and iOS memory-limit/long-session acceptance are not established by this route. The black floor has not been attributed to texture allocation. ## Files and shaders - Every RomFS file but the Wwise banks (`wwiseaudio/*.pck`; `wwiseaudio/streamedfiles.pck` is the profile's `romfs_marker`) is an archive of the engine's own: `.dict` indexes zlib chunks in `.data` (`init`, `global`, `Hotel/`, … 98 of them). - The `.dict` is 3,203 bytes in every archive: u32 magic `0xa9f32458`, u8 4, u8 1, u8 compressed, u8 0, u32 the largest stored chunk, u8 chunk count (120), u8 record count (52), u8 name count (3), u8 0; then the records (0x18 bytes: u32 hash, u16 the top-level entry count of its table, u16, and one chunk index for each of 16 sections), the chunks (0x10 bytes: u32 offset, length, stored length (one zlib stream; 0 in an archive stored as is), u16 alignment as log2, u8 file, u8 GPU memory) and the file names `.data`, `.debug`, `.nxpc`. Only `.data` ships: the 13 even records are the game's variants (languages), the odd ones point into `.nxpc`. Record v's resource table is chunk v: 12-byte entries {u32 kind (bit 31: a list of w1 entries from entry w2; bits 27–30 the section; bits 22–26 the alignment as log2), u32 length, u32 offset in the section}. The game reads an archive in `main+0x1ba00` (1.4.0). - Update 1.4.0 leaves `.dict` and `.data` as they were and adds `.patch`: u64 a bitmask of the records it patches, u32 offset, stored and inflated length of its body (one zlib stream: 16 u32 section offsets, 16 u16 alignments, then each section's new data), then 0xd0 bytes per patched record {u32 offset, stored length and entry count of its new resource table (one zlib stream), u16 its top-level entry count, u16, u32 the new length of each of 16 sections, 16 u32 end and 16 u32 start of the old data that is overwritten before it is read}. After loading a section the game walks the new table and packs that section's entries from its start at their alignment, each from the old section (an offset below its length) or from the body (the rest, counted from the old length), and writes the new offset into the entry (`main+0x1c8d0`, `main+0x1cbd0`); it does this in place, keeping a copy of the old data in [start, end). `shader_split.py` `nlg_patch_section` does the same ([01, Compressed RomFS content](../../../docs/agents/01-extract.md#compressed-romfs-content)). - Shaders: 5,317 (5,149 in the base game), all in `init` (`shader_split.py` `nlg_shaders`): control sections back to back in one section, their code areas in the same order in another, one NVN program per stage (paired as single-stage programs). 46 of the 103 the title draws exist only in the patched sections. ## Mac routes - The work folder's `run/ctl.sh` drives a run started with `DM_CTL`; `DM_KEYS=14:A,18:A,26:A,31:A,41:Right,42:A,54:A` goes from the boot notices through a new game into the opening cutscene. ## Found on this game General host changes first needed here; each applies to every game: - **Single texture binds recorded in the hardware's layout** (`OP_BIND_TEX1`, `p_bind_tex1`, `bind_rec`): [Recorded commands patched in place by the game (`OP_BIND_TEX1`)](../../../docs/reference/nvn-objects.md#recorded-commands-patched-in-place-by-the-game-op_bind_tex1). - **An integer-output entry point** (`fs_uint`, `main0_u`, `pso_all_uint`), **driver-table handle arrays** (`c2_array`, `tex_c2a` / `samp_c2a`, `NVN_RB_TEXA` / `NVN_RB_SAMPA`, `c2_handle`), **depth textures drawn through a colour view** (`shrt_block`) and the **`draw with geometry shader … which has no library`** line: [Integer entry points for integer colour outputs](../../../docs/reference/shaders.md#integer-entry-points-for-integer-colour-outputs), [An array of handles from the driver table c2](../../../docs/reference/shaders.md#an-array-of-handles-from-the-driver-table-c2), [A depth texture drawn through a colour view](../../../docs/reference/rendering.md#a-depth-texture-drawn-through-a-colour-view), [Geometry shaders with no Metal stage](../../../docs/reference/shaders.md#geometry-shaders-with-no-metal-stage). - **Pixel-format-view usage on every depth-stencil texture** (`tex_desc`, PR #106): [Stencil-mode texture views read through `X32_Stencil8`](../../../docs/reference/rendering.md#stencil-mode-texture-views-read-through-x32_stencil8). - **Live dynamic-resolution images backed by shared physical storage** (`image_texture`): [Live resolution variants share physical image storage](../../../docs/reference/rendering.md#live-resolution-variants-share-physical-image-storage). - **The engine's `.dict` / `.data` / `.patch` archives** (`nlg_shaders`, `nlg_patch_section`): [01, Compressed RomFS content](../../../docs/agents/01-extract.md#compressed-romfs-content). - **The frame limiter's milliseconds written in game memory** (`hle_fpslock.c`, `fps_target` with `present_interval display`): [the profile keys](../README.md#keys). ## Open - Tessellation (15 hull, 18 domain shaders) and the 10 geometry shaders are not built ([Rendering](#rendering)). - [docs/geometry-metal-research.md](../../../docs/geometry-metal-research.md) describes geometry shaders now built as compute kernels, checked on AI: THE SOMNIUM FILES only ("Other games and iOS device execution are unvalidated"); whether this game's 10 build with it is not recorded. - The limiter's phase lock stays off while `nn::vi::ListFrameHistory` returns no entries.