Files
nxnative-easy/dm/games/game.py
T
seedds 79d82f7b64 Remove the D2R code and the legacy frame step
D2R (Diablo II Resurrected) has no profile and its code could not run here: the
host-call svcs its cuts emitted only died, the touch-run-v4 rule set needed a
missing dm/PRODUCT, and the host files its hooks called were never in this
repository. Removed with it:
- rewriter: D2R cuts, observation gates and parameter slots, the touch-run-v4
  rule set and private gates, the boot list, the cut scope column and the
  KRES / KASPECT key slots (fixups.tsv keeps the four thread slots; no
  params.tsv any more); dm/tools/product.sh
- host: D2R save edits, add-on content, render-scale controller, time()
  takeover and Terror Zone clock, crash decoding at D2R offsets, host-call
  registry and stubs, i18n, UI-screen, shell-app loader, zero-stripped
  archives, boot profiling hooks with no implementation; D2R headers, tests
  and scripts; dm/ios/Sources/dm_touch_controls; dm/third_party/libdeflate
- graphics: runtime extrapolation, world freeze, iPad UI shift, shop drag
  image, brightness LUT, visual ruler, bloom / FXAA shader hashes, DM_GFXUSE,
  the eco-fps setting, the loading-screen gate; shader_map.py,
  nometal_run.py, nvn_slots.py, nvn_slots.tsv and the PC-texture samples

The legacy frame step (frame_step legacy, DM_FRAME_STEP) is retired: the
Breath of the Wild and Tears of the Kingdom stubs lose their mode test.
Breath of the Wild's interval-controller cuts stay: the framework keeps the
controller's interval while the game's VFR override holds.

patch_nso.py output is byte-identical for all 15 games on this machine except
the two frame-step stubs; all 18 profiles build; the host tests pass (their
Makefile now builds the general tests only).
2026-10-21 02:09:49 +08:00

193 lines
14 KiB
Python

