Skip to content
NX Native

Fire Emblem: Three Houses

60 fps with 1.2.0 Other engines010055D009F78000

Plays on the iPhone; 60 fps (30 on the Switch) through rewriter cuts on 1.2.0 main.

Fire Emblem: Three Houses with nxnative
Title ID
010055D009F78000
Tested build
1.2.0
Nintendo SDK
6.5.0
Engine
Koei Tecmo's KTGL
Graphics API
NVN

Build this game

Keep [010055D009F78000] 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.2.0 (v327680) over the base game, SDK 6.5.0. Koei Tecmo's older KTGL engine on NVN (the game is co-developed with Intelligent Systems). Modules main, subsdk0 (GLSLC, NVN's runtime shader compiler), subsdk1 (the movie library, NintendoSDK_movie) and sdk. Plays on the iPhone; 60 fps (30 on the Switch) through rewriter cuts on 1.2.0 main.

What it needs

Setting Where Why
Present interval table → 1 for every render mode (int and float tables) patches.py CUTS, main build 89048449… 60 fps at the same game speed: KTGL already updates the game 60 times a second; see Frame rate
Render thread's forced interval 2 ignored (main+0x50fb7c) patches.py the opening movie and scenes such as character creation run at 60 too
Frame limiter sleeps instead of yielding (main+0x5be4c0–0x5be4d0) patches.py its yield loop spun on the host: 59.5 updates a second and 1.5 W of CPU on the Mac
Render thread waits a frame instead of 16 µs (main+0x50f8e8–0x50f8f4) patches.py its wait polled about 50,000 times a second at 60 fps
GLSLC capture (/glslc_capture/=runtime in shader_groups) game.tsv, NSP script 9 built-in shaders exist only as compiles of the game's own GLSLC at boot; see Files and shaders
4 GB layout: mem_reserve 0x100000000, mem_alias 0x10000000, mem_stack 0x10000000, app_pool 0xCD500000 game.tsv the NPDM declares no system resource (META +0x14 = 0): the game uses SetHeapSize. Link's Awakening's partition, not measured for this game
pad_layout full game.tsv the 3D gamepad layout: the camera is on the right stick in the monastery and in battle

