Files
2026-10-08 13:25:54 +00:00

127 lines
5.3 KiB
Python

"""Write runtime/install/manifest.inc: the file list the installer checks a
user's disc against (relative path, size, SHA-256 of every file).
python3 scripts/gen_install_manifest.py game/files
Run it over a known-good extracted dump (extract-xiso output). It reads the dump
and writes only names, sizes and hashes, never game content, so the output is
committed. default.xex must match the hash this build was recompiled from
(config/nfsmw.toml); --expect-xex overrides that for test manifests.
The manifest version is derived from the entries, so installs made with an
older manifest stop counting as valid (FindGameInstall) whenever it changes.
"""
import argparse
import hashlib
import os
import pathlib
import re
import struct
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
OUT = ROOT / 'runtime/install/manifest.inc'
FORMAT = 1 # bump if the meaning of the entries changes
MARKER = '.nfsmw-install' # written by the installer; not a disc file
CHUNK = 8 << 20
def expected_xex_hash():
text = (ROOT / 'config/nfsmw.toml').read_text()
m = re.search(r'default\.xex SHA-256 ([0-9a-f]{64})', text)
if not m:
sys.exit('config/nfsmw.toml has no "default.xex SHA-256 <hash>" comment; pass --expect-xex')
return m.group(1)
def xex_title_id(path):
# XEX2 header: optional header count at 0x14, then (key, value) pairs.
# Key 0x00040006 is the execution info, whose title ID is at +12.
data = path.read_bytes()[:0x10000]
if data[:4] != b'XEX2':
sys.exit(f'{path} is not an XEX2 executable')
count = struct.unpack_from('>I', data, 0x14)[0]
for i in range(min(count, 64)):
key, value = struct.unpack_from('>II', data, 0x18 + 8 * i)
if key == 0x00040006 and value + 16 <= len(data):
return struct.unpack_from('>I', data, value + 12)[0]
sys.exit(f'{path} has no execution info header')
def sha256_of(path, done, total):
h = hashlib.sha256()
with open(path, 'rb') as f:
while chunk := f.read(CHUNK):
h.update(chunk)
done[0] += len(chunk)
if sys.stderr.isatty():
print(f'\r{done[0] * 100 // max(total, 1):3d}% {path.name[:48]:<48}', end='', file=sys.stderr, flush=True)
return h.hexdigest()
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('game_dir', type=pathlib.Path, help='extracted disc (default.xex, NFS/, Movies/)')
ap.add_argument('-o', '--out', type=pathlib.Path, default=OUT)
ap.add_argument('--expect-xex', help='required default.xex SHA-256 (default: from config/nfsmw.toml)')
args = ap.parse_args()
game = args.game_dir.resolve()
files = []
for dirpath, dirnames, filenames in os.walk(game):
# Hidden entries are host clutter (.DS_Store, AppleDouble ._ files on
# exFAT drives, the install marker), never disc files; the installer
# skips them the same way.
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
for name in filenames:
if name.startswith('.'):
continue
path = pathlib.Path(dirpath) / name
rel = path.relative_to(game).as_posix()
if not re.fullmatch(r'[\x21-\x7e ]+', rel) or '"' in rel or '\\' in rel:
sys.exit(f'unexpected characters in {rel!r}')
files.append((rel, path))
files.sort()
by_lower = {}
for rel, _ in files:
if rel.lower() in by_lower:
sys.exit(f'{rel} and {by_lower[rel.lower()]} differ only in case')
by_lower[rel.lower()] = rel
if 'default.xex' not in [rel for rel, _ in files]:
sys.exit(f'{game} has no default.xex at its root')
total = sum(p.stat().st_size for _, p in files)
done = [0]
entries = [(rel, p.stat().st_size, sha256_of(p, done, total)) for rel, p in files]
if sys.stderr.isatty():
print(file=sys.stderr)
xex_hash = next(h for rel, _, h in entries if rel == 'default.xex')
expected = args.expect_xex or expected_xex_hash()
if xex_hash != expected.lower():
sys.exit(f'default.xex is {xex_hash}, expected {expected}: not the dump this build was recompiled from')
canonical = f'nfsmw-install-manifest {FORMAT}\n' + ''.join(f'{rel}\t{size}\t{h}\n' for rel, size, h in entries)
version = hashlib.sha256(canonical.encode()).hexdigest()[:16]
title_id = xex_title_id(game / 'default.xex')
lines = [
'// Generated by scripts/gen_install_manifest.py from a known-good dump. Do not edit;',
'// rerun the script. File names, sizes and SHA-256 hashes only: no game content.',
'// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).',
'//',
'// MANIFEST_INFO(version, default.xex title ID, file count, total bytes)',
'// MANIFEST_FILE(path relative to the disc root, size in bytes, SHA-256)',
'',
f'MANIFEST_INFO("{version}", 0x{title_id:08X}, {len(entries)}, {total}ull)',
]
lines += [f'MANIFEST_FILE("{rel}", {size}ull, "{h}")' for rel, size, h in entries]
args.out.write_text('\n'.join(lines) + '\n')
shown = args.out.resolve()
shown = shown.relative_to(ROOT) if shown.is_relative_to(ROOT) else shown
print(f'{shown}: {len(entries)} files, {total:,} bytes, manifest {version}, title {title_id:08X}')
if __name__ == '__main__':
main()