Files
nxnative-easy/dm/games/README.md
T
seedds 787f9a178e Hades runs on the Mac through a new Vulkan-on-Metal layer
Hades (0100535012974000, update 1.0.38233, SDK 9) renders with Vulkan through The Forge, not NVN.
The host now implements its 100 vk* imports on Metal in dm/gfx/vk/hle_vk.m, and the Vulkan loader
(subsdk0) and NVIDIA's driver (subsdk1) are not loaded. The title menu, the intro and Tartarus gameplay
run at 60 fps on the Mac, and a Metal validation run is clean. The iPhone build is untested.

- dm/gfx/vk/hle_vk.m: one Tegra X1-shaped device. Memory is a lazily made shared MTLBuffer; images are
  private MTLTextures. Command buffers are recorded on the host and replayed at vkQueueSubmit on the
  layer's own queue, which signals an MTLSharedEvent. Descriptor sets, update templates, render passes
  and pipelines are supported. The swapchain presents through the NVN layer's present path.
  vkGet*ProcAddr look names up in the HLE table, also trying a name without its KHR suffix.
- dm/gfx/tools/vk_shaders.py: SPIR-V to one metallib plus a slot table. It renumbers bindings per kind,
  flips vertex y, and lists only the resources the Metal entry point takes.
- nvn_mtl.m/h: mtl_device, mtl_ext_tex and mtl_present_ext for external producers. Their present
  waits on the producer's event.
- hle_nvn.c: dm_gfx_enter/leave and dm_vk_drain, so the background suspend also covers Vulkan submits.
- hle_oe.c: GetNotificationMessageEvent returns a host-owned auto-clear SystemEvent. The SDK's version
  returned an event the no-op oe::Initialize never created.
- patch_nso.py: the byte-table jump search looks 16 instructions back, not 8 (Hades main+0x1135d0).
  The output for every other module is byte-identical.
- Profile key `graphics vulkan`: build_ipa.sh ships $MSL_IOS/vk instead of the NVN chain libraries.
- dm/third_party/vulkan-headers: Vulkan-Headers 1.4.363, unmodified.
- dm/.gitignore: per-game host binaries (dm/host/dmhost_*).
2026-10-18 10:29:06 +08:00

11 KiB
Raw Blame History

Game profiles

Each game the port knows has a folder dm/games/<id>/, named after the base game's title ID: Mario's is 010015100B514000, Engage's 0100A6301214E000. The folder holds:

  • game.tsv: the game's constants, one key<TAB>value row each, with comments on their own lines.
  • The shader tables that shader_split.py writes: shader_map.tsv (+ .conflicts.tsv), shader_facts.tsv, shader_pairs.tsv.
  • shader_facts_manual.tsv (optional): hand-written translation facts in shader_facts.tsv's row format, for what the machine code cannot tell (Link's Awakening: a vertex input the integer-input guess gets wrong). shader_split.py merges it over the generated rows; since the table is on the chain, changing it changes only that profile's chain version.
  • c1_full.tsv: regenerated by c1_guard.py after each shader build.
  • pso_manifest.tsv: the prewarm seed list.
  • bf_want.tsv.
  • dmsh_chain.h: the chain version, written by dmsh_chain.py --header.

Everything game-specific belongs here. Code reads the profile; it does not hard-code a game.

Who Picks the profile with Reads
Shader tools (shader_split.py, dmsh_chain.py, dmsh_build.py, dmsh_facts.py) DM_GAME=<id> tables, nvn_*, shader_* keys
Host (make -C dm/host, dm/gfx/tests) GAME=<id> gen/<id>/game_profile.h, generated from game.tsv by game.py header
dm/ios/tools/build_ipa.sh GAME=<id> app name, bundle ID, IPA name, modules, RomFS marker, tables
scripts/build_ipa_from_nsp.py the NSPs' title ID everything above. With no matching profile it writes a survey, then a provisional profile outside the repository and a best-effort IPA (--survey-only stops after the survey)

None of them has a default game: without one they stop and list the profiles. To query a profile:

  • python3 dm/games/game.py list lists the profiles.
  • python3 dm/games/game.py <id> <key> prints one key.

Keys

