mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 17:09:57 -04:00
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>
221 lines
9.4 KiB
Python
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())
|