Mod framework: library API, build hook and follow-ups¶
For contributors. The portable core lives in crates/napple-mods (no macOS dependencies, cargo test -p napple-mods runs on Linux). User-facing rules are in format.
Library map¶
| Module | What it does |
|---|---|
manifest |
mod.json parse and field checks |
source, model |
open a .napmod or a folder safely (LoadedMod::open/load), capabilities, hash manifest, component loading, Selection and ResolvedMod |
ips, pchtxt |
parse/write IPS and IPS32, parse/write/convert pchtxt and its JSON form |
cheats |
cheat file parser, opcode decoder (decode), interpreter (run over a CheatMemory) |
romfs |
build_overlay(&[&LoadedMod]) -> OverlayMap with lookup("/path") and read_entry |
config |
config.json parse and merge |
shader |
shaders/<hash>.msl validation |
validate |
validate(&LoadedMod) -> Report (errors, warnings, capabilities) |
pack |
pack_dir(dir, out), deterministic |
apply |
the hook below and the apply report |
cli |
napple mods ..., registered in napple-cli/src/main.rs as Some("mods") => napple_mods::run |
The build hook¶
pub fn apply_mods_to_module(build_id: &[u8; 32], module: &mut ModuleBytes, mods: &[ResolvedMod]) -> ApplyReport;
pub fn dry_run_mods_for_module(build_id: &[u8; 32], module: &ModuleBytes, mods: &[ResolvedMod]) -> ApplyReport;
ModuleBytes holds the three decompressed segments with their memory offsets (ModuleBytes::from_segments([(off, bytes); 3])). apply_mods_to_module applies, in the given order, every patch of every enabled mod whose build id equals build_id (Selection::enabled && code_patches, plus per-file and per-block overrides, @disabled blocks off), never writes outside a segment, never panics on hostile input, and returns hits, misses, overlaps (later wins), skipped patches and the SHA-256 of the module before and after. Truncation is never applied.
Exact call site proposal for napple-build (a Mac follow-up; crates/napple-build/src was read only in this batch)¶
crates/napple-build/src/pipeline.rs, rewrite_version, inside the par_iter closure, right after let nso = Nso::parse(bytes)... and before the AArch32 check and napple_rewrite::rewrite_module:
let mut nso = Nso::parse(bytes).map_err(...)?; // was: let nso
if !mods.is_empty() { // new parameter: &[napple_mods::ResolvedMod]
let mut seg = napple_mods::ModuleBytes::from_segments([
(nso.text.mem_offset, std::mem::take(&mut nso.text.data)),
(nso.rodata.mem_offset, std::mem::take(&mut nso.rodata.data)),
(nso.data.mem_offset, std::mem::take(&mut nso.data.data)),
]);
let rep = napple_mods::apply_mods_to_module(&nso.build_id, &mut seg, mods);
[nso.text.data, nso.rodata.data, nso.data.data] = seg.into_segments();
progress("mods", ..., &format!("{label} {name}: {} hit(s), {} miss(es)", rep.hits.len(), rep.misses.len()));
reports.lock().push((name.clone(), rep)); // written to modules/<name>.mods.json
}
Caveats for the integrator:
Nso::parsechecks segment hashes before the hook runs, so patched segments are not re-verified: correct.tables::build_tablesreceivesnso: Some(bytes)(the original file) and reads symbols and relocations from it. Patches that touch.dynsym,.dynamicor relocation data would be missed; refuse such patches in the hook (module offsets inside theDT_*tables) or pass the patched segments through.- The runtime data directory is named by the unpatched
mainbuild id. A patched build needs its own directory (for example<buildid16>+<modhash8>) andmanifest.jsonshould record the mod ids, versions and content hashes that were applied, so a rebuild with a different mod set never reuses a stalepatched.bin. Build caches must include the content hashes of the mods in their key. - A miss in a requested patch should fail the build when the user asked for strict mode, and warn otherwise.
- AArch32 modules (MK8D) go through
tables32instead of the rewriter; the same hook works on their segments (offsets are file offsets as well).
Status of the build glue (2026-10-27, mod-glue-core)¶
Done (tested on Linux, not verified on a Mac): napple build --mods <a.napmod,dir,...> (comma list, option may repeat).
napple_mods::buildglue::ModSetloads and validates every path (a validation error or a mod for another title fails the build, id twice fails), resolves withenabled + code_patches + cheats + shaders(naming a mod on the command line is the explicit enable), and exposeskey(),apply(),manifest_json()andwrite_overlay().pipeline.rsrewrite_version_modsapplies the patches right afterNso::parseand before the AArch32 check and the AOT rewrite; per module reports go tomodules/<name>.mods.json; misses become build notes. Tables are still read from the original NSO bytes (see the caveat above: patches on.dynsym/.dynamic/relocation data are not seen by the tables).- The data directory keeps its unpatched name (
<buildid16>); the applied set is inmanifest.json(mod_set= hash of ids, versions, content hashes and selections;mods= id, version, content hash, per-module hits and misses;mods_overlaywhen a romfs overlay exists). The RomFS stream cache directory name includes the mod key, so a different mod set never reuses another set's cache. Building a different mod set into the same output folder rewrites that data directory. <data dir>/mods/overlay.json(one entry per line:{"path":"/a/b","file":"mods/files/<n>/a/b","size":N}or{"delete":"/a/b"}) plus the copied files belowmods/files/<n>/. Paths with quotes, backslashes or control characters are refused at build time.- Runtime:
romfs_overlay_load/lookup/child/freeinruntime/src/services/romfs_map.c(portable C;make -C runtime build/host/test_romfs_overlay). The lookup is not yet called fromfs.c(the RomFS is served as a raw IStorage image, so a path-level overlay needs a romfs directory/file reader in front of it, or the builder re-packing the image): open item 2 below. napple buildalso writes<data dir>/control.nacp(the raw NACP of the newest version with a control NCA) for ns 400/21/23.
Status of the runtime (2026-10-28, mods-runtime; tested on Linux, not verified on a Mac)¶
- RomFS overlay (done): decision: the runtime builds a virtual RomFS image; the builder is not involved.
romfs_map.cromfs_merge_openreads the original image through whatever backend serves it (romfs.bin, romfs.map, delta layer), parses the directory and file tables into a tree, appliesmods/overlay.json(deletes first, then replacements and additions, so a replacement under a deleted subtree wins, as inromfs_overlay_lookup) and generates a fresh header, directory/file tables and hash tables in memory. File data is never copied: every file is an extent of the original image or of a mod file (fds opened on demand, 8 cached).fs.cstorage_with_overlayswaps it in forOpenDataStorageByCurrentProcess; any failure serves the original image and logs why. Why not re-pack in the builder: the builder would have to read and rewrite multi-GB images for every mod set and would need its own cache keys; the runtime variant costs memory only for the tables and works for all three image backends. Log line:mods: romfs overlay applied: N replaced, N added, N removed, N skipped.NAPPLE_NO_MODS=1serves the original image. DLC images (OpenDataStorageByDataId) are not overlaid. - Build wiring (done):
--mods(CLI),--mods-strict(a missed patch record fails the build). The build now rewrites the whole<data dir>/modsdirectory on every build (an empty set clears it) and removes stalemodules/*.mods.json, so a rebuild into the same output folder never leaves an earlier set's overlay, cheats, config or reports. New files:mods/cheats.txt,mods/config.json;manifest.jsongainsmods_cheatsandmods_configbesidemods_overlay. The data directory keeps its unpatched build-id name (the launcher and runtime find it by that name); the applied set is inmanifest.json(mod_set), the RomFS stream cache is keyed by it. Keying the directory name itself would need the launcher/runtime lookup changed (not in this branch's area). - Cheats (done, see format): Rust and C interpreters, loops, registers, pointer chains, conditions;
mods_runtime.cruns them fromvi.c's vsync thread. - Config (done):
mods_config_apply_settingsmapsrenderScale,aspect,fpsModeonto a copy of the frontend settings inapp.mpush_gfx_settings(the user'ssettings.jsonis never rewritten);fixesare read withmods_fix_enabled(name, default)by title-specific code (no title consumes one yet).
Mac follow-up list¶
- Build integration (done for the CLI, see above; Studio field still open, not this area): add a
--mods <file.napmod|dir>...option (and a Studio field) tonapple build; load withLoadedMod::load,validate,resolve(Selection), call the hook as above, store the reports, and key the data directory and caches by the applied mod set. - Runtime romfs overlay (done, see the runtime status above; the text below is the original proposal):
runtime/src/services/romfs_map.c(fsp-srv romfs lookup) should resolve paths through anOverlayMap. Suggested shape: the build step writes<datadir>/mods/overlay.json(romfs path -> file inside the app bundle, plus tombstones) and the runtime consults it before the RomFS image forOpenFile,GetEntryType, directory listings (merge and hide). Keeping this a data file keeps the runtime free of zip code. - Cheats (done, see above; the original proposal): the runtime supplies a
CheatMemoryover the guest address space (main module base, heap, alias, aslr) and runscheats::runor a C port at a safe point once per frame; a C port needs the same opcode table (see format). - Config (done, see above): apply
mergedModConfigas per-title settings (render scale in gfx, aspect, fps mode, fix toggles named by the title page). - Shaders: the gfx shader cache looks up
shaders/<hash>.mslby program hash before translating; compile errors must fall back to the translated shader and log once. - UI: Studio and the overlay list installed mods with author, license, hashes, capabilities and warnings from
inspect --json, the four switches (enabled, code patches, cheats, shaders), per-block toggles, mod order, and the overlay conflicts. Code patches need an explicit second confirmation.napple mods inspect --jsonandvalidate --jsonare the machine interface. - Signatures: a later format version can fill
signatureand verify it against a user-managed key list; the hash manifest already gives the signed digest.
Tests¶
cargo test -p napple-mods: unit tests per parser (IPS, pchtxt, cheats, config, shader, manifest), corpus tests that mutate valid input thousands of times and must never panic, and tests/mods.rs: golden patch application on the synthetic module (examples/synthetic-module.bin, regenerated and compared by a test; NAPMOD_BLESS=1 rewrites it), path-traversal and symlink packages rejected, hash-manifest tampering, policy checks, overlay ordering, selection gating, corrupted-package fuzz, and the CLI round trip.