Key Meaning
title_id, program_id Identity. program_id is what svcGetInfo(ProgramId) reports. The game's name is not stored: the NSP script reads it from the NSP's control data (NACP)
name The game's name, only a label for game.py list, error messages and the Mac window title. The iOS app's name is the game name in the base NSP's control data and its bundle ID is org.nxnative.<title ID in lowercase>; the NSP script's APP_NAME / BUNDLE_ID and build_ipa.sh's DM_DISPLAY_NAME / DM_BUNDLE_ID override them
ipa, app_category iOS app: the default IPA file name and the App Store category
build <ver> <display version> <module>=<build ID>… One row per known version. The NSP script rejects other builds. The host reports the display version by main's build ID
package Version packed into the IPA by default
shader_versions Versions whose union the shader tables are (base game first). All of them are needed to build
modules Code modules rewritten and loaded, in load order. rtld is replaced by the host
romfs_marker A path that must exist in the RomFS
zip_store Extensions the IPA stores without compressing
graphics nvn (the default) or vulkan: the game renders with Vulkan and the host implements its vk* imports on Metal (dm/gfx/vk/hle_vk.m, Hades). Its shaders are the game's SPIR-V files, translated by dm/gfx/tools/vk_shaders.py <RomFS> <shader dir> [--ios <dir>] into <dir>/vk/; build_ipa.sh then ships $MSL_IOS/vk instead of the NVN chain libraries, and the NVN shader tables stay empty
mem_reserve, mem_alias, mem_stack, app_pool Guest address-space partition and application pool (hle_svc.c)
present_interval game (the default): frames are paced by the present interval the game sets, as on the Switch (a frame presented with interval 2 stays on screen for two display ticks; a game that keeps asking for 2 for a second gets the 30 fps target, and on iOS the display link then runs at 30 Hz; Mario 1.2.1 switches to 2 for single frames after a slow one; Engage asks for 2 on the Switch, and a rewriter cut on its 2.0.0 main makes it ask for 1, see 0100A6301214E000/game.tsv). display: one frame per display tick whatever the game asks, which is only right for games whose logic does not count frames (D2R's runs on its own 25 Hz clock). DM_PRESENT_INTERVAL=game / display overrides it, also from Documents/env.txt
core_preempt on / off (the default): approximate Horizon's per-core preemption. When an SVC wakes a higher-priority thread pinned to the caller's core, the caller waits (at most 5 ms) until such threads block again (hle_thread.c). Metroid Dread's movie player relies on it; Mario's loading gets slower with it (first frame 0.6 → 2.9 s), so it is off unless a game needs it. DM_PREEMPT=0 / 1 overrides
shared_font What the shared-font address points at: bfttf (the default) = the 8-byte BFTTF header (with the "already decoded" marker) followed by the TTF, which Mario passes to ScalableFontEngineHelper::Decode; ttf = the TTF itself, which Super Mario 3D World + Bowser's Fury (SDK 10) hands straight to ScalableFontEngine::LoadFont (with the header the engine fails with error 403 and the game crashes looking up a glyph)
device_api NVN API version major.minor that nvnDeviceGetInteger reports (default 55.15, what Mario requires). Games check it and skip creating their device on a mismatch: Super Mario 3D World + Bowser's Fury (SDK 10) requires major 53 and minor ≥ 313. DM_NVN_API overrides. Not part of the shader chain version (only nvn_* / shader_* keys are)
pad_layout The iPhone virtual gamepad's layout on first launch: simple (the default) = 2D (left stick, A/B/X/Y, shoulders, − / +), full = 3D (adds a D-pad, a right stick and L3 / R3). The layout button at the top centre switches between them in game and the app remembers the player's choice (DmGamepad.m)
feedback_snap Fragment shader keys (prefixes) whose draws sample a colour attachment of their own render pass and must read a snapshot instead (fb_prepare in nvn_mtl.m). Writing and sampling one attachment in a render pass is undefined in Metal: the Mac happens to read the old content, the iPhone sometimes reads whole 16×16 blocks as magenta. Each such draw costs a pass split and a copy, so only the shaders seen to break are listed (Super Mario 3D World + Bowser's Fury: its final composite). DM_NVN_FB_SNAP=0 / all / <keys> overrides, also from Documents/env.txt
alpha_from_red Fragment shader keys (prefixes, up to 48) that read a single-channel render target whose channel mapping is exactly (R, 0, 0, 1) as (R, 0, 0, R) (tex_alpha_red in nvn_mtl.m). Breath of the Wild's layout captures (panes tagged A8) keep a blurred alpha mask in the red channel of such an R8 texture, and every layout pane that samples one treats it as alpha; read literally, HUD prompts get hard-edged shadow boxes and cloud-filled glow rectangles. BotW lists all 26 pixel-shader variants of its layout library. DM_NVN_ALPHA_RED=0 / <keys> overrides, also from Documents/env.txt
nvn_tex_base, nvn_tex_slots, nvn_samp_base, nvn_img_base, nvn_img_slots, nvn_ssbo_bases NVN driver constant segments, measured over all shaders
shader_groups, shader_note shader_split.py source groups and the map header note (Engage: engine for data.unity3d and Resources/, assets for the Addressables bundles)
shader_main_glslc on / off (the default): shader_split.py also reads GLSLC compiler outputs (magic 0x19866891) in main. 13 Sentinels keeps all its shaders there; Engage's main has 11 that it does not use, so the key stays off for other games
shader_separable on / off (the default): the game builds single-stage programs and combines them per draw, so the real vertex / pixel pairs exist only at run time. shader_split.py then pairs every vertex shader with a cover of pixel shaders that together read every input any pixel shader reads, and dmsh_build.py's vs_pad makes every vertex shader link with any pixel shader (13 Sentinels)

The translator (dmsh_xlate/Program.cs) still has Mario's texture / sampler bases and the depth convention built in. It uses the bases only to look up per-slot texture / image types in shader_facts.tsv; dmsh_build.py decodes the bindings from the profile. So a profile with other bases (Engage: 0x168 / 0x568) builds as long as its facts table has no per-slot texture / image entries; otherwise dmsh_build.py stops, and the translator must take the bases from the profile first.

Adding a game

  1. Survey. Run scripts/build_ipa_from_nsp.py on the game's NSP folder. Without a profile it extracts only the code modules and a RomFS list, then writes <NSP folder>/<title ID>_survey/survey.md. The survey covers modules and build IDs, SDK, engine and graphics API markers, RomFS containers, the rewrite-site census, and imports compared with the host hooks. Unless told --survey-only, the script then writes a provisional profile to <NSP folder>/<title ID>_build/profile/<title ID>/ (outside the repository) and carries on to a best-effort IPA. game.tsv there is guessed from the survey: identity and name, the build row it found, all ExeFS modules, the largest RomFS file as romfs_marker, big file types in zip_store, shader_groups by RomFS top-level folder, SDK 8 / 10 → shared_font ttf and device_api 53.313, Engage's memory partition when the NPDM declares a system resource and the 4 GB one otherwise, Mario's NVN values, and shader_main_glslc / shader_separable when main holds GLSLC outputs and the RomFS has no shader containers. The shader tables, dmsh_chain.h and c1_full.tsv are written into the same folder. A second run reuses that game.tsv (delete it to survey again), so it can be edited by hand. Tools find profiles outside dm/games through DM_GAMES_DIR (folders separated by :, searched after dm/games); make -C dm/host reaches such a profile through the symlink dm/host/gen/<id>/profile. To keep a game, move the folder into dm/games/ and bring it up as below.
  2. Profile. Copy 010015100B514000/game.tsv (or the provisional profile) to dm/games/<title ID>/game.tsv (the base game's title ID, as the folder name) and fill in the identity, build rows and modules. Leave the memory and NVN values as Mario's until the bring-up measures them (docs/agents).
  3. Shader tables. Run DM_GAME=<id> shader_split.py … dm/games/<id>/shader_map.tsv dm/games/<id>/shader_facts.tsv dm/games/<id>/shader_pairs.tsv. Add an empty shader_map.tsv.conflicts.tsv, bf_want.tsv and pso_manifest.tsv (copy Mario's headers).
  4. Chain.
    1. DM_GAME=<id> dmsh_chain.py --header
    2. DM_GAME=<id> [IOSOUT=…] dmsh_build.py <pkg> <cache>
    3. c1_guard.py <cache>/b-<V> dm/games/<id>/c1_full.tsv
  5. Host bring-up. Run make -C dm/host GAME=<id>, then work through the bring-up loop in docs/agents. Code that only fits one game is gated by its build ID (dm_main_build()), never left to run on every game.
  6. IPA. Use GAME=<id> dm/ios/tools/build_ipa.sh, or the NSP script, which now finds the profile.