Skip to content

Latest commit

 

History

History
498 lines (380 loc) · 121 KB

memory.md

File metadata and controls

498 lines (380 loc) · 121 KB

Memory and address space

The guest address space, heaps, pools, virtual address memory and the allocators games bring. Each entry: symptom, cause, fix, the games it was found on. The symptom index lists every symptom.

Guest layout

The Mac's alias region is the Switch's 64 GB

  • Symptom: on the Mac, a game crashes once its address regions pass the profile's alias region: at launch (Trails in the Sky 1st Chapter: sig=11 addr=0x1 at sdk+0x15576c, after an 11.27 GB region at alias +1.00 GB) or a little later (Just Dance: a null allocation past 4.375 GB after about 40 s, then a crash at main+0x639ab0), though the build's address region check reports a larger reach. A provisional profile's red line past the profile's … alias region, the iPhone layout says the same. Tears of the Kingdom's long-session memory-shortage Blood Moon ([game] MSF / R / Rf after about 87 minutes at +2.25 of 2.38 GB, issue #639) may be this too: not confirmed by a long session, and a mark near the top alone is not exhaustion.
  • Cause: the profile's mem_alias is sized for an iPhone, which gives a process one reservation of at most 6 GB without the extended virtual addressing entitlement, and the Mac used it too. A Switch's alias region is 64 GB, and games lay regions out for that much.
  • Fix: on the Mac hle_svc.c reserves the larger of the profile's mem_alias and 64 GB; an iPhone keeps the profile's. It is address space, not memory: the application pool still bounds what is mapped, and the bitmaps are lazy zero pages. With room for the request, StandardAllocator's and Unreal's gates change nothing on the Mac; a profile's stdalloc_cap and the rewriter's cuts still apply. DM_ALIAS_SIZE sets another size, and the profile's value reproduces an iPhone's address space on the Mac, which is how the NSP script's heap check runs, since the keys it sets are for that layout.
  • Addresses past 2^39: a Switch's addresses stay below 2^39, and on macOS 27 this block cannot. The system reserves 0x1000000000–0x7000000000, which leaves exactly 64 GB below 2^39, with two reservations of about 12 GB at random addresses inside it (the largest free block is 21–26 GB), and the block is larger than 64 GB. So the block crosses 2^39, at a place that changes per launch: over 600 launches of a bare process a 68 GB block started 0–33 GB below 2^39, with a median of 13.5 GB, and 15% of launches left under 5 GB. The block is laid out heap, stacks, alias region, so the crossing usually falls in the alias region; when the heap and stacks reach past it the host logs [mem] the heap and stacks reach past 2^39. The block's end stays below 2^40, which NVN GPU addresses (host pointers) need. No game is known to keep a tag at bit 39: a crash on a cut-off pointer in a log with that line would be the first, and DM_ALIAS_SIZE=<profile's mem_alias> is the check.
  • Found on: Trails in the Sky 1st Chapter (#877), Just Dance (#1049) and Trails in the Sky 2nd Chapter (PR #1276), reported on their bring-ups, none of them in the games table yet; The Legend of Zelda: Tears of the Kingdom (the Blood Moon, unconfirmed).

Partitioning the guest block between alias region, stacks and heap

  • Symptom: large resources silently skipped, later a null deref where the resource is used; also visible as SetHeapSize asking for much less than the pool.
  • Cause: the game reserves "virtual address memory" (Mario: 1.75 GB in HeapDef.byml) and maps it into the alias region with MapPhysicalMemory on demand; the alias region left after the node area (The SDK's address-region node area) was too small.
  • Fix: repartition the guest block (hle_svc.c mem_init): Mario uses alias 2.875 GB, stacks 256 MB, heap 1.625 GB (it asks for 1.47 GB), 5 GB in total.
  • Found on: Super Mario Bros. Wonder.
  • Check: measure before sizing (DM_SVC=1: the SetHeapSize request and the highest MapPhysicalMemory address).
  • Note: on iOS without the extended-virtual-addressing entitlement a single 6 GB reservation succeeds and 7 GB fails, but Metal and the rest of the process need address space too; 5 GB is the total proven on a phone (D2R).

Thread stacks placed at random in the stack region

  • Symptom: an nn::diag abort with Result 0 under nn::os::detail::ThreadManager::CreateThread → ThreadManagerImplByHorizon::MapAliasStack, on some boots and not on others, with no MapMemory refused line.
  • Cause: CreateThread moves each stack into the stack region at a random address: StackGuardManager::MapAtRandomAddress asks AddressSpaceAllocatorBase::AllocateSpace for a place, which tries 512 random addresses and gives up when none has the stack and its guard pages free; MapAliasStack aborts on that. Horizon gives a 39-bit process a 2 GB stack region; the host's is the profile's mem_stack, 256 MB in every profile until then. Super Monkey Ball Banana Rumble's Unity player maps 48 stacks, 106 MB, in its first 30 s, nine of them 8 MB: the positions those stacks rule out for a new 8 MB one add up to more than 256 MB, so some boots found none. A game whose stacks fill a smaller part of the region only fails more rarely.
  • Fix: the profile's mem_stack sized for the game's stacks with room to spare: 640 MB for Super Monkey Ball Banana Rumble, taken from its alias region in the same 5 GB block (an iPhone has no room for a larger one).
  • Found on: Super Monkey Ball Banana Rumble (SDK 17.5.8: MapAliasStack at sdk+0x13b23c, the abort at +0x13b2b8).
  • Check: DM_THR=1 DM_SVC=1: the MapMemory <stack region address> <- … +<size> lines are the stacks; their sum and the largest against mem_stack (the guest AS line gives the stack region's start).

Heap and application pool

Provisional Unreal layout leaves no heap

  • Symptom: SetHeapSize OOM: req 0x600000 > cap 0, followed by an SDK abort in nninitStartup on the first provisional boot.
  • Cause: the provisional Unreal profile reserved 0x1028000000 bytes but assigned 0x1008000000 to the alias region. The 256 MB module region and 256 MB stacks consumed the remainder, leaving no heap.
  • Fix: profile_guess.py gives the alias region exactly 64 GB (0x1000000000), leaving 128 MB for the initial heap before the heap check measures the game. test_profile_guess.py checks the partition through the real profile generator.
  • Found on: SHIN MEGAMI TENSEI V: Vengeance 1.0.3, whose C library asks for 6 MB.

Heap sized from the application pool less used memory

  • Symptom: nnMain returns right after nv::InitializeGraphics, with no error (Hyrule Warriors: Age of Calamity); [mem] SetHeapSize … + modules … > app pool (Monster Hunter Rise), [mem] SetHeapSize … + modules 0x5c00000 > app pool (Catherine: Full Body); SetHeapSize asks for almost the whole application pool and fails at boot (Metroid Dread: 0xc9400000, larger than the heap region); the memory pool for the game's 96 MB GPU heap has no storage (MemoryPoolInitialize red line), then a null read at main+0x2eb954 (Catherine: Full Body); heap allocations fail late in a busy scene.
  • Cause: the SDK sizes the heap as total − used memory (QueryMemoryInfo), so the reported application pool and used memory decide the game's heap size. Age of Calamity's nninitStartup does so, calls SetMemoryHeapSize and hands the block to its own malloc. The host reported 64 MB used beside the heap ("used" = heap + 64 MB), but SetHeapSize checks a request against the real size of the loaded modules (66.6 MB in Age of Calamity), so SetHeapSize(pool − 64 MB) failed with ResultLimitReached, AllocateMemoryBlock returned 0x1003, malloc was never set up, the 866 MB graphics heap allocation came back null and nnMain returned. Monster Hunter Rise's modules take 320 MB (main alone 314 MB), so its SetHeapSize(pool − 64 MB − bss) failed the same way. Catherine: Full Body's rewritten modules take 92 MB, so SetHeapSize(pool − 64 MB) failed and the game ran on a 32 MB heap. The 64 MB covers ordinary modules (Mario's two are about 74 MB, and its heap request matches hardware), but Metroid Dread's main carries a 1.8 GB bss that the Switch counts as used.
  • Fix: report Switch-accurate numbers. The application pool was a host constant (APP_POOL, 3285 MB); it is now the profile key app_pool (hle_svc.c: #define APP_POOL DM_GAME_APP_POOL, Mario 0xCD500000, the same 3285 MB). The profile key mem_used sets the used-memory figure: 72 MB for Age of Calamity (the image plus stacks); 328 MB for Monster Hunter Rise, which gives a 2.88 GB heap; 96 MB for Catherine: Full Body, the NSP script's guess after the rewrite. Used memory now also adds the bss of modules in the front-patch layout (big_bss_bytes in hle_svc.c; the layout is in Patch area before the module base for a large bss); Dread then asks for 1.38 GB.
  • Found on: Hyrule Warriors: Age of Calamity; Monster Hunter Rise; Metroid Dread; Catherine: Full Body (SDK 8.2.1; one boot). The source names no game for the late failures in a busy scene.
  • Check: probe DM_SVC=1's svc 0x1 request and the AllocateMemoryBlock result.

Offline NRO copies are not additional guest memory

  • Symptom: SetHeapSize OOM: req 0xffffffffe0000000 > cap 0xd0000000, followed by an abort when allocating the launcher NRO.
  • Cause: big_bss_bytes counted the bss of every offline-mapped NRO, including inactive ones, as additional used memory. The game already allocates the original NRO image and bss buffers from its heap. Subtracting the inflated used value from the application pool underflowed the startup heap request.
  • Fix: exclude NROs from big_bss_bytes, as image_bytes already does. Ordinary NSO modules' large bss still counts. No application-pool increase or heap clamp is needed.
  • Found on: FINAL FANTASY X/X-2 HD Remaster; after the fix its startup heap is 3.08 GB.
  • Check: the memory SVC tests compare used-memory queries with an ordinary large-bss module and a larger NRO present together.

Thread stacks backed by active NRO data

  • Symptom: repeated [RED] MapMemory refused (0xd401) for a stack source inside a loaded NRO's writable data, then an abort in MapAliasStack / CreateThread.
  • Cause: the host rejected all NRO-backed stack sources because reload can overwrite their backing. The launcher's static allocator legitimately supplies thread stacks from its bss.
  • Fix: permit the writable, unsealed range of an active NRO. Deactivation checks outstanding stack borrowers and marks the module inactive under the same memory-map lock used by MapMemory; reload cannot replace borrowed backing. Unloading with a live borrower is rejected. Loaded-state reads are atomic.
  • Found on: FINAL FANTASY X/X-2 HD Remaster, launcher nro815+0x124854, through SDK 5.4's thread creation path.
  • Check: make -C dm/host/tests GAME=01007BB017812000 memory checks active-NRO mapping, refusal to deactivate with a live mapping, successful deactivation after unmap, and rejection of an inactive source under the sanitizers.

A heap request beyond the heap region returns ResultOutOfMemory

  • Symptom: SetHeapSize OOM: req 0x120000000 > cap 0xd0000000, then Break#1 reason=0 in nninitStartup, with a backtrace through nn::os::SetMemoryHeapSize (sdk+0x967a4).
  • Cause: a game may look for the largest heap it can have by asking for more than there is. Donkey Kong Country: Tropical Freeze's nninitStartup (main+0x1e84c) binary-searches SetMemoryHeapSize between 3 and 6 GB (6 GB is the heap region of a 39-bit address space) and narrows on every failure. SDK 4's SetMemoryHeapSize (sdk+0x9672c) turns the kernel's ResultOutOfMemory (description 104), 103 and ResultLimitReached (132) into os::ResultOutOfMemory (0x1003) and aborts on any other result. For a size larger than the heap region the host returned 0xD401 (InvalidCurrentMemory, 106), so the SDK aborted. Horizon's KPageTable::SetHeapSize returns ResultOutOfMemory there.
  • Fix: a request larger than the heap region returns 0xD001 (hle_svc.c, svc 0x1). The search then settles on 3.09 GB, the pool less the rewritten modules (the same LimitReached check as before).
  • Found on: Donkey Kong Country: Tropical Freeze (SDK 4.4.0).

The heap check grows the block around the profile's alias region

  • Symptom: a provisional profile's heap check reports "the heap request did not fit; set mem_reserve", the build finishes, and the game still stops at its first run: SetHeapSize OOM: req 0x18ce00000 > cap 0xb8000000, then CRASH sig=10 (SIGBUS) in nninitStartup on the heap it never got.
  • Cause: when the block has not even 1 GB left for the alias region beside the requested heap region, profile_guess.py heap_signals grew mem_reserve to 256 MB + 1 GB + the stack region + the heap region, as if the alias region were 1 GB, but set only mem_reserve. The heap region is what mem_reserve leaves after 256 MB, mem_stack and mem_alias (hle_svc.c mem_init; the Mac's larger alias region is added to the block and takes nothing from it), so a guessed alias region above 1 GB came off the heap region: the Switch 2 guess for a game with a system resource, 4.375 GB (0x118000000), left a 6.20 GB request 2.88 GB.
  • Fix: the grown block keeps the profile's alias region (at least 1 GB, as before): mem_reserve = 256 MB + max(mem_alias, 1 GB) + mem_stack + the request rounded up to 128 MB. A profile whose alias region is 1 GB or less gets the same keys as before. test_profile_guess.py (HeapSignals) checks the layout the keys give. Keeping the alias region rather than cutting it to 1 GB changes nothing on the Mac, whose alias region is a Switch's 64 GB either way; it matters to the region check, which measures against the profile's value, and to an iPhone, though a block this size is over its reservation limit in both cases. A provisional profile stamped by the old check keeps its keys and is reused: delete the profile (<build folder>/profile/<title ID>/) so the next run surveys and checks again. Deleting only its # heap check: line solves from the old block instead and gives a 1 GB alias region.
  • Found on: The Legend of Zelda: Ocarina of Time (Switch 2, 0400486024DEC000, update v131072, SDK 22.2.8), GitHub issue #1361: mem_reserve 0x1f0000000 from the old check, 0x2c8000000 now (a 6.25 GB heap region beside the 4.375 GB alias region). Open: the boot then stops at the NPLN / gRPC nn::diag abort at sdk+0xb8fe8, reported on pull request #1373; pull request #1299's eventfd change addresses it.

The heap check stopped before the app pool check

  • Symptom: a provisional profile's heap check reports "the heap request did not fit; set mem_alias", and every later boot aborts in nninitStartup (an nn::diag abort) after [mem] SetHeapSize 0x… + modules 0x… > app pool 0x…; the NSP script's GLSLC capture then finds no shaders and stops with "captured only 0 run-time shaders".
  • Cause: the heap check booted once and stopped at the first heap request. A request larger than the heap region (SetHeapSize OOM) never reaches the app pool check behind it (hle_svc.c, svc 0x1), so the boot that sized the heap region could not show that the heap and the modules overflow the pool, which heap_signals answers with mem_used. The file-based mem_used guess skips games with a system resource: none of their profiles sets it, and with mem_map_limit it is charged against what the game maps.
  • Fix: a boot that set mem_alias or mem_reserve is followed by another on the resized layout, up to HEAP_CHECK_ROUNDS (3) boots (build_ipa_from_nsp.py heap_check); the stamp names every boot's result and every key set. test_nsp_incremental.py checks the two boots and their keys.
  • Found on: Pokémon FireRed Version and LeafGreen Version (0100554023408000, 010034D02340E000, SDK 20.5.19, provisional profiles), pull request #1620: a 3.14 GB heap plus 102 MB of modules over the pool; the second boot sets mem_used 0x6a00000.

nninitStartup's heap only without virtual address memory

Unreal games without virtual address memory take a heap

  • Symptom: the app quits at launch; the host log shows SetHeapSize OOM: req 0xc5200000 > cap 0.
  • Cause: profile_guess.py gave every Unreal game the 64 GB alias region and no heap, after the Unreal 4.26+ games (Unreal's engine address region and its options). An older Unreal game whose NPDM declares no system resource has no virtual address memory, and its nninitStartup sets a heap of the application pool less used memory instead (Heap sized from the application pool less used memory).
  • Fix: the guesser's Unreal memory rule applies only with an NPDM system resource; other Unreal games get the usual heap region (3.25 GB here).
  • Found on: Yoshi's Crafted World (Unreal Engine 4.18, SDK 4.4.1): a 3.15 GB heap; GMalloc is a FMallocBinned on it.

Sizing the pool and alias region for virtual address memory

  • A title that uses virtual address memory may never call SetHeapSize: everything comes from MapPhysicalMemory in the alias region. The [mem] log lines show what it maps (Measuring address regions and commits).
  • What fills the alias region first is address space, not memory. StandardAllocator reserves the whole free pool (pool − 64 MB, 1 GB aligned) but Engage commits only 0.58 GB of it, and Unity's own regions commit 0.97 GB. So Engage's profile reports a 2.5 GB pool (app_pool) and uses a 4.375 GB alias region in a 5 GB block (reserved address space peaks at 3.47 GB of 4.31 GB).
  • iOS without the extended-virtual-addressing entitlement allows a single 6 GB reservation, but Metal needs address space too; 5 GB is the total proven on a phone (Partitioning the guest block between alias region, stacks and heap).
  • The pool a game is told decides how much StandardAllocator reserves (Initialize(nullptr, pool − used)), and the host does not enforce the pool (only nninitStartup reads it), so a pool a little above what the game maps leaves the most address space:
    • Ace Attorney Investigations Collection: with Engage's 2.5 GB pool the game ran, but StandardAllocator's 2.44 GB reservation beside Unity's pools (1.06 GB live, from main+0x40f1d4) ended 4.37 GB into the 4.375 GB alias region after 140 s, while the MapPhysicalMemory log showed StandardAllocator committing 0.19 GB. The profile reports a 1 GB pool, a little less than the 1.02 GB the game maps at its peak; ten minutes then peak at +2.85 GB of address space, against +3.91 GB with a 1.5 GB pool.
    • Brain Memory Training Academy: with Ace Attorney's 1 GB pool and 5 GB block the game boots to its title in 5 s with no red line; StandardAllocator ends at +1.0 GB, and five minutes of level loads take the alias high-water mark to +2.25 GB with at most 0.5 GB mapped.
    • Patrick's Parabox: with Ace Attorney's 1 GB pool and 5 GB block (in place of the provisional profile's hardware pool) it boots to its title with no red line, and the alias region is mapped up to +1.5 GB with 0.27 GB in use.
    • Advance Wars 1+2: Re-Boot Camp: the hardware pool used the alias region up to +4.25 GB; the heap check's 1 GB pool starved its malloc (Unity's GPU pool block allocation spins at size 0); with a 2 GB pool the game maps up to 2.26 GB of the alias region through its menus and first battle.
  • Found on: Fire Emblem Engage; Ace Attorney Investigations Collection (Unity 2022.3, SDK 17.5.3); Brain Memory Training Academy; Patrick's Parabox; Advance Wars 1+2: Re-Boot Camp.
  • Note: to measure regions and commits, see Measuring address regions and commits.

nv::GetGraphicsMemoryInfo with no graphics device

  • Symptom: CRASH sig=11 addr=0 pc=sdk+0x4656c8 right after the window is created, with x0 = 0 and the return address in main (main+0x3508a90): the faulting code is ldr x0,[0xc5d000+0xc68]; ldr x8,[x0]; ldr x1,[x8,#0x68]; br x1.
  • Cause: the game reports GPU memory in its startup log, so it calls nv::GetGraphicsMemoryInfo and then nv::DumpGraphicsMemoryInfo (both imported by main). The SDK's implementation reads a driver object from its own static (0xc5d000+0xc68) that only nv::InitializeGraphics sets; a game that never initialises an NVN device — as every EGL/OpenGL game does — leaves it null, and the SDK dereferences it. nv::MemoryInfo is 64-bit counters filled from sign-extended 32-bit driver counters, and its size follows the SDK: 0x20 bytes (four counters) on SDK 10 and older, 0x40 bytes from SDK 11 on, where the SDK zeroes all 0x40 when nv is not initialised. Every sdk module checked, from 3.4.0 to 20.5.22, fills four counters; on SDK 3.4.0–10.8.1 the function has no 0x40 path, from 11.4.3 on it has. A host answer of 0x40 bytes on an old SDK overruns the caller's 32-byte record.
  • Fix: dm/gfx/hle_nv.c answers the five nv:: symbols such a game imports: it records the allocator callbacks (SetGraphicsAllocator, SetGraphicsDevtoolsAllocator) and the pool from InitializeGraphics(void*, size_t), and GetGraphicsMemoryInfo reports that pool (total and free = its size) or zeroes the structure when nothing was initialised. It writes the record of the game's SDK: 0x20 bytes when DM_SDK_OLD(dm_sdk_major()), 0x40 otherwise (the boundary the NVN API version and the shared-font layout follow too); the pool figures lie in the first four counters, so both sizes carry them.
  • Found on: Well Dweller (SDK 22.2.0, GameMaker Studio 2, OpenGL through EGL). The game's allocator is never called again, so a zeroed report is equally correct on a GL-only title. The 32-byte overrun on old SDKs was found on Hyrule Warriors: Definitive Edition 1.0.1 (SDK 3.5.1, KTGL, NVN; GitHub issue #708, PR #709): its startup caller passes a 32-byte record, and AddressSanitizer reported the write past it.

An SDK 21 Switch 2 game with a 4.5 GB alias region

  • Symptom: a null pointer in the game's resource code (main+0x15a69b0, null + 0x78, thread BaseProcCreateAndDeleteThread): a model's load returned null, its status 9, written hundreds of times by a loader thread. The [mem] lines end near the top of the alias region.
  • Cause: the alias region ran out. An SDK 21 game keeps the SDK's 1 GB node area, as the sdk patches that cut it do not match this SDK, and maps past 4.25 GB of it at the first level.
  • Fix: the profile's mem_alias: 16 GB (Splatoon Raiders: 0x400000000, with mem_reserve 0x550000000). The peak then is about +8.5 GB.
  • Found on: Splatoon Raiders. Fire Emblem Fortune's Weave (SDK 22.2.0) takes the same split, and its SDK has its node-area cut (dm/tools/sdk_patches.py), from 1 GB to 62 MB, because alias_room aborted in StandardAllocator::Initialize without one. Open: 16 GB is over iOS's reservation limit, so an IPA needs the SDK 21 node-area and alignment cuts first.

Virtual address memory and the alias region

Virtual address memory follows the NPDM's system resource

  • Symptom: StandardAllocator::Initialize aborts in nninitStartup (Fire Emblem Engage); nn::diag abort in StandardAllocator::Initialize(void *, size_t, bool) from nninitStartup (sdk+0x2f98e4 ← main+0x5b69e0, Big Brain Academy: Brain vs. Brain).
  • Cause: the SDK's IsVirtualAddressMemoryEnabled is GetInfo(TotalSystemResourceSize) != 0 (VammManager::IsVirtualAddressMemoryEnabled, sdk+0x34e074), the system resource the NPDM declares (offset 0x14 of META). With it the game's allocator takes its memory as address regions in the alias region. Engage's NPDM declares one, so IsVirtualAddressMemoryEnabled is true and the allocator reserves all free memory (about 3.2 GB for Engage) as one region in the alias region, after the ~1 GB node area (The SDK's address-region node area). Big Brain Academy's nninitStartup (main+0x5b6984) asks os::IsVirtualAddressMemoryEnabled(): when true it gives StandardAllocator a 60 GB address region (0xf00000000), when false it sets a heap of pool − used (2 MB aligned) and hands that over. The host reported a 32 MB system resource to every game, while this game's NPDM declares none.
  • Fix: the profile key sys_resource reports the NPDM's size (0 for Big Brain Academy, so the heap path, as on hardware; hle_svc.c); the survey writes it for every new game, and profiles without it keep the 32 MB answer. A game with virtual address memory needs a profile mem_alias / mem_reserve that holds its regions: Engage first used 5.5 / 8 GB on the Mac; its profile now has mem_reserve 0x140000000 / mem_alias 0x118000000 (5 GB / 4.375 GB) with the node area cut and a 2.5 GB app_pool (Sizing the pool and alias region for virtual address memory).
  • Found on: Fire Emblem Engage; Big Brain Academy: Brain vs. Brain (SDK 11.4.3, NintendoWare Bezel Engine).

The SDK's address-region node area

  • Symptom: the first AllocateAddressRegion fails with 0x1803; StandardAllocator::Initialize aborts in nninitStartup (an nn::diag abort; the NSP script's heap check reports "the host stopped before nnMain"); the alias region fills in minutes; regions a game reserves later fail (Size-aligned regions exhaust the alias address space, The movie player's own StandardAllocator).
  • Cause: SDK 16's AddressRegionManager takes 0x3fe00000 at the start of the alias region as a node area, so the alias region must be larger than 1 GB; SDK 8 has the same 1 GB node area as the newer SDKs, and SDK 7 / 8 builds load it in another form (below). On hardware the alias region is 64 GB. On Ace Attorney Investigations Collection's SDK 17 build, CentralHeap::Init(nullptr, pool − used) got no 1 GB-aligned region for 3.14 GB after the 1 GB node area.
  • Fix: shrink the node area with a rewriter cut for that SDK build (dm/tools/sdk_patches.py): the two mov w8/w9, #0x3fe00000 in the AddressRegionManager constructor → 62 MB.
  • SDK-wide: the same cut is also an SDK-wide function patch (sdk_patches.py MATCH_CUTS), which every game's sdk gets when its build has no CUTS: a new game, or a new version of one, needs no cut of its own on a reviewed implementation. A registered build whose cuts the rules give exactly has no row (18 builds, checked on the game drive on 2026-10-29 and 2026-10-30); a build a profile was brought up on gets the rules too (before, the rules skipped such builds, which kept those rows). Seven implementations cover the cut builds on the game drives (SDK 7.3.2 / 8.2.1, 10.4.x, 11.4.3 to 13.3.5, 15.3.2 to 18.3.5, 19.3.x / 20.5.x, 22.2.0, and SDK 19.3 / 20.5 built for the Switch 2); on those 19 each rule gives exactly the registered cuts, and on the 36 sdk builds there it matched once or not at all. The SDKs of Fire Emblem: Three Houses, Bayonetta, Donkey Kong Country: Tropical Freeze, Xenoblade Chronicles 2 and Yoshi's Crafted World have other implementations, not reviewed. A build with no reviewed implementation keeps the 1 GB node area; the NSP script lists it (not red: a game without virtual address memory never reaches it) (Function-matched cuts for unregistered builds).
  • Finding the constructor on a new sdk build: search its text for the two mov w8 / w9, #0x3fe00000 words (08fca752, 09fca752); sdis.py --fn names the function AddressRegionManagerC2Emm from its dynamic symbols. SDK 7 / 8 builds load the node area with orr w8 / w9, wzr, #0x3fe00000 (e8230b32, e9230b32), so the mov search finds nothing there.
  • Found on (the builds that take the cut):
    • Mario + Rabbids Kingdom Battle (SDK 4.4.0): the older VammManager constructor reserves a full 1 GB rather than 0x3fe00000; its limit and occupied length at sdk+0x93dfc / +0x93e8c are reduced together to 64 MB. Nodes are mapped in 4 KB increments as needed.
    • Xenoblade Chronicles 3 (SDK 12.3.9): constructor sdk+0x346420, cuts +0x346430 / +0x3464cc, the same offsets as Engage's SDK 12.3.7, beside the large-region alignment cut. The game's own allocator needs a separate compact layout.
    • Pokémon Brilliant Diamond (Unity 2019.4.27f1, SDK 11.4.3): constructor sdk+0x34e0b8, node-area limit +0x34e0c8 and occupied length +0x34e164. The original node area prevents StandardAllocator's pool-minus-used reservation from fitting at startup.
    • Yoshi and the Mysterious Book (Unreal Engine 5.4, SDK 20.5.15 for the Switch 2, the sdk of Hyrule Warriors: Age of Imprisonment): constructor sdk+0x14df00, cuts +0x14df18 / +0x14dfa4. The same function in Mario Kart World's and Donkey Kong Bananza's sdk. Unreal's engine region counts on the 62 MB: with 1 GB nninitStartup calls exit(1), a game called nn::diag abort under abort from exit.
    • Fire Emblem Engage: the two mov w8/w9, #0x3fe00000 in the constructor → 62 MB; without it the alias region ran out of address space minutes into play (Size-aligned regions exhaust the alias address space).
    • Ace Attorney Investigations Collection (Unity 2022.3, SDK 17.5.3): StandardAllocator::Initialize aborts in nninitStartup on SDK 17, then the alias region fills in minutes; the same node area as Engage's SDK; the constructor is at sdk+0x139458 on that build and takes the same cut.
    • Brain Memory Training Academy (Unity 2022.3.5f1, SDK 16.2.4): nn::diag abort at sdk+0x126490, StandardAllocator::Initialize, called from nninitStartup at main+0x73adcc. Its nninitStartup is Ace Attorney's: Initialize(nullptr, pool − used) with virtual address memory and no SetMemoryHeapSize. The sdk build is Unicorn Overlord's, whose NPDM declares no system resource, so it never needed the cut; the constructor (AddressRegionManagerC2, sdk+0x134768) takes it at +0x134778 / +0x13480c.
    • Sea of Stars (Unity 2021.3.25f1, SDK 16.2.0, the sdk build of Super Mario Bros. Wonder 1.0.0): nn::diag abort at sdk+0x126440, StandardAllocator::Initialize, called from nninitStartup at main+0x8b6710; the heap check reports "the host stopped before nnMain". Brain Memory Training Academy's constructor at sdk+0x134718 takes the cut at +0x134728 / +0x1347bc, beside the region alignment cut.
    • Patrick's Parabox (Unity 2020.3.30f1, SDK 13.3.7): StandardAllocator::Initialize at sdk+0x324dec, called from nninitStartup at main+0x1cd080; the same nninitStartup and the same cut, on this build's constructor (sdk+0x37c3a0: +0x37c3b0 / +0x37c44c).
    • Advance Wars 1+2: Re-Boot Camp (Unity 2020.2.1f3, SDK 10.4.1): StandardAllocator::Initialize at sdk+0x2edf90, called from nninitStartup at main+0x365310; the heap check reports "the host stopped before nnMain"; the same nninitStartup. The sdk build is The Great Ace Attorney Chronicles', which has no virtual address memory; its constructor (sdk+0x340e90) has the orr form (e8230b32 at +0x340ea0, e9230b32 at +0x340f3c).
    • Pikmin 4 (SDK 10.4.8): the build's AddressRegionManager is the 10.4.1 build's at the same offsets and takes the same three cuts (node area and alignment).
    • Super Smash Bros. Ultimate (SDK 8.2.1): constructor sdk+0x336b40, orr w9, wzr, #0x3fe00000 at +0x336b50 / +0x336bf0 (A 62 GB page allocator behind nu::VirtualAllocHook).
    • DRAGON QUEST XI S (SDK 7.3.2): the node area cut on that SDK build (sdk_patches.py).
    • Paper Mario: The Thousand-Year Door (SDK 15.3.2, Celeste's sdk build, which has no virtual address memory): constructor sdk+0x12e0e0: +0x12e0f0 / +0x12e184.
    • Super Mario Galaxy 2 (SDK 18.3.5): sdk+0x13be4c / +0x13bee0 (The movie player's own StandardAllocator).
    • Absolum (SDK 18.3.2): the node-area cut beside the alignment cut.
    • Octopath Traveler 0 (SDK 19): SDK 19's AddressRegionManager node area is cut to 62 MB as Engage's (sdk+0x1423fc / +0x142468).
    • Pokémon Legends: Z-A (SDK 19.3.11): the same constructor offsets, sdk+0x1423fc and +0x142468, beside the host's StandardAllocator cap (StandardAllocators over 12 to 62 GB of address space).
    • FINAL FANTASY RESONANCE (SDK 20.5.17): the sdk is STEINS;GATE RE:BOOT's build; its node area takes the same cut (sdk+0x145cbc / +0x145d28).
    • Hollow Knight: Silksong (SDK 20.5.8): sdk+0x145bfc / +0x145c68 (Unity 6 takes its memory with no StandardAllocator and no heap).
    • The Legend of Zelda: Tears of the Kingdom (SDK 20.5.22, the same sdk build as Breath of the Wild 1.9.0): constructor sdk+0x145cfc, +0x145d0c / +0x145d78; no alignment cut (VammManager::AllocateAddressRegion, sdk+0x145df0, aligns regions to 64 KB or 2 MB only). Its resource heaps live in address regions after the node area: at the first descent from the Great Sky Island the alias region's high-water mark was +2.25 GB of its 2.375 GB with 1.00 GB mapped, and the game took a memory shortage (its frame check at main+0x1fd32ec logs MSF / R / Rf and plays the memory-shortage Blood Moon, which puts Link back at the start; GitHub issue #70, 7 of 8 Mac runs from the issue's save). With an 8 GB alias region the mark went on to +2.50 GB; with the cut it stays at +1.75 GB, and neither showed the shortage.
    • BALL x PIT (SDK 20.5.19): sdk+0x145d0c / +0x145d78, the SDK 20.5.22 build's offsets.
    • EA SPORTS FC 27 (Frostbite, SDK 22.2.0, sdk fed212d6…, also Well Dweller's): constructor sdk+0x14def0, +0x14df00 / +0x14df6c. SDK 22's constructor is SDK 19 / 20's instruction for instruction, with 32 more bytes on its cold path (an abort code per failing result), so it is the SDK-wide rule's sixth implementation; region placement aligns to 64 KB or 2 MB only (sdk+0x14e060). Frostbite's VirtualAlloc (main+0x17f9884) reserves 264 regions, 2.8 GB of address space, in its first minute while mapping 0.7 GB; behind the 1 GB node area they ran past the alias region (Couldn't reserve memory of size: 536870912, a deliberate null store at main+0x17f98c4).
    • Cult of the Lamb (Unity 2022.3.62f3, SDK 19.3.14, sdk 871fb219…): sdk+0x1423fc / +0x142468, Octopath Traveler 0's offsets. The SDK-wide rule cut it while the profile was provisional and skipped it once the profile named the build (the rewrite had no cut), so the cuts are registered for this build; region placement aligns to 64 KB or 2 MB only (sdk+0x142544). It also needs a 1 GB pool, as Ace Attorney Investigations Collection and Monster Train 2.
    • Batman: Arkham Knight (SDK 14.3.0, sdk 2aef2cff…): constructor sdk+0x12d8a0, +0x12d8b0 / +0x12d94c, registered as Cult of the Lamb's was until 2026-10-30; the SDK-wide rules now give it the node area cut and the 2 MB alignment (placement constants sdk+0x12daa4 / +0x12daac / +0x12dab0).

Size-aligned regions exhaust the alias address space

  • Symptom: a null-based write minutes into play, at the pool size minus 0x20 (Engage: a write to NULL + 0x1fffe0, addr=0x1fffe0 at main+0x9a4664); NULL index pointers written on several job threads at once (main+0xdb0ad4, 0xdaea58, 0xdadb5c), with 32-bit mesh indices turning up inside texture objects.
  • Cause: the alias region ran out of address space, not memory (the SDK's node area took about 1 GB of it), so new address regions came back empty. Unity's TLSF heap allocator (main+0x9a45d0) added a pool (a new 2 MB pool), and its low-level virtual allocator (vtable at main+0x61ffeb0; reserve main+0x9aa0f4 → nn::os::AllocateAddressRegion) got no address region and returned 0. Buffers mapped fresh past 2 MB got no mapping: Unity's particle geometry scheduler (main+0xd91e0c) takes its vertex and index chunks from a fallback that creates and maps a fresh buffer each time (main+0x7b9c64; Unity's Switch renderer always takes it, as on hardware). The AddressRegionManager aligns each region by its size (64 KB under 2 MB, 2 MB under 4 MB, 4 MB under 32 MB, 32 MB under 1 GB, else 1 GB) and Unity asks for its block plus alignment slack, so a 2.25 MB block takes a 4 MB slot and a 16.25 MB block a 20 MB one. At the crash Engage held 4.17 of the 4.375 GB left after the node area (StandardAllocator 3.14 GB, Unity about 1.05 GB, the rest fragments). On hardware the alias region is 64 GB.
  • Fix: shrink the node area with a rewriter cut for the game's SDK build (dm/tools/sdk_patches.py, Engage: the two mov w8/w9, #0x3fe00000 in the constructor → 62 MB; The SDK's address-region node area).
  • Found on: Fire Emblem Engage: the prologue battle crash (scripted run, about 357 s; a 12-minute scripted run now plays through the battle) and the idle attract demo crash after about 220 s (from about 180 s 32-bit mesh indices also turned up inside Unity's texture objects). With the cut an 850 s idle run is clean, with no corrupted texture objects; with the old SDK the same run dies at 159 s in Unity's heap allocator.
  • Check: probe the SDK's VammManager::AllocateAddressRegion at its success path and FreeAddressRegion after its prologue, and sum the live regions by caller (Measuring address regions and commits).
  • Note: to reproduce the old state: boot.sh <log> 245 with no input on an SDK rewritten without the cut.

1 GB alignment of regions of 1 GB and more

  • Symptom: a cut engine region still does not fit; two large regions lose up to 1 GB each to alignment, depending on where mmap put the guest block (Absolum); the region comes 0.69 GB past the 1 GB node area, at the next 1 GB boundary (Paper Mario: The Thousand-Year Door).
  • Cause: AllocateAddressRegion aligns a region of 1 GB or more to 1 GB in absolute address (SDK 18: sdk+0x13c03c; 32 MB from 32 MB up). SDK 19.3.14 and SDK 20 align to 64 KB or 2 MB only.
  • Fix: an SDK-wide function patch beside the node area's (above) gives the three classes from 4 MB up SDK 20's 2 MB, what SDK 19.3.14 and SDK 20 do themselves, which also closes the gaps of Size-class alignment doubles Unity's regions: four implementations (SDK 7.3.2 to 10.4.8, 11.4.0, 12.3.9 to 15.3.2, 16.2.0 / 18.3.2), with the 32 MB, 1 GB and 4 MB constants at +0x64, +0x6c and +0x70 in each. Until 2026-10-30 it aligned only the 1 GB class, to 32 MB (what the SDK gives from 32 MB up), and two builds' rows gave every large class 2 MB; one rule now covers both, and the rows were removed. Mario + Rabbids Kingdom Battle's SDK 4.4.0 row, an older implementation, still aligns its large regions to 32 MB.
  • Found on:
    • Pokémon Scarlet (1.0.0, SDK 13.3.5).
    • Pokémon Legends: Arceus (1.1.1, SDK 11.4.0, sdk b39add63…, Kirby and the Forgotten Land's build): StandardAllocator::Initialize(nullptr, 32 GB), capped to 4.06 GB by the host, is placed at the next 1 GB boundary after the 62 MB node area and runs past the 4.38 GB alias region (Break at sdk+0x39fb54 in nninitStartup). The placement helper (sdk+0x34e1d8, 360 bytes: the 12.3.9 implementation's size classes with 8 more bytes) takes the cuts at +0x64, +0x6c and +0x70; the SDK-wide rules serve this build (its row, the same cuts, was removed); on the 41 sdk builds on the game drive it matches only this one and the SDK 11.4.3 build of Metroid Dread and Brilliant Diamond besides (which keeps its registered cuts).
    • Mario + Rabbids Kingdom Battle (SDK 4.4.0): large-region alignment at sdk+0x93fe0, beside its older VammManager node-area cut.
    • Absolum (SDK 18.3.2: sdk+0x13c03c): the two large regions, with a 4 KB one between them, lost up to 1 GB each.
    • DRAGON QUEST XI S (SDK 7.3.2): 32 MB alignment for large regions on that SDK build.
    • Advance Wars 1+2: Re-Boot Camp (SDK 10.4.1): AllocateAddressRegion (sdk+0x341030) aligns a region of 1 GB or more to 1 GB (+0x34109c), as DRAGON QUEST XI S's SDK 7.3.2 build, and takes the same 32 MB cut.
    • Super Smash Bros. Ultimate (SDK 8.2.1): alignment at sdk+0x336d5c, cut as DRAGON QUEST XI S's SDK 7.3.2 build.
    • Paper Mario: The Thousand-Year Door (SDK 15.3.2, Celeste's sdk build): AllocateAddressRegion's alignment at sdk+0x12e2e0, as Absolum's did.
    • Pikmin 4 (SDK 10.4.8): the 10.4.1 build's offsets.
    • Xenoblade Chronicles 3 (SDK 12.3.9): large-region alignment at sdk+0x34662c, 1 GB to 32 MB; its compact 4 GB allocator reservation then fits after the 62 MB node area.
    • OCTOPATH TRAVELER II (SDK 12.3.2, sdk 5bcf525f…): the 2 GB engine region came at alias +0.75 GB behind the 62 MB node area; with the rules (constants at sdk+0x346384 / +0x34638c / +0x346390) it starts at +0.06 GB, and the alias region's high-water mark on the smoke route fell from +1.75 GB to +1.00 GB (2026-10-30).
  • Note: FINAL FANTASY RESONANCE's SDK 20.5.17 build aligns regions of 2 MB and more to 2 MB only (VammManager::AllocateAddressRegion, sdk+0x145e00), so it needs no alignment cut.

Size-class alignment doubles Unity's regions

  • Symptom: a write at NULL + 0x3fffe0 (a 4 MB pool − 0x20) at main+0x48b770, about six minutes into the prologue, with Unity's ALLOC_CACHEOBJECTS_THREAD allocator in x19; the [mem] high-water mark at +4.26 GB of the 4.375 GB alias region while 1.26 GB is mapped (Sea of Stars).
  • Cause: before SDK 20, VammManager::AllocateAddressRegion aligns a region by its size (64 KB under 2 MB, 2 MB under 4 MB, 4 MB under 32 MB, 32 MB under 1 GB, else 1 GB) and starts the next one at that alignment past a 4 KB guard. Unity 2021.3's virtual allocator (main+0x48deb0 → main+0x12e12c0) asks for N MB + 252 KB and aligns its blocks to 256 KB itself, so every region spills just past a size class: a 16.25 MB region takes a 20 MB slot, a 4.25 MB one an 8 MB slot, a 32.5 MB one 64 MB. Measured with probes on the success path (sdk+0x134868: x21 size, x22 address) and on FreeAddressRegion (sdk+0x1350cc: x1 address): 94 regions of 16.25 MB, 35 of 4.25 MB and 15 of 2.25 MB among 167 live regions holding 2.59 GB, with 0.86 GB of gaps between them, ending at +4.22 GB. On a Switch the alias region is 64 GB.
  • Fix: the 4 MB, 32 MB and 1 GB classes take SDK 20's 2 MB: first a cut on each sdk build, now the SDK-wide alignment rule (1 GB alignment of regions of 1 GB and more), which gives both builds below the same cuts. The same route then ends its live regions (2.59 GB) at +3.24 GB with 0.58 GB of gaps, and the high-water mark stays at +3.0 GB over 20 minutes.
  • Found on: Subnautica: Below Zero (Unity 2019.4.36f1, SDK 13.3.5, the sdk build of AI: THE SOMNIUM FILES - nirvanA Initiative and Pokémon Scarlet 1.0.0: sdk+0x37c334, +0x37c33c, +0x37c340; 336 live regions ended at +4.37 GB with 0.87 GB of gaps at the opening's crash site, before any crash; the cut alone took the end to +4.29 GB, and the game also needed its 1 GB bucket allocator reservation cut to reach +3.50 GB). Sea of Stars (SDK 16.2.0: sdk+0x134914, +0x13491c, +0x134920), beside the node-area cut and a 1.5 GB pool.

Unity 6 takes its memory with no StandardAllocator and no heap

  • Symptom: nothing aborts at startup as with Unity 2022.3, but the provisional profile gave a 1 GB block and a 256 MB alias region while Unity maps 1.5 GB in the first room.
  • Cause: nninitStartup (main+0x118cb70) only calls QueryMemoryInfo and SetMemoryAllocatorForThreadLocal; Unity's own allocator reserves address regions with nn::os::AllocateAddressRegion and commits pages with AllocateMemoryPages. main imports no SetMemoryHeapSize, and the provisional profile took that for a game living in its bss.
  • Fix: profile_guess.py now gives every game with an NPDM system resource the 5 GB block (Absolum and Pokémon Legends: Z-A, heapless too, came out right with it). The node area still sits at the start of the alias region: without the cut the high-water mark was +1.00 GB at nninitStartup and +3.00 GB in the first room, with it (sdk+0x145bfc / +0x145c68) +2.26 GB.
  • Found on: Hollow Knight: Silksong (Unity 6000.0.50f1, SDK 20.5.8); BALL x PIT (Unity 6000.0.62f1, SDK 20.5.19) is the same: the provisional profile was right as generated, and its sdk build only needed the same cut (sdk+0x145d0c / +0x145d78, the SDK 20.5.22 build's offsets): +2.00 GB in the base. Also Absolum and Pokémon Legends: Z-A (the 5 GB rule).

The movie player's own StandardAllocator

  • Symptom: nn::diag abort from StandardAllocator::Initialize long after nninitStartup, right after the first Vulkan query pool (sdk+0x12dad0 ← main+0x62c52c).
  • Cause: the movie player initializes its own allocator with virtual address memory, Initialize(nullptr, 384 MB) (main+0x62c280); Super Mario Galaxy, on the same SDK, allocates that block from its main allocator first. The guessed 256 MB alias region holds no region after the 1 GB node area.
  • Fix: the same node area cut for that sdk build (sdk+0x13be4c / +0x13bee0) and a 512 MB alias region: the region is 32 MB aligned, so it starts 64 MB in.
  • Found on: Super Mario Galaxy 2 (SDK 18.3.5); compare Super Mario Galaxy.

Measuring address regions and commits

  • What a game really needs: boot it on the Mac, whose alias region is a Switch's 64 GB, so whatever it reserves fits; DM_ALIAS_SIZE=<profile's mem_alias> shows what an iPhone's would do (the heap and stacks stay as the profile has them either way). Absolum, Super Smash Bros. Ultimate and Paper Mario: The Thousand-Year Door were sized this way, then with mem_reserve / mem_alias keys.
  • Regions: the rewriter puts three observation gates in every sdk that exports VammManager::AllocateAddressRegion and FreeAddressRegion (patch_nso.py "Address regions"): the entry (the size in x2, the caller's frames), the one store of the new address through the out pointer (mov xB, x1 in the prologue, str xA, [xB]; A rides in the gate number), and FreeAddressRegion's entry (x1). They are found by those symbols, so they hold for any SDK build that has them (all 35 of the 36 sdk builds on the game drives that have a symbol table; New Super Mario Bros. U Deluxe's is a 32-bit module, whose table this does not read). The host keeps the table (hle_svc.c "Address regions", growing as needed): where the request returns to in the game (the first return address outside the sdk on the frame chain: the function that asked, or its caller when it tail-called the SDK API, whose frame is gone by then; Z-A's size is set in a function that tail-calls StandardAllocator::Initialize, so its request returns to nninitStartup) and the SDK API it came through. A region of 256 MB or more is logged when made ([mem] address region 4.00 GB at alias +0.06 GB, returning to main+0x37c9a64); DM_REGIONS=<file> writes the live table every 2 s with each region's mapped bytes and the part ever opened.
  • The NSP script's region check: for a provisional or derived profile whose game has virtual address memory, it boots the game that way for 15 s into nnMain and reports each region of 256 MB or more and where the regions reach against the profile's alias region. A derived build (a version the profile was not brought up on) has none of the registered builds' fixed-address cuts, only the function-matched ones, so a region those cuts shrank is red here at pack time rather than an abort on the iPhone (Super Smash Bros. Ultimate 13.0.1, GitHub issue #375). That reach is a lower bound: regions the game takes later in play (Tears of the Kingdom's at the first descent, Engage's minutes in) are not in the table (profile_guess.py region_findings; stamped # address regions: in the profile with the rewrite's key, so a run with other rewritten modules, a new cut for instance, checks again). A red line when they reach past it, which says who lays the region out: one that comes through nn::mem::StandardAllocator::Initialize the host makes fit at run time (StandardAllocators over 12 to 62 GB of address space), and when the regions made after it need more than the 256 MB the host leaves them, the check sets stdalloc_cap in the provisional or derived profile: the alias region less the regions before it, those after it and the same 256 MB for regions made later in play, in 32 MB steps (profile_guess.py stdalloc_room; a red line only when that leaves it no room, or when a value set by hand leaves them too little; one the check set is worked out again by a later check); one the game takes with nn::os::AllocateAddressRegion for its own allocator works smaller only if that allocator places nothing by fixed offsets (Super Smash Bros. Ultimate: A 62 GB page allocator behind nu::VirtualAllocHook). Where a region is used does not tell them apart: Z-A's StandardAllocator puts its 3.15 GB startup block at the top of its region, Smash's engine its node pool. Measured: Smash's region 4 GB through nn::os::AllocateAddressRegion, returning to main+0x37c9a64, opened up to 14 MB below its end; Z-A's 4 GB through nn::mem::StandardAllocator::Initialize, returning to main+0x1b2e690, opened to its end (both with their cuts).
  • Commits: probe VammManager::AllocateMemoryPages / FreeMemoryPages (x1 address, x2 size) and split them by region. On DRAGON QUEST VII Reimagined, nn::os::AllocateMemoryPages was probed past its adrp (sdk+0x140f84).
  • Windows: DM_ALIAS_WINDOWS=<window MB> (for example DM_ALIAS_WINDOWS=512) logs, every 30 s, the mapped memory of each window of that size in the alias region (hle_svc.c); it measured Octopath Traveler 0's allocator pools and Super Smash Bros. Ultimate's battle commits.
  • Log: the [mem] lines report the alias high-water mark (one line per 256 MB), the total actually mapped (page bitmap) and every heap growth.

An alias high-water mark near the top is not exhaustion

  • Symptom: the [mem] high-water mark climbs close to the end of the alias region while far less is mapped (SUPER ROBOT WARS Y: +4.27 GB of 4.38 GB with 1.82 GB of live regions), and it looks like the address space is about to run out.
  • Cause: none to fix: this is how VammManager::AllocateAddressRegion places regions. It walks its tree from the highest region down, takes the first hole that fits and places the region at that hole's low end (read on SDK 18.3.5, sdk+0x13bfd0; other implementations not read). So while the top has room, every new region lands just past the highest one and freed holes below stay idle, and the mark rises with address churn, not with use. Only when no hole fits does the function return its failure (w0 = 2 at sdk+0x13c11c).
  • Fix: none. To judge the room left, read the live regions and their holes in the DM_REGIONS table, not the mark, or run with a smaller alias region (DM_ALIAS_SIZE): if the game goes on with the end of the table pinned at the top, the holes are serving it. A real exhaustion is what Size-aligned regions exhaust the alias address space and Size-class alignment doubles Unity's regions describe: a request no hole fits.
  • Found on: SUPER ROBOT WARS Y: every one of 228 regions placed above the top skipped a hole that would have held it, and with the alias region cut to 3 GB the end stayed at +2.99 GB for about 10 minutes of battle demos with no failure.

Engine allocators

StandardAllocators over 12 to 62 GB of address space

  • Symptom: StandardAllocator::Initialize aborts in nninitStartup (Pokémon Legends: Z-A: main+0x1b2e690); nn::diag abort at sdk+0x11ff34 (StandardAllocator::Initialize) in nninitStartup, SDK 15.3.2, called from main+0x31370c (Paper Mario: The Thousand-Year Door).
  • Cause: with virtual address memory the game initializes a StandardAllocator over more address space than a phone gives a process, as the Switch's 64 GB alias region allows.
    • Pokémon Legends: Z-A (a 16 MB system resource) initializes its allocator over 12 GB, StandardAllocator::Initialize(nullptr, 0x300000000) (main+0x1b3e3ec). The alias region must hold the SDK's 1 GB node area and that region, 13 GB.
    • Paper Mario: The Thousand-Year Door's malloc is a StandardAllocator over 62 GB of address space (Initialize(nullptr, 0xf80000000, false), main+0x3136e8), out of which it cuts 256 MB, 16 MB and 1 MB allocators. Booted once with a 64 GB alias region, the region came 0.69 GB past the 1 GB node area, at the next 1 GB boundary: this sdk build (Celeste's, which has no virtual address memory) aligns regions of 1 GB and more to 1 GB, as Absolum's did (1 GB alignment of regions of 1 GB and more).
  • Fix: the node-area cut on the sdk build (The SDK's address-region node area), where that build aligns large regions to 1 GB the alignment cut, and less address space for the allocator, as below. SDK 19's AddressRegionManager puts the region right after the node area (+0x3fe2c000 before the cut), not 1 GB aligned as Engage's SDK did, so Z-A's 4.25 GB alias region (mem_alias 0x110000000) holds both; Paper Mario's sdk (Absolum's cuts on Celeste's build) puts its region at alias +62 MB to +4.06 GB in Engage's 5 GB block. The allocator's size was first a cut on each game's main (to 4 GB; Absolum's to 2.5 GB); the host's cap replaced those cuts.
  • The host gives the request what the alias region has room for, in every game and build. The rewriter places a gate at the entry of both nn::mem::StandardAllocator::Initialize overloads, found by symbol in every sdk that has them (patch_nso.py "Address regions"). With a null address (virtual address memory) and a size the alias region has no room for, the host lowers the size in x2 before the SDK sees it (hle_svc.c "StandardAllocator's address space"): to the alias region less the regions already made (at least the 62 MB node area) and 256 MB for the regions made after it, in 32 MB steps. The SDK lays the region and its CentralHeap out from that size, so it holds all the way down, and the pool, not the region, bounds what the heap maps. The profile key stdalloc_cap sets the size instead, where the regions made after it need more room (Absolum's garbage collector); the NSP script's address region check sets it in a provisional or derived profile (Measuring address regions and commits): a provisional pack of Absolum 1.1.1 with its game_env set 0xa2000000 (2.53 GB), between the 2.5 GB and 2.72 GB that both play its route (without game_env its GC's region is 3.75 GB and the check reports no room). A request that fits is never changed, so a Switch-sized alias region (DM_ALIAS_SIZE) sees the game's own size. It is a workaround for the iPhone's address space, as the cuts are; a region the game's own allocator takes with nn::os::AllocateAddressRegion is not touched, since its layout may be fixed (A 62 GB page allocator behind nu::VirtualAllocHook). Checked with main rewritten without its cut: Pokémon Legends: Z-A 2.0.2 (12 GB → 3.94 GB, frame 2,571 in 90 s headless; without the gate it aborts at sdk+0x134214 as before the cut; with a 64 GB alias region the request stays 12 GB), Paper Mario: The Thousand-Year Door 1.0.1 (62 GB → 4.06 GB, frame 1,800 in 75 s), Absolum 1.1.1 (40 GB: the default 4.06 GB leaves its GC's 1.5 GB region no room and the SDK aborts at sdk+0x13a684; with stdalloc_cap 0xa0000000, 2.5 GB, frame 3,900 in 75 s; the region check computes 2.72 GB). Pokémon Scarlet was not checked (no game files). Then the main cuts of Z-A, Paper Mario and Absolum (and Z-A's matched rule) were removed: each game's route from a fresh save into play, headless, with and without the cut on the same build (Z-A 380 s into Lumiose City, Paper Mario 480 s into Rogueport, Absolum 320 s into the first level, fighting), reached the same frames with the same [RED] kinds and alias region use, and screenshots of the same scenes. Z-A's cut of 4 GB had already been lowered to 3.94 GB by the cap. Pokémon Scarlet keeps its cut (no game files to check it on).
  • Found on: Pokémon Legends: Z-A (2.0.2, SDK 19.3.11; one boot); Paper Mario: The Thousand-Year Door: the allocator maps 1.5 GB at boot and 2.0 GB by the arrival in Rogueport. The sdk build is Celeste's. Absolum's malloc StandardAllocator (40 GB, 2.5 GB with its stdalloc_cap) is in .NET NativeAOT malloc and the GC region range. Sonic Superstars (Unity 2021.3, SDK 16.2.0): a request that fits, 3.12 GB of the pool, capped to 1.5 GB by stdalloc_cap because Unity's own regions, taken after the region check's 15 s, need the room.
  • Also found on: Pokémon Scarlet (1.0.0, SDK 13.3.5): its main cut was removed for the cap (2026-10-30, 4.06 GB on its 4.375 GB alias region).
  • Note: on Pokémon Legends: Z-A, QueryMemoryInfo reporting more than 4 GB would take the game's big-memory path (heaps of allocatable − 4.27 GB instead of − 2.76 GB).

Bezel Engine's two memory setups

  • Symptom: a SIGSEGV writing just past a null base as a menu or board loads (nn::bezel::CoreSystem::Allocate returned null for a 46 MB request at main+0x8d9a48, Super Mario Party Jamboree 2.3.0), sometimes first [RED] fs ReadFile into 0x0+0x400000 failed at +0 (EFAULT), a read into a null buffer from the same allocator; the alias region's [mem] lines show StandardAllocator::Initialize(nullptr, 56.00 GB) … 2.06 GB of address space.
  • Cause: Bezel's nninitStartup chooses between two setups by os::IsVirtualAddressMemoryEnabled(). With virtual address memory, malloc's StandardAllocator gets a 2 GB address region and Bezel's own a 56 GB one (Big Brain Academy: 60 GB); without, it sets a heap of pool − used memory, gives malloc 32 MB of it and Bezel the rest. In the 4.375 GB alias region of the 5 GB block, the host's allocator gate leaves Bezel 2.06 GB beside malloc's 2 GB, and Bezel runs out: measured with a 64 GB alias region (DM_ALIAS_SIZE), malloc's region held 5 MB and Bezel's 2.3 GB at Jamboree's Party Plaza. Holding both needs about 5.2 GB of alias region, more than a phone without extended addressing leaves beside Metal.
  • Fix: profile data: sys_resource 0, so the game takes its heap path, with the 4 GB layout and the Switch's 3285 MB pool (app_pool 0xCD500000): a 3.14 GB heap, all but 32 MB of it Bezel's, as much memory as the Switch's pool gives it. The NPDM's 16 MB of system resource are not reported, so this is a choice between the game's own two setups, made for the phone's address space, not a fix of the virtual-memory path.
  • Found on: Super Mario Party Jamboree (Bezel Engine 2.9, SDK 17.5.9); Big Brain Academy: Brain vs. Brain has the same nninitStartup and declares no system resource, so it always took the heap path.

Separate virtual ranges in Snowdrop's page allocator

  • Symptom: a write to 0x20000ffff in static initialization (main+0x164990), after AllocateAddressRegion returns 0x1803; reducing the request to 3 GB still returns 0x1803 with the SDK's original large-region alignment. With only the outer ranges reduced, a later free crashes at main+0x1668dc after reading compressed file data as a slab-owner pointer. With all three ranges cut, a write to 0x78 at main+0x2c534a8 (lr=main+0x2c53484) when the opening quest's overalls bush is shaken, or when a later save loads. With a pool too small for the area: nn::diag abort with Result 0 and an empty message while a world loads, backtrace sdk+0x16ba48 sdk+0x37d1b0 sdk+0x37c2f4 sdk+0x37c27c sdk+0x37a980 main+0x12300c main+0x164b40 main+0x164954 main+0x168e54 on Sparks of Hope 1.6.2225577 and SDK 13.3.5.
  • Cause: the engine reserves three adjacent virtual ranges before committing pages in them. Its enclosing reservation exceeds the iPhone's available guest address space, and the engine does not check the failed reserve before deriving the page addresses. A smaller reservation also needs the SDK's node area and large-region alignment adapted: a separate metadata reservation comes first, and the 1 GB alignment wastes the remaining contiguous space. The large-allocation child has an independent maximum page count; reducing only the outer range lets it allocate across the boundary used by free to distinguish allocation classes. Beside the three ranges, the memory system's startup reserves a 1.06 GB pool of 272-byte slots that nothing allocates from, and every UI node context reserves two small ranges of its own (576 KB and 256 KB) without checking them. With the pool, the regions fill the 4.375 GB alias region in the opening quest; a context left without its range writes its first entry to NULL + 0x78. A range below the game's peak has its own failure: the slab allocators' fallback asks for another 4 GB (main+0x164b34 for 64 KB pages, main+0x164a34 for 512 KB pages), which never runs on a Switch, where a 4 GB range is larger than the whole application pool. Here it does not fit, and the fallback retries without end. Each failed AllocateAddressRegion leaks a 48-byte node (sdk+0x37c3f8 returns without freeing what sdk+0x37d0c8 allocated), until the SDK's 62 MB node area is full and it aborts (sdk+0x37d1ac). The abort is thus far from its cause, and says nothing about address space.
  • Fix: dm/tools/snowdrop_pools.py, called by patch_nso.py for any main, finds the constructor by its structure, without a build ID: the 4 GB constant and its three adjacent size fields, the two reservations through one function that overwrites x1, the two 12 GB constants (either encoding) and the large allocator's page count. Every part is checked, one match or nothing is cut, with a red line if the shape is there. It reduces the range sizes, their combined reservation and the large allocator's maximum page count together, to the profile's snowdrop_pools (large, 512 KB and 64 KB ranges in MB; 2048 512 1280 without the key). The profile's build-gated cut reduces the unused pool to 17 MB. The fixed metadata reserve remains large enough, and page allocation, commits and frees retain their behavior. The SDK build gets the SDK-wide node-area cut and 2 MB alignment of the large classes. Each range is still a fixed limit below what the game could ask for, so per-range occupancy and the original growth fallbacks remain open until sustained gameplay validates them; see the game note for the layout and current validation. To reproduce the abort, give the 64 KB range less than the area uses (128 MB in Sparks of Hope's opening: the abort comes right after the title).
  • Found on: Mario + Rabbids Sparks of Hope (1.6.2225577, SDK 13.3.5; the rule also finds the base game's allocator, and the abort is GitHub issue #1010); Mario + Rabbids Kingdom Battle (1.9.589692, SDK 4.4.0), with the same three-pool layout and an independent large-allocation page count. Its base game has no such routine, and the rule leaves it alone.

.NET NativeAOT malloc and the GC region range

  • Symptom: nn::diag abort at sdk+0x12dad0 in nninitStartup, called from main+0x756310 (1.1.1: main+0x7562d0).
  • Cause: malloc's StandardAllocator::Initialize(nullptr, 0xa00000000): 40 GB of address space (main+0x28c084; 1.1.1: main+0x28bff4). Absolum's regions (measured on a 64 GB alias region): malloc's (2.19 GB used into the first level, 1.75 GB of it FNA3D's GPU heap, allocated whole at FNA3D_CreateDevice), the .NET GC's region range (3.75 GB) and a few small ones. The GC sizes its range as 5 × its hard limit (768 MB = 75% of the 1 GB its GetPhysicalMemoryLimit, main+0x28c08c in 1.1.2, main+0x28bffc in 1.1.1, reports), and it reads DOTNET_GCRegionRange through getenv first.
  • Fix: the host now answers getenv from the profile key game_env (hle_os.c; every other name goes to the SDK's own getenv, an empty environment), which sets 1.5 GB. The GC then uses its range from both ends, 756 MB from the start and 748 MB from the end, where it used only the first 1.14 GB of 3.75. The profile's stdalloc_cap gives malloc 2.5 GB through the host's allocator gate, in every build (the 1.1.1 cut on main it replaced is gone). One more thing kept it from fitting, the 1 GB alignment of large regions (1 GB alignment of regions of 1 GB and more), cut beside the node-area cut. The regions then end 4.13 GB into the 4.375 GB alias region of Engage's 5 GB block.
  • Found on: Absolum (.NET NativeAOT on SDK 18.3.2).
  • Check: boot once on the Mac with a 64 GB alias region (mem_reserve 0x1030000000, mem_alias 0x1008000000) and probe VammManager::AllocateAddressRegion's success path (sdk+0x13bf8c: x21 size, x22 address) with DM_SVC=1's map log.

A 62 GB page allocator behind nu::VirtualAllocHook

  • Symptom: the game aborts in its static initialisers, before nnMain, when its own 62 GB address region fails (main+0x37c9bd0, AllocateAddressRegion(&p, 0xf80000000) at main+0x37c9a5c).
  • Cause: the allocator behind nu::VirtualAllocHook is a 64 KB page allocator whose own tables fill the region's last 14 MB, so the size, the header offset, the page count and three region-end bounds all follow from 62 GB.
  • Fix: the profile's patches.py cuts all 11 instructions to a 4 GB region. The cut region still did not fit: SDK 8 has the same 1 GB node area and 1 GB alignment of regions of 1 GB and more as the newer SDKs (constructor sdk+0x336b40, orr w9, wzr, #0x3fe00000 at +0x336b50 / +0x336bf0; alignment at +0x336d5c), cut in sdk_patches.py as DRAGON QUEST XI S's SDK 7.3.2 build; the region then starts 64 MB into the 4.375 GB alias region of the 5 GB block.
  • Found on: Super Smash Bros. Ultimate (13.0.5, SDK 8.2.1). Booted once with a 64 GB alias region (mem_reserve 0x1030000000, mem_alias 0x1008000000) and DM_SVC=1's map log, the game commits from the region's start upward (1.73 GB contiguous at the title) and its page tables from the top; a battle commits up to 2.5 GB (DM_ALIAS_WINDOWS).

Unreal's engine address region and its options

  • Symptom: the game calls exit(1) in nninitStartup (Octopath Traveler 0: main+0x12e74); nn::diag::Abort from main+0x352fbec (Unreal's fatal error) after the first commit, AllocateMemoryPages(0, 2 MB) while the RHI starts (main+0x353ff8c, result 0xe03) (Pikmin 4); a crash in NumberLineAllocator::Compaction at boot (DRAGON QUEST XI S, region option 4224).

  • Cause: Unreal's nninitStartup needs virtual address memory and reserves one address region for the engine with AllocateAddressRegion, 62 GB by default (-SwitchVirtualAddressSpaceSize= in MB, or in bytes above 4095). The other options it reads are -SwitchMaxMemorySize=, -NVNCommandBufferGranularity=, -NVNControlBufferSize=, -SwitchMainThreadStackSize=, -NVNTempMemoryPageSize=, -ResX= / -ResY= and -SwitchDisableVirtualAllocator, which also exits. It reads them from the game arguments, which the host used to set only after nninitStartup.

  • Fix: the game arguments are set before nninitStartup now, as on hardware. The region's size no longer comes from them: the host gives the region what the alias region has room for, in any Unreal Engine 4 / 5 main.

  • Engine-wide: the rewriter finds the reservation without a build ID (dm/tools/ue_region.py, called by patch_nso.py), each link unique or nothing is gated: the option's name SwitchVirtualAddressSpaceSize in ro, nninitStartup (a b to the engine's function; in UE 4.18 a bl to it), and in that function the one bl to the nn::os::AllocateAddressRegion PLT stub whose straight-line code sets x0 to the region's base variable and stores the size in the word after it. x1 at the call must be that size (the stored register, or both set from one constant), and written after the store only with that same constant while the stored register still holds it; the rewriter then makes that instruction mov x1, xS. Observation gate OBS_UE_REGION goes on that store, with the register in the gate number's upper bits. The host (hle_svc.c) sets that register and x1 to StandardAllocator's room (StandardAllocators over 12 to 62 GB of address space): the alias region less the regions made so far and 256 MB for later ones, in 32 MB steps. That is 2.81 GB in the 3.75 GB block and 4.06 GB in Engage's 5 GB block. With a Switch-sized DM_ALIAS_SIZE the request fits and nothing changes. A chain that breaks after the option's name is a [RED] line in the rewrite, and the engine asks for its whole default.

    • Forms found:
      • a csel of the default and the option's size rounded up to 4 KB, then str x1 (UE 4.27 and 5);
      • the default stored only while the variable is 0, then rounded into x1 and stored again (OCTOPATH TRAVELER II);
      • str x8 of the default, with x1 set from the same constant (Pikmin 4, UE 4.26);
      • the same with the constant set after the store (TRIANGLE STRATEGY, UE 4.27). Until 2026-11-02 this form was a [RED] line in the rewrite: the provisional 64 GB alias region hid it, and a 3.75 GB block aborted in nn::diag just after the NVN device started (main+0xd0f130), with no region in the log;
      • str x1 of the 16 GB default (DRAGON QUEST XI S, UE 4.18).
    • Checked (2026-10-31): on the 123 main files on the game drives, it found the reservation of each of the nine Unreal builds with the option and nothing in the others. OCTOPATH TRAVELER and Yoshi's Crafted World (UE 4.18, no virtual address memory) have the same code, which their nninitStartup never reaches. Checked again on 2026-11-02 for the form after the store, on the 131 main files on the drive: only TRIANGLE STRATEGY's result changed.
    • Before: until 2026-10-31, seven profiles set -SwitchVirtualAddressSpaceSize= in game_args (2048 MB in the 3.75 GB block, 4064 MB in the 5 GB one), and Pikmin 4's patches.py cut its default to 4064 MB.
  • Found on:

    • Octopath Traveler 0 (Unreal Engine 5): main+0x12e24. With the host allocator (Unreal's FMallocBinned3 replaced by the host allocator) the region holds only what the engine maps itself; with SDK 19's node area cut the guest block is 3.75 GB, and the region 2.81 GB (2 GB until 2026-10-31).
    • DRAGON QUEST VII Reimagined (Unreal Engine 4.27, SDK 19.3.1): the same 62 GB region from nninitStartup (main+0xe8a2c0). With a 2 GB region it was mapped up to +0.75 GB through the opening and the new-game menus.
    • FINAL FANTASY RESONANCE (Unreal Engine 5.6.1, SDK 20.5.17): nninitStartup (main+0x13370) reserves the same 62 GB region (main+0x137d0); the region gets 4.06 GB in Engage's 5 GB block, as DRAGON QUEST XI S's (Unreal 5.6's page allocator never gives address space back).
    • Pikmin 4 (Unreal Engine 4.26.2, 1.1.1, SDK 10.4.8): the region option is the trap. This nninitStartup reserves the region only while its size variable is 0 (ldr x8,[x22,#0x130]; cbnz x8 past the default 62 GB and its AllocateAddressRegion, main+0x353f564): the variable is also its "reserved" flag, so with the argument the base stayed 0 and the first commit went into Unreal's fatal error. The game's own code, not the host's: UE 4.27 and 5 reserve in both cases (the csel form). The default path takes the gate (str x8 at main+0x353f57c). With Engage's 5 GB block and a 4064 MB region, from the boot into the Olimar prologue: up to +1.5 GB of the alias region with 1.0 GB mapped, an Unreal heap of 1 GB.
    • DRAGON QUEST XI S (Unreal Engine 4.18, SDK 7.3.2) reads the same option and the others from argv[1] on (main+0x177b5a4; main+0x177c440 parses each one: atoi, MB up to 4095, bytes above). Its region is 16 GB by default, reserved at main+0x177bbf4 in the function nninitStartup calls first. A value of 4224 was taken as bytes: a 4 KB region, and the first 2 MB request crashed in NumberLineAllocator::Compaction at boot. With the node area cut and 2 MB alignment for large regions on that SDK build (sdk_patches.py), the region starts 64 MB into Engage's 4.375 GB alias region. On the Mac, with 4064 MB, 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.
    • SHIN MEGAMI TENSEI V: Vengeance (Unreal Engine 4.27): main+0x1b89038, the csel form.
    • OCTOPATH TRAVELER II (Unreal Engine 4.27, SDK 12.3.2): main+0xbec3b4.
  • Note: without virtual address memory there is no engine region: OCTOPATH TRAVELER (Unreal Engine 4.18, SDK 3.5.1, no system resource in its NPDM) has nninitStartup set a heap of pool − used (3.08 GB) and the engine runs on it, so it takes the 4 GB heap layout. The provisional profile used to give every Unreal game the 64 GB alias layout, which left this one no heap region (SetHeapSize OOM: req 0xc5400000 > cap 0 and a Break in nninitStartup); profile_guess.py now gives it only with virtual address memory.

Unreal's FMallocBinned3 replaced by the host allocator

  • Symptom: a null read in the out-of-memory report at main+0x1005824 when the engine region is smaller than the allocator's pools (Octopath Traveler 0).
  • Cause: Unreal's allocator, an FMallocBinned3, reserves 37 GB of the engine region (74 pools of 512 MB, pool index = offset >> 29). The pools are uneven (at character creation the largest holds 68 MB, the 74 together 732 MB; measured with DM_ALIAS_WINDOWS=512), so no smaller window fits a phone's ~5 GB.
  • Fix: the host puts its own allocator in the object instead (hle_uemalloc.c: the FMalloc vtable on a host malloc zone, the trivial slots kept). With it the region holds only what the engine maps itself (709 MB at Octopath Traveler 0's character creation).
  • Engine-wide: the rewriter finds the allocator in any Unreal Engine 4 / 5 main without a build ID (dm/tools/ue_malloc.py, called by patch_nso.py), each link unique or nothing is cut: the UTF-16 name Binned3, its getter (adrp x0 + add x0, ret), the one data word holding the getter once the module's relative relocations are applied (OCTOPATH TRAVELER II's and Pikmin 4's vtables are relocated, the others' are in place), the vtable it is a slot of (back to two words of 0: offset to top, no RTTI), the one function other than the vtable's two destructors that stores the vtable (the constructor) and its one call, a bl. It cuts that call and puts observation gate OBS_UE_MALLOC on it, where x0 is the object (made by GCreateMalloc, or a bss object in nninitStartup); the fixups table names the vtable and the getter's slot (UE_MALLOC_VT, UE_MALLOC_SLOT), which names the slot layout: 19 for UE 4.26 / 4.27, 22 for UE 5, 26 for UE 5.6. A chain that breaks after the name is a [RED] line in the rewrite and Unreal's own allocator runs. On the 68 main builds on the game drive (2026-10-30) it found exactly the call, vtable and layout of the five that had a cut of their own, and nothing in the other 63, Batman: Arkham Knight's included; on SHIN MEGAMI TENSEI V: Vengeance's main, extracted from the archive drive, its registered ones too. Those cuts were removed, with FINAL FANTASY RESONANCE's Asian release's, which had the global release's offsets. Until then each build had a cut and a gate in its profile's patches.py and a row of fixed addresses in hle_uemalloc.c.
  • Found on:
    • Octopath Traveler 0 (Unreal Engine 5): the allocator is constructed in place in the bss.
    • DRAGON QUEST VII Reimagined (Unreal Engine 4.27, SDK 19.3.1): an FMallocBinned3 whose 37 GB of pools are cut out of the 62 GB region (the mapped high-water mark reached +38 GB of a 64 GB alias region at boot), so a capped region would starve it. Here the allocator is not in the bss: GCreateMalloc (main+0x32758b0) mallocs 0x1c38 bytes on the first allocation, during the static constructors, and constructs it there. Found from the commit calls (probe nn::os::AllocateMemoryPages past its adrp, sdk+0x140f84: every backtrace runs through the allocator's methods around main+0x3270000), the relative relocations that point at them (the vtable main+0x5e4c0e8; this build has no RTTI) and the one store of that vtable outside the destructors. No code outside the allocator reads its pool base or calls its methods directly. UE 4.27's FMalloc has one FExec slot, not four, so the slot numbers differ from UE5's (Malloc 3 … GetAllocationSize 9; Free is main+0x10, right after the 16-byte module header).
    • FINAL FANTASY RESONANCE (Unreal Engine 5.6.1, SDK 20.5.17): nninitStartup constructs its FMallocBinned3 in place in the bss, as Octopath Traveler 0's (main+0x77c6210; the call at main+0x137f0 is cut, and its constructor would reserve 38 GB). UE 5.5 and later add MallocZeroed / TryMallocZeroed after Free, two TLS cache calls (MarkTLSCachesAsUsed / Unused) and two cached-memory queries, so the FMalloc vtable has 32 slots (layout UE5_6 in hle_uemalloc.c: Malloc 6 … Free 10, QuantizeSize 13, GetAllocationSize 14). The base vtable shows it: its three pure slots are 0, and its slots 11 and 12 are one function that calls slot 6, then memset.
    • Pikmin 4 (Unreal Engine 4.26.2, 1.1.1, SDK 10.4.8): the allocator is made as in 4.27, with the same 21-slot FMalloc vtable (GCreateMalloc main+0x353f770 mallocs 0x1c38 bytes and calls the constructor main+0x35ba610, the one store of the vtable main+0x8da4758 outside its two destructors; found from the FMallocBinned3 error strings through the relocated slots). The engine-wide rule's first relocated vtable with OCTOPATH TRAVELER II's.
    • OCTOPATH TRAVELER II (Unreal Engine 4.27, 1.1.1, SDK 12.3.2): as DRAGON QUEST VII Reimagined's (GCreateMalloc main+0xbecbf0, the constructor main+0xc4d778, the vtable main+0x64710b0), with 20 slots: no OnMallocInitialized, so the 21st word the host copies is the next vtable's offset to top, which nothing calls.
    • SHIN MEGAMI TENSEI V: Vengeance (1.0.3, SDK 15.3.2): the same UE4 layout (GCreateMalloc calls the constructor main+0x1578bc0 at main+0x1f2f63c; the vtable main+0x5a7f0a8), checked on its main from the archive drive.
    • Batman: Arkham Knight (Unreal Engine 3 with a binned allocator of this kind, 1.0.5, SDK 14.3.0): the static constructors reserve 1 GB for its tables and 37 GB for its pools (main+0x192eee8, main+0x192fe50; the region check stops the provisional build there). The object is in the bss (main+0x51818c0) and its constructor is inlined into the guarded function behind GMalloc (main+0x192eb30), so the cut is a branch from the guard's acquisition to its release, not a nop; hle_uemalloc.c checks for that word. It has no Binned3 name for the engine-wide rule, so its cut stays its build's (patches.py) with a row of fixed addresses. Its FMalloc interface is UE3's: 25 slots with 32-bit sizes and no TryMalloc / TryRealloc (layout UE3: QuantizeSize 3, Malloc 4, Realloc 5, Free 6, GetAllocationSize 20, which stores a 32-bit size), found by the vtable offsets of GMalloc's 36,800 call sites (+0x20 / +0x28 / +0x30 for 8,090 / 8,423 / 20,308 of them). The host heap held 1.75 GB 45 s into the boot.
  • Note: Unreal Engine 4.18 (DRAGON QUEST XI S) needs no allocator cut: the SDK's NumberLineAllocator hands its region out in 64 KB units (main+0x177c4d0), and the engine maps only what it uses.

Wwise loses the span header in a virtual block from Unreal's heap

  • Symptom: Wwise crashes in AK::MemoryMgr::Init while reallocating or freeing a block, after Unreal switches to the host heap. Whether a boot crashes depends on where the heap puts the block, and the fault address can be a null read or garbage.
  • Cause: Wwise requests page-aligned virtual memory through Unreal and locates a span header by clearing the low 16 bits of an interior pointer. A host allocation aligned to only 4 or 16 KiB can put that address before the block. Unreal's virtual allocator supplies 64 KiB alignment for these spans. On an M6 Mac with 16 GB, 10 of 16 boots of Princess Peach: Showtime! crashed this way, and none of 15 with the fix.
  • Fix: the host preserves the 64 KiB boundary for allocations and reallocations of at least 64 KiB, and honors larger requested alignments. A reallocation still calls the zone's realloc first and copies only a block that comes back off the boundary. Copying every block of that size instead held 1,053 MB of footprint where 289 MB was enough for one array grown to 256 MB by 1.375 times, took 5 times as long, and gave nothing back on a shrink (a synthetic run on a Mac). Aligning the allocations themselves cost little: the footprint of 68 KiB blocks went from 1.182 to 1.185 times the requested size, and at frame 1,500 of a boot with no shader libraries DRAGON QUEST I & II held about 2% more resident memory over four runs each (2,192 to 2,237 MB, overlapping ranges).
  • Found on: Princess Peach: Showtime! 1.0.0 (#996), whose Wwise integration requests 4 KiB alignment.

Thread stacks from the host's Unreal heap

  • Symptom: an abort right at nnMain, under nn::os::CreateThread → ThreadManagerImplByHorizon::MapAliasStack → StackGuardManager::MapAtRandomAddress; with the log line, [RED] MapMemory refused (0xd401): <stack region> <- <address outside the guest block>.
  • Cause: Unreal allocates the stacks of the threads it creates with FMemory (OCTOPATH TRAVELER II's game thread: nnMain mallocs it with 4 KB alignment at main+0x544; its FRunnableThreads take theirs from inside larger allocations), and CreateThread moves such a stack into the stack region with svcMapMemory. With the host allocator that memory is a host malloc zone outside the guest block, and MapMemory takes only a backed source: the heap, mapped alias pages or a module's writable data (map_source_backed, hle_svc.c).
  • Fix: with the host allocator installed, a source outside the guest block that the host has mapped readable and writable is taken too (dm_ue_heap_owns, hle_uemalloc.c; vm_region_64, since malloc has no cheap lookup from an interior pointer). A refused map now logs its arguments.
  • Found on: OCTOPATH TRAVELER II, after the stricter source check of MapMemory; Pikmin 4 boots with the same host.

Unreal 5.6's page allocator never gives address space back

  • Symptom: nn::diag abort from main+0x11bc, no message, in the first battle, with about 1 GB mapped (FINAL FANTASY RESONANCE with DRAGON QUEST VII Reimagined's 2 GB region).
  • Cause: the engine hands its region out with a page allocator of its own (main+0x13ef0), a bump pointer plus free lists per size class, so the address space it has used only grows, and past the region's end it calls OnOutOfMemory (main+0x13c74).
  • Fix: the profile gives the region 4064 MB in Engage's 5 GB block, as DRAGON QUEST XI S's (profile key game_args). This SDK build aligns regions of 2 MB and more to 2 MB only (VammManager::AllocateAddressRegion, sdk+0x145e00), so it needs no alignment cut.
  • Found on: FINAL FANTASY RESONANCE (Unreal Engine 5.6.1, SDK 20.5.17). Probed where each new block comes back (DM_PROBE=main:13fcc: x0 the block, x21 its size, x8 the region's end): a 1 GB block at boot from a static constructor, +1.5 GB 15 s in, +2.0 GB in the battle, level at +2.03 GB over seven minutes.

Ring heaps cut from a pool at 64 KB alignment

  • Symptom: write to 0x268 right after the title screen (main+0x5d6708, a constructor whose this is 0x200).
  • Cause: operator new (main+0x5a55d4) takes the thread's ring heap, a bump allocator whose cursor starts at its base. One ring heap kept base 0, as in 13 Sentinels: the engine cuts ring heaps out of a memory pool at 64 KB GPU alignment, and the host's pool storage from malloc is only 4 KB aligned. The first allocation returned 0, which operator new's null check skipped, and the next returned 0x200.
  • Fix: the profile key alloc_big_align aligns allocations of 64 KB or more to 64 KB (dm/games/README.md); the game then plays from the title screen into its opening story scene.
  • Found on: Unicorn Overlord (SDK 16.2.4, Vanillaware's engine as in 13 Sentinels: Aegis Rim; one boot).

Mapping SVCs

svcQueryMemory must report each module's real layout

  • Symptom: C++ exception aborts in nn::ro::detail::GetExceptionInfo.
  • Cause: the SDK's unwinder finds a module from svcQueryMemory: it walks back over Code+RX regions to the module start, forward over Code then CodeData, and reads MOD0 for .eh_frame_hdr.
  • Fix: the host reports each loaded module's real layout and reports other host memory as free (hle_svc.c).
  • Found on: Fire Emblem Engage (its note lists it among the host changes first needed there). IL2CPP throws a C++ exception for every managed exception, so Unity games need this; Mario (Super Mario Bros. Wonder) never threw.

MapPhysicalMemory in 16 KB host pages

  • Symptom: MapPhysicalMemory faults on part of a page.
  • Fix: round to 16 KB host pages.
  • Found on: the source names no game.

Maps past the application pool fail as on Horizon

  • Symptom: the alias region's mapped total runs past the application pool in the first minute.
  • Cause: nninitStartup allocates and frees everything allocatable (pool − used, 3.15 GB, mapped at the top of the region) and the SDK keeps the freed pages mapped; later allocations map new pages at the bottom until a map fails, and only then does it unmap the cached block and retry. The host never refused a map, so the block stayed mapped beside the 0.4 GB the game used.
  • Fix: profile key mem_map_limit (hle_svc.c): a map past the pool fails with ResultLimitReached, and the used memory GetInfo reports counts the mapped pages, as on Horizon (without that, the startup probe itself, sized as total − used, was refused).
  • Found on: Pokémon Legends: Z-A (2.0.2, SDK 19.3.11; one boot). Measured: one refused 4 MB map, the 3.15 GB unmap, the same map again.

Unmapped guest memory stays resident

  • Symptom: after a large UnmapPhysicalMemory or heap shrink, RAM does not come back. phys_footprint may still fall, so it hides this: the guest class in DM_MEMSTAT shows the real figure.
  • Cause: on Darwin mprotect(PROT_NONE) only removes the page table entries. The pages stay resident and dirty, out of phys_footprint, and madvise(MADV_FREE) on pages that are not writable does nothing, though it returns 0. Measured on 1 GiB on the Mac: 1,024 MiB still resident and dirty, footprint 1.2 MiB.
  • Fix: guest_release (hle_svc.c) puts a fresh inaccessible mapping over the range (MAP_FIXED), which empties it at once and gives zero pages. UnmapPhysicalMemory then clears the range's exposed bits, so mapping it again needs no memset. A memory pool's no-copy buffer over the range keeps the old pages for the GPU until the buffer goes (measured: the GPU still reads the old data, the CPU sees zeros). A guest that unmaps and remaps under a pool it never finalized would see the two diverge, which Horizon does not allow.
  • Found on: Pokémon Scarlet, one A/B on its 180 s route on the Mac: peak footprint 4,540 → 3,775 MiB, guest charged 1,809 → 1,372 MiB, 30 fps held. 437 MiB of the footprint drop is guest memory; the other 328 MiB is not explained. Tears of the Kingdom on its 95 s route: footprint flat (4,935 → 4,969 MiB), guest charged 1,887 → 1,685 MiB, with the baseline's pages partly compressed, so that drop is less certain. Open: the iPhone and Z-A, whose shader libraries were stale, were not measured.

GetInfo(UserExceptionContextAddress) returns a real buffer

  • Symptom: a write through a null pointer at +0x1c0 during startup.
  • Fix: GetInfo(UserExceptionContextAddress) must return a real buffer.
  • Found on: the source names no game.

Running out of memory

A null-pool buffer from a game heap that ran out

  • Symptom: a null-pool buffer even with virtual pools working (Virtual memory pools mapped onto one physical pool).
  • Cause: the game's own heap ran out.
  • Tracing it with probes (05): the gsys GPU allocator at main+0x4290c0 and the heap's tryAlloc (vtable slot +0x30) show which heap, its size and every allocation into it; the !xN heap walk shows the free and used blocks at the moment of failure.
  • Note: read the probe log carefully: tryAlloc probes show requests, not successes, so a running total that already exceeds the heap means an earlier request failed.
  • Found on: Super Mario Bros. Wonder, The Legend of Zelda: Breath of the Wild (the two entries below).

Model heaps sized by the host's command record sizes

  • Symptom: a model load exhausts its resource heap: a null operator new at the world load, or BufferInitialize: pool has no storage followed by a null-pool crash. A display-list heap that runs out shows as AddCommandMemory with a null pool outside a recording, then host-lent segments growing until FATAL NVN command memory (Host-lent command memory segments).
  • Cause: games size model allocations by measuring scratch command recordings; oversized host records can exceed the budgets in their resource-size tables. In Breath of the Wild, each model's heap is its table entry + 0x1008, the file goes in 4 KB aligned, and the setup (main+0x155a2ac) takes the rest as a frame heap (vtable main+0x1d4f950, head +0xe0, tail +0xe8). Its use is the same in every run; only the file's alignment pad varies (0 to 0xff8), and the entries leave room for any pad, about 0xd00 to spare on most models. Three models were a 4 KB page short, so the loads with a large pad failed. The game sizes the model's prerecorded commands by recording each mesh's draw and each shape's per-pass state into a scratch command buffer and summing GetCommandMemoryUsed (main+0xf9aeb4, main+0xf9bb54), and our records were larger than the driver's (draws carried the indirect and multi-draw fields, every recording began with a BEGIN record). Super Mario Odyssey keeps its display lists in a 62 MB GPU bump heap named "DisplayList" (main+0x5e8280: used at +0x68, capacity at +0x6c, a null block when full), allocated while a kingdom loads; with 40-byte state binds (39% of the display-list bytes) it filled at the load and every later list got a null block.
  • Fix: plain draws leave unused indirect fields out and recordings start with their first command. Uniform and single vertex-buffer bindings also use compact 24-byte records instead of 32, preserving full GPU addresses and retaining full-width fallback records for ranges above UINT32_MAX; multi-vertex ranges retain their array representation. State binds carry only the state object's bytes (16 bytes for color, channel mask and polygon state, 24 for blend, multisample and depth-stencil, instead of 40), and a single texture bind drops its pool bases when both are 0 (24 bytes instead of 32, the handle still 16 bytes in). Replay expands the compact forms into the same canonical binding state. The native inline uniform command takes 20 bytes and the native vertex-buffer command 24, so these small differences can accumulate into an extra 4 KB GPU-pool chunk. Report the bytes actually recorded; do not underreport usage or add a game-specific heap allowance. See What GetCommandMemoryUsed counts.
  • Check: for Breath of the Wild's whole scene, probe where the setup heap is cut (main+0x155a520 with !x21, the resource heap) and the end of the setup (main+0x155a5f4 with !x2, the frame heap), with DM_PROBE_HEAP=28,38,40,e0,f8 DM_PROBE_HEAPFRAME=1d4f950 DM_PROBE_HEAPWORDS=1 (05). A model fits any placement when entry + 0x1008 covers the resource heap's header and object (0x310), the file, the frame heap's block header and its peak use, plus 0xff8 of pad.
  • Found on: The Legend of Zelda: Breath of the Wild, Super Mario Bros. Wonder (buffer bindings; the game note records the failed allocation, measured sizes and validation), Super Mario Odyssey (state and single texture binds; the note has the heap's measurements).

Unity's GPU pool block allocation spins at size 0

  • Symptom: the frames stop at the first loading screen, a graphics worker at 100% CPU (17 s in, UnityGfxDeviceWorker running, the main thread waiting on a condition variable, no red line).
  • Cause: the heap check's 1 GB pool (the hardware pool had used the alias region up to +4.25 GB) left the game's malloc too little: Unity's GPU memory pool block allocation (main+0x96f430) calls aligned_alloc through main+0x866810, halves the request after each failure, and once the request rounds to 0 it calls on for ever with a size of 0.
  • Fix: a 2 GB pool: the game then maps up to 2.26 GB of the alias region through its menus and first battle.
  • Found on: Advance Wars 1+2: Re-Boot Camp (Unity 2020.2.1f3, SDK 10.4.1), after the node-area and alignment cuts.
  • Check: sample <pid> on the Mac host shows such a spin (the guest frames are ??? in the host's image list; subtract the module base from the log's base lines); it worked here where lldb does not.

Host calls on small guest stacks

  • Symptom: SIGBUS inside a host hook (snprintf under mount_ex / h_fs_MountSave), then the process hangs (Super Mario 3D World + Bowser's Fury); SIGBUS inside Metal (AGCDeserializedReply, an AGX compiler block) at an address in the guest stack region (Breath of the Wild).
  • Cause: host hooks run on the calling guest thread's stack. Super Mario 3D World's save thread has an 8 KB stack (CreateThread stack top 0x…9fe0, fault 0x21a0 below it), and the file system hooks size their path buffers by PATH_MAX: one file open takes 6–7 KB (-fstack-usage: OpenFile 1.3 KB → host_path_ex 1.9 KB → path_component 3.2 KB); on hardware the same call is one IPC. In Breath of the Wild a host NVN call did Metal work on a game thread with a small stack: nvnQueueSubmitCommands replays the frame, and ending a render pass made the driver compile its fast-clear blit program on the calling thread.
  • Fix: every hook in hle_fs.c now registers through HLE_FORCE_BIG (hle.h): a compile-time stub loads the implementation into x16 and jumps to dm_bigstack_enter (hle_bigstack.c), which moves the call to a per-thread 512 KB host stack (with a guard page) when sp lies in the guest address block, passing x0–x8 and d0–d7 through. Only for functions with register arguments only (no variadic or stack arguments). iOS forbids generating code at run time, so the stubs are static. NVN functions are not registered through HLE_FORCE, so they got their own big-stack entry: every implemented NVN function except the nvnCommandBuffer* recording calls goes through nvn_big_entry (nvn_entry.S), which finds the implementation by slot and hands it to dm_bigstack_enter (NVN entry points on the big host stack).
  • Found on: Super Mario 3D World + Bowser's Fury (file system hooks); The Legend of Zelda: Breath of the Wild (NVN entry points).

Deferred-release drain without an autorelease pool

  • Symptom: the iPhone app is killed for memory; resident memory grows steadily (about 0.2 MB a frame) while the residency set and the texture counts stay flat.
  • Cause: the deferred-release drain ran on the guest submit thread with no autorelease pool, so autoreleased Metal objects were never freed (the Metroid Dread leak).
  • Fix: defer_drain has its own @autoreleasepool.
  • Found on: The Legend of Zelda: Breath of the Wild: GitHub issue #9 (iPhone 16 Pro Max, handheld), killed in the first shrine. An 8 GB iPhone gives this app about 3.4 GB (resident plus headroom to the system memory limit stayed at 3,375 MB); resident memory grew from 2.6 GB at the shrine load until the kill. On the Mac a host without that pool grew 1 MB a frame in the shrine (GPU 3.0 → 4.4 GB in 2,400 frames); with it the shrine stays at 2.7–2.9 GB resident and 1.5–1.7 GB GPU, idle or walking (vmmap at 2.8 GB: about 1.6 GB guest memory, 0.75 GB private textures, 0.17 GB IOAccelerator).
  • Note: found on the way: dead texture serials and the fixed-size pool tracking table (Dead texture serials kept in a ghost table, Memory-pool tracking table grows in blocks).

Upload staging grows within one rendering submission

  • Symptom: travelling produces a brief multi-GB footprint spike, then memory returns to its earlier level; periodic memory lines can miss the peak. Upload diagnostics show many swapped-out blocks still alive in one batch.
  • Cause: up_alloc could append unlimited staging blocks before up_flush. Overflow staging then retired on the main rendering fence, so splitting uploads alone still retained completed staging while that submission was being built. Weak-table insertion and enumeration in the staging diagnostics also left temporary strong references in the submission's autorelease pool.
  • Fix: split GPU deswizzle batches before another block would exceed 128 MiB. The three upload allocator slots provide backpressure, publishing deferred commits before waiting. Retire overflow and CPU-upload staging from the upload completion handler, remove residency and clear its retained array before freeing the slot; keep diagnostic weak-table operations in their own autorelease pools. Rendering destinations keep their existing lifetime. A single texture larger than the batch budget still gets its own block; this is not a cap on all renderer memory.
  • Check: make -C dm/gfx/tests GAME=<id> check_t_upload_budget uses actual Metal uploads during an open render pass, with direct and deferred encoding and Metal validation. A 2 GiB burst retained 2,048 MiB before the fix; afterwards it peaks at 384 MiB and retains only the three reusable blocks (96 MiB) after upload completion, before the rendering fence. It also checks every uploaded pixel and the render pass's clear. For a game, capture both frequent phys_footprint samples and the kernel's peak ledger; the existing resident log label denotes footprint, and staging blocks created in total is cumulative, not live memory.
  • Found on: The Legend of Zelda: Tears of the Kingdom, investigating issue #147. Mac reproduction and device validation are tracked in the game note.

GPU readback without an autorelease pool

  • Symptom: GPU memory grows with repeated nvnTextureReadTexels calls on a long-lived game thread, even after each readback completes.
  • Cause: the GPU path in mtl_read_texels runs outside a submission's autorelease pool. Metal's temporary command buffer and blit encoder keep the readback buffer alive in the calling thread's undrained pool.
  • Fix: mtl_read_texels encloses texture resolution, readback encoding, the GPU wait and the CPU copy in its own @autoreleasepool. The guest-memory path needs no Metal objects.
  • Found on: The Legend of Zelda: Breath of the Wild, measured upstream in MacNativeGames commit 635fb31 and ported in PR #195: about 130 readbacks a second retained 3.7 GB of GPU memory after 30 minutes. Those game measurements have not been repeated here.
  • Check: make -C dm/gfx/tests GAME=010015100B514000 check_t_readback checks literal GPU and CPU readback pixels and observes the real readback buffer through a weak reference while the caller's outer pool remains open. Removing only the local pool leaves that buffer alive and fails the lifetime check.

Completion readbacks survive a guest waiter's pool

  • Symptom: process footprint and Metal allocation grow during stationary gameplay even after readback fences complete; the deferred-release queue and command allocator sizes remain bounded.
  • Cause: post_run can execute on a long-lived guest waiter through the GPU-event fast-wake path, not only on Metal's feedback thread. MTLBuffer.contents returns an interior pointer (NS_RETURNS_INNER_POINTER), whose receiver may be retained and autoreleased by ARC. Explicit CF readback/visibility ownership is balanced, but those temporary receiver lifetimes escape into the guest thread's undrained outer pool. A submission's pool or a later callback on another thread cannot drain it.
  • Fix: give the shared post_run body its own autorelease pool, ending after its mutex unlock. Guest-storage writes, event effects, absorption bookkeeping and existing GPU retirement fences keep their order. This caller drains its temporary completion objects before returning; this is not a new global drain-before-completion synchronization guarantee.
  • Check: make -C dm/gfx/tests GAME=<id> check_t_readback gates real GPU feedback and exercises both DM_NVN_FAST_WAKE=1 guest-owned completion and 0 callback-owned completion. It checks literal mirrored pixels and event visibility, delayed-feedback idempotence, then, after real retirement while the guest outer pool remains open, that the landed buffer is held only by the kept readback buffers: dropped from them, it is destroyed at once. The lifetime assertion fails without the local pool and passes with it.
  • Found on: Tears of the Kingdom, M2 Pro stationary cave route. A bounded paired diagnostic on chain d1043b780a74 showed footprint growth of 617 MiB over about 86 seconds without the pool versus 1 MiB over about 83 seconds with it. This is route-local memory evidence, not an FPS improvement, complete allocation census or verified fix for an iPhone termination.

A pipeline archive on an external drive is read into memory

  • Symptom: footprint shows Malloc Large about the size of pso_archive/ (the log's pipeline archive …: N files, … MB), resident or swapped for the whole run. MallocStackLogging=1 with malloc_history <pid> -allBySize gives newArchiveWithURL → MTL4ArchiveImpl::loadFromURL → LoaderGlobalState::loadFile → -[NSData initWithContentsOfFile:options:maxLength:error:] → malloc.
  • Cause: where the file is. Metal loads each archive through NSData with NSDataReadingMappedIfSafe, and Foundation counts a file on an external APFS drive on USB (Removable Media: Fixed) as unsafe to map: it reads the whole file into allocated memory. On the internal disk it maps the file. newArchiveWithURL:error: takes only a URL.
  • Fix: arch_open_begin in nvn_mtl.m, for every game: while the host opens its archives, it replaces that NSData method's implementation, and a read of a .mtl4a asks for NSDataReadingMappedAlways. It is a private method, so a system without it reads the files as before. A workaround of the framework's choice, as no public call offers one. The game already runs from that drive, so a drive pulled out ends the run either way. Mapped files are clean pages the system can drop, and they do not count in the footprint.
  • Check: a 133 MB archive on the external drive: footprint +129 MB without it, +2 MB with it. Ocarina of Time on its walk with a 3.5 GB archive: footprint 15.0 → 11.8 GB, peak 16.7 → 12.8 GB, Malloc Large 3.5 GB → 59 MB, opening 4.6 → 3.0 s. Long frames did not change.
  • Found on: Kirby Star Allies: the same stage route gave a footprint of 5,109 MB with its 1.13 GB archive on the external drive and 4,013 MB on the internal disk. The Legend of Zelda: Ocarina of Time. iOS was not measured; its files are on the internal disk.

Pool tables and readback buffers made anew each frame

  • Symptom: the periodic residency set up to frame line shows hundreds of allocations added and removed per 300 frames in a scene where nothing loads: Ocarina of Time, 1,500 (pool_gpu 900 of 180 KB, mrbuf_get 600 of 0.5 MB), and 650 commits.
  • Cause: a replaced pool table was released and a new buffer made, about 3 a frame. The readback cache turned a buffer away when it was full (16 buffers or 16 MB) and never evicted, so once it held other sizes every readback of a new size was a new buffer.
  • Fix: pool_table_take and pool_table_give in nvn_mtl.m, for every game. A replaced table is kept with the fence of the submission that may read it, which the last segment's completion covers, and reused at the same size (rounded up to 64 KB) once that has passed. mrbuf_put evicts its oldest buffers to make room. A walk also skips an ID whose texture type it has seen and which is not the table's (tt1), so that ID is neither looked up nor charged to the budget. The idea of reusing tables and skipping by type comes from PR 1428.
  • Check: make -C dm/gfx/tests GAME=<id> check_t_feedback (reuse_tables_case). Ocarina of Time: 11 tables made and 18,990 reused in 11,700 frames; residency per 300 frames, added 1,506 to 6, commits 654 to 12, and 47 to 0.4 ms. Long frames did not change from this alone. The pool walk line reports the counts.
  • Found on: The Legend of Zelda: Ocarina of Time.

A texture in a sparse pool came out CPU-written at its first use

  • Symptom: with a profile that has tex_dirty_upload, a texture in a sparse pool that the game draws into or writes as an image logs CPU-written texture ser=… at its first use, and takes its content from its storage. t_sparse_tiles crashed at and unmapped, not drawn: its heap tile goes back when built with such a profile, and passed with one without the key.
  • Cause: ft_add listed it for later uploads. A sparse pool's addresses are no range nvn_dirty tracks, and an untracked range counts as written, so nvn_ft_written found it written at its first use. The list exists for memory the CPU writes, which those addresses are not.
  • Fix: ft_add in hle_nvn.c, for every game: a texture in a sparse pool is not listed. What the CPU writes to the physical pages behind one is seen through the page table (sparse textures). A virtual pool aliased onto one physical pool has no sparse state and stays listed.
  • Check: make -C dm/gfx/tests GAME=0100F2C0115B6000 check_t_sparse_tiles passes under both kinds of profile. No game was seen drawing wrong from this.
  • Found on: the unit test, on Tears of the Kingdom's and Ocarina of Time's profiles. Monster Hunter Rise is the other game with the key and a sparse pool.

iOS ends the app once the guest's touched pages reach its memory limit

  • Symptom: on an iPhone the app is ended by the system (a jetsam event: no crash report, no [dm] CRASH line) once its footprint reaches the limit: about 6 GB on a 12 GB iPhone with the increased memory limit (6,143 MiB measured in LiveContainer), 3.3 GB without it. The log stops mid-frame.
  • Cause: every guest page the game has touched is anonymous memory: phys_footprint charges it, and iOS cannot write it out as a Mac swaps it. What the game gives back is charged again as soon as it maps the range anew.
  • Fix: the guest block in sparse files, unlinked at once, mapped shared over the reservation at the same addresses (hle_svc.c; DM_GUEST_FILE_DIR, which the iOS app sets to NSTemporaryDirectory()). File pages take the same memory while they stay in it, but they are out of the footprint and the kernel writes them out when it needs the room. The cost is disk (Mario Kart World on a 16 GB Mac: 5.6 GB on disk and 8-10 GB of logical writes in 5.5 minutes), so a game that fits stays anonymous: on iOS the files are on when the game's memory pool is larger than os_proc_available_memory() at start, and the log's [mem] guest memory line gives both; on the Mac only with DM_GUEST_FILE=1, and 0 keeps them off anywhere. A game whose memory is mostly outside the block is not helped: Unreal's heap is a host malloc zone (Thread stacks from the host's Unreal heap). The files are 1 GB each (DM_GUEST_FILE_CHUNK), since the stall at the system's periodic flush, about every 30 s, grows with the file (on a 16 GB M5 Mac, with 1 GB touched, up to 148 ms with one 20 GB file, 4 ms with 20 files of 1 GB). guest_release only makes a range the game gives back PROT_NONE there, since a fresh anonymous mapping would bring it back into the footprint; its pages keep their bytes, so alias_initialize and SetHeapSize zero them when they are opened again, leaving the 4 KB pages that already read 0. DM_GUEST_FILE_MIN_FREE (default 5 GB) stops the game (exit code 87) before the disk fills; under it at start the guest stays anonymous, and on the Mac, which can swap, under that plus 8 GB. make -C dm/host/tests memory-file runs the memory SVC tests with the guest in files.
  • Found on: Mario Kart World 1.0.0 in handheld mode, on the switch2ios fork (a 12 GB iPhone, iOS 27.3, LiveContainer's 6,143 MiB limit; host from nxnative 8c0b8083 with that fork's changes): ended at 90 s with the guest anonymous; with one file, 7 min 33 s played, peak 5.56 GiB; with 1 GB files (and a larger texture view cache), a 14-minute session held, peak 5,663 MB, no 30-s stall left. On a 16 GB Mac the peak footprint fell from 11.6 GiB to 5.6. On this host (975bb4c1 with this switch, 1 GB files, a larger texture view cache and the profile's map limit), a 16 GB M5 Mac with the iPhone's settings peaked at 4,365 and 4,792 MiB over an 18- and a 30-minute session, and the same build plays on the 12 GB iPhone. On iPhones with an 8 GiB limit (#1184), where the game was ended at course load, or 30 s in at 8.1 GB, the fork's version of this switch merged by hand let it finish races: 4.41 GB at most over 13 minutes and two races on an iPhone 18 Pro with 512 MB files. Those phone runs also had a larger texture view cache (DM_NVN_VC_WAYS=64), which is not on main.

Memory pools nothing gave an address to stay out of the raw pool table

  • Symptom: a Switch 2 game goes past Metal's recommended working set while its first 3D scene loads: Insufficient Memory command buffer errors, then a hung Mac. Ocarina of Time on a 16 GB Mac: 12 GB and more wired, once a kernel panic in IOGPUGroupMemory.cpp at 14.7 GB wired.
  • Cause: a shader that loads or stores through a pointer it read from memory may reach any pool (Global memory at an address loaded from memory), so the raw pool table listed every pool and made each resident. That wires the game's texture-only pools too, gigabytes the GPU reads only through Metal textures that hold their own copy.
  • Fix: general (nvn_mtl.m mtl_pool_addressed, gmem_map). A GPU address exists only if the driver handed one out: nvnMemoryPoolGetBufferAddress, nvnBufferInitialize, nvnTextureGetTextureAddress and a virtual pool's mapping note their ranges, and the table lists only the pools that meet one. A pool with none stays out, and a texture upload from it borrows a temporary no-copy buffer that the upload batch holds (up_big_src). Ocarina of Time before its first scene: 2.9 GB wired against 5.3 GB.
  • Found on: The Legend of Zelda: Ocarina of Time.

Shaders report which textures they read through a pool

  • Symptom: a game whose shaders read texture handles from memory wires far past the working set while a scene loads, with Insufficient Memory errors or a panic, and stutters when new textures stream in. With a limit on how many it makes, surfaces in shadow go near black instead.
  • Cause: handles read from game memory may name any registered texture, and which ones is known only on the GPU, so the pool walk (pool_gpu) had to make all of them. On a console that costs nothing: a handle is a number and the data is already in the pool. Here each is a Metal texture beside the pool's copy. Ocarina of Time registers about 33,000 IDs and reads about 300 of them, about 1 GB. The rest were made in case, or, under a budget, exactly the large ones the shaders read were left out, and read the dummy for good. Each upload also reads pages the system may have swapped out and wires them: one 300-frame window spent 8.6 s in residency commits.
  • Fix: general, in the chain and the host. A read of a texture pool sets the ID's bit in a bitmap (dmsh_build.py pool_arrays, dm_pool_mark; its address in the table's head, DMSH_POOL_HEAD). The word is read first, so nearly every read costs a load, not an atomic. The host folds the bitmap at each submission (pool_seen_scan) and makes those textures whatever the budget, up to 128 MB a frame. It also puts them into the existing tables in place, as a table can stand for a submission once walks have taken their 2 ms: dummy to texture is safe for a draw already encoded. Registering another texture at an ID clears its bit. A render target no pass has written is not made for a read: it holds nothing, and a pass makes it when it renders (pool_unwritten_target). A walk still makes the smallest textures up front, 32 MB a frame up to a twentieth of the working set, as a pass that runs once has no second frame to find its texture in. t_pool_chunks checks this.
  • Result on Ocarina of Time, a 16 GB Mac, the village: Metal textures and targets 5.5 → 3.9 GB, physical footprint 14.4 → 13.0 GB, p95 from 42–79 ms (with long frames of 300 ms and more) to 28–32 ms, and the house and bark in shadow lit as on the console.
  • Cost: the write makes each such fragment shader one with side effects, which may lose early depth tests. Not measured apart from the other changes: without the bitmap the same textures are missing, so the frames are not comparable.
  • Not done: the guest's copy of a texture stays. Only 154 MB of it lies in wired pools on Ocarina of Time, and the system can swap the rest, so releasing it would save swap and no RAM. It would also corrupt a later texture over the same bytes under another description, as those bytes would be gone.
  • Reading the numbers: currentAllocatedSize ("GPU allocated") read 16 GB on this game while vmmap showed 3.9 GB of Metal textures and targets. Use vmmap -summary (owned unmapped (graphics), IOAccelerator (graphics)) for what memory holds.
  • Found on: The Legend of Zelda: Ocarina of Time.

Repeated GPU out-of-memory failures end the game

  • Symptom: the Mac grows unresponsive for hours, then panics, with the host still submitting work that fails.
  • Cause: a command buffer that fails with Insufficient Memory frees nothing, and the game submits the next one.
  • Fix: general (nvn_mtl.m res_top_once): 32 such failures within 60 s end the process with a red line, as the session cannot recover and the machine may not either.
  • Found on: The Legend of Zelda: Ocarina of Time.