#!/usr/bin/env python3
# Game profiles: dm/games/<base game title ID>/game.tsv (key <TAB> values..., lines starting with # are comments) + the shader tables and dmsh_chain.h in the same folder. The shader tools, the host build, iOS packaging and the NSP script take each game's constants from here.
# Usage: game.py list (list all profiles); game.py <id> <key> (print that key's value, for shell); game.py header <id> <out.h> (generate the host's game_profile.h);
# game.py dir <id> (print the profile folder); game.py link <id> <link> (point a symlink at the profile folder: make cannot take paths with spaces)
# From Python: import game; g = game.load() (takes DM_GAME; there is no default game: unset is an error that lists the profiles); game.by_title(title ID); g.get / g.words / g.int / g.builds() / g.table(file name)
# DM_GAMES_DIR (folders separated by ':') adds profile folders outside the repository, searched after dm/games: the NSP script writes a provisional profile for a game without one into
# <NSP folder>/<id>_build/profile/<id>/ and points DM_GAMES_DIR at <id>_build/profile, so the shader tools, the host build and build_ipa.sh all find it.
# DM_GAMES_FIRST=1 searches DM_GAMES_DIR before dm/games (the NSP script's --provisional: a provisional profile for a game that has one in dm/games, to test the guesses)
import os, re, sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
STAGES = {'vs': 0, 'ps': 1, 'gs': 2, 'hs': 3, 'ds': 4, 'cs': 5} # NVN shader stage numbers
class Game:
def __init__(self, d):
self.dir = Path(d).resolve(); self.id = self.dir.name; self.rows = []
for line in (self.dir / 'game.tsv').read_text(encoding='utf-8').splitlines():
if not line.strip() or line.lstrip().startswith('#'): continue
c = line.split('\t'); self.rows.append((c[0].strip(), [v.strip() for v in c[1:]]))
def values(self, key): # every row of this key (keys such as build repeat)
return [v for k, v in self.rows if k == key]
def get(self, key, default=None):
r = self.values(key)
if r: return '\t'.join(r[0])
if default is None: raise KeyError(f'{self.dir / "game.tsv"} has no key {key}')
return default
def words(self, key, default=None): return self.get(key, default).split()
def int(self, key): return int(self.get(key), 0)
def table(self, name): return self.dir / name
@property
def title_id(self): return int(self.get('title_id'), 16)
def builds(self):
"""{version: {'display': display version, module name: build ID, ...}}"""
out = {}
for ver, disp, *mods in self.values('build'):
out[ver] = dict([('display', disp)] + [tuple(m.split('=', 1)) for m in mods])
return out
def pairs(self, key): # "a=b c=d" → [(a, b), (c, d)]
return [tuple(w.split('=', 1)) for w in self.words(key)]
def extra_dirs(): # profile folders outside the repository (DM_GAMES_DIR), searched after dm/games
return [Path(p) for p in os.environ.get('DM_GAMES_DIR', '').split(':') if p]
def search_dirs(): # dm/games, then DM_GAMES_DIR; DM_GAMES_FIRST=1 the other way round
return extra_dirs() + [HERE] if os.environ.get('DM_GAMES_FIRST') else [HERE] + extra_dirs()
def find_dir(name): # the profile folder of a title ID (the first of search_dirs that has it); a path is taken as is
if os.sep in str(name): return Path(name)
return next((d / name for d in search_dirs() if (d / name / 'game.tsv').exists()), HERE / name)
def load(name=None):
name = name or os.environ.get('DM_GAME')
if not name: # there is no default game
have = ', '.join(f'{g.id} ({g.get("name", "")})' for g in all_games())
raise SystemExit(f'[RED] DM_GAME is not set: pick a game profile by base game title ID: {have}')
d = find_dir(name)
if not (d / 'game.tsv').exists(): # profile folders are named after the base game's title ID (the old names smbw / fe_engage are gone)
have = ', '.join(f'{g.id} ({g.get("name", "")})' for g in all_games())
raise SystemExit(f'[RED] no game profile {d / "game.tsv"} (DM_GAME / GAME take the base game title ID: {have})')
return Game(d)
def all_games():
out, seen = [], set()
for root in search_dirs():
if not root.is_dir(): continue
for d in sorted(root.iterdir()):
if (d / 'game.tsv').exists() and d.name not in seen: seen.add(d.name); out.append(Game(d))
return out
def by_title(tid):
return next((g for g in all_games() if g.title_id == tid), None)
def header(g):
"""the host's game_profile.h: the profile constants the host uses, as macros"""
q = lambda s: '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'
mods = g.words('modules')
ssbo = dict(g.pairs('nvn_ssbo_bases'))
sb = ' : '.join(f'(stage) == {STAGES[k]} ? {v}' for k, v in ssbo.items()) + ' : -1'
vers = ', '.join('{' + q(b['main']) + ', ' + q(b['display']) + '}' for b in g.builds().values() if 'main' in b)
pi = g.get('present_interval', 'game') # present interval: game = as the game sets it (as on the Switch), display = one frame per display tick
if pi not in ('game', 'display'): sys.exit(f'{g.id}: present_interval must be game or display: {pi}')
ft = g.get('fps_target', '0') # frame rate cap of the rewriter's frame step (patch_nso.py FRAME_STEP: Tears of the Kingdom 1.4.3, Breath of the Wild 1.9.0) or of the engine's own frame limit (hle_fpslock.c): 0 = the game's own step, 30 / 60
if ft not in ('0', '30', '60'): sys.exit(f'{g.id}: fps_target must be 0, 30 or 60: {ft}')
cp = g.get('core_preempt', 'off') # same-core preemption approximation (hle_thread.c): on / off
if cp not in ('on', 'off'): sys.exit(f'{g.id}: core_preempt must be on or off: {cp}')
aba = g.get('alloc_big_align', 'off') # nn::mem::StandardAllocator::Allocate aligns blocks of 64 KB or more to 64 KB (hle_os.c; Vanillaware's memory pool storage): on / off
if aba not in ('on', 'off'): sys.exit(f'{g.id}: alloc_big_align must be on or off: {aba}')
sf = g.get('shared_font', 'bfttf') # what the shared-font address points at (hle_sdk16.c): bfttf = 8-byte header + TTF, ttf = the TTF alone
if sf not in ('bfttf', 'ttf'): sys.exit(f'{g.id}: shared_font must be bfttf or ttf: {sf}')
pl = g.get('pad_layout', 'simple') # the virtual gamepad's first layout (DmGamepad.m; afterwards whatever the player switched to in game is kept): simple = 2D, full = 3D (D-pad, right stick, L3 / R3)
if pl not in ('simple', 'full'): sys.exit(f'{g.id}: pad_layout must be simple or full: {pl}')
fbs = g.words('feedback_snap', '') # fragment shader key prefixes whose same-pass feedback sampling gets a snapshot first (nvn_mtl.m fb_prepare); none by default
if any(not re.fullmatch(r'[0-9a-f]{8,16}', k) for k in fbs): sys.exit(f'{g.id}: feedback_snap takes fragment shader keys (8-16 lowercase hex digits): {fbs}')
afr = g.words('alpha_from_red', '') # fragment shader key prefixes that read single-channel textures mapped (R, 0, 0, 1) with alpha = red (nvn_mtl.m tex_alpha_red); none by default
if any(not re.fullmatch(r'[0-9a-f]{8,16}', k) for k in afr): sys.exit(f'{g.id}: alpha_from_red takes fragment shader keys (8-16 lowercase hex digits): {afr}')
rcd = g.get('render_cond', 'off') # NVN conditional rendering evaluated at replay, timestamp reports with counter 0 (nvn_mtl.m OP_RENDER_COND): on / off
if rcd not in ('on', 'off'): sys.exit(f'{g.id}: render_cond must be on or off: {rcd}')
tfu = g.get('tex_flush_upload', 'off') # textures written by the CPU into a pool and published with nvnMemoryPoolFlushMappedRange are uploaded like packaged ones (hle_nvn.c): on / off
if tfu not in ('on', 'off'): sys.exit(f'{g.id}: tex_flush_upload must be on or off: {tfu}')
tdu = g.get('tex_dirty_upload', 'off') # with tex_flush_upload: a registered texture the CPU writes after init without any flush is uploaded too (dirty pages, hle_nvn.c nvn_ft_written): on / off
if tdu not in ('on', 'off') or (tdu == 'on' and tfu != 'on'): sys.exit(f'{g.id}: tex_dirty_upload must be on or off, and needs tex_flush_upload on: {tdu}')
tds = g.get('tex_dyn_slots', 'off') # shaders whose texture handle the translator could not trace to one slot: slot indices in c2, the bound slots in the fallback arrays (nvn_mtl.m): on / off
if tds not in ('on', 'off'): sys.exit(f'{g.id}: tex_dyn_slots must be on or off: {tds}')
vcr = g.get('vconv_range', 'off') # vertex attributes converted on the CPU only over the vertices each draw reads (nvn_mtl.m vc_draw_range): on / off
if vcr not in ('on', 'off'): sys.exit(f'{g.id}: vconv_range must be on or off: {vcr}')
gts = g.get('gpu_timestamps', 'submission') # counter timestamps (nvn_mtl.m rep_ticks): submission = one time per submission, segment = the GPU start / end of the segment holding the report
if gts not in ('submission', 'segment'): sys.exit(f'{g.id}: gpu_timestamps must be submission or segment: {gts}')
om = g.get('op_mode', 'console') # the operation mode reported at start (hle_oe.c dm_opmode; DM_OPMODE overrides): console = docked, handheld = touch screen in use (Super Mario Galaxy's pointer)
if om not in ('console', 'handheld'): sys.exit(f'{g.id}: op_mode must be console or handheld: {om}')
pm = g.get('perf_mode', 'follow') # the performance mode reported (hle_oe.c): follow = the operation mode's (Boost docked, Normal handheld), boost = Boost always
if pm not in ('follow', 'boost'): sys.exit(f'{g.id}: perf_mode must be follow or boost: {pm}')
api = g.get('device_api', '55.15') # the NVN API major.minor version nvnDeviceGetInteger reports (hle_nvn.c)
if not re.fullmatch(r'\d+\.\d+', api): sys.exit(f'{g.id}: device_api must be major.minor: {api}')
src = f'dm/games/{g.id}' if g.dir.parent == HERE else str(g.dir)
L = [f'// Generated by dm/games/game.py from {src}/game.tsv; do not edit',
'#pragma once',
f'#define DM_GAME_ID {q(g.id)}',
f'#define DM_GAME_NAME {q(g.get("name"))}',
f'#define DM_GAME_PROGRAM_ID 0x{int(g.get("program_id"), 16):016X}ull',
f'#define DM_GAME_MODULES {{{", ".join(map(q, mods))}}}',
f'#define DM_GAME_NMOD {len(mods)}',
f'#define DM_GAME_ROMFS_MARKER {q(g.get("romfs_marker"))}',
f'#define DM_GAME_MEM_RESERVE {g.int("mem_reserve"):#x}ull',
f'#define DM_GAME_MEM_ALIAS {g.int("mem_alias"):#x}ull',
f'#define DM_GAME_MEM_STACK {g.int("mem_stack"):#x}ull',
f'#define DM_GAME_APP_POOL {g.int("app_pool"):#x}ull',
f'#define DM_GAME_MEM_USED {int(g.get("mem_used", "0x4000000"), 0):#x}ull', # used memory reported beside the heap (hle_svc.c GetInfo UsedMemorySize)
f'#define DM_GAME_ARGS {q(g.get("game_args", ""))}', # game arguments when the command line gives none (main.c; Octopath Traveler 0: Unreal's -SwitchVirtualAddressSpaceSize=)
f'#define DM_GAME_NVN_SSBO_BASE(stage) ({sb})',
f'#define DM_GAME_NVN_TEX_BASE {g.int("nvn_tex_base"):#x}',
f'#define DM_GAME_NVN_TEX_SLOTS {g.int("nvn_tex_slots")}',
f'#define DM_GAME_NVN_SAMP_BASE {g.int("nvn_samp_base"):#x}',
f'#define DM_GAME_NVN_IMG_BASE {g.int("nvn_img_base"):#x}',
f'#define DM_GAME_NVN_IMG_SLOTS {g.int("nvn_img_slots")}',
*([f'#define DM_GAME_NVN_SEPTEX_BASE {g.int("nvn_septex_base"):#x}', f'#define DM_GAME_NVN_SEPTEX_SLOTS {g.int("nvn_septex_slots")}'] if g.get('nvn_septex_base', '') else []), # a separate texture table of its own (Monster Hunter Rise)
f'#define DM_GAME_DISPLAY_VERSIONS {vers}',
f'#define DM_GAME_PRESENT_DISPLAY {int(pi == "display")}',
f'#define DM_GAME_CORE_PREEMPT {int(cp == "on")}',
f'#define DM_GAME_ALLOC_BIG_ALIGN {int(aba == "on")}',
f'#define DM_GAME_FPS_TARGET {int(ft)}',
f'#define DM_GAME_SHARED_FONT_TTF {int(sf == "ttf")}',
f'#define DM_GAME_PAD_FULL {int(pl == "full")}',
f'#define DM_GAME_OPMODE {int(om == "console")}',
f'#define DM_GAME_PERF_BOOST {int(pm == "boost")}',
f'#define DM_GAME_FEEDBACK_SNAP {q(" ".join(fbs))}',
f'#define DM_GAME_ALPHA_FROM_RED {q(" ".join(afr))}',
f'#define DM_GAME_TEX_FLUSH_UPLOAD {int(tfu == "on")}',
f'#define DM_GAME_RENDER_COND {int(rcd == "on")}',
f'#define DM_GAME_TEX_DIRTY_UPLOAD {int(tdu == "on")}',
f'#define DM_GAME_TEX_DYN_SLOTS {int(tds == "on")}',
f'#define DM_GAME_GPU_TS_SEGMENT {int(gts == "segment")}',
f'#define DM_GAME_VCONV_RANGE {int(vcr == "on")}',
f'#define DM_GAME_NVN_API_MAJOR {api.split(".")[0]}',
f'#define DM_GAME_NVN_API_MINOR {api.split(".")[1]}',
'']
return '\n'.join(L)
if __name__ == '__main__':
a = sys.argv[1:]
if a == ['list']:
for g in all_games(): print(f'{g.id}\t{g.title_id:016X}\t{g.get("name", "")}')
elif len(a) == 3 and a[0] == 'header':
out, text = Path(a[2]), header(load(a[1])); out.parent.mkdir(parents=True, exist_ok=True)
if not (out.exists() and out.read_text() == text): # written only when it changes: make runs this every time (the profile folder can change under the same GAME), and an unchanged header must not rebuild the host
tmp = out.with_name(out.name + '.tmp'); tmp.write_text(text); tmp.replace(out)
elif len(a) == 2 and a[0] == 'dir':
print(load(a[1]).dir)
elif len(a) == 3 and a[0] == 'link': # link → the profile folder (replaced when it points elsewhere)
d, ln = load(a[1]).dir, Path(a[2]); ln.parent.mkdir(parents=True, exist_ok=True)
if not (ln.is_symlink() and Path(os.readlink(ln)) == d):
if ln.is_symlink() or ln.exists(): ln.unlink()
ln.symlink_to(d)
elif len(a) == 2:
print(load(a[0]).get(a[1]))
else:
sys.exit(__doc__ or 'usage: game.py list | game.py <id> <key> | game.py header <id> <out.h> | game.py dir <id> | game.py link <id> <link>')