Skip to content
NX Native

Octopath Traveler 0

60 fps with 1.0.5 Unreal Engine01005270232F2000

Plays on the iPhone at 60 fps (30 on the Switch) by the host's writes of Unreal's frame limits.

Octopath Traveler 0 with nxnative
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=30 in [ConsoleVariables] of DefaultEngine.ini (and FrameRateLimit=30 in SwitchGameUserSettings.ini, which sets it again), and rhi.syncinterval=2 in SwitchDeviceProfiles.ini, the present interval 2 the game asks for. The shipping build reads no console variables from the command line (no -ExecCmds, -DPCVars or -ini:), so the host writes 60 and 1 into the two console variables (hle_fpslock.c, profile key fps_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_REGLUM traces 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.txt on 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) in nninitStartup (main+0x12e74; the function is at main+0x12d74). 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; 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 → constructor main+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 at main+0x1005824 is 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 with DM_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 through FPlatformMemory::BaseAllocator (main+0x132b4), and the thread-local storage allocator nninitStartup registers (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 from game_args until 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) and ShaderArchive-Octopath_Traveler0_Chunk0-… (9,377), both stored in the pak. The profile groups them as global and game (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 header 8C 0A). The repository has no Oodle decoder, so the game's own OodleLZ_Decompress (statically linked into main, 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's decode_ue_shaders, as for any game with Unreal paks; about 10 s for all of them (see Open).
  • A decoded shader is an LSLG record: LSLG, a stage tag (SV, SP, SC), a header with the source name (/Engine/Private/…usf:MainPS), then u32 code length, the code area, u32 control 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.py separable_pairs, dmsh_build.py vs_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), so tex_dyn_slots is 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 r32ui image did not build (SPIRV-Cross's MSL 2.3 emulation indexed its pointer with the int3 coordinate: array subscript is not an integer; GitHub issue #290 listed them beside 2,077 returning atomics, which build since 2e655b7: 40b643b builds 10,308 of 10,324). With native image atomics all 10,324 build (chain 0ce4b5e8aefb, 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 no depth_mode, so pso_offline.py built 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. With depth_mode zero the 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's romfs_marker), next to loose CRIWARE .awb sound banks and one .usm movie.
  • 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.py reads 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 whose add is far from the br (main+0x37f9bc8: adr x18,#L; ldrb w0,[x17,x15]; add x18,x18,x0,lsl #2, then 17 register moves before br x18). The byte / halfword search looked 16 instructions back; jump_bytes_far looks 32 back. One of the 12 br x18 sites in this main.
  • main has 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 and b within 256 KB.
  • The spin-wait search takes 75 → 78 loops in main since 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 in subsdk0.

Services

  • Resolved: an abort after UNIMPL on the weakly imported thread_local init 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::getQueuedFrameNumber under nn::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.c answers with the frames presented so far; ListFrameHistory returns 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, as GetSaveDataSize does (hle_fs.c). Listing main's undefined imports that no host hook names, before they crashed, gave fs::GetCacheStorageSize and fs::GetFreeSpaceSize (the same answers), account::PushOpenUsers (nothing to hand over), web::ShowOfflineHtmlPage (cut, as ShowWebPage) and oe::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 a Socket() 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 / nvnSamplerPoolGetSize and their memory pool / offset getters (an abort in device setup when they returned 0), and nvnCommandBufferFenceSync, 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_KBD on 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::ShowKeyboard was 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=8000 holds 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):

Open

  • The engine region's gate (2.81 GB instead of the 2 GB game_args gave) has not been run on this game yet: the [mem] Unreal's engine region line in a boot's log shows it.
  • Battles are not tested yet.
  • Shader decode counts and times differ between sources: docs/development.md says 10,324 shaders decoded in about 10 s; dm/games/README.md says 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.

More Unreal Engine games