Skip to content

Runtime data directory and module tables

work/games/<titleid>/<buildid16>/
  manifest.json      {format, title_id, build_id (first 16 hex of the main ModuleId), load_order[], modules[{name,file_size,sha256,build_id,...}]}
  main.npdm          copied from the ExeFS (service access list, svc mask, main thread)
  modules/<name>.patched.bin   rewritten image (rewriter output: image + key page + patch area)
  modules/<name>.tables        NTB1 tables (below); the rewriter's .gates.tsv/.fixups.tsv/.sites.tsv stay alongside as evidence
  romfs.bin          decrypted RomFS image (IVFC data level), served raw by fsp-srv
Load order: main, subsdk0..N, sdk (symbols resolve in that order). Nothing per game is compiled into the runtime.

NTB1 (little endian): "NTB1", u32 version=1, u32 nsym,nrel,ngate,strtab_size; u64 file_size,text_end,ro_off,data_off,patch_off,key_off; u64 dt_init,dt_fini,init_array,init_array_size,fini_array,fini_array_size; u8 build_id[32]; then sym[nsym]{u32 name_off,u8 binding,type,defined,pad,u64 value,size}, rel[nrel]{u64 where,i64 addend,u32 kind,u32 symbol} (kinds 257/1025/1026/1027; RELR is expanded to RELATIVE), gate[ngate]{u64 ret,u64 original,u32 timer,u32 arg} and the string table (timer: 0 svc, 1 cntpct/cntvct, 2 cntfrq). Now written in process by napple build (crates/napple-build/src/tables.rs, a byte-exact Rust port of tools/prepare_tables.py: identical bytes on Super Mario Odyssey and Link's Awakening, plus a golden test against the Python output). The patched image/.gates.tsv/.fixups.tsv formats are the rewriter's.

Pipeline for development: napple build <game files> -k <prod.keys> --data-only -o work/games (no Python, replaces tools/prepare_game.sh: writes work/games/<titleid>/<buildid16>/ for the base and every selected update, plus dlc/, system/, napple-app.json; add --updates none for the base only). Legacy pipeline: tools/prepare_game.sh <nsp|xci> <prod.keys> [--romfs] (ExeFS dump via napple build --dump-exefs, the Rust rewriter napple rewrite (rewrite-format.md; REWRITER=python selects the reference stopgap), tables, npdm). Both games boot identically with either rewriter. napple build --dump-romfs streams romfs.bin. Keep one game's romfs.bin on disk at a time. tools/make_empty_romfs.py makes a valid empty image for tests. Local change to the rewriter: ldrsw_table_scan (jump tables whose bound is not a plain cmp/b.hi), needed by subsdk0 of both games.

Entry: the runtime calls nn::init::Start(mainThreadHandle, 0, cb1, cb2) (or the 3-callback SDK 15 form): cb1 runs the sdk module's DT_INIT after nn::os is up, cb2 runs every other module's DT_INIT before nnMain.

App bundle layout and launch contract (napple build ... -o <outdir> -> <outdir>/<Name>.app)

<Name>.app/Contents/
  Info.plist  PkgInfo  _CodeSignature/  Resources/AppIcon.icns
  MacOS/napple-launcher      packaging/launcher.c: no-argument entry point, execs the sibling runtime
  MacOS/napple-runtime       the runtime binary (copied from runtime/build/napple-runtime or --runtime)
  Resources/game/
    napple-app.json          {format, title_id, name, display_version, versions[], dlc[], defaults{version,dlc[]}, system{fonts,firmware_version}, notes[]}
    default                  one line: directory name of the default version (the newest embedded one)
    <buildid16>/             one runtime data dir per version (base and every update): the layout above + manifest.json extras
    dlc/<titleid16>/v<ver>/romfs.bin    one layer file per DLC pack (decrypted RomFS data level)
    system/fonts/            shared fonts extracted from the user's firmware (see below)
Launch contract. Finder / open start Contents/MacOS/napple-launcher with no arguments. It runs napple-runtime <Contents/Resources/game/<version dir>> [args...] where the version dir is $NAPPLE_VERSION if set, else the first line of game/default. Any argument is passed through (--headless --seconds 20, --save-root DIR...), Finder's -psn_* is dropped, and NAPPLE_BUNDLE_DIR=<Contents> is exported. So the runtime keeps its single-argument contract (<data-dir>); it needs no bundle-relative logic. The runtime may later read <data-dir>/../system, ../dlc/... itself (paths are in each manifest.json).

manifest.json of a version dir keeps the keys the runtime reads (format,title_id,build_id,load_order,modules[]) and adds, ignored today: kind (base|update), version, label, romfs {mode: full|patched|delta|base, file, size, sha256}, system_dir (../system) and dlc[] {title_id, version, label, pack_size, romfs_size, sha256, dir ("../dlc/<id>/v<ver>"), romfs ("romfs.bin"), default_enabled}. title_id is the application (base) id for every version; build_id is the first 16 hex of that version's own main ModuleId, which is also its directory name. Each version has its own modules/ (own rewrite and tables), main.npdm and RomFS.

Update RomFS. Default (--update-romfs materialize): the BKTR-patched RomFS is streamed to <update dir>/romfs.bin (a full image, boots with today's runtime; costs one RomFS of disk per update). --update-romfs delta: <update dir>/romfs.delta/{romfs.json, romfs.reloc, patch.bin} and no romfs.bin; romfs.reloc = "RLC1", u32 count, u64 data_size, then count x {u64 data_offset, u64 source_offset, u32 source, u32 0} where entry i covers [data_offset_i, data_offset_{i+1}) (the last to data_size) of the patched RomFS data level, source 0 = <base dir>/romfs.bin (base_dir in romfs.json) and 1 = patch.bin. An update without a RomFS of its own gets a clone of the base romfs.bin. The runtime composes read(off) through the table (open_romfs_delta in runtime/src/services/fs.c). BKTR patching was written against synthetic fixtures first (see the packaging note); later notes record real update builds.

In-place RomFS (development). --update-romfs in-place (with --data-only) writes romfs.map instead of a RomFS copy: format "RMP1", u32 file count, u64 extent count, u64 image size; per file a u32 length and a UTF-8 path; per extent 64 bytes {u64 image offset, u64 length, u64 file offset, u64 counter offset, u32 file index, u32 flags (bit 0 AES-CTR), u8 iv_hi[8], u8 key[16]} (little endian, extents sorted and contiguous from 0). The runtime decrypts on read (runtime/src/services/romfs_map.c, writer crates/napple-build/src/romfs_map.rs). The map holds content keys and absolute host paths, so it is for development data directories only and must never be shipped.

Shared fonts (pl:u). system/fonts/<firmware file name>.{bfttf,ttf} + fonts.json (type = nn::pl::SharedFontType 0..5, name, sizes, sha256, source title id; BFTTF = u32 0x18029A7F^K, u32 BE size^K, TTF XOR K with K=0x06186249). napple build --dump-fonts <firmware.zip|dir> -k <prod.keys> -o <dir> writes the same files straight into <dir> (what runtime/src/services/pl.c reads from <save-root>/system/fonts). Because that service reads the save root, the launcher copies/clones the bundle's .ttf files into <save-root>/system/fonts when missing or of a different size; a runtime fallback to <data-dir>/../system/fonts would make this unnecessary. Without --firmware the build succeeds and records a compat: note in napple-app.json.

Run-time modules (NROs). napple build also rewrites every *.nro file of each version's RomFS into <version dir>/nro/<module id hex, 64 chars>.{patched.bin,tables}. The tables are NTB1 with the module id as build id, and the dynsym extent comes from the NRO header (0x78/0x7C) for GNU-hash modules. These are listed in manifest.json under nro as [{path, module_id, nro_sha256, file_size, gates, relocs}]. ldr:ro (runtime/src/ro.c) maps the image whose module id matches the NRO the game passes. A RomFS .nro that is not a valid NRO (or cannot be rewritten) is listed in the app's notes instead. The rewriter treats an NRO header (0x10..0x80 of text, "NRO0" at 0x10) as data.

Offline HTML (html/). When the title (newest update first, else base) carries an HtmlDocument NCA (CNMT content type 4: manual / offline HTML), napple build writes its RomFS files under <version dir>/html/ next to romfs.map / romfs.bin (paths confined to the directory; .., absolute and backslash names are dropped; files over 256 MiB or beyond 1 GiB in total are skipped). service_register_applet uses this directory as the offline web root. Titles without an HtmlDocument get no html/. Code: crates/napple-build/src/html.rs. Nested RomFS directories are written as nested directories (html/img/ui/a.png); the e2e test html_document_written_next_to_romfs_map builds a real directory tree (romfs_tree in tests/support). Offline web resolve contract: a page URL <root>/<rel> is looked up under <version dir>/html/ using the same /-separated relative path the RomFS stored (case-sensitive, no query/fragment in the file name); the runtime must refuse any path that escapes that root, mirroring safe_rel. A name safe_rel rejects (.., backslash, NUL) is never written, so a link to it resolves to "not found".