
- Title ID
- 01005270232F2000
- Tested build
- 1.0.5
- Nintendo SDK
- 19.3.13
- Engine
- Unreal Engine 5
- Graphics API
- NVN
Build this game
Keep [01005270232F2000] in your file names, put the base game, update and DLC in one folder and run the script. Follow the build guide, then the sideloading guide. Needs an iPhone 15 Pro or newer, or an M1 iPad or later.
Technical notes
The project's own record of bringing this game up: what its profile needs and why, what was found and what is still open.
Update 1.0.5 (v327680) over the base game, an XCI; SDK 19.3.13. Square Enix, Unreal Engine 5 (the paged global distance field shaders and the LowLevelTasks task system) on its mobile renderer (SF_GLSL_SWITCH_MOBILE shaders), NVN, CRIWARE sound and movies. Modules main, subsdk0 (the movie library, which main imports from) and sdk. Plays on the iPhone at 60 fps (30 on the Switch) by the host's writes of Unreal's frame limits.
Status: brought up on the Mac through character creation and the story opening into free play (26 minutes from a fresh save with no red line, 2.74 GB resident). Playable on an iPhone: 30 fps through character creation, name entry and the story opening, no audio underruns, three saves, a footprint of about 2.35 GB after 3.4 minutes; it plays at 60 fps on an iPhone too (fps_target 60). Battles are not tested yet.
What it needs
| Setting | Where | Why |
|---|---|---|
fps_target 60 |
game.tsv, hle_fpslock.c |
the host writes 60 into t.MaxFPS and 1 into rhi.SyncInterval; see Frame rate |
FMallocBinned3 constructor call cut, main+0x12e40 bl 0x9f480 → nop, with gate OBS_UE_MALLOC on it |
the rewriter's engine-wide rule (dm/tools/ue_malloc.py), no build ID |
the constructor reserves 37 GB of address space; the host's allocator runs Unreal's heap (hle_uemalloc.c); see Memory. This build's own cut until 2026-10-30 |
Engine region given what the alias region has room for, 2.81 GB: gate OBS_UE_REGION on the store of its size, main+0x12e20 |
the rewriter's engine-wide rule (dm/tools/ue_region.py), no build ID; the host's room (hle_svc.c) |
Unreal's nninitStartup reserves 62 GB by default. Until 2026-10-31 game_args -SwitchVirtualAddressSpaceSize=2048, a 2 GB region |
Node area cut 1 GB → 62 MB (sdk+0x1423fc / +0x142468) |
dm/tools/sdk_patches.py SDK-wide rules, sdk build 7bba185a… |
Unreal's region then starts 62 MB into the alias region, and the guest block stays small enough for an iPhone |
3.75 GB guest block: mem_reserve 0xF0000000, mem_alias 0xC8000000, mem_stack 0x10000000, app_pool 0xCD500000 |
game.tsv |
alias region = node area + the engine region (this SDK build aligns regions to 2 MB only) + 256 MB for regions made later; 256 MB stacks, 128 MB heap |
tex_dyn_slots on |
game.tsv |
61 particle vertex shaders pick one of several c2 table slots at run time (separate texture slots 0-10, samplers 0-4); see Shaders |
No pad_layout key: the iPhone gamepad starts in the 2D layout (README).
Frame rate
- Current: 60 fps (30 on the Switch). The game's config caps Unreal at 30:
t.MaxFPS=30in[ConsoleVariables]ofDefaultEngine.ini(andFrameRateLimit=30inSwitchGameUserSettings.ini, which sets it again), andrhi.syncinterval=2inSwitchDeviceProfiles.ini, the present interval 2 the game asks for. The shipping build reads no console variables from the command line (no-ExecCmds,-DPCVarsor-ini:), so the host writes 60 and 1 into the two console variables (hle_fpslock.c, profile keyfps_target 60; the rewriter finds them by their registered names,dm/tools/ue_cvar.py, and until 2026-10-30 two rows of this build's addresses did), whenever the game sets them back; the game then asks for present interval 1 itself. - Unreal steps the game by the time that really passed: on the Mac,
DM_NVN_REGLUMtraces of the same input script from a fresh save at 30 and 60 fps (frame time p50 33.4 → 16.7 ms) put the 1.5 s fade to white 70 s in at the same real time with the same shape, and both runs reach the same screens at the same times. DM_FPS_TARGET=30(Documents/env.txton the iPhone) keeps the game's own 30 and saves power.- Unreal paces frames by the layer's frame number (
nn::vi::GetLatestFrameNumber,main+0x10140e8); see Services.
Memory
- Resolved:
exit(1)innninitStartup(main+0x12e74; the function is atmain+0x12d74). Unreal'snninitStartupneeds virtual address memory and reserves one address region for the engine withAllocateAddressRegion, 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 afternninitStartup; they are set before it now, as on hardware. The region's size no longer comes from them: Unreal's engine address region and its options. - Current: the allocator is the host's. Unreal's allocator, an FMallocBinned3, is constructed in place at
main+0x68022c8(main+0x12e40→ constructormain+0x9f480) and reserves 37 GB of the region (main+0x9f748: 74 pools of 512 MB, pool index = offset >> 29; the null read in the out-of-memory report atmain+0x1005824is the symptom when the region is smaller). The pools are uneven: at character creation the largest holds 68 MB, the 74 together 732 MB (measured withDM_ALIAS_WINDOWS=512, 05, Memory growth), so no smaller window fits a phone's ~5 GB (an iPhone has about 5 GB of address space without the extended virtual addressing entitlement). The constructor call is cut by the rewriter's engine-wide rule (dm/tools/ue_malloc.py; this build's own cut until 2026-10-30), and at the gate on the call the host puts its own allocator's vtable in the object (hle_uemalloc.c: Malloc, Realloc, Free and the size queries on a host malloc zone, the trivial slots kept). Every allocation goes through it: FMemory reaches the object throughFPlatformMemory::BaseAllocator(main+0x132b4), and the thread-local storage allocatornninitStartupregisters (main+0x12e80/main+0x12ec0) through its vtable. The Unreal heap logs its peak as[mem] Unreal heap … in use. - Current: a 2.81 GB region in a 3.75 GB block. With the host allocator the region holds only what the engine maps itself (709 MB at character creation, spread over 1 GB), so it takes what the alias region has room for (the rewriter's gate,
dm/tools/ue_region.py; 2 GB fromgame_argsuntil 2026-10-31); SDK 19's AddressRegionManager node area is cut to 62 MB as Engage's (sdk+0x1423fc/+0x142468), so the guest block is 3.75 GB. The Unreal heap lives outside the guest block. - 15 minutes into the story on the Mac: region mapped up to 1.26 GB of the alias region, Unreal heap 0.75 GB, 2.8 GB resident, 1.84 GB of GPU allocations.
Shaders
- 10,324 unique shaders (1,457 vertex, 6,346 pixel, 2,521 compute) in the pak's two shader code archives,
ShaderArchive-Global-SF_GLSL_SWITCH_MOBILE-SP_GLSL_SWITCH_MOBILE.ushaderbytecode(1,219 shaders) andShaderArchive-Octopath_Traveler0_Chunk0-…(9,377), both stored in the pak. The profile groups them asglobalandgame(shader_groups). - Version 2 archives: shader map hashes, shader hashes, shader map entries, shader entries
{u64 offset, u32 size, u32 raw size, u8 frequency}(17 bytes packed; frequency 0 vertex, 3 pixel, 5 compute), preload entries and shader indices, then the code. Every shader is Oodle-compressed (Mermaid, block header8C 0A). The repository has no Oodle decoder, so the game's ownOodleLZ_Decompress(statically linked intomain,main+0x4f77e10, found as the one function that takes the address of the library's own name string, its profiler scope) decodes them in the Mac host:DM_OODLE_JOB(dm/host/hle_oodle.c), the NSP script'sdecode_ue_shaders, as for any game with Unreal paks; about 10 s for all of them (see Open). - A decoded shader is an
LSLGrecord:LSLG, a stage tag (SV,SP,SC), a header with the source name (/Engine/Private/…usf:MainPS), thenu32code length, the code area,u32control length (0x880), the control section and a 24- or 29-byte tail, all at unaligned offsets (uepak.lslg). The host keys came out exactly: 37 of 37 shaders a first run reported missing were in the two archives. - Vertex and pixel shaders combine per draw from Unreal's pipeline state, so every vertex shader is paired with the single-stage cover and made to link with any pixel shader (
shader_split.pyseparable_pairs,dmsh_build.pyvs_pad). - Bindings: separate textures and samplers, as Engage's (
0x168/0x568). - Current: 61 vertex shaders pick a texture slot at run time (particles reading their data from buffer textures). The shader reads one of several c2 texture-table words, chosen by branches, and ORs it with a sampler-table word; the translator cannot trace that to one slot and falls back to Ryujinx's bindless arrays (
array<texture_buffer<float>, 32> vp_tb_cb0_40000000,array<sampler, 32> vp_s_cb0_40000000), which used not to load. Every integer read of c2 in those shaders is a table slot (texture slots 0-10, samplers 0-4), sotex_dyn_slotsis right here. Host only: the chain version does not change. - Current: 2,084 shaders do atomics on buffer images (Unreal's GPU particles), native texture atomics on the buffer texture since chain
0ce4b5e8aefb(Image atomics). On the fresh-save route (2026-10-27, Mac) three compute shaders run them (766bc02c3b4c76d3,65327534de18f480,df56bcd022bf1e73, image slot 2, texture ID 2049: an 8 KB R32Uint buffer texture), with no red line to frame 14,400 under Metal API validation (no validation error). - Current: a buffer texture at pool offset
0x168, read by the particle shaders, is created there (it used to be left a dummy, below Metal's reported 16-byte alignment). - Resolved: 16 compute shaders with atomics on a 3D
r32uiimage did not build (SPIRV-Cross's MSL 2.3 emulation indexed its pointer with theint3coordinate:array subscript is not an integer; GitHub issue #290 listed them beside 2,077 returning atomics, which build since2e655b7:40b643bbuilds 10,308 of 10,324). With native image atomics all 10,324 build (chain0ce4b5e8aefb, 2026-10-27). They are Unreal's paged global distance field (GlobalDistanceFieldCompositeObjects.usf:CompositeObjectsIntoPagesCS), which the mobile renderer does not dispatch (none in any run). - Resolved: the pipeline archive made offline was built for the wrong depth mode. The game sets near = 0 (
nvnDeviceSetDepthMode), but the profile had nodepth_mode, sopso_offline.pybuilt the Mac app's archive for the GL convention and the host rejected it (pipeline archive bundled with the app: made for chain 157e1b96c8da; depth near=-w … this run needs … depth near=0): the first start compiled 365 of its 366 bases itself (the prepare step took 1.01 s on 14 workers with the progress screen) and then prebuilt the 2,914 pairs on the device. Withdepth_mode zerothe archive is taken (0 compiled, 366 from the archive, 0.04 s, no progress screen). The key picks the vertex entries of the pipeline archive made offline (main0_z); the host itself follows the mode the game sets. Found on an M4 Pro, 1.0.5, 2026-10-27.
Files
- Everything is in one pak,
Octopath_Traveler0/Content/Paks/pakchunk0-Switch.pak(1.5 GB, 159,757 files; the profile'sromfs_marker), next to loose CRIWARE.awbsound banks and one.usmmovie. - A version 11 pak: its footer is the last 0xdd bytes (encryption key GUID, encrypted-index flag, magic
0x5A6F12E1, version, index offset and size, SHA-1, five compression method names), its magic 0xc8 bytes from the end. The index is AES-256 (ECB) encrypted: mount point, entry count, path hash seed, path hash index, full directory index (a second encrypted block) and the encoded entries (one bit-packed u32 per file). - The key is in
main: the one 32-byte window of its read-only or data segment that decrypts the index start to the mount point../../../(main+0x57c7f70, found among 116,287 candidates in under a second). Never print it. - Most files are Oodle-compressed in 64 KB blocks; the two shader code archives are stored.
dm/gfx/tools/uepak.pyreads all of this (AES through macOS CommonCrypto) and lists a pak (uepak.py list <pak> <main NSO> <out tsv>). Details: 01, Compressed RomFS content.
Rewriter
- Resolved: a
jump table <addr>assertion on a byte table whoseaddis far from thebr(main+0x37f9bc8:adr x18,#L; ldrb w0,[x17,x15]; add x18,x18,x0,lsl #2, then 17 register moves beforebr x18). The byte / halfword search looked 16 instructions back;jump_bytes_farlooks 32 back. One of the 12br x18sites in thismain. mainhas a 71 MB stretch without unwind entries; it got through the rewrite on a quieter Mac before functions were analysed in pieces, and comes out the same with 1 MB pieces andbwithin 256 KB.- The spin-wait search takes 75 → 78 loops in
mainsince the store rule (a parser state that leaves on the next pass,main+0x4ebfa54, among them), and the movie library's thread loop is wider insubsdk0.
Services
- Resolved: an abort after
UNIMPLon the weakly importedthread_localinit function_ZTHN13LowLevelTasks5FTask10ActiveTaskE. The loader bound every undefined import to a named stub, so the address test the compiler emits for a weak symbol saw a function. Such a symbol is now 0 (loader.c,undefined weak … bound to 0). - Resolved: SIGSEGV in
android::Surface::getQueuedFrameNumberundernn::vi::GetLatestFrameNumber(Unreal paces frames by the layer's frame number,main+0x10140e8): the SDK read the vi service's surface, which the host never made.hle_vi.canswers with the frames presented so far;ListFrameHistoryreturns no entries. - Resolved: SIGSEGV at address 0 in
nn::fs::GetSaveDataSizeMax, about 13 minutes into a new game (Unreal's save system asks before its first save): it now reports 256 MB of data and journal, asGetSaveDataSizedoes (hle_fs.c). Listingmain's undefined imports that no host hook names, before they crashed, gavefs::GetCacheStorageSizeandfs::GetFreeSpaceSize(the same answers),account::PushOpenUsers(nothing to hand over),web::ShowOfflineHtmlPage(cut, asShowWebPage) andoe::ExecuteProgram(logged red: the host runs one program). The rest were pure computations (the swkbd presets, the web argument constructors,time::GetDayOfWeek, the audio resampler) or socket calls behind aSocket()that already fails offline. - Saves: four saves in a 26-minute run, and a later boot continues from them.
- NVN calls new with Unreal:
nvnTexturePoolGetSize/nvnSamplerPoolGetSizeand their memory pool / offset getters (an abort in device setup when they returned 0), andnvnCommandBufferFenceSync, which Unreal's RHI records in its command buffers (OP_FENCE_SYNC: the replay fences the sync with the submission that reaches it).
Input
- Keyboard: the character's name and favourite dish come from the system keyboard (
DM_KBDon the Mac, the input box on the iPhone). - Resolved: the sound stuttered and the input box lagged while the player typed a name (on an iPhone: one 4.7 s frame, 5,035 wakeups a second instead of about 1,800, no audio underruns). The host froze the guest clock while
swkbd::ShowKeyboardwas up; Unreal calls it off its game thread, so the game kept running, and every thread that waits on the clock spun while its audio mixing starved. On the Switch the applet blocks only the caller while the clock runs; the host now does the same (hle_swkbd.c). The iOS input box froze the clock a second time around its own wait (front_ios.m), so the first fix changed nothing on the phone (4,977 ms and 2,577 ms frames, 5,181 wakeups a second). On the Mac,DM_KBD=<text> DM_TEST_SWKBD_HOLD_MS=8000holds the keyboard up: frozen, 8.8 fps and 7,936 wakeups a second in that window; running, 30 fps and 1,788. - The game reads a mouse and no touch screen (Romance of the Three Kingdoms 8 Remake's note).
Mac routes
- A fresh save reaches the story with
DM_KBD=Lyra DM_KEYS="20:Plus,24:A*27/4,150:Plus,154:A*62/3,340:Right*12/3,341.5:A*12/3,380:Plus*5/3,396:A*20/3,470:A,474:Up,476:A,480:A*140/3"(initial settings, the prologue, character creation with three belongings, "Begin Lyra's journey?").
Found on this game
General host changes first needed here; each applies to every game (or every Unreal game):
- Unreal's allocator replaced by the host's (
hle_uemalloc.c, a rewriter cut of the constructor), explained in Memory above; the later Unreal builds and their vtable layouts: Unreal's FMallocBinned3 replaced by the host allocator. - Unreal's frame limits written in game memory (
hle_fpslock.c,fps_target), explained in Frame rate above. - Game arguments set before
nninitStartup(main.c, profile keygame_args), and the engine region given what the alias region has room for (dm/tools/ue_region.py,hle_svc.c): Unreal's engine address region and its options. - Unreal pak reading and Oodle-compressed shaders decoded by the game's own Oodle (
uepak.py,DM_OODLE_JOB,decode_ue_shaders): 01, Compressed RomFS content, Unreal Engine 5 shader code archives (Oodle). - Texture slots picked at run time (
tex_dyn_slots,NVN_RB_TEXD/NVN_RB_SAMPD), image atomics, now native texture atomics (dmsh_atomic.py,img_usage,dummy_img_atomic), buffer textures below 16 bytes: Texture slots picked at run time, Image atomics, Buffer textures at offsets below 16 bytes. - Rewriter: byte tables far from their
br(jump_bytes_far): Byte tables whose add is far from the br. Not first found here but met here: long functions analysed in pieces (pieces, Long functions analysed in pieces) and spin waits that store (patch_nso.py"Spin waits", development.md, Power). - Undefined weak imports bound to 0 (
loader.c),vi::GetLatestFrameNumber(hle_vi.c),fs::GetSaveDataSizeMax(hle_fs.c), NVN pool getters andnvnCommandBufferFenceSync(OP_FENCE_SYNC), the guest clock left running behind the software keyboard (hle_swkbd.c,front_ios.m,guest_clock_freeze_for): Undefined weak imports bind to 0, Layer frame numbers, Save data size queries, Pool size getters, texture address and command memory size, Fence syncs recorded in command buffers, The software keyboard blocks only its caller. DM_ALIAS_WINDOWS, the alias-region window census: 05, Memory growth.
Open
- The engine region's gate (2.81 GB instead of the 2 GB
game_argsgave) has not been run on this game yet: the[mem] Unreal's engine regionline in a boot's log shows it. - Battles are not tested yet.
- Shader decode counts and times differ between sources:
docs/development.mdsays 10,324 shaders decoded in about 10 s;dm/games/README.mdsays 10,596 shaders in 2 s (10,596 is the two archives' total, 1,219 + 9,377; 10,324 the unique count). - Touches as a mouse would suit Romance of the Three Kingdoms 8 Remake, but this game, Tears of the Kingdom and Unicorn Overlord also read a mouse and no touch screen, so such a rule needs testing here first.
These notes are republished from the nxnative repository under its MIT license (license and attribution). The original, which may be newer, is the game's notes on GitHub. This site hosts no game files.