Frame rate

  • KTGL runs the game on the main thread at 60 updates a second whatever it shows: each update steps by 1/fps with fps = 60.0 (main+0x1a38f08, set at main+0x50e114; dt at main+0x510010), and the frame end (main+0x50f680) waits out 60/fps ticks of 1/59.94 s in a yield loop (main+0x5be430).
  • The render thread (main+0x50f8c0) draws the latest update and presents it with the interval of the render mode (frame +0xbb84, set at main+0x50f3d0): 0 = 1080p docked, 2 = 720p handheld, 3 / 5 their two-view variants (interval 2); 1 and 4 are 720p / 1080p modes at interval 1 that nothing selects. Measured on the Mac before the cuts: 59.2 updates and 29.8 presents a second.
  • Current: the per-mode interval table (int at main+0xd13de0, {2,1,2,2,1,2}, read by main+0x4b0420 for the present and by the 6-tick fade counter in main+0x4b7530; float at main+0xd13e40, the per-frame scroll step in main+0x4c2b30) becomes 1 for every mode, so every update is shown, the game runs at the same speed and the render thread's own steps stay at the same speed. Resolutions stay per mode.
  • Current: the render thread also forced interval 2 while either of two flags was set: main+0x21c9a03, a full-screen image drawn before the frame (main+0x4b4908: the opening movie), and frame +0xc55f / +0xc560 (from the object at app +0x40: the character creation screen after the movie, and so on). In the engine's own 60 fps modes this traded 60 fps for resolution in those scenes (main+0x50f7d0 drops 720p mode 1 to 1080p mode 0). A cut at main+0x50fb7c (orr w8,w9,w8 → mov w8,wzr) ignores both, so those scenes run at 60 too; the movie still lasts 210.6 s (6,318 frames at 30 fps), as in the original.
  • Current: the frame limiter (main+0x5be430: yield, read the tick, until the target has passed; x8 = time passed and x23 = target, in 100 ns) yielded and polled the clock, which on the host spun its first 2 ms on a performance core and then overslept by up to 250 µs: frames took 16.8 ms (59.5 fps, a repeated display frame every 2 s) and CPU energy went from 0.16 W at 30 fps to 1.5 W on the Mac (228,000 yields a frame on an iPhone). The yield becomes nn::os::SleepThread of 96% of the time left (PLT main+0xafc300), and the loop goes back to the limiter's own tick read at main+0x5be44c; the frames take 16.68 ms.
  • Current: the render thread waits for the next update with KTGL's event wait (main+0xaa90e0, timeout in µs: × 1000 → nn::os::TimedWaitEvent) and passed 1/fps × 1000 = 16, one frame in ms, so it polled every 16 µs (42,000–53,000 wakeups a second on the Mac at 60 fps, against 1,200 at 30 fps, where it waited in the present instead). The timeout becomes 1000/fps × 1000 = 16,666 µs. The other four callers wait without a timeout.
  • Current: both pacing cuts stay per-build, and neither stands in for a host fault (checked 2026-10-26, host at 7753b7b, headless boot to the title on the Mac, 100 s per run, each cut undone in the rewritten main). Without the limiter cut the general yield back-off (thr_yield) holds 59.7–59.8 fps (frame p50 16.73 ms against 16.68) at 0.9–1.2 W of CPU energy against 0.14–0.42 W: about 9,700 plain yields a frame before the back-off starts at 2 ms. Without the timeout cut: 53,000–55,000 wakeups a second against 1,450, and 0.7–1.0 W. The 16 µs is the game's own: the render thread passes (1/fps) × 1000 = 16 to main+0xaa90e0, which multiplies it by 1000 into the TimeSpan (nanoseconds) of nn::os::TimedWaitEvent, so a frame in milliseconds goes where microseconds are taken. Only a wait that knows its deadline can sleep until it, and only the game's code holds it: of the 206 modules on hand, the limiter (main+0x5be4b4) is the only loop whose only calls are YieldThread and GetSystemTick, so a rewriter pass for such loops would match this one site.
  • Prologue battle on the Mac with the cuts: 59.9 fps, 55% of a core and 1.8 W of CPU energy (the original at 30 fps: 44% and 1.7 W).
  • Before the limiter cut, the host's yield back-off alone took its CPU time on the Mac from 78% to 19% of a core, same frame times (development.md, Power).

Memory

  • About 3.5 GB resident and 2.8 GB of Metal allocations on the Mac; the game itself makes a 1.5 GB GPU-only memory pool.

Rendering

