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).
193 lines
14 KiB
Python
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>')
|