Files
NX2navi/tests/save_zip_check.py
chrg6 b2074499de Let players export their save into one zip for a bug report and import a save someone shared, in the launcher and in the game's menu.
The launcher's home has a Saves screen: Export Save writes NX2navi-save-<date>_<time>.zip into Downloads (or a chosen folder) with a manifest, the save the game would load next and, for a bug report, the game's latest log and nx2navi.ini, never a key file; Import a Save checks a zip's manifest, game, paths and CRCs, backs the current save up into the saves folder and replaces it while the game is not running. The game's Game tab has the same exports and an import that waits in profile/save-import and becomes the save at the next start, since the running game would write its progress back over a save replaced under it.

The zip code (runtime/save_zip.c) and the import at start are checked without game files by tests/save_zip_check.py and tests/save_share_check.py; the launcher side by its test suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:13:43 -05:00

221 lines
9.4 KiB
Python

"""Build and run tests/save_zip_check.c: runtime/save_zip.c, the zip of a shared save as the game's menu writes and
reads it (runtime/save_share.c), against Python's zlib and zipfile, which the launcher uses for the same zip
(launcher/saves.py).
- the C program's selftest: the manifest's JSON keys, round trips in memory, damaged and cut input
- inflate: raw deflate streams zlib makes at every level and strategy (stored, fixed and dynamic blocks) inflate to
the same bytes, and a damaged one is refused
- deflate: what the C deflate makes, zlib inflates to the same bytes
- zip: a zip the C writer makes passes zipfile's testzip with the same members; zips zipfile makes (stored, deflated,
with data descriptors, the launcher's export) unzip in C to the same members with their CRCs; a link is marked
- the launcher imports a zip the C writer made (launcher/saves.py import_zip)
Usage: save_zip_check.py (no game files)
"""
import io
import json
import os
import random
import shutil
import subprocess
import sys
import tempfile
import zipfile
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
MSYS_BIN = Path(r'C:\msys64\ucrt64\bin')
def compiler():
"""$CC, else MSYS2 UCRT64's clang, else clang on PATH (as tests/diff/common.py picks it, without its Unicorn)."""
if os.environ.get('CC'):
return os.environ['CC']
if os.name == 'nt' and (MSYS_BIN / 'clang.exe').exists():
return str(MSYS_BIN / 'clang.exe')
return shutil.which('clang') or 'clang'
class NoSeek(io.RawIOBase):
"""A write-only stream zipfile cannot seek in: it writes data descriptors after the members."""
def __init__(self):
self.buf = bytearray()
def writable(self):
return True
def write(self, b):
self.buf += b
return len(b)
def samples():
rnd = random.Random(7)
log = ''.join(f'[{i:06d}] fs commit save: {rnd.randint(1, 20)} files, {rnd.randint(0, 9)} changed\n'
for i in range(4000)).encode()
return {
'empty': b'',
'one': b'x',
'text': log,
'random': bytes(rnd.getrandbits(8) for _ in range(70000)),
'runs': b'\0' * 100000 + b'\xff' * 3 + b'ab' * 40000,
'save': bytes(rnd.choice(b'\0\0\0\1\2\x80\xff') for _ in range(150000)),
}
def main():
work = ROOT / 'build' / 'test' / 'save_zip'
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)
exe = work / ('save_zip_check.exe' if os.name == 'nt' else 'save_zip_check')
env = dict(os.environ)
if os.name == 'nt' and MSYS_BIN.exists():
env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '')
subprocess.check_call([compiler(), '-O1', '-g', '-Wall', '-Wextra', '-Werror', '-I', str(ROOT / 'runtime'),
str(ROOT / 'tests' / 'save_zip_check.c'), str(ROOT / 'runtime' / 'save_zip.c'), '-o',
str(exe)], env=env)
run = lambda *a: subprocess.run([str(exe), *map(str, a)], env=env, capture_output=True, text=True) # noqa: E731
bad = 0
def check(ok, what):
nonlocal bad
if not ok:
bad += 1
print('FAIL', what)
r = run('selftest')
print(r.stdout.strip())
check(r.returncode == 0, 'selftest: ' + r.stderr.strip())
data = samples()
for name, raw in data.items():
src = work / f'{name}.bin'
src.write_bytes(raw)
for level in (0, 1, 6, 9):
for strategy in (zlib.Z_DEFAULT_STRATEGY, zlib.Z_FIXED, zlib.Z_HUFFMAN_ONLY, zlib.Z_RLE):
c = zlib.compressobj(level, zlib.DEFLATED, -15, 9, strategy)
packed = work / f'{name}-{level}-{strategy}.deflate'
packed.write_bytes(c.compress(raw) + c.flush())
out = work / 'out.bin'
r = run('inflate', packed, len(raw), out)
check(r.returncode == 0 and out.read_bytes() == raw, f'inflate {name} level {level} strategy {strategy}')
out = work / f'{name}.c-deflate'
r = run('deflate', src, out)
try:
got = zlib.decompress(out.read_bytes(), -15) if r.returncode == 0 else None
except zlib.error as e:
got = e
check(got == raw, f'C deflate of {name} read by zlib: {got!r:.80}')
if raw:
print(f' {name}: {len(raw)} bytes, C deflate {out.stat().st_size}, zlib -9 '
f'{len(zlib.compress(raw, 9)) - 6}')
# a damaged stream: a flipped bit in a dynamic block, and one cut short
packed = zlib.compressobj(9, zlib.DEFLATED, -15)
stream = bytearray(packed.compress(data['text']) + packed.flush())
for mutate in ('flip', 'cut'):
s = bytearray(stream)
if mutate == 'flip':
s[len(s) // 2] ^= 0x10
else:
del s[len(s) * 2 // 3:]
(work / 'bad.deflate').write_bytes(s)
r = run('inflate', work / 'bad.deflate', len(data['text']), work / 'out.bin')
check(r.returncode != 0 or (work / 'out.bin').read_bytes() != data['text'], f'damaged stream ({mutate}) read')
# C writes, zipfile reads
members = {'manifest.json': b'{"format": "nx2navi-save"}', 'save/option.sav': data['save'][:3000],
'save/slot_00/progress.sav': data['save'], 'bug-report/nx2navi.log': data['text'],
'save/empty.sav': b''}
args = []
for i, (n, b) in enumerate(members.items()):
(work / f'm{i}').write_bytes(b)
args += [n, work / f'm{i}']
cz = work / 'c.zip'
check(run('zip', cz, *args).returncode == 0, 'C zip')
with zipfile.ZipFile(cz) as z:
check(z.testzip() is None, 'zipfile testzip of the C zip')
check({i.filename: z.read(i) for i in z.infolist()} == members, 'C zip members')
check(z.getinfo('save/slot_00/progress.sav').compress_type == zipfile.ZIP_DEFLATED, 'deflated member')
check(z.getinfo('manifest.json').compress_type == zipfile.ZIP_STORED, 'small member stored')
# zipfile writes, C reads
def unzip(path):
out = work / 'unz'
shutil.rmtree(out, ignore_errors=True)
out.mkdir()
r = run('unzip', path, out)
if r.returncode:
return r.stdout.strip()
got = {}
for i, line in enumerate(r.stdout.splitlines()):
name, crc, size, is_dir, is_link = line.rsplit('|', 4)
got[name] = ((out / str(i)).read_bytes(), int(crc, 16), int(size), is_dir == '1', is_link == '1')
return got
for how in ('stored', 'deflated', 'descriptors'):
if how == 'descriptors':
stream = NoSeek()
with zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as z:
for n, b in members.items():
with z.open(n, 'w') as f:
f.write(b)
path = work / 'py-desc.zip'
path.write_bytes(bytes(stream.buf))
else:
path = work / f'py-{how}.zip'
with zipfile.ZipFile(path, 'w', zipfile.ZIP_STORED if how == 'stored' else zipfile.ZIP_DEFLATED) as z:
for n, b in members.items():
z.writestr(n, b)
z.writestr('save/folder/', b'')
z.comment = b'a comment the end record carries'
got = unzip(path)
ok = isinstance(got, dict) and all(got[n][0] == b and got[n][1] == zlib.crc32(b) for n, b in members.items())
check(ok, f'C unzip of a zipfile zip ({how}): {str(got)[:200]}')
if how != 'descriptors':
check(isinstance(got, dict) and got['save/folder/'][3], f'folder entry ({how})')
# a link member, as a Unix zip tool stores one
path = work / 'link.zip'
with zipfile.ZipFile(path, 'w') as z:
info = zipfile.ZipInfo('save/link.sav')
info.create_system = 3
info.external_attr = (0o120777) << 16
z.writestr(info, '../../outside')
got = unzip(path)
check(isinstance(got, dict) and got['save/link.sav'][4], 'link marked')
# a damaged member: refused by CRC or inflate
raw = bytearray((work / 'py-deflated.zip').read_bytes())
at = raw.index(b'save/slot_00/progress.sav') + 40
raw[at] ^= 0x21
(work / 'bad.zip').write_bytes(raw)
got = unzip(work / 'bad.zip')
check(isinstance(got, str) and got.splitlines()[-1] == 'refused 2 save/slot_00/progress.sav',
f'damaged zip read: {str(got)[:120]}')
# the launcher imports what the game's menu writes
from launcher import saves
manifest = json.dumps({'format': 'nx2navi-save', 'format_version': 1, 'title_id': '0400486024dec000',
'nx2navi': '0.6.1', 'exported': '2026-11-09T17:30:05Z', 'exported_by': 'game'}).encode()
(work / 'manifest').write_bytes(manifest)
(work / 'opt').write_bytes(b'option')
(work / 'prog').write_bytes(data['save'])
gz = work / 'game.zip'
check(run('zip', gz, 'manifest.json', work / 'manifest', 'save/option.sav', work / 'opt',
'save/slot_03/progress.sav', work / 'prog').returncode == 0, 'C zip of a save')
game = Path(tempfile.mkdtemp(prefix='nx2navi-zipcheck-'))
try:
r = saves.import_save(game, gz, log=lambda m: None)
acct = saves.account_dir(game)
check(r['manifest']['exported_by'] == 'game' and (acct / 'slot_03' / 'progress.sav').read_bytes() ==
data['save'], 'launcher import of the C zip')
finally:
shutil.rmtree(game, ignore_errors=True)
print('save_zip_check:', 'FAILED' if bad else 'ok')
return 1 if bad else 0
if __name__ == '__main__':
sys.exit(main())