Game-specific findings. The general host changes they led to are listed under Found on this game.

  • Resolved: frames stopped after about 15 s; a game thread spun in its command-memory callback. KTGL gives its main command buffer one 16 MB chunk of a ring and, in its callback (main+0x9ca960), takes chunk start + GetCommandMemoryUsed as the position the GPU has reached; the ring allocator (main+0x9cc120) waited for fences that had all signalled. NVN counts used bytes from the last AddCommandMemory; the host counted from BeginRecording, so the position stayed at one frame's size and the ring never freed.
  • Resolved: FATAL submit re-entered in the first frames. The game initializes two NVN queues and submits on both from two threads; submissions are serialized now.
  • Resolved: menus showed plain bars or only the cursor; the screen was black. The scene binds a 12,544-entry texture pool and a 3,856-entry sampler pool, the 2D UI's command buffer binds 4,096-entry ones, and both fill IDs from 256. With one ID table the scene's default 8×8 white texture overwrote the UI's textures: the title menu's labels drew as plain bars, other menus were empty except for the cursor. The pools are now kept apart. The scene registers each draw's textures into fresh IDs of a ring (8,729 and up a few seconds in), which used to read as empty slots and left the screen black.
  • Resolved: black silhouettes, then flat tan characters. The character shaders read Half2 UVs through integer-declared inputs: Metal refused the pipeline (Vertex attribute m_36(5) of type uint4 cannot be read using MTLAttributeFormatHalf2), so the characters' shading draws were dropped, and reading the raw bytes as integers gave denormals (UVs collapsed onto one texel). They are now converted to float32 bits.
  • Current: depth clamp is replayed (the game turns SetDepthClamp on; it used to be ignored).
  • Resolved: the ground under close-up battle cameras was flat steel blue. The terrain's vertex shader (56778feb1cfdee7e) reads its grid position from an RGBA16I attribute: x and z the grid cell, y the height, which spans the whole int16 range; so does the side pass that draws the river surface into targets of its own (db725c85ca060403). The input was declared uint4, and the host swapped Short4 for UShort4, which widens with zeros: every negative height read as 32,768–65,535, and those vertices went far up out of view. Where the ground was missing nothing wrote the depth (reversed-Z, cleared to 0), and the lighting takes depth 0 for sky and paints the fog colour. The G-buffer colour targets are never cleared, so they kept an old image there: cobblestones of a mesh under the terrain, which from the map camera showed through the gaps like a road.
    • How it was traced, since two steps misled: a HILITE colour over the gap was stale too (the never-cleared albedo), and the end-of-frame depth showed 0 there. Recomputing every draw's clip positions from CBDUMP data showed that no draw of the shader reached the gap. Then the side pass's layer, which showed only scattered tiles of the river, led to the vertex format. Ruled out on the way: depth clamp (Metal's clip and clamp modes gave the same depth), the pass without a depth attachment (142595 is a screen-space decal pass: it samples the depth and writes albedo and normals), polygon offset (none), dropped draws.
  • Light and decal lists: 321 shaders read them through the instruction Maxwell also uses for 1D textures, and they used to read 0. The decal pass (417a523053457352, slots 16 and 18) and the lighting (094a2d11253f126c, f54830a75e07e14a, slots 18–20) read them from texture buffers declared texture2d (texture type mismatch: shader declares 2, texture is 9). Since row wrap they read the real lists: slot 18 holds 1,052,640 elements, 65 rows. On the Mac prologue route the torches and burning houses now light the battle map and the close-up ground (they lit nothing before), and the blood decals take the scene's light instead of showing as dark blue patches. Its Half2 / RGBA16I vertex attributes go through shadow vertex inputs instead of the CPU conversion.
  • Resolved: the first loading screen took 78 s on the iPhone (5 s on the Mac). KTGL's loader uploads each resource with a copy on the queue followed by nvnQueueFinish, hundreds of times per loading screen (effect banks, face models), and that call waited for the display to take every queued frame: one to two display frames per call. The windowless Mac runs present at once, so they never showed it; DM_WINDOW=1 does (one step took 20 s instead of 1.7 s). nvnQueueFinish now waits only for the GPU. Windowed Mac run after the fix: first load 6.1 s, frame pacing unchanged. Found on the device with Documents/env.txt holding DM_WATCH=50 (thread table at 50 s: only the yield-polling main thread used CPU) and DM_FSLOG=<absolute path> (the reads came in bursts, with stalls of 4 / 24 / 13 s right after the effect banks).

Audio

  • Resolved: no sound; red audren: command 1 in the command list is malformed (magic 0 size 0). SDK 6's DSP command header is +0x04 size (s16), +0x06 command ID (s16), +0x10 enabled, where SDK 8–20 have +0x04 enabled, +0x05 ID, +0x06 size (CommandListProcessor::Process, sdk+0xaaa00); its DeviceSink is ID 0x10.

Services

  • Resolved: the first loading screen never ended; DM_FS=1 showed save:/system "not found" and no fs mount save. In SDK 6 account::OpenPreselectedUser returns a Result, and the game skips its save mount when it is not 0 (main+0x53b7e0). The host's hook returned void and left the handle pointer in w0; it returns 0 now.
  • DLC: the Mac needs DM_AOC_DIR=<NSP folder>/010055D009F78000_build/aoc, or a "DLC deleted" dialog blocks loading a save.

Files and shaders

  • RomFS: the LINKDATA archive (DATA0.bin is an index of 32-byte entries {u64 offset, u64 length, u64 stored length, u64 compressed} into DATA1.bin, 6.9 GB), loose overrides in patch1–patch4 (listed by patchN/INFO0.bin, 0x120-byte entries ending in a rom:/patchN/… path), WebM movies in nx/movie and KTSL sound banks (*.ktsl2stbin). Compressed entries and *.gz files are Koei Tecmo's chunked zlib (kt_gz; 01).
  • Shaders: 1,364. shader_split.py (kt_shaders) reads the SPKG packages (G2S containers, as in Age of Calamity) in DATA1.bin (132 entries) and patch*/nx/shader, a _SPK4100 post-effect package without G2S (DATA1.bin entry 549), map and model archives carrying packages of their own, and the BNSH and GLSLC outputs in main. DATA1.bin is split into 64 scan jobs by entry range (linkdata_jobs).
  • Its GLSLC (output version 0x1d.2) writes 0x848 / 0x850-byte control sections and compute code with no header, which the reader used to drop: 24 shaders before, 1,355 after. A few outputs of version 0x1d.0 keep something else at control +0x20 (odd values such as 0x136b), so a control length must be a multiple of 4; glslc_section later took 8 more of them (GLSLC outputs read through their section headers).
  • Shaders compiled at run time: KTGL compiles 9 built-in GLSL shaders at boot with the game's own GLSLC (glslcCompile from main+0x9c89f8; the sources are strings in main): 2D primitives, text, the loading quill. No game file holds their output, so they are captured on the Mac: the game runs with DM_GLSLC_CAPTURE=<version dir>/glslc_capture (hle_glslc.c), and shader_split.py reads that folder. The outputs are identical on every run. The NSP script does this by itself in its GLSLC capture stage, so the NSPs and prod.keys are enough: it builds the Mac host into <id>_build/tools/ and boots the game until the 9 files are in <id>_build/extract/v327680/glslc_capture. Without the folder the split tables would differ from the profile, and the script would stop at its table check. By hand, run the game for a few seconds with that variable (step 5 of the build by hand).

Mac routes

  • A fresh save needs DM_KBD=<name> for the name entry, and its confirm dialogs default to No.
  • From a new game into the prologue battle (frame-based at 60 fps, about 7 minutes): DM_KEYS="f720:A*185/90,f2400:Plus*35/438,f17400:Left~0.3*120/500,f17480:A*120/500,f17560:Left~0.3*120/500,f17640:A*120/500,f18020:Plus*100/562"; the game offers a save right before the battle. The repeating Left, A, Left, A reaches Yes whether a confirm dialog is open or not (A, Left, A loops once it starts on an open dialog: A answers No, Left changes the selection, A reopens it). A presses during the opening movie pause it, so the scenes after it start a few seconds later from run to run.
  • To a first fight, from a save right before the prologue battle, with DM_AOC_DIR set: DM_KEYS with A every 90 frames from f720 to f2610, then f2700:B,f2800:B,f2900:A,f3000:Up,f3060:Up,f3120:Up,f3180:Up,f3240:A,f3340:A,f3440:A,f3540:A,f3640:A,f3740:A closes the movement tutorial, moves Byleth up to the thief and attacks: the combat close-up runs at frames 3,400–3,700.

Found on this game

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

Open

  • To confirm: 64-pixel rectangles in the albedo where decals land; skipping the decal pass removed them. They did not appear on the Mac prologue route before or after row wrap (no 64-pixel structure in the close-up frames), so the scene where they were seen still has to be checked. The player-facing README lists "small square patches on the ground where decals land" as a known issue.
  • Deferred binds are not remapped to the pool base (red line).
  • game.tsv still describes the game as "In bring-up" and the memory layout as Link's Awakening's, not measured; the development notes call SDK 6.5.0 "the oldest so far", though DARK SOULS: REMASTERED (SDK 4.5.0) and Mario Kart 8 Deluxe (SDK 4.4.0) are older.
  • The capture boot's time: the development notes give "about 10 s in all" for the NSP script's stage and "about a second after launch" for the 9 files.

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 Other engines games