mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
tools/cheats.py decodes the pc-relative instructions of a code cheat at main's base and held that base as its own constant, 0x7000000000. It equals recomp/layout.py FIRST_BASE today, where main is the first module of ORDER, but a change of the layout would have left the cheats decoding at the old base, and AGENTS.md has recomp/layout.py alone decide module bases. BASE is now layout.FIRST_BASE. cheats.py is part of the mods' lift key (make_game.py mod_digest), so an install with code cheats lifts once more at its next build; an install without them is not affected. Not reproduced before the change: no fault today, the two values being equal. Checks that passed: tests/launcher_test.py (218 tests, a new one in which cheats.BASE follows a changed layout.FIRST_BASE, which fails before the change) and tools/check_content.py --staged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6022 lines
339 KiB
Python
6022 lines
339 KiB
Python
"""launcher/ and tools/make_game.py's bookkeeping, without game files or Windows.
|
|
|
|
Run: python tests/launcher_test.py (Python 3.14 for the zstd tests; the window test needs tkinter, numpy and a display)
|
|
|
|
- versions, settings
|
|
- toolchain: the package database, dependency resolution and an install from a local fake repository
|
|
- net: downloads that resume, check their sha256 and stop on cancel; the certificate checks against a local HTTPS
|
|
server with certificates the openssl command makes (an expired one, an unknown one), the clock comparison
|
|
- feed: GitHub releases (stable, preview, private with a token) and a plain manifest URL, served locally
|
|
- updater: staging a release, switching, rollback, pruning, refusing unsafe archives
|
|
- background updates: a release staged beside the running version without switching to it, the ready marker, a
|
|
staged release found and installed after a restart, no install while the game runs, the install once it exited,
|
|
nothing by itself with Install Updates Automatically off, a failed staging tried again after the next check
|
|
- gamefiles: the .bfotf font wrapping and zstd dictionaries, on a font of this system made into a fake RomFS
|
|
- the button art of a release: the files tools/prompt_art.py copies, and the packages and tools the prompts need
|
|
- make_game.py: a synthetic .nup through the check, exefs and romfs steps with --events, skipped steps on a second
|
|
run, --rebuild without the game files, the shaders key following the translator the build uses (the kit's bin/ or
|
|
a source checkout's build/xlate/), the prompts step with a stand-in prompt tool, the object cache of the compile
|
|
step with the system's C compiler, and its windres call under folders with spaces (a script in windres's place)
|
|
- jobs: an install driven by a fake make_game.py, with and without the prompts row
|
|
- Add to Steam: the binary shortcuts file read and written back unchanged, a shortcut added and updated in place
|
|
- the look: the colour schemes with the Custom one, its colour square and a typed hex code, the background picture's
|
|
fitting below the title band and kept copy, the sound effects (low by default,
|
|
only the game's own select and back sounds, decoded from a made-up .bwav)
|
|
- the window: --selftest draws every screen (skipped without a display)
|
|
"""
|
|
import contextlib
|
|
import hashlib
|
|
import http.server
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import ssl
|
|
import stat
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
import zipfile
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / 'tools'))
|
|
|
|
from launcher import build, feed, gameini, gamefiles, jobs, locks, net, saves, settings, toolchain, updater, versions, winsys # noqa: E402
|
|
from launcher.context import Context # noqa: E402
|
|
from launcher.paths import Layout # noqa: E402
|
|
|
|
try:
|
|
from compression import zstd as ZSTD
|
|
except ImportError:
|
|
ZSTD = None
|
|
|
|
|
|
# ---------------------------------------------------------------- helpers
|
|
|
|
class Server:
|
|
"""A local HTTP server for a folder, with extra routes (path -> (status, headers, body)) and Range support; HTTPS
|
|
with cert (a PEM file holding the certificate and its key). HEAD answers a redirect to https, as a server that
|
|
serves only HTTPS does."""
|
|
|
|
def __init__(self, folder, routes=None, cert=None):
|
|
folder, routes = Path(folder), routes if routes is not None else {}
|
|
self.hits = []
|
|
server = self
|
|
|
|
class H(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def do_HEAD(self):
|
|
server.hits.append((self.path, dict(self.headers)))
|
|
self.send_response(301)
|
|
self.send_header('Location', 'https://127.0.0.1' + self.path)
|
|
self.send_header('Content-Length', '0')
|
|
self.end_headers()
|
|
|
|
def do_GET(self):
|
|
server.hits.append((self.path, dict(self.headers)))
|
|
if self.path in routes:
|
|
status, headers, body = routes[self.path]
|
|
self.send_response(status)
|
|
for k, v in headers.items():
|
|
self.send_header(k, v)
|
|
if 'Content-Length' not in headers:
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
return
|
|
p = folder / self.path.lstrip('/').split('?')[0]
|
|
if not p.is_file():
|
|
self.send_error(404)
|
|
return
|
|
data = p.read_bytes()
|
|
rng = self.headers.get('Range')
|
|
if rng and rng.startswith('bytes='):
|
|
start = int(rng[6:].split('-')[0])
|
|
if start >= len(data):
|
|
self.send_error(416)
|
|
return
|
|
self.send_response(206)
|
|
self.send_header('Content-Range', f'bytes {start}-{len(data) - 1}/{len(data)}')
|
|
data = data[start:]
|
|
else:
|
|
self.send_response(200)
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.end_headers()
|
|
try:
|
|
self.wfile.write(data)
|
|
except ConnectionError: # the client stopped reading (the cancel test)
|
|
pass
|
|
|
|
self.httpd = http.server.ThreadingHTTPServer(('127.0.0.1', 0), H)
|
|
self.url = f'http://127.0.0.1:{self.httpd.server_address[1]}/'
|
|
if cert:
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
ctx.load_cert_chain(cert)
|
|
self.httpd.socket = ctx.wrap_socket(self.httpd.socket, server_side=True)
|
|
self.url = 'https' + self.url[4:]
|
|
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
|
|
def close(self):
|
|
self.httpd.shutdown()
|
|
self.httpd.server_close()
|
|
|
|
|
|
def sha(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def make_cert(folder, start, end):
|
|
"""A self-signed certificate for 127.0.0.1 valid from start to end (epoch seconds), with its key, as one PEM file
|
|
in the new folder; made by the openssl command (its ca command takes any dates, its req command none in the
|
|
past)."""
|
|
folder = Path(folder)
|
|
folder.mkdir(parents=True)
|
|
(folder / 'index.txt').write_text('')
|
|
(folder / 'serial.txt').write_text('01\n')
|
|
(folder / 'ca.cnf').write_text('[ca]\ndefault_ca = d\n[d]\ndatabase = index.txt\nserial = serial.txt\n'
|
|
'new_certs_dir = .\ndefault_md = sha256\npolicy = p\ncopy_extensions = copy\n'
|
|
'unique_subject = no\n[p]\ncommonName = supplied\n')
|
|
def stamp(t):
|
|
return time.strftime('%Y%m%d%H%M%SZ', time.gmtime(t))
|
|
|
|
def run(*args):
|
|
subprocess.run(['openssl', *args], cwd=folder, check=True, capture_output=True)
|
|
|
|
run('req', '-new', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-keyout', 'key.pem',
|
|
'-out', 'req.csr', '-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1')
|
|
run('ca', '-batch', '-config', 'ca.cnf', '-selfsign', '-keyfile', 'key.pem', '-in', 'req.csr', '-out', 'cert.pem',
|
|
'-startdate', stamp(start), '-enddate', stamp(end), '-notext')
|
|
pem = folder / 'both.pem'
|
|
pem.write_bytes((folder / 'cert.pem').read_bytes() + (folder / 'key.pem').read_bytes())
|
|
return pem
|
|
|
|
|
|
class CountingContext(ssl.SSLContext):
|
|
"""A client SSLContext that counts its handshakes."""
|
|
|
|
def wrap_socket(self, *args, **kwargs):
|
|
self.handshakes = getattr(self, 'handshakes', 0) + 1
|
|
return super().wrap_socket(*args, **kwargs)
|
|
|
|
|
|
def client_context(trust=None):
|
|
ctx = CountingContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
if trust:
|
|
ctx.load_verify_locations(cafile=trust)
|
|
else:
|
|
ctx.load_default_certs()
|
|
return ctx
|
|
|
|
|
|
def tar_bytes(files, compress=True):
|
|
"""A tar (zstd-compressed when the stdlib has zstd, else gzip) of {name: bytes}."""
|
|
buf = io.BytesIO()
|
|
mode = 'w:gz' if not (compress and ZSTD) else 'w'
|
|
with tarfile.open(fileobj=buf, mode=mode) as t:
|
|
for name, data in files.items():
|
|
info = tarfile.TarInfo(name)
|
|
info.size = len(data)
|
|
t.addfile(info, io.BytesIO(data))
|
|
data = buf.getvalue()
|
|
return ZSTD.compress(data) if compress and ZSTD else data
|
|
|
|
|
|
def desc(name, version, filename, data, depends=(), provides=()):
|
|
lines = ['%FILENAME%', filename, '', '%NAME%', name, '', '%VERSION%', version, '', '%CSIZE%', str(len(data)), '',
|
|
'%ISIZE%', str(len(data) * 3), '', '%SHA256SUM%', sha(data), '']
|
|
if depends:
|
|
lines += ['%DEPENDS%', *depends, '']
|
|
if provides:
|
|
lines += ['%PROVIDES%', *provides, '']
|
|
return '\n'.join(lines).encode()
|
|
|
|
|
|
def fake_repo(folder, damage=None):
|
|
"""A pacman repository with the ten root packages (each holding one of make_game.py's required files), one
|
|
dependency and one name provided by another package."""
|
|
P = toolchain.PREFIX
|
|
files = {'clang': {'ucrt64/bin/clang.exe': b'clang', 'ucrt64/bin/clang++.exe': b'clang++'},
|
|
'sdl3': {'ucrt64/bin/SDL3.dll': b'sdl', 'ucrt64/lib/libSDL3.dll.a': b'implib'},
|
|
'vulkan-memory-allocator': {'ucrt64/include/vma/vk_mem_alloc.h': b'// vma'},
|
|
'vulkan-loader': {'ucrt64/lib/libvulkan-1.dll.a': b'implib'},
|
|
'llvm-libs': {'ucrt64/bin/libLLVM.dll': b'llvm', 'ucrt64/share/doc/llvm/README': b'skipped'}}
|
|
deps = {'clang': [P + 'llvm-libs=23.1', P + 'gcc'], 'sdl3': [P + 'vulkan']}
|
|
entries = {}
|
|
for root in list(toolchain.ROOTS) + ['llvm-libs']:
|
|
name = P + root
|
|
fn = f'{name}-1.0-1-any.pkg.tar.zst'
|
|
data = tar_bytes({'.PKGINFO': b'pkg', **files.get(root, {f'ucrt64/share/{root}/x': b'x'})})
|
|
if damage == root:
|
|
(folder / fn).write_bytes(data[:-3] + b'bad')
|
|
else:
|
|
(folder / fn).write_bytes(data)
|
|
entries[f'{name}-1.0-1/desc'] = desc(name, '1.0-1', fn, data, deps.get(root, ()),
|
|
[P + 'vulkan'] if root == 'vulkan-loader' else ())
|
|
(folder / 'ucrt64.db').write_bytes(tar_bytes(entries))
|
|
|
|
|
|
def compiled_sleeper(exe, ms, code):
|
|
"""exe compiled from a C program that sleeps ms milliseconds and exits with code, with the ucrt64 clang or a clang
|
|
on PATH; False without one. It stands in for the game under a name that no game runner watches."""
|
|
cc = next((c for c in (r'C:\msys64\ucrt64\bin\clang.exe', shutil.which('clang')) if c and Path(c).is_file()),
|
|
None)
|
|
if not cc:
|
|
return False
|
|
exe = Path(exe)
|
|
exe.parent.mkdir(parents=True, exist_ok=True)
|
|
src = exe.with_suffix('.c')
|
|
src.write_text(f'#include <windows.h>\nint main(void) {{ Sleep({ms}); return {code}; }}\n', encoding='utf-8')
|
|
subprocess.run([cc, '-O1', '-o', str(exe), str(src)], check=True, capture_output=True, timeout=300)
|
|
return True
|
|
|
|
|
|
def fake_fast_repo(folder, clang_version='1.0-1'):
|
|
"""The CLANG64 packages toolchain.FAST_PACKAGES names, in folder/clang64."""
|
|
folder = folder / 'clang64'
|
|
folder.mkdir()
|
|
P = toolchain.FAST_PREFIX
|
|
files = {'clang': {'clang64/bin/clang.exe': b'clang64', 'clang64/bin/clang-repl.exe': b'not needed',
|
|
'clang64/lib/clang/23/include/immintrin.h': b'// x86'},
|
|
'llvm-libs': {'clang64/bin/libLLVM-23.dll': b'llvm', 'clang64/share/doc/x': b'skipped'}}
|
|
entries = {}
|
|
for root in toolchain.FAST_PACKAGES:
|
|
name, ver = P + root, clang_version if root == 'clang' else '1.0-1'
|
|
fn = f'{name}-{ver}-any.pkg.tar.zst'
|
|
data = tar_bytes({'.PKGINFO': b'pkg', **files.get(root, {f'clang64/bin/{root}.dll': b'dll'})})
|
|
(folder / fn).write_bytes(data)
|
|
entries[f'{name}-{ver}/desc'] = desc(name, ver, fn, data, (), ())
|
|
(folder / 'clang64.db').write_bytes(tar_bytes(entries))
|
|
|
|
|
|
class Tmp(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp(prefix='nx2navi-test-'))
|
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
self.layout = Layout(root=self.tmp / 'launcher', app=ROOT).ensure()
|
|
|
|
|
|
# ---------------------------------------------------------------- versions, settings
|
|
|
|
class VersionTest(unittest.TestCase):
|
|
def test_order(self):
|
|
self.assertTrue(versions.newer('0.3.11', '0.3.10'))
|
|
self.assertTrue(versions.newer('0.4', '0.3.99'))
|
|
self.assertFalse(versions.newer('0.3.10+abc', '0.3.10'))
|
|
self.assertTrue(versions.newer('1.0.0', '1.0.0-rc1'))
|
|
self.assertEqual(versions.display('0.3.11+abc1234'), '0.3.11')
|
|
with self.assertRaises(ValueError):
|
|
versions.parse('banana')
|
|
|
|
|
|
# A player's settings file as a report showed it (an AMD card, 0.7.1): what the game's menu wrote, three lines added by
|
|
# hand that are no settings of the game (async_compute, vram_safe_mode, exception_handler: the player took the first for
|
|
# the fix of a crash and asked for it as a default, because "the configuration file gets overwritten on updates"), and a
|
|
# comment. Saved as an editor on Windows saves it: CRLF line ends, no line end after the last line. Nothing in an
|
|
# update may change a byte of it (PlayerIniTest, BackgroundUpdateTest and MakeGameTest each run their part of one).
|
|
PLAYER_INI = (
|
|
b'# my settings\r\n'
|
|
b'[display]\r\n'
|
|
b'async_compute = 1\r\n'
|
|
b'fullscreen = 1\r\n'
|
|
b'window_size = 1920x1080\r\n'
|
|
b'gpu_index = 0\r\n'
|
|
b'perf_overlay = fps\r\n'
|
|
b'vk_present = mailbox\r\n'
|
|
b'upscaler = bilinear\r\n'
|
|
b'dlaa = 1\r\n'
|
|
b'framegen = off\r\n'
|
|
b'taa = fsr3\r\n'
|
|
b'hdr = 0\r\n'
|
|
b'aspect = fit\r\n'
|
|
b'game_res = 1800\r\n'
|
|
b'sharpness = 80\r\n'
|
|
b'aniso = 2\r\n'
|
|
b'assume_focus = 1\r\n'
|
|
b'texture_detail = auto\r\n'
|
|
b'\r\n'
|
|
b'[input]\r\n'
|
|
b'bind_a = key:default, pad:south\r\n'
|
|
b'bind_b = key:default, pad:east\r\n'
|
|
b'bind_x = key:default, pad:north\r\n'
|
|
b'bind_y = key:default, pad:west\r\n'
|
|
b'\r\n'
|
|
b'[language]\r\n'
|
|
b'operation_mode = docked\r\n'
|
|
b'\r\n'
|
|
b'[performance]\r\n'
|
|
b'wb_lazy = 0\r\n'
|
|
b'vram_safe_mode = 1\r\n'
|
|
b'exception_handler = 1\r\n'
|
|
b'\r\n'
|
|
b'[frame rate]\r\n'
|
|
b'fps_limit = 120')
|
|
|
|
|
|
class GameIniTest(Tmp):
|
|
"""The game settings the launcher switches (launcher/gameini.py), read as runtime/cfg.c reads nx2navi.ini."""
|
|
|
|
def test_new_file_gets_the_section(self):
|
|
gameini.put(self.tmp, 'discord', 'discord_rpc', '1')
|
|
self.assertEqual((self.tmp / 'nx2navi.ini').read_bytes(), b'[discord]\ndiscord_rpc = 1\n')
|
|
self.assertEqual(gameini.get(self.tmp, 'discord_rpc'), '1')
|
|
|
|
def test_only_the_key_line_changes(self):
|
|
# the byte order mark, the line ends and a line that is not UTF-8 stay as they were
|
|
ini = self.tmp / 'nx2navi.ini'
|
|
ini.write_bytes(b'\xef\xbb\xbf[display]\r\nfullscreen = 1\r\n# caf\xe9\r\n# discord_rpc = 1\r\n[discord]\r\n'
|
|
b'NX2NAVI_DISCORD_RPC = "1"\r\ndiscord_app_id = 42')
|
|
self.assertEqual(gameini.get(self.tmp, 'DISCORD_RPC'), '1') # quotes, prefix and case as the game reads them
|
|
gameini.put(self.tmp, 'discord', 'discord_rpc', '0')
|
|
self.assertEqual(ini.read_bytes(), b'\xef\xbb\xbf[display]\r\nfullscreen = 1\r\n# caf\xe9\r\n# discord_rpc = 1\r\n'
|
|
b'[discord]\r\ndiscord_rpc = 0\r\ndiscord_app_id = 42')
|
|
self.assertEqual(gameini.get(self.tmp, 'discord_rpc'), '0')
|
|
self.assertFalse((self.tmp / 'nx2navi.ini.tmp').exists())
|
|
|
|
def test_missing_key_goes_under_its_section(self):
|
|
ini = self.tmp / 'nx2navi.ini'
|
|
ini.write_bytes(b'[discord]\ndiscord_app_id = 42\n[display]\nfullscreen = 1')
|
|
self.assertEqual(gameini.get(self.tmp, 'discord_rpc'), '')
|
|
gameini.put(self.tmp, 'discord', 'discord_rpc', '1')
|
|
self.assertEqual(ini.read_bytes(), b'[discord]\ndiscord_rpc = 1\ndiscord_app_id = 42\n[display]\nfullscreen = 1')
|
|
|
|
def test_an_empty_line_leaves_the_earlier_value(self):
|
|
# runtime/cfg.c: "key =" leaves the setting as it was, so the game keeps 0 here, and a change goes to that line
|
|
ini = self.tmp / 'nx2navi.ini'
|
|
ini.write_bytes(b'discord_rpc = 0\ndiscord_rpc =\n')
|
|
self.assertEqual(gameini.get(self.tmp, 'discord_rpc'), '0')
|
|
gameini.put(self.tmp, 'discord', 'discord_rpc', '1')
|
|
self.assertEqual(ini.read_bytes(), b'discord_rpc = 1\ndiscord_rpc =\n')
|
|
|
|
def test_an_unreadable_file_is_not_rewritten(self):
|
|
(self.tmp / 'nx2navi.ini').mkdir() # there, but not a file that can be read
|
|
with self.assertRaises(OSError):
|
|
gameini.get(self.tmp, 'discord_rpc')
|
|
with self.assertRaises(OSError):
|
|
gameini.put(self.tmp, 'discord', 'discord_rpc', '1')
|
|
self.assertTrue((self.tmp / 'nx2navi.ini').is_dir())
|
|
|
|
class PlayerIniTest(Tmp):
|
|
"""The settings file of a player (PLAYER_INI) under the two writers outside the game: the launcher's Settings
|
|
(launcher/gameini.py) and the older installer's last step (installer/ini.py). Each changes the one line it is
|
|
for; the lines that are no settings of the game, the comment and every other byte stay."""
|
|
|
|
def test_the_launchers_setting_adds_one_line(self):
|
|
ini = self.tmp / 'nx2navi.ini'
|
|
ini.write_bytes(PLAYER_INI)
|
|
self.assertEqual(gameini.get(self.tmp, 'wb_lazy'), '0')
|
|
self.assertEqual(gameini.get(self.tmp, 'async_compute'), '1') # read like any line; the game ignores it
|
|
gameini.put(self.tmp, 'discord', 'discord_rpc', '0')
|
|
self.assertEqual(ini.read_bytes(), PLAYER_INI + b'\r\n\r\n[discord]\r\ndiscord_rpc = 0\r\n')
|
|
gameini.put(self.tmp, 'discord', 'discord_rpc', '1')
|
|
self.assertEqual(ini.read_bytes(), PLAYER_INI + b'\r\n\r\n[discord]\r\ndiscord_rpc = 1\r\n')
|
|
|
|
def test_the_older_installer_changes_the_language_line_only(self):
|
|
"""installer/ini.py sets the language, in the older installer's last step at every install and update: the
|
|
one line goes under [language] with the file's own line ends, and every other byte stays (it used to write
|
|
the whole file again with LF line ends, a line end after the last line and no byte order mark). A second run
|
|
leaves the file alone; a changed language changes that line; a byte order mark and a line that is not UTF-8
|
|
stay too."""
|
|
from installer import ini as inst_ini
|
|
path = self.tmp / 'nx2navi.ini'
|
|
path.write_bytes(PLAYER_INI)
|
|
inst_ini.write(self.tmp, 'en-US')
|
|
want = PLAYER_INI.replace(b'[language]\r\n', b'[language]\r\nlang = en-US\r\n')
|
|
self.assertEqual(path.read_bytes(), want)
|
|
self.assertEqual(want.count(b'lang = en-US'), 1)
|
|
stamp = path.stat().st_mtime_ns
|
|
inst_ini.write(self.tmp, 'en-US')
|
|
self.assertEqual((path.read_bytes(), path.stat().st_mtime_ns), (want, stamp))
|
|
inst_ini.write(self.tmp, 'de')
|
|
self.assertEqual(path.read_bytes(), want.replace(b'lang = en-US', b'lang = de'))
|
|
marked = b'\xef\xbb\xbf# caf\xe9\r\nlang = ja\r\nasync_compute = 1'
|
|
path.write_bytes(marked)
|
|
inst_ini.write(self.tmp, 'fr')
|
|
self.assertEqual(path.read_bytes(), marked.replace(b'lang = ja', b'lang = fr'))
|
|
self.assertFalse((self.tmp / 'nx2navi.ini.tmp').exists())
|
|
|
|
|
|
class RebuildArgsTest(Tmp):
|
|
"""Rebuild the Game compiles the game's code for this processor's level (--retune); an update keeps the level its
|
|
objects were compiled for, so that it compiles only what changed (tools/make_game.py lifted_level)."""
|
|
|
|
def test_retune_only_for_rebuild_the_game(self):
|
|
self.assertIn('--retune', build.rebuild_args(self.layout, 'G:/game', True))
|
|
self.assertNotIn('--retune', build.rebuild_args(self.layout, 'G:/game'))
|
|
self.assertTrue(jobs.RebuildJob.retune)
|
|
self.assertFalse(jobs.ContinueUpdateJob.retune)
|
|
|
|
|
|
class SettingsTest(Tmp):
|
|
def test_roundtrip_and_reset(self):
|
|
s = settings.Settings(self.layout.settings)
|
|
self.assertTrue(s['auto_check'])
|
|
s['auto_check'], s['game_dir'], s['channel'] = False, 'D:/g', 'preview'
|
|
s.save()
|
|
t = settings.Settings(self.layout.settings)
|
|
self.assertEqual((t['auto_check'], t['game_dir'], t['channel']), (False, 'D:/g', 'preview'))
|
|
t.reset()
|
|
self.assertEqual((t['auto_check'], t['game_dir'], t['channel']), (True, 'D:/g', 'stable'))
|
|
|
|
def test_defaults(self):
|
|
# the Settings page of a new install, and what Default Settings goes back to: updates checked at start, the
|
|
# launcher hidden while playing, the build cache kept (an update recompiles only what changed), Stable
|
|
chosen = ('auto_check', 'hide_while_playing', 'keep_cache', 'channel')
|
|
want = (True, True, True, 'stable')
|
|
s = settings.Settings(self.layout.settings)
|
|
self.assertEqual(tuple(s[k] for k in chosen), want)
|
|
s.update(auto_check=False, hide_while_playing=False, keep_cache=False, channel='preview')
|
|
s.save()
|
|
t = settings.Settings(self.layout.settings) # a player's own choices stay across starts and updates
|
|
self.assertEqual(tuple(t[k] for k in chosen), (False, False, False, 'preview'))
|
|
t.reset()
|
|
self.assertEqual(tuple(t[k] for k in chosen), want)
|
|
|
|
def test_bad_types_ignored(self):
|
|
self.layout.settings.write_text(json.dumps({'auto_check': 'yes', 'channel': 'preview', 'junk': 1}))
|
|
s = settings.Settings(self.layout.settings)
|
|
self.assertEqual((s['auto_check'], s['channel']), (True, 'preview'))
|
|
self.assertNotIn('junk', s)
|
|
|
|
def test_bom_and_typo(self):
|
|
# a file saved by an editor with a BOM reads as written; one with a typo keeps a copy before a save replaces it
|
|
self.layout.settings.write_bytes(b'\xef\xbb\xbf' + json.dumps({'game_dir': 'D:/g'}).encode())
|
|
s = settings.Settings(self.layout.settings)
|
|
self.assertEqual(s['game_dir'], 'D:/g')
|
|
self.assertFalse(s.unreadable)
|
|
typo = '{"game_dir": "D:/g", "github_token": "t",}'
|
|
self.layout.settings.write_text(typo, encoding='utf-8')
|
|
s = settings.Settings(self.layout.settings)
|
|
self.assertTrue(s.unreadable)
|
|
self.assertEqual(s['game_dir'], '')
|
|
s.save()
|
|
self.assertEqual(self.layout.settings.with_name('launcher.json.bad').read_text(encoding='utf-8'), typo)
|
|
|
|
|
|
class UninstallTest(Tmp):
|
|
def tree(self, root, rels):
|
|
for rel in rels:
|
|
p = root / rel
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text('x')
|
|
|
|
def test_plan_keeps_the_game_and_what_is_not_ours(self):
|
|
from types import SimpleNamespace
|
|
root = self.tmp / 'NX2navi'
|
|
self.tree(root, ('NX2navi.exe', 'app/current.txt', 'python.old-3/x', 'game/profile/save/1/a.sav', 'mine.txt'))
|
|
layout = SimpleNamespace(root=root, toolchain=root / 'toolchain')
|
|
gone, empty = winsys.uninstall_plan(layout)
|
|
self.assertNotIn(root / 'game', gone)
|
|
self.assertNotIn(root, gone)
|
|
self.assertIn(root / 'python.old-3', gone)
|
|
self.assertEqual(empty, [root])
|
|
gone, _ = winsys.uninstall_plan(layout, str(root / 'game'))
|
|
self.assertEqual(gone[0], root / 'game')
|
|
# the toolchain moved to C:\NX2navi: that folder goes only when nothing else is left in it
|
|
other = self.tmp / 'C NX2navi' / 'toolchain'
|
|
gone, empty = winsys.uninstall_plan(SimpleNamespace(root=root, toolchain=other))
|
|
self.assertIn(other, gone)
|
|
self.assertNotIn(other.parent, gone)
|
|
self.assertEqual(empty, [root, other.parent])
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'runs the cmd.exe script')
|
|
def test_script_on_windows(self):
|
|
from types import SimpleNamespace
|
|
root = self.tmp / 'NX2navi ÿ x' # spaces and a character outside ASCII
|
|
self.tree(root, ('NX2navi.exe', 'app/1/VERSION', 'python/pythonw.exe', 'python.old-12/x', 'pydeps/z',
|
|
'data/launcher.json', 'toolchain/msys64/a', 'game/profile/save/1/a.sav', 'mine.txt'))
|
|
layout = SimpleNamespace(root=root, toolchain=root / 'toolchain')
|
|
script = winsys.uninstall_script(*winsys.uninstall_plan(layout)).replace('ping -n 3 127.0.0.1 >nul', 'echo')
|
|
subprocess.run(f'cmd.exe /c "{script}"', capture_output=True)
|
|
self.assertEqual(sorted(p.name for p in root.iterdir()), ['game', 'mine.txt'])
|
|
self.assertTrue((root / 'game/profile/save/1/a.sav').is_file())
|
|
(root / 'mine.txt').unlink()
|
|
script = winsys.uninstall_script(*winsys.uninstall_plan(layout, str(root / 'game')))
|
|
subprocess.run(f'cmd.exe /c "{script.replace("ping -n 3 127.0.0.1 >nul", "echo")}"', capture_output=True)
|
|
self.assertFalse(root.exists())
|
|
|
|
def test_script_leaves_out_percent_paths(self):
|
|
script = winsys.uninstall_script([Path('C:/%HOMEPATH%'), Path('C:/x/a')], [Path('C:/%TEMP%/b')])
|
|
self.assertNotIn('%', script)
|
|
self.assertIn('a', script)
|
|
|
|
|
|
# ---------------------------------------------------------------- toolchain
|
|
|
|
class ToolchainTest(Tmp):
|
|
def test_zstd_missing(self):
|
|
"""A Python without compression.zstd and zstandard is named at startup, with the pip line for a checkout."""
|
|
with mock.patch.dict(sys.modules, {'compression.zstd': None, 'zstandard': mock.MagicMock()}):
|
|
self.assertEqual(toolchain.zstd_missing(self.layout), '')
|
|
with mock.patch.dict(sys.modules, {'compression.zstd': None, 'zstandard': None}):
|
|
msg = toolchain.zstd_missing(self.layout)
|
|
ctx = Context(self.layout)
|
|
self.assertIn('zstandard', msg)
|
|
self.assertIn('-m pip install -r', msg)
|
|
self.assertIn('requirements-launcher.txt', msg)
|
|
self.assertEqual(ctx.zstd_missing, msg)
|
|
self.assertTrue(any(msg in line for line in ctx.log_tail()))
|
|
|
|
def test_resolve_and_install(self):
|
|
repo = self.tmp / 'repo'
|
|
repo.mkdir()
|
|
fake_repo(repo)
|
|
srv = Server(repo)
|
|
self.addCleanup(srv.close)
|
|
lock = toolchain.make_lock(srv.url)
|
|
names = [p['name'] for p in lock['packages']]
|
|
self.assertEqual(names[0], toolchain.PREFIX + 'clang')
|
|
self.assertIn(toolchain.PREFIX + 'llvm-libs', names) # a dependency with a version constraint
|
|
self.assertEqual(names.count(toolchain.PREFIX + 'vulkan-loader'), 1) # 'vulkan' resolves to its provider
|
|
self.assertFalse(toolchain.is_current(self.layout, lock))
|
|
phases = []
|
|
toolchain.install(self.layout, lock, progress=lambda ph, d, t: phases.append(ph), log=lambda m: None)
|
|
self.assertTrue(toolchain.is_current(self.layout, lock))
|
|
self.assertTrue((self.layout.msys / 'bin' / 'clang.exe').is_file())
|
|
self.assertFalse((self.layout.msys / 'share' / 'doc').exists()) # documentation left out
|
|
self.assertIn('download', phases)
|
|
self.assertIn('unpack', phases)
|
|
hits = len(srv.hits)
|
|
toolchain.install(self.layout, lock, log=lambda m: None) # cached package files: no download
|
|
self.assertEqual(len(srv.hits), hits)
|
|
self.assertEqual(toolchain.lock_digest(lock), toolchain.installed(self.layout)['digest'])
|
|
|
|
def test_fast_clang_beside(self):
|
|
"""CLANG64's clang goes into msys64/clang64 when its version matches UCRT64's: clang.exe, the DLLs and its
|
|
headers only."""
|
|
repo = self.tmp / 'repo'
|
|
repo.mkdir()
|
|
fake_repo(repo)
|
|
fake_fast_repo(repo)
|
|
srv = Server(repo)
|
|
self.addCleanup(srv.close)
|
|
lock = toolchain.make_lock(srv.url, fast_repo=srv.url + 'clang64/')
|
|
self.assertEqual(lock.get('fast_repo'), srv.url + 'clang64/')
|
|
self.assertEqual(sum(p.get('env') == 'clang64' for p in lock['packages']), len(toolchain.FAST_PACKAGES))
|
|
toolchain.install(self.layout, lock, log=lambda m: None)
|
|
fast = self.layout.msys.parent / 'clang64'
|
|
self.assertEqual((fast / 'bin' / 'clang.exe').read_bytes(), b'clang64')
|
|
self.assertTrue((fast / 'bin' / 'libLLVM-23.dll').is_file())
|
|
self.assertTrue((fast / 'lib' / 'clang' / '23' / 'include' / 'immintrin.h').is_file())
|
|
self.assertFalse((fast / 'bin' / 'clang-repl.exe').exists())
|
|
self.assertFalse((fast / 'share').exists())
|
|
self.assertTrue(toolchain.is_current(self.layout, lock))
|
|
|
|
def test_fast_clang_left_out_on_another_version(self):
|
|
repo = self.tmp / 'repo'
|
|
repo.mkdir()
|
|
fake_repo(repo)
|
|
fake_fast_repo(repo, clang_version='2.0-1')
|
|
srv = Server(repo)
|
|
self.addCleanup(srv.close)
|
|
lock = toolchain.make_lock(srv.url, fast_repo=srv.url + 'clang64/')
|
|
self.assertNotIn('fast_repo', lock)
|
|
self.assertIn('2.0-1', lock['fast_skipped'])
|
|
self.assertFalse(any(p.get('env') for p in lock['packages']))
|
|
|
|
def test_damaged_package_refused(self):
|
|
repo = self.tmp / 'repo'
|
|
repo.mkdir()
|
|
fake_repo(repo, damage='opus')
|
|
srv = Server(repo)
|
|
self.addCleanup(srv.close)
|
|
lock = toolchain.make_lock(srv.url)
|
|
with self.assertRaises(toolchain.ToolchainError):
|
|
toolchain.install(self.layout, lock, log=lambda m: None)
|
|
self.assertFalse(toolchain.is_current(self.layout, lock))
|
|
|
|
def test_wheel_tag(self):
|
|
tag = toolchain.wheel_tag()[0]
|
|
self.assertTrue(tag.startswith(f'cp{sys.version_info.major}{sys.version_info.minor}-'))
|
|
|
|
|
|
# ---------------------------------------------------------------- net
|
|
|
|
class NetTest(Tmp):
|
|
def test_get_bytes_retries_a_cut_connection(self):
|
|
"""A connection reset (WinError 10054 during Gather Build Tools) or a server error is tried again; a 404 is not."""
|
|
import urllib.error
|
|
calls = []
|
|
|
|
def flaky(url, headers=None, timeout=30, version='dev'):
|
|
calls.append(url)
|
|
if len(calls) == 1:
|
|
raise ConnectionResetError(10054, 'An existing connection was forcibly closed by the remote host')
|
|
if len(calls) == 2:
|
|
raise urllib.error.HTTPError(url, 503, 'busy', {}, None)
|
|
return io.BytesIO(b'{"ok": 1}')
|
|
|
|
with mock.patch.object(net, 'request', flaky), mock.patch.object(net.time, 'sleep') as slept:
|
|
self.assertEqual(net.get_json('https://example.invalid/x.json'), {'ok': 1})
|
|
self.assertEqual(len(calls), 3)
|
|
self.assertEqual(slept.call_count, 2)
|
|
|
|
def missing(url, headers=None, timeout=30, version='dev'):
|
|
calls.append(url)
|
|
raise urllib.error.HTTPError(url, 404, 'not found', {}, None)
|
|
|
|
calls.clear()
|
|
with mock.patch.object(net, 'request', missing), mock.patch.object(net.time, 'sleep'):
|
|
with self.assertRaises(urllib.error.HTTPError):
|
|
net.get_bytes('https://example.invalid/missing')
|
|
self.assertEqual(len(calls), 1)
|
|
|
|
def down(url, headers=None, timeout=30, version='dev'):
|
|
raise ConnectionResetError(10054, 'reset')
|
|
|
|
with mock.patch.object(net, 'request', down), mock.patch.object(net.time, 'sleep'):
|
|
with self.assertRaises(ConnectionResetError): # every try failed: the last error goes up
|
|
net.get_bytes('https://example.invalid/down')
|
|
|
|
def test_early_eof_retries_metadata_and_resumes_file(self):
|
|
data = b'{"build":123}'
|
|
(self.tmp / 'asset').write_bytes(data)
|
|
cut = (200, {'Content-Length': str(len(data))}, data[:7])
|
|
routes = {'/asset': cut}
|
|
srv = Server(self.tmp, routes)
|
|
self.addCleanup(srv.close)
|
|
url = srv.url + 'asset'
|
|
with mock.patch.object(net.time, 'sleep', side_effect=lambda _: routes.clear()):
|
|
self.assertEqual(net.get_json(url), {'build': 123})
|
|
routes['/asset'] = cut
|
|
dest = self.tmp / 'download'
|
|
net.download(url, dest, sha256=sha(data), size=len(data))
|
|
self.assertEqual(dest.read_bytes(), data)
|
|
self.assertEqual([h.get('Range') for _, h in srv.hits], [None, None, None, 'bytes=7-'])
|
|
|
|
def test_download_resume_and_checks(self):
|
|
data = os.urandom(3 * net.CHUNK + 123)
|
|
(self.tmp / 'f.bin').write_bytes(data)
|
|
srv = Server(self.tmp)
|
|
self.addCleanup(srv.close)
|
|
dest = self.tmp / 'out' / 'f.bin'
|
|
dest.parent.mkdir()
|
|
(dest.parent / 'f.bin.part').write_bytes(data[:1000]) # a part file from a cut connection
|
|
net.download(srv.url + 'f.bin', dest, sha256=sha(data), size=len(data))
|
|
self.assertEqual(dest.read_bytes(), data)
|
|
self.assertEqual(srv.hits[-1][1].get('Range'), 'bytes=1000-')
|
|
with self.assertRaises(net.DownloadError):
|
|
net.download(srv.url + 'f.bin', self.tmp / 'out' / 'g.bin', sha256='0' * 64)
|
|
self.assertFalse((self.tmp / 'out' / 'g.bin').exists())
|
|
with self.assertRaises(net.Cancelled):
|
|
net.download(srv.url + 'f.bin', self.tmp / 'out' / 'h.bin', cancel=lambda: True)
|
|
with self.assertRaises(net.DownloadError):
|
|
net.download(srv.url + 'missing.bin', self.tmp / 'out' / 'm.bin')
|
|
|
|
def test_the_download_total(self):
|
|
"""progress gets the whole file's size as its total: the expected size, else the length the server names
|
|
plus what the part file holds, else the bytes so far. A resumed answer without a length gave the part
|
|
file's size as the total, so the bar passed 100 %."""
|
|
data = os.urandom(2 * net.CHUNK + 500)
|
|
have = 1000
|
|
|
|
class Answer(io.BytesIO): # what net.request returns: the rest of the file after a Range request
|
|
status = 206
|
|
length = None
|
|
|
|
def __init__(self, headers):
|
|
super().__init__(data[have:])
|
|
self.headers = headers
|
|
|
|
def fetch(headers, **kw):
|
|
dest = self.tmp / 'total' / 'f.bin'
|
|
shutil.rmtree(dest.parent, ignore_errors=True)
|
|
dest.parent.mkdir()
|
|
dest.with_name('f.bin.part').write_bytes(data[:have])
|
|
seen = []
|
|
with mock.patch.object(net, 'request', lambda url, h, timeout, version: Answer(headers)):
|
|
net.download('https://example.invalid/f.bin', dest, progress=lambda d, t: seen.append((d, t)), **kw)
|
|
self.assertEqual(dest.read_bytes(), data)
|
|
self.assertEqual(seen[-1][0], len(data))
|
|
return seen
|
|
|
|
for d, t in fetch({}): # no length, no expected size
|
|
self.assertEqual(t, d) # (the part's 1000 bytes as the total before)
|
|
for d, t in fetch({'Content-Length': str(len(data) - have)}): # the length of the rest
|
|
self.assertEqual(t, len(data))
|
|
for d, t in fetch({}, size=len(data)): # the expected size, whatever the server says
|
|
self.assertEqual(t, len(data))
|
|
for d, t in fetch({'Content-Length': '7'}, size=len(data)):
|
|
self.assertEqual(t, len(data))
|
|
|
|
def test_a_part_file_of_another_download_is_not_resumed(self):
|
|
"""A part file is resumed only for the file it was begun for (f.bin.part.of names it by the expected sha256).
|
|
One begun for other bytes under the same name, as when a release is published again, is deleted with a log
|
|
line; resumed, it ended in a sha256 error that read as a damaged download. The same file from another
|
|
server and a part without a note are resumed."""
|
|
old, new = os.urandom(6000), os.urandom(6000)
|
|
(self.tmp / 'f.bin').write_bytes(new)
|
|
srv = Server(self.tmp)
|
|
self.addCleanup(srv.close)
|
|
dest = self.tmp / 'out' / 'f.bin'
|
|
dest.parent.mkdir()
|
|
part, note = dest.with_name('f.bin.part'), dest.with_name('f.bin.part.of')
|
|
part.write_bytes(old[:2000]) # cut while the old release downloaded
|
|
note.write_text(f'sha256 {sha(old)}')
|
|
lines = []
|
|
net.download(srv.url + 'f.bin', dest, sha256=sha(new), size=len(new), log=lines.append)
|
|
self.assertEqual(dest.read_bytes(), new)
|
|
self.assertIsNone(srv.hits[-1][1].get('Range')) # from the start
|
|
self.assertEqual(len(lines), 1)
|
|
self.assertIn('f.bin.part was begun for another file', lines[0])
|
|
self.assertFalse(part.exists() or note.exists()) # nothing stays beside the finished file
|
|
dest.unlink()
|
|
part.write_bytes(new[:2000]) # cut during this same file, on another server
|
|
note.write_text(f'sha256 {sha(new)}')
|
|
other = srv.url.replace('127.0.0.1', 'localhost')
|
|
net.download(other + 'f.bin', dest, sha256=sha(new), size=len(new), log=lines.append)
|
|
self.assertEqual(srv.hits[-1][1].get('Range'), 'bytes=2000-')
|
|
self.assertEqual((dest.read_bytes(), len(lines)), (new, 1))
|
|
dest.unlink()
|
|
part.write_bytes(new[:3000]) # left by a launcher that wrote no note
|
|
net.download(srv.url + 'f.bin', dest, sha256=sha(new), size=len(new), log=lines.append)
|
|
self.assertEqual(srv.hits[-1][1].get('Range'), 'bytes=3000-')
|
|
self.assertEqual(dest.read_bytes(), new)
|
|
with self.assertRaises(net.Cancelled): # a cut download says what its part is of
|
|
net.download(srv.url + 'f.bin', self.tmp / 'out' / 'c.bin', sha256=sha(new), cancel=lambda: True)
|
|
self.assertEqual((self.tmp / 'out' / 'c.bin.part.of').read_text(), f'sha256 {sha(new)}')
|
|
self.layout.downloads.mkdir(parents=True, exist_ok=True)
|
|
(self.layout.downloads / 'x.zip.part.of').write_text('sha256 0')
|
|
updater.prune(self.layout) # and a note left behind is cleared away
|
|
self.assertEqual(list(self.layout.downloads.iterdir()), [])
|
|
|
|
def low_disk(self, free):
|
|
"""shutil.disk_usage as net sees it, answering with free[0] bytes free."""
|
|
import collections
|
|
usage = collections.namedtuple('usage', 'total used free')
|
|
return mock.patch.object(net.shutil, 'disk_usage', lambda path: usage(10 ** 12, 0, free[0]))
|
|
|
|
def test_the_free_space_is_asked_before_a_download(self):
|
|
"""A download whose rest does not fit the disk stops before a byte is written, says how much is needed and
|
|
how much is free, and keeps its part file for when there is room; a disk that fills in the middle is named
|
|
as full at once instead of being tried four times."""
|
|
data = os.urandom(5000)
|
|
(self.tmp / 'f.bin').write_bytes(data)
|
|
srv = Server(self.tmp)
|
|
self.addCleanup(srv.close)
|
|
dest = self.tmp / 'out' / 'f.bin'
|
|
dest.parent.mkdir()
|
|
part = dest.with_name('f.bin.part')
|
|
part.write_bytes(data[:1000])
|
|
free = [40 << 20]
|
|
with self.low_disk(free):
|
|
self.assertEqual(net.space_problem(dest.parent, 1 << 20, 'x'), '') # 1 MB and the margin fit
|
|
self.assertEqual(net.space_problem(self.tmp / 'not' / 'made' / 'yet', 1 << 20, 'x'), '')
|
|
with self.assertRaises(net.NoSpace) as refused: # the expected size is known:
|
|
net.download(srv.url + 'f.bin', dest, sha256=sha(data), size=64 << 20) # no request is made
|
|
self.assertEqual(srv.hits, [])
|
|
self.assertIn('no room for f.bin on ', str(refused.exception))
|
|
self.assertIn('MB needed, 42 MB free', str(refused.exception))
|
|
self.assertEqual(part.read_bytes(), data[:1000])
|
|
free[0] = 1 << 20
|
|
with self.assertRaises(net.NoSpace): # else the server's length is
|
|
net.download(srv.url + 'f.bin', dest, sha256=sha(data))
|
|
self.assertEqual(len(srv.hits), 1)
|
|
self.assertEqual(part.read_bytes(), data[:1000]) # kept for when there is room
|
|
free[0] = 1 << 30
|
|
net.download(srv.url + 'f.bin', dest, sha256=sha(data), size=len(data))
|
|
self.assertEqual(dest.read_bytes(), data)
|
|
self.assertEqual(srv.hits[-1][1].get('Range'), 'bytes=1000-')
|
|
|
|
class Full(io.BytesIO): # an answer whose bytes cannot be written: the disk filled meanwhile
|
|
status, length, headers = 200, None, {}
|
|
|
|
def read(self, n=-1):
|
|
raise OSError(28, 'No space left on device')
|
|
|
|
with mock.patch.object(net, 'request', lambda url, h, timeout, version: Full()), \
|
|
mock.patch.object(net.time, 'sleep') as slept, self.assertRaises(net.NoSpace) as refused:
|
|
net.download('https://example.invalid/g.bin', self.tmp / 'out' / 'g.bin')
|
|
slept.assert_not_called()
|
|
self.assertIn('is full: g.bin could not be written', str(refused.exception))
|
|
with mock.patch.object(net.shutil, 'disk_usage', side_effect=OSError('no such drive')):
|
|
self.assertEqual(net.space_problem(dest.parent, 1 << 40, 'x'), '') # cannot be asked: not refused
|
|
|
|
def test_the_free_space_is_asked_before_an_unpack(self):
|
|
z = make_app_zip(self.tmp, '0.9.0', extra={'big.bin': bytes(3 << 20)})
|
|
free = [2 << 20]
|
|
with self.low_disk(free):
|
|
with self.assertRaises(updater.UpdateError) as refused:
|
|
updater.safe_extract(z, self.tmp / 'x')
|
|
self.assertIn('no room for the files of NX2navi-app-0.9.0.zip', str(refused.exception))
|
|
self.assertFalse((self.tmp / 'x').exists()) # nothing was unpacked
|
|
from launcher import mods
|
|
with self.assertRaises(mods.ModError) as refused:
|
|
mods.unpack(z, self.tmp / 'mod')
|
|
self.assertIn('no room for its files', str(refused.exception))
|
|
self.assertFalse((self.tmp / 'mod').exists())
|
|
lock = {'packages': [{'name': 'p', 'version': '1', 'filename': 'p.pkg.tar.zst', 'sha256': '0' * 64,
|
|
'csize': 5 << 20, 'isize': 50 << 20}]}
|
|
with mock.patch.object(net, 'download') as fetched, self.assertRaises(toolchain.ToolchainError) as refused:
|
|
toolchain.install(self.layout, lock, log=lambda line: None)
|
|
fetched.assert_not_called() # asked before the first download
|
|
self.assertIn('no room for the build tools', str(refused.exception))
|
|
self.assertIn('(91 MB needed, 2 MB free)', str(refused.exception)) # 5 + 50 MiB and the margin
|
|
free[0] = 1 << 30
|
|
updater.safe_extract(z, self.tmp / 'x')
|
|
self.assertEqual((self.tmp / 'x' / 'big.bin').stat().st_size, 3 << 20)
|
|
|
|
def test_plain_http_redirects_and_oversize(self):
|
|
with self.assertRaises(net.DownloadError): # refused before any connection
|
|
net.request('http://example.invalid/f.bin')
|
|
(self.tmp / 'f.bin').write_bytes(b'0123456789')
|
|
routes = {}
|
|
srv = Server(self.tmp, routes)
|
|
self.addCleanup(srv.close)
|
|
other = srv.url.replace('127.0.0.1', 'localhost') # the same server under another host name
|
|
routes['/same'] = (302, {'Location': srv.url + 'f.bin'}, b'')
|
|
routes['/away'] = (302, {'Location': other + 'f.bin'}, b'')
|
|
token = {'Authorization': 'Bearer secret'}
|
|
self.assertEqual(net.get_bytes(srv.url + 'same', token), b'0123456789')
|
|
self.assertEqual(srv.hits[-1][1].get('Authorization'), 'Bearer secret')
|
|
self.assertEqual(net.get_bytes(srv.url + 'away', token), b'0123456789')
|
|
self.assertEqual(srv.hits[-2][1].get('Authorization'), 'Bearer secret')
|
|
self.assertIsNone(srv.hits[-1][1].get('Authorization')) # not sent on to another host
|
|
dest = self.tmp / 'out' / 'o.bin'
|
|
with self.assertRaises(net.DownloadError): # more than the expected size
|
|
net.download(srv.url + 'f.bin', dest, size=4)
|
|
self.assertFalse(dest.exists() or dest.with_name('o.bin.part').exists())
|
|
|
|
def test_server_clock(self):
|
|
srv = Server(self.tmp)
|
|
self.addCleanup(srv.close)
|
|
t = net.server_clock(srv.url) # the Date of the redirect to https, which is not followed
|
|
self.assertIsNotNone(t)
|
|
self.assertLess(abs(t - time.time()), 5)
|
|
self.assertEqual([h[0] for h in srv.hits], ['/'])
|
|
srv.close()
|
|
self.assertIsNone(net.server_clock(srv.url, timeout=2))
|
|
|
|
|
|
@unittest.skipUnless(shutil.which('openssl'), 'needs the openssl command')
|
|
class CertificateTest(Tmp):
|
|
def setUp(self):
|
|
super().setUp()
|
|
saved = net._CHECKS, dict(net._ACCEPTED), net.server_clock
|
|
self.addCleanup(lambda: setattr(net, '_CHECKS', saved[0]) or setattr(net, '_ACCEPTED', saved[1]) or
|
|
setattr(net, 'server_clock', saved[2]))
|
|
net._ACCEPTED = {}
|
|
self.data = os.urandom(100_000)
|
|
(self.tmp / 'f.bin').write_bytes(self.data)
|
|
|
|
def serve(self, start, end):
|
|
cert = make_cert(self.tmp / f'cert{start}', start, end)
|
|
srv = Server(self.tmp, cert=cert)
|
|
self.addCleanup(srv.close)
|
|
return srv, cert
|
|
|
|
def test_refused_certificate(self):
|
|
"""An expired certificate in the trust store: the player's 'certificate has expired', at once, not retried,
|
|
with the advice the server's clock calls for."""
|
|
now = time.time()
|
|
srv, cert = self.serve(now - 400 * 86400, now - 300 * 86400)
|
|
check = client_context(trust=cert)
|
|
net._CHECKS = [("Python's check", check)]
|
|
asked = []
|
|
cases = ((now + 400 * 86400, "This PC's clock reads"), (now - 3 * 86400, 'set the date, time and time zone'),
|
|
(now + 60, "This PC's clock agrees with 127.0.0.1's. Security software"),
|
|
(None, "Check that this PC's date, time and time zone are right"))
|
|
for i, (theirs, says) in enumerate(cases):
|
|
net.server_clock = lambda url, timeout=10, version='dev', t=theirs: asked.append(url) or t
|
|
t0 = time.time()
|
|
with self.assertRaises(net.CertificateError) as cm:
|
|
net.download(srv.url + 'f.bin', self.tmp / 'out' / 'f.bin', sha256=sha(self.data))
|
|
self.assertLess(time.time() - t0, 5) # no retries: the answer would be the same
|
|
msg = str(cm.exception)
|
|
self.assertIn("the certificate of 127.0.0.1 was not accepted (Python's check: certificate has expired)",
|
|
msg)
|
|
self.assertIn(says, msg)
|
|
self.assertEqual(check.handshakes, i + 1)
|
|
self.assertEqual(asked, ['http://127.0.0.1/'] * len(cases)) # the clock is asked over plain HTTP
|
|
self.assertEqual(srv.hits, []) # no request got through
|
|
self.assertFalse((self.tmp / 'out' / 'f.bin.part').exists())
|
|
|
|
def test_second_check_accepts(self):
|
|
"""A certificate the first check refuses and the second accepts downloads, and the second goes first for that
|
|
host from then on."""
|
|
now = time.time()
|
|
srv, cert = self.serve(now - 86400, now + 2 * 86400)
|
|
refuses, accepts = client_context(), client_context(trust=cert)
|
|
net._CHECKS = [("Python's check", refuses), ("Windows' check", accepts)]
|
|
dest = net.download(srv.url + 'f.bin', self.tmp / 'out' / 'f.bin', sha256=sha(self.data))
|
|
self.assertEqual(dest.read_bytes(), self.data)
|
|
self.assertEqual((refuses.handshakes, accepts.handshakes), (1, 1))
|
|
self.assertEqual(net._ACCEPTED, {'127.0.0.1': 1})
|
|
self.assertEqual(net.get_bytes(srv.url + 'f.bin'), self.data)
|
|
self.assertEqual((refuses.handshakes, accepts.handshakes), (1, 2))
|
|
net._CHECKS = [("Python's check", refuses)]
|
|
net.server_clock = lambda *a, **k: None
|
|
with self.assertRaises(net.CertificateError) as cm:
|
|
net.get_bytes(srv.url + 'f.bin')
|
|
self.assertIn("(Python's check: self-signed certificate)", str(cm.exception))
|
|
|
|
|
|
# ---------------------------------------------------------------- feed and updater
|
|
|
|
def make_app_zip(folder, ver, extra=None):
|
|
app = folder / f'app-{ver}'
|
|
(app / 'launcher').mkdir(parents=True)
|
|
(app / 'launcher' / 'start.pyw').write_text('# test\n')
|
|
(app / 'VERSION').write_text(ver + '\n')
|
|
z = folder / f'NX2navi-app-{ver}.zip'
|
|
with zipfile.ZipFile(z, 'w') as zf:
|
|
for p in app.rglob('*'):
|
|
if p.is_file():
|
|
zf.write(p, p.relative_to(app).as_posix())
|
|
for name, data in (extra or {}).items():
|
|
zf.writestr(name, data)
|
|
return z
|
|
|
|
|
|
def manifest_for(z, ver):
|
|
return {'format': 1, 'version': ver, 'notes': '- One\n- Two',
|
|
'app': {'name': z.name, 'size': z.stat().st_size, 'sha256': sha(z.read_bytes())}}
|
|
|
|
|
|
class FeedTest(Tmp):
|
|
def test_asset_needs_a_plain_name_size_and_sha256(self):
|
|
good = {'name': 'a.zip', 'size': 10, 'sha256': '0' * 64}
|
|
r = feed.Release({'format': 1, 'version': '1.0.0', 'app': good}, assets={'a.zip': 'https://x/a.zip'})
|
|
self.assertEqual(r.asset('app')[2:], (10, '0' * 64))
|
|
for bad in ({'name': 'a.zip'}, dict(good, sha256='x'), dict(good, size=0), dict(good, size='10'),
|
|
dict(good, name='../a.zip'), dict(good, name='C:a.zip'), dict(good, name='sub\\a.zip')):
|
|
r = feed.Release({'format': 1, 'version': '1.0.0', 'app': bad}, assets={bad['name']: 'https://x/a'})
|
|
with self.assertRaises(feed.FeedError, msg=repr(bad)):
|
|
r.asset('app')
|
|
|
|
def serve_github(self):
|
|
self.routes = {}
|
|
srv = Server(self.tmp, self.routes)
|
|
self.addCleanup(srv.close)
|
|
return srv
|
|
|
|
def publish(self, releases):
|
|
self.routes['/repos/o/r/releases?per_page=20'] = (200, {'Content-Type': 'application/json'},
|
|
json.dumps(releases).encode())
|
|
|
|
def release(self, srv, tag, ver, pre=False, manifest=True):
|
|
z = make_app_zip(self.tmp, ver)
|
|
m = self.tmp / f'm-{ver}.json'
|
|
m.write_text(json.dumps(manifest_for(z, ver)))
|
|
assets = [{'name': z.name, 'browser_download_url': srv.url + z.name, 'url': srv.url + z.name}]
|
|
if manifest:
|
|
assets.append({'name': feed.MANIFEST, 'browser_download_url': srv.url + m.name, 'url': srv.url + m.name})
|
|
return {'tag_name': tag, 'prerelease': pre, 'draft': False, 'body': 'notes', 'html_url': 'x', 'assets': assets}
|
|
|
|
def test_github_channels(self):
|
|
srv = self.serve_github()
|
|
self.publish([self.release(srv, 'v0.4.0-rc1', '0.4.0-rc1', pre=True),
|
|
self.release(srv, 'v0.3.12', '0.3.12', manifest=False), self.release(srv, 'v0.3.11', '0.3.11')])
|
|
f = {'type': 'github', 'repo': 'o/r', 'api': srv.url.rstrip('/')}
|
|
self.assertEqual(feed.latest(f, 'stable').version, '0.3.11') # 0.3.12 lacks a manifest
|
|
self.assertEqual(feed.latest(f, 'preview').version, '0.4.0-rc1')
|
|
r = feed.latest(f, 'stable', token='secret')
|
|
self.assertEqual(r.headers.get('Authorization'), 'Bearer secret')
|
|
self.assertTrue(any(h.get('Authorization') == 'Bearer secret' for _, h in srv.hits))
|
|
self.assertEqual(feed.plain_notes('## Head\n- **One**\n* `Two`\n<!-- x -->'), ['Head', 'One', 'Two'])
|
|
|
|
def test_a_refused_check_says_when_to_try_again(self):
|
|
"""GitHub's answer to a request over the hourly allowance carries the time the allowance is whole again:
|
|
the message names it, and the log gets one line for the failed check, not two tracebacks."""
|
|
srv = self.serve_github()
|
|
reset = int(time.time()) + 1500
|
|
self.routes['/repos/o/r/releases?per_page=20'] = (
|
|
403, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(reset)}, b'{"message": "rate limit"}')
|
|
f = {'type': 'github', 'repo': 'o/r', 'api': srv.url.rstrip('/')}
|
|
with self.assertRaises(feed.FeedError) as c:
|
|
feed.latest(f, 'stable')
|
|
at = time.strftime('%H:%M', time.localtime(reset))
|
|
self.assertRegex(str(c.exception), rf'^GitHub refused the request \(rate limit\); try again after {at} '
|
|
r'\(in 2[45] minutes\)$')
|
|
# the header's forms
|
|
now = 1700000000
|
|
text = feed.retry_text
|
|
self.assertTrue(text({'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(now + 61)}, now)
|
|
.endswith(' (in 2 minutes)'))
|
|
self.assertTrue(text({'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(now + 60)}, now)
|
|
.endswith(' (in 1 minute)'))
|
|
self.assertEqual(text({'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(now - 5)}, now), 'in a minute')
|
|
self.assertTrue(text({'Retry-After': '120'}, now).endswith(' (in 2 minutes)'))
|
|
self.assertTrue(text({'Retry-After': '120'}, now).startswith('after '))
|
|
self.assertEqual(text({'X-RateLimit-Remaining': '12', 'X-RateLimit-Reset': str(now + 600)}, now), '')
|
|
for nothing in ({}, None, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': 'soon'},
|
|
{'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(now + 10 ** 6)}):
|
|
self.assertEqual(text(nothing, now), '')
|
|
# a refusal without the headers keeps the old advice
|
|
self.routes['/repos/o/r/releases?per_page=20'] = (403, {}, b'{}')
|
|
with self.assertRaises(feed.FeedError) as c:
|
|
feed.latest(f, 'stable')
|
|
self.assertEqual(str(c.exception), 'GitHub refused the request (rate limit); try again in an hour')
|
|
# the job: one line in the log
|
|
ctx = Context(self.layout)
|
|
self.addCleanup(ctx._file.close)
|
|
ctx.settings['feed_repo'] = 'o/r'
|
|
with mock.patch.object(feed, 'GITHUB_API', srv.url.rstrip('/')):
|
|
j = jobs.CheckJob(ctx).run_inline()
|
|
self.assertEqual((j.result, j.error), ('failed', 'GitHub refused the request (rate limit); try again in an hour'))
|
|
ctx._file.flush()
|
|
log = (self.layout.logs / 'launcher.log').read_text(encoding='utf-8').splitlines()[1:]
|
|
self.assertEqual(len(log), 1, log)
|
|
self.assertIn('FeedError: GitHub refused the request (rate limit); try again in an hour (HTTPError: HTTP Error '
|
|
'403', log[0])
|
|
self.assertNotIn('Traceback', log[0])
|
|
|
|
def test_other_failures_keep_their_traceback(self):
|
|
ctx = Context(self.layout)
|
|
self.addCleanup(ctx._file.close)
|
|
j = jobs.Job(ctx, [])
|
|
j.run = lambda: [][1]
|
|
j.run_inline()
|
|
ctx._file.flush()
|
|
log = (self.layout.logs / 'launcher.log').read_text(encoding='utf-8')
|
|
self.assertIn('Traceback (most recent call last)', log)
|
|
self.assertIn('IndexError', log)
|
|
|
|
def test_url_feed_and_update(self):
|
|
z = make_app_zip(self.tmp, '0.9.0')
|
|
(self.tmp / feed.MANIFEST).write_text(json.dumps(manifest_for(z, '0.9.0')))
|
|
srv = Server(self.tmp)
|
|
self.addCleanup(srv.close)
|
|
rel = feed.latest({'type': 'url', 'url': srv.url + feed.MANIFEST})
|
|
self.assertEqual(rel.version, '0.9.0')
|
|
self.layout.apps.mkdir(parents=True)
|
|
(self.layout.apps / 'current.txt').write_text('0.8.0\n')
|
|
old = self.layout.apps / '0.8.0' / 'launcher'
|
|
old.mkdir(parents=True)
|
|
(old / 'start.pyw').write_text('')
|
|
for stale in ('0.7.0', '0.6.0'):
|
|
(self.layout.apps / stale).mkdir()
|
|
folder = updater.stage_app(self.layout, rel)
|
|
self.assertTrue((folder / 'launcher' / 'start.pyw').is_file())
|
|
updater.switch_to(self.layout, '0.9.0')
|
|
self.assertEqual((updater.current(self.layout), updater.previous(self.layout)), ('0.9.0', '0.8.0'))
|
|
updater.prune(self.layout)
|
|
self.assertEqual(sorted(p.name for p in self.layout.apps.iterdir() if p.is_dir()), ['0.8.0', '0.9.0'])
|
|
self.assertTrue(updater.rollback(self.layout))
|
|
self.assertEqual(updater.current(self.layout), '0.8.0')
|
|
self.assertFalse(updater.rollback(self.layout)) # nothing further back
|
|
|
|
def test_unsafe_or_wrong_archives(self):
|
|
z = make_app_zip(self.tmp, '1.0.0', extra={'../evil.txt': 'x'})
|
|
with self.assertRaises(updater.UpdateError):
|
|
updater.safe_extract(z, self.tmp / 'x')
|
|
self.assertFalse((self.tmp / 'evil.txt').exists())
|
|
z2 = make_app_zip(self.tmp, '1.0.1')
|
|
m = manifest_for(z2, '1.0.2') # the archive says 1.0.1
|
|
(self.tmp / feed.MANIFEST).write_text(json.dumps(m))
|
|
srv = Server(self.tmp)
|
|
self.addCleanup(srv.close)
|
|
rel = feed.Release(m, assets={z2.name: srv.url + z2.name})
|
|
with self.assertRaises(updater.UpdateError):
|
|
updater.stage_app(self.layout, rel)
|
|
self.assertFalse((self.layout.apps / '1.0.2').exists())
|
|
|
|
|
|
class BackgroundUpdateTest(Tmp):
|
|
"""An update fetched while the launcher runs (updater.stage, jobs.StageJob, jobs.BackgroundUpdate): staged beside
|
|
the running version without switching to it, and installed only when the game is not running, by itself only
|
|
with Install Updates Automatically on. The releases come from the local server through a URL feed."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.srv = Server(self.tmp)
|
|
self.addCleanup(self.srv.close)
|
|
self.layout = self.installed('0.4.0')
|
|
self.game = str(self.tmp / 'game')
|
|
self.running = False # what build.game_running answers: a copy of the game the launcher did not start
|
|
saved = build.game_running
|
|
build.game_running = lambda game_dir, exe='ocarina.exe', log=None: self.running
|
|
self.addCleanup(setattr, build, 'game_running', saved)
|
|
self.restarts = []
|
|
|
|
def installed(self, ver):
|
|
"""An installed launcher's folder (not a checkout) running version ver on the Python py-1."""
|
|
root = self.tmp / 'nx'
|
|
app = root / 'app' / ver
|
|
(app / 'launcher').mkdir(parents=True)
|
|
(app / 'launcher' / 'start.pyw').write_text('')
|
|
(app / 'VERSION').write_text(ver + '\n')
|
|
(root / 'app' / 'current.txt').write_text(ver + '\n')
|
|
(root / 'python').mkdir()
|
|
(root / 'python' / 'runtime-id.txt').write_text('py-1\n')
|
|
layout = Layout(root=root, app=app).ensure()
|
|
self.assertFalse(layout.dev)
|
|
return layout
|
|
|
|
def publish(self, ver, runtime='', pre=False, damaged=False):
|
|
"""A release of ver on the local server, read through a URL feed; runtime: the id of the Python it brings;
|
|
damaged: its manifest gives the wrong sha256 for the app archive."""
|
|
z = make_app_zip(self.tmp, ver)
|
|
m = manifest_for(z, ver)
|
|
if runtime:
|
|
r = self.tmp / f'NX2navi-runtime-{runtime}.zip'
|
|
with zipfile.ZipFile(r, 'w') as zf:
|
|
zf.writestr('python.exe', 'MZ')
|
|
zf.writestr('runtime-id.txt', runtime + '\n')
|
|
m['runtime'] = {'id': runtime, 'name': r.name, 'size': r.stat().st_size, 'sha256': sha(r.read_bytes())}
|
|
if damaged:
|
|
m['app']['sha256'] = '0' * 64
|
|
(self.tmp / f'm-{ver}.json').write_text(json.dumps(m))
|
|
rel = feed.latest({'type': 'url', 'url': self.srv.url + f'm-{ver}.json'})
|
|
rel.prerelease = pre
|
|
return rel
|
|
|
|
def context(self):
|
|
"""The launcher starting (again) in the installed folder: its settings, its log."""
|
|
ctx = Context(self.layout)
|
|
self.addCleanup(lambda: ctx._file and ctx._file.close())
|
|
return ctx
|
|
|
|
def background(self, ctx):
|
|
return jobs.BackgroundUpdate(ctx, lambda args: self.restarts.append(args) or True)
|
|
|
|
def stage(self, bg, rel):
|
|
"""poll() as the window's timer calls it: start the staging, wait for it, and return how it ended."""
|
|
self.assertEqual(bg.poll(rel), 'started')
|
|
deadline = time.time() + 60
|
|
while bg.staging() and time.time() < deadline:
|
|
time.sleep(0.02)
|
|
return bg.poll(rel)
|
|
|
|
def state(self):
|
|
return updater.current(self.layout), updater.previous(self.layout), self.restarts
|
|
|
|
def test_staging_leaves_the_running_version(self):
|
|
"""The release is unpacked beside the running version, its Python in its own folder rather than python.next
|
|
(which the stub would swap in at the next start, under the running version), and nothing is switched or
|
|
restarted."""
|
|
bg = self.background(self.context())
|
|
rel = self.publish('0.5.0', runtime='py-2')
|
|
self.assertEqual(self.stage(bg, rel), 'staged')
|
|
folder = self.layout.apps / '0.5.0'
|
|
self.assertTrue((folder / 'launcher' / 'start.pyw').is_file())
|
|
self.assertEqual(updater.read_text(folder / updater.STAGED_PYTHON / 'runtime-id.txt'), 'py-2')
|
|
self.assertFalse((self.layout.root / 'python.next').exists())
|
|
self.assertEqual(self.state(), ('0.4.0', '', []))
|
|
self.assertEqual(bg.ready(rel)['version'], '0.5.0')
|
|
self.assertIsNone(bg.poll(rel)) # once per check
|
|
bg.checked()
|
|
self.assertIsNone(bg.poll(rel)) # the next check finds it staged: nothing to download
|
|
self.assertFalse(bg.staging())
|
|
older = feed.Release({'format': 1, 'version': '0.4.5', 'app': {}})
|
|
self.assertIsNone(bg.ready(older)) # the feed no longer offers it: not installed
|
|
updater.prune(self.layout) # an update's cleanup keeps a newer staged release
|
|
self.assertTrue((folder / updater.STAGED).is_file())
|
|
|
|
def test_ready_marker(self):
|
|
"""Only a release whose staging finished (staged.json, written last) and that is still whole installs."""
|
|
rel = self.publish('0.5.0', runtime='py-2')
|
|
updater.stage_app(self.layout, rel) # unpacked, as an interrupted staging leaves it
|
|
self.assertIsNone(updater.staged(self.layout))
|
|
with self.assertRaises(updater.UpdateError):
|
|
updater.install_staged(self.layout, '0.5.0')
|
|
self.assertEqual(updater.current(self.layout), '0.4.0')
|
|
updater.stage(self.layout, rel)
|
|
self.assertEqual(updater.staged(self.layout)['version'], '0.5.0')
|
|
self.assertIsNone(updater.staged(self.layout, skip='0.5.0')) # the version that failed to start
|
|
py = self.layout.apps / '0.5.0' / updater.STAGED_PYTHON
|
|
shutil.move(py, self.tmp / 'py') # the Python it needs is gone
|
|
self.assertIsNone(updater.staged(self.layout))
|
|
shutil.move(self.tmp / 'py', py)
|
|
self.assertIsNotNone(updater.staged(self.layout))
|
|
(self.layout.apps / '0.5.0' / updater.STAGED).write_text('{"version": "0.') # cut short
|
|
self.assertIsNone(updater.staged(self.layout))
|
|
updater.stage(self.layout, rel)
|
|
# a pre-release counts only on the Preview channel; staging it removes the staged release it supersedes
|
|
updater.stage(self.layout, self.publish('0.6.0-rc1', pre=True))
|
|
self.assertIsNone(updater.staged(self.layout, 'stable'))
|
|
self.assertEqual(updater.staged(self.layout, 'preview')['version'], '0.6.0-rc1')
|
|
self.assertFalse((self.layout.apps / '0.5.0').exists())
|
|
self.assertTrue((self.layout.apps / '0.4.0').is_dir())
|
|
|
|
def test_staged_version_installs_after_a_restart(self):
|
|
"""A staged release outlives the launcher: the next start finds it without a check and installs it: its
|
|
Python to python.next for the stub, current.txt, and the restart that finishes in ContinueUpdateJob."""
|
|
bg = self.background(self.context())
|
|
rel = self.publish('0.5.0', runtime='py-2')
|
|
self.assertEqual(self.stage(bg, rel), 'staged')
|
|
bg = self.background(self.context()) # the launcher started again, still 0.4.0
|
|
self.assertEqual(bg.ready()['version'], '0.5.0')
|
|
self.assertEqual(bg.install(self.game, why='the launcher started'), '0.5.0')
|
|
self.assertEqual(self.state(), ('0.5.0', '0.4.0', [['--continue-update', '0.4.0']]))
|
|
self.assertTrue(bg.restarting)
|
|
self.assertEqual(updater.read_text(self.layout.root / 'python.next' / 'runtime-id.txt'), 'py-2')
|
|
self.assertFalse((self.layout.apps / '0.5.0' / updater.STAGED).exists())
|
|
# the stub rolled back from it: it is neither staged nor installed by itself again
|
|
self.assertTrue(updater.rollback(self.layout))
|
|
ctx = self.context()
|
|
ctx.settings['rolled_back'] = '0.5.0'
|
|
bg = self.background(ctx)
|
|
self.assertIsNone(bg.poll(rel))
|
|
self.assertEqual(bg.install(self.game, rel), '')
|
|
self.assertEqual(len(self.restarts), 1)
|
|
|
|
def test_an_update_leaves_the_games_settings_file(self):
|
|
"""The launcher's part of an update (the release staged beside the running version, the switch to it, the
|
|
cleanup after the rebuild) changes nothing in the game folder, also in its default place inside the
|
|
launcher's folder: the player's nx2navi.ini is the same bytes afterwards, and an uninstall that keeps the game
|
|
does not name it."""
|
|
game = self.layout.root / 'game'
|
|
(game / 'profile').mkdir(parents=True)
|
|
ini = game / 'nx2navi.ini'
|
|
ini.write_bytes(PLAYER_INI)
|
|
before = sorted(p.relative_to(game).as_posix() for p in game.rglob('*'))
|
|
bg = self.background(self.context())
|
|
rel = self.publish('0.5.0', runtime='py-2')
|
|
self.assertEqual(self.stage(bg, rel), 'staged')
|
|
self.assertEqual(ini.read_bytes(), PLAYER_INI)
|
|
self.assertEqual(bg.install(str(game), rel, why='the launcher started'), '0.5.0')
|
|
self.assertEqual(self.state(), ('0.5.0', '0.4.0', [['--continue-update', '0.4.0']]))
|
|
updater.prune(self.layout) # what ContinueUpdateJob ends with
|
|
self.assertEqual(ini.read_bytes(), PLAYER_INI)
|
|
self.assertEqual(sorted(p.relative_to(game).as_posix() for p in game.rglob('*')), before)
|
|
gone, _ = winsys.uninstall_plan(self.layout)
|
|
self.assertFalse([p for p in gone if p == game or p in ini.parents])
|
|
|
|
def test_no_install_while_the_game_runs(self):
|
|
"""A copy of the game started elsewhere, the copy the launcher started, and a download under way each hold
|
|
the install back, also one the player asked for."""
|
|
bg = self.background(self.context())
|
|
rel = self.publish('0.5.0')
|
|
self.assertEqual(self.stage(bg, rel), 'staged')
|
|
self.running = True
|
|
self.assertEqual(bg.install(self.game, rel, why='the launcher started'), '')
|
|
self.assertTrue(bg.waiting)
|
|
self.assertEqual(bg.install(self.game, rel, asked=True), '')
|
|
self.running = False
|
|
self.assertEqual(bg.install(self.game, rel, running=True), '')
|
|
bg.job = jobs.StageJob(bg.ctx, feed.Release({'format': 1, 'version': '0.5.1', 'app': {}})) # not finished
|
|
self.assertEqual(bg.install(self.game, rel), '')
|
|
self.assertEqual(self.state(), ('0.4.0', '', []))
|
|
self.assertTrue((self.layout.apps / '0.5.0' / updater.STAGED).is_file())
|
|
|
|
def test_install_after_the_game_exits(self):
|
|
"""Install Updates Automatically on (the default): a release staged during play installs once the game has
|
|
exited."""
|
|
ctx = self.context()
|
|
self.assertTrue(ctx.settings['auto_install'])
|
|
bg = self.background(ctx)
|
|
self.running = True # the check during play finds the release
|
|
rel = self.publish('0.5.0')
|
|
self.assertEqual(self.stage(bg, rel), 'staged')
|
|
self.assertEqual(bg.install(self.game, rel, why='the download finished'), '')
|
|
self.assertEqual(self.state(), ('0.4.0', '', []))
|
|
self.running = False # the game exits
|
|
self.assertEqual(bg.install(self.game, rel, why='the game exited'), '0.5.0')
|
|
self.assertEqual(self.state(), ('0.5.0', '0.4.0', [['--continue-update', '0.4.0']]))
|
|
self.assertFalse(bg.waiting)
|
|
|
|
def test_nothing_automatic_when_the_setting_is_off(self):
|
|
"""Install Updates Automatically off: the release is still staged in the background, and only selecting
|
|
Updates installs it (also once selected during the download)."""
|
|
ctx = self.context()
|
|
ctx.settings['auto_install'] = False
|
|
bg = self.background(ctx)
|
|
rel = self.publish('0.5.0')
|
|
self.assertEqual(self.stage(bg, rel), 'staged')
|
|
for why in ('the launcher started', 'the game exited', 'the game closed', 'the download finished'):
|
|
self.assertEqual(bg.install(self.game, rel, why=why), '')
|
|
self.assertFalse(bg.waiting)
|
|
self.assertEqual(self.state(), ('0.4.0', '', []))
|
|
bg.asked = True # selected while it downloaded
|
|
self.assertEqual(bg.install(self.game, rel, why='the download finished'), '0.5.0')
|
|
self.assertEqual(self.state(), ('0.5.0', '0.4.0', [['--continue-update', '0.4.0']]))
|
|
|
|
def test_failed_download_waits_for_the_next_check(self):
|
|
"""A staging that fails is logged, never raised, leaves nothing to install, and is tried again only after the
|
|
next update check."""
|
|
ctx = self.context()
|
|
bg = self.background(ctx)
|
|
rel = self.publish('0.5.0', damaged=True)
|
|
self.assertEqual(self.stage(bg, rel), 'failed')
|
|
self.assertIn('0.5.0 was not staged', (self.layout.logs / 'launcher.log').read_text(encoding='utf-8'))
|
|
self.assertIsNone(bg.poll(rel))
|
|
self.assertIsNone(bg.ready(rel))
|
|
self.assertEqual(bg.install(self.game, rel), '')
|
|
bg.checked()
|
|
self.assertEqual(self.stage(bg, rel), 'failed')
|
|
self.assertEqual(self.state(), ('0.4.0', '', []))
|
|
|
|
|
|
# ---------------------------------------------------------------- gamefiles
|
|
|
|
def sarc(files):
|
|
names = b''
|
|
offs = []
|
|
for n in files:
|
|
offs.append(len(names))
|
|
b = n.encode() + b'\0'
|
|
names += b + b'\0' * (-len(b) % 4)
|
|
data, ents = b'', b''
|
|
for (n, d), no in zip(files.items(), offs):
|
|
start = len(data)
|
|
data += d
|
|
ents += struct.pack('<IIII', 0, 0x01000000 | (no // 4), start, len(data))
|
|
data += b'\0' * (-len(data) % 8)
|
|
sfat = b'SFAT' + struct.pack('<HHI', 0xC, len(files), 0x65) + ents
|
|
sfnt = b'SFNT' + struct.pack('<HH', 8, 0) + names
|
|
base = 0x14 + len(sfat) + len(sfnt)
|
|
head = b'SARC' + struct.pack('<HHIIHH', 0x14, 0xFEFF, base + len(data), base, 0x100, 0)
|
|
return head + sfat + sfnt + data
|
|
|
|
|
|
def bfotf(otf, key=0x1234ABCD):
|
|
body = otf + b'\0' * (-len(otf) % 4)
|
|
words = struct.unpack(f'>{len(body) // 4}I', body)
|
|
return struct.pack('>II', 0x7F9A0218 ^ key, len(otf) ^ key) + struct.pack(f'>{len(words)}I',
|
|
*(w ^ key for w in words))
|
|
|
|
|
|
@unittest.skipUnless(ZSTD, 'needs compression.zstd (Python 3.14)')
|
|
class FontTest(Tmp):
|
|
FONT = Path('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf')
|
|
|
|
@unittest.skipUnless(FONT.is_file(), 'needs DejaVu Sans')
|
|
def test_font_from_romfs_image(self):
|
|
otf = self.FONT.read_bytes()
|
|
self.assertEqual(gamefiles.bfotf_to_otf(bfotf(otf)), otf)
|
|
self.assertIsNone(gamefiles.bfotf_to_otf(b'\0' * 64))
|
|
samples = [otf[i:i + 512] for i in range(0, min(len(otf), 400000), 997)]
|
|
d = ZSTD.train_dict(samples, 16384)
|
|
packed = ZSTD.compress(bfotf(otf), zstd_dict=d)
|
|
self.assertNotEqual(ZSTD.get_frame_info(packed).dictionary_id, 0)
|
|
files = {'Pack/ZsDic.pack.zs': ZSTD.compress(sarc({'zs.zsdic': d.dict_content})),
|
|
gamefiles.FONTS['title']: packed}
|
|
romfs = self.tmp / 'game' / 'romfs'
|
|
romfs.mkdir(parents=True)
|
|
entries, blob = [], b''
|
|
for path, data in sorted(files.items()):
|
|
blob += b'\0' * (-len(blob) % 16)
|
|
entries.append({'path': path, 'offset': len(blob), 'size': len(data)})
|
|
blob += data
|
|
(romfs / 'romfs.bin').write_bytes(blob)
|
|
(romfs / 'index.json').write_text(json.dumps({'files': entries}))
|
|
got = gamefiles.extract_fonts(self.layout, self.tmp / 'game')
|
|
self.assertEqual(got['title'][1], 'DejaVu Sans')
|
|
self.assertEqual(Path(got['title'][0]).read_bytes(), otf)
|
|
self.assertEqual(gamefiles.cached_fonts(self.layout)['title'][1], 'DejaVu Sans')
|
|
# Registered as a font, Windows keeps the file open: decoding it again must not fail on the unchanged file,
|
|
# and once the registration is given up a file that went bad is replaced.
|
|
path = Path(got['title'][0])
|
|
winsys.register_font(path)
|
|
self.assertEqual(gamefiles.extract_fonts(self.layout, self.tmp / 'game')['title'][1], 'DejaVu Sans')
|
|
winsys.unregister_font(path)
|
|
path.write_bytes(b'not a font')
|
|
self.assertEqual(Path(gamefiles.extract_fonts(self.layout, self.tmp / 'game')['title'][0]).read_bytes(), otf)
|
|
|
|
|
|
# ---------------------------------------------------------------- the button art of a release
|
|
|
|
class PromptArtTest(Tmp):
|
|
def make_art(self):
|
|
"""Every picture tools/prompt_art.py names, the pads in one pack's folder and the keys in another's, with two
|
|
keys and two pictures the tool never reads. Returns (prompt_art, pads, keys, the files a release takes)."""
|
|
import prompt_art as pa
|
|
pads, keys = self.tmp / 'pack' / 'Source', self.tmp / 'keys' / 'Source'
|
|
wanted = {rel for style in pa.STYLES.values() for part in style.values() for rel in part.values()
|
|
if rel != pa.OPTIONS}
|
|
wanted |= {pa.SHARE, *pa.BLANKS.values(), f'{pa.KM}Dark/T_E_Key_Dark.png', f'{pa.KM}White/T_E_Key_White.png',
|
|
f'{pa.KM}Dark/T_F12_Key_Dark.png'}
|
|
for rel in wanted | {'XGamepad/Default/T_X_Unused.png', f'{pa.KM}Retro/T_E_Key_Retro.png'}:
|
|
path = (keys if rel.startswith(pa.KM) else pads) / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(rel.encode())
|
|
return pa, pads, keys, wanted
|
|
|
|
def test_art_files(self):
|
|
"""A release takes the pictures pad_prompts.py reads and every key, from folders that hold the art's sets or
|
|
are those sets; a set named twice, a set missing or a picture missing stops it."""
|
|
pa, pads, keys, wanted = self.make_art()
|
|
for folders in ([pads, keys], [pads, keys / 'Keyboard_Mouse'], [pads / 'XGamepad', pads / 'P5Gamepad', keys]):
|
|
self.assertEqual(set(pa.art_files(folders)), wanted)
|
|
dest = self.tmp / 'release' / pa.FOLDER
|
|
n, size = pa.copy_art([pads, keys], dest)
|
|
copied = {p.relative_to(dest).as_posix(): p.read_bytes() for p in dest.rglob('*') if p.is_file()}
|
|
self.assertEqual(copied, {rel: rel.encode() for rel in wanted})
|
|
self.assertEqual((n, size), (len(wanted), sum(len(rel) for rel in wanted)))
|
|
# a set named itself wins over one inside a folder named; the same set twice at one rank is refused
|
|
other = self.tmp / 'other' / 'Keyboard_Mouse'
|
|
other.mkdir(parents=True)
|
|
self.assertEqual(pa.art_roots([pads, keys, other])['Keyboard_Mouse'], other.parent)
|
|
self.assertEqual(pa.art_roots([pads, keys])['Keyboard_Mouse'], keys)
|
|
for folders, says in (([pads, keys, other.parent], 'in both'),
|
|
([pads, keys / 'Keyboard_Mouse', other], 'in both'),
|
|
([pads], 'no Keyboard_Mouse folder'), ([self.tmp / 'release'], 'holds none of')):
|
|
with self.assertRaises(SystemExit) as e:
|
|
pa.art_files(folders)
|
|
self.assertIn(says, str(e.exception.code))
|
|
(pads / pa.SHARE).unlink()
|
|
with self.assertRaises(SystemExit) as e:
|
|
pa.art_files([pads, keys])
|
|
self.assertIn('T_X_Share-1.png', str(e.exception.code))
|
|
|
|
def test_release_carries_what_the_prompts_need(self):
|
|
"""The release's Python has every package tools/pad_prompts.py imports, and the launcher release and the build
|
|
kit carry every tool it imports."""
|
|
import ast
|
|
import make_kit
|
|
import make_launcher
|
|
pip = {'PIL': 'pillow', 'fontTools': 'fonttools'}
|
|
local, packages = set(), set()
|
|
for tool in ('pad_prompts.py', 'button_icons.py', 'prompt_art.py'):
|
|
for node in ast.walk(ast.parse((ROOT / 'tools' / tool).read_text(encoding='utf-8'))):
|
|
names = [a.name for a in node.names] if isinstance(node, ast.Import) else \
|
|
[node.module] if isinstance(node, ast.ImportFrom) and node.module else []
|
|
for root in (n.split('.')[0] for n in names):
|
|
if (ROOT / 'tools' / f'{root}.py').is_file():
|
|
local.add(f'{root}.py')
|
|
elif root not in sys.stdlib_module_names:
|
|
packages.add(pip.get(root, root))
|
|
self.assertEqual(packages, {'zstandard', 'pillow', 'fonttools'})
|
|
reqs = (ROOT / 'tools' / 'requirements-launcher.txt').read_text(encoding='utf-8')
|
|
pinned = {ln.split('==')[0].strip().lower() for ln in reqs.splitlines()
|
|
if '==' in ln and not ln.startswith('#')}
|
|
self.assertLessEqual(packages, pinned)
|
|
for tools in (make_launcher.TOOLS, make_kit.TOOLS):
|
|
self.assertLessEqual(local | {'pad_prompts.py'}, set(tools))
|
|
|
|
def test_launcher_release_carries_what_make_game_imports(self):
|
|
"""Every tool make_game.py imports is in the launcher release, unless the import sits in a try that catches
|
|
ImportError (build_shaders: a source checkout's step the release's prebuilt headers do not need) or only runs
|
|
on Linux (build_game, whose kit carries it). An import that is neither stopped every player's update at its
|
|
compile step."""
|
|
import ast
|
|
import make_launcher
|
|
tree = ast.parse((ROOT / 'tools' / 'make_game.py').read_text(encoding='utf-8'))
|
|
guarded = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Try) and any(
|
|
isinstance(h.type, ast.Name) and h.type.id in ('ImportError', 'ModuleNotFoundError')
|
|
for h in node.handlers):
|
|
for inner in node.body:
|
|
guarded |= {id(n) for n in ast.walk(inner)}
|
|
needed = set()
|
|
for node in ast.walk(tree):
|
|
names = [a.name for a in node.names] if isinstance(node, ast.Import) else \
|
|
[node.module] if isinstance(node, ast.ImportFrom) and node.module else []
|
|
for root in (n.split('.')[0] for n in names):
|
|
if (ROOT / 'tools' / f'{root}.py').is_file() and id(node) not in guarded and root != 'build_game':
|
|
needed.add(f'{root}.py')
|
|
self.assertLessEqual(needed, set(make_launcher.TOOLS))
|
|
|
|
|
|
# ---------------------------------------------------------------- make_game.py
|
|
|
|
def nso(build_id=b'\0' * 16, size=0x300):
|
|
head = bytearray(0x100)
|
|
head[0:4] = b'NSO0'
|
|
segs = [(0x100 + i * size, i * 0x10000, size) for i in range(3)]
|
|
for i, (fo, mo, sz) in enumerate(segs):
|
|
struct.pack_into('<III', head, 0x10 + i * 0x10, fo, mo, sz)
|
|
struct.pack_into('<I', head, 0x60 + i * 4, sz)
|
|
head[0x40:0x50] = build_id
|
|
return bytes(head) + b''.join(bytes([i + 1]) * size for i in range(3))
|
|
|
|
|
|
def write_nup(path, exefs, romfs, title_id=0x0400486024DEC000):
|
|
"""A .nup (tools/nup.py's format) with one base package: exefs {name: bytes}, romfs {path: bytes}."""
|
|
import nup as nupmod
|
|
pool = bytearray()
|
|
|
|
def name(s):
|
|
off = len(pool)
|
|
pool.extend(s.encode())
|
|
return off, len(s.encode())
|
|
|
|
dirs, files = [], []
|
|
|
|
def add_tree(tree):
|
|
"""tree: {name: bytes | dict}; returns the directory index."""
|
|
idx = len(dirs)
|
|
dirs.append(None)
|
|
kids = []
|
|
for n, v in sorted(tree.items()):
|
|
if isinstance(v, dict):
|
|
kids.append(('d', add_tree(v), n))
|
|
else:
|
|
files.append([n, v])
|
|
kids.append(('f', len(files) - 1, n))
|
|
dirs[idx] = kids
|
|
return idx
|
|
|
|
root = add_tree({'exefs': exefs, 'romfs': _nest(romfs)})
|
|
nd = len(dirs)
|
|
dir_rows, child_list = [], []
|
|
for kids in dirs:
|
|
first = len(child_list)
|
|
for kind, i, _n in kids:
|
|
child_list.append(i if kind == 'd' else nd + i)
|
|
dir_rows.append((first, len(kids)))
|
|
dir_names = {root: ''}
|
|
for kids in dirs:
|
|
for kind, i, n in kids:
|
|
if kind == 'd':
|
|
dir_names[i] = n
|
|
exefs_dir = next(i for k, i, n in dirs[root] if k == 'd' and n == 'exefs')
|
|
romfs_dir = next(i for k, i, n in dirs[root] if k == 'd' and n == 'romfs')
|
|
toc_head = 0x30 + nupmod.PACKAGE.size + nd * nupmod.DIRECTORY.size + len(files) * nupmod.FILE.size + \
|
|
4 * len(child_list)
|
|
blob_off = nupmod.SUPER.size
|
|
data = bytearray()
|
|
file_rows = []
|
|
for n, v in files:
|
|
no, nl = name(n)
|
|
file_rows.append((no, nl, len(v), blob_off + len(data), len(v), hashlib.sha256(v).digest(), 0))
|
|
data.extend(v)
|
|
dir_name_rows = [name(dir_names[i]) for i in range(nd)]
|
|
toc_off = blob_off + len(data)
|
|
toc = bytearray(struct.pack('<IIQQQQQ', 0x01434F54, 1, nd, len(files), toc_head, len(pool), 0))
|
|
toc += nupmod.PACKAGE.pack(title_id, root, exefs_dir, romfs_dir, 0, 0, 0, 0, 0, 0, 0, b'\0' * 20)
|
|
for i in range(nd):
|
|
toc += nupmod.DIRECTORY.pack(*dir_name_rows[i], *dir_rows[i], 0)
|
|
for no, nl, size, off, stored, digest, codec in file_rows:
|
|
toc += nupmod.FILE.pack(no, nl, size, off, stored, digest, codec, 0, 0, b'\0' * 12)
|
|
toc += struct.pack(f'<{len(child_list)}I', *child_list)
|
|
toc += pool
|
|
total = toc_off + len(toc)
|
|
sup = nupmod.SUPER.pack(b'NUP\x01', 0x80, 1, toc_off, len(toc), hashlib.sha256(bytes(toc)).digest(), blob_off,
|
|
total, 1, 0, b'\0' * 16, b'\0' * 32)
|
|
Path(path).write_bytes(sup + bytes(data) + bytes(toc))
|
|
|
|
|
|
def _nest(flat):
|
|
tree = {}
|
|
for p, v in flat.items():
|
|
parts = p.split('/')
|
|
node = tree
|
|
for part in parts[:-1]:
|
|
node = node.setdefault(part, {})
|
|
node[parts[-1]] = v
|
|
return tree
|
|
|
|
|
|
def made_up_game(out, steps=('exefs', 'romfs', 'lift', 'compile', 'shaders'), exe=True, kit='0.6.1'):
|
|
"""A game folder as tools/make_game.py leaves it, made up without game files: an ExeFS whose main is a header
|
|
with the game's build id, a RomFS image of zeros, the exe unless exe is False, and a state.json in which the
|
|
game-file steps are current and every other step named has the key of an earlier kit."""
|
|
import make_game
|
|
out = Path(out)
|
|
for d in ('exefs', 'romfs', 'build-cache'):
|
|
(out / d).mkdir(parents=True, exist_ok=True)
|
|
main = bytearray(0x100)
|
|
main[:4], main[0x40:0x50] = b'NSO0', bytes.fromhex(make_game.MAIN_BUILD_ID)
|
|
for n in make_game.EXEFS_FILES:
|
|
(out / 'exefs' / n).write_bytes(bytes(main) if n == 'main' else b'x' * 64)
|
|
(out / 'romfs' / 'romfs.bin').write_bytes(bytes(16))
|
|
(out / 'romfs' / 'index.json').write_text('{}')
|
|
if exe:
|
|
(out / 'ocarina.exe').write_bytes(b'MZ')
|
|
ident = 'nup X 1 1'
|
|
keys = {s: f'{s}-1|{ident}' if s in ('exefs', 'romfs') else f'old-{s}' for s in steps}
|
|
(out / 'build-cache' / 'state.json').write_text(json.dumps(
|
|
{'version': make_game.STATE_VERSION, 'input': ident, 'kit': kit, 'steps': keys}))
|
|
return out
|
|
|
|
|
|
# Stands in for windres.exe: records its arguments, working directory and temporary folder, opens the .rc and the
|
|
# files it names from the working directory as windres does, and leaves two temporary files behind as windres does.
|
|
FAKE_WINDRES = r'''
|
|
import json, os, re, sys
|
|
from pathlib import Path
|
|
a = sys.argv[1:]
|
|
tmp = os.environ['TMP']
|
|
for n in ('cc1', 'cc2'):
|
|
open(os.path.join(tmp, n), 'w').close()
|
|
Path(__file__).with_suffix('.json').write_text(json.dumps(
|
|
{'argv': a, 'cwd': os.getcwd(), 'tmp': tmp, 'temp': os.environ['TEMP']}))
|
|
if os.environ.get('FAKE_WINDRES_FAIL'):
|
|
sys.exit('windres: preprocessing failed.')
|
|
text = open(a[a.index('-i') + 1]).read()
|
|
for name in re.findall(r'^\s*\w+\s+\w+\s+"([^"]+)"', text, re.M):
|
|
open(name, 'rb').close()
|
|
open(a[a.index('-o') + 1], 'wb').write(b'coff')
|
|
'''
|
|
|
|
# Stands in for tools/pad_prompts.py: counts its runs in the game folder and writes every overlay folder's manifest,
|
|
# printing the line the real tool prints for each and its closing report.
|
|
FAKE_PAD_PROMPTS = r'''
|
|
import json, sys
|
|
from pathlib import Path
|
|
import prompt_art
|
|
a = sys.argv[1:]
|
|
art, game = Path(a[a.index('--art') + 1]), Path(a[a.index('--game') + 1])
|
|
assert (art / 'XGamepad').is_dir(), art
|
|
with open(game / 'prompt-runs.txt', 'a') as f:
|
|
f.write('run\n')
|
|
for name in prompt_art.OVERLAYS:
|
|
(game / name).mkdir(exist_ok=True)
|
|
(game / name / 'manifest.tsv').write_text('# path\n')
|
|
print(f'{game / name}: 3 files, the font grew by 12 bytes', flush=True)
|
|
print(json.dumps({'status': 'pass', 'folders': []}))
|
|
'''
|
|
|
|
|
|
def prepared_list(programs, answers, translator, records=1):
|
|
"""A version 2 shader-prepared.bin (runtime/prp_file.h): the head, what it was prepared from, then records."""
|
|
return b'NX2NSPRP' + struct.pack('<II', 2, 36) + programs + answers + translator + bytes(36 * records)
|
|
|
|
|
|
class MakeGameTest(Tmp):
|
|
def setUp(self):
|
|
super().setUp()
|
|
import make_game
|
|
self.mg = make_game
|
|
exefs = {'main': nso(bytes.fromhex(make_game.MAIN_BUILD_ID)), 'sdk': nso(), 'subsdk0': nso(), 'rtld': nso(),
|
|
'main.npdm': b'META' + b'\0' * 0x100}
|
|
self.romfs = {'a.txt': b'hello', 'dir/b.bin': os.urandom(5000), 'dir/sub/c': b'c' * 33}
|
|
self.nup = self.tmp / 'game.nup'
|
|
write_nup(self.nup, exefs, self.romfs)
|
|
self.msys = self.tmp / 'msys'
|
|
for need in ('bin/clang.exe', 'bin/clang++.exe', 'bin/SDL3.dll', 'lib/libSDL3.dll.a'):
|
|
(self.msys / need).parent.mkdir(parents=True, exist_ok=True)
|
|
(self.msys / need).write_bytes(b'')
|
|
(self.msys / 'include' / 'vma').mkdir(parents=True)
|
|
self.layout.pydeps.mkdir(parents=True, exist_ok=True)
|
|
(self.layout.pydeps / 'zbic.py').write_text('def decompress(b):\n return b\n')
|
|
self.out = self.tmp / 'game out'
|
|
# the made-up build tools above are empty files: a build in this process does not try them
|
|
# (test_the_build_tools_are_tried_before_the_long_steps does, with a stand-in compiler)
|
|
self.real_probe = make_game.probe_toolchain
|
|
probe = mock.patch.object(make_game, 'probe_toolchain', lambda msys, work: None)
|
|
probe.start()
|
|
self.addCleanup(probe.stop)
|
|
|
|
def run_mg(self, *args):
|
|
events, lines = [], []
|
|
run = build.Run(build.command(self.layout, list(args)), events.append, lines.append)
|
|
code = run.wait()
|
|
return code, events, lines
|
|
|
|
def test_pipeline_rebuild_and_keys(self):
|
|
code, ev, lines = self.run_mg('--nup', self.nup, '--out', self.out, '--msys', self.msys, '--until', 'romfs')
|
|
self.assertEqual(code, 0, '\n'.join(lines))
|
|
states = [(e['step'], e['state']) for e in ev if e['ev'] == 'step']
|
|
self.assertEqual(states, [('check', 'start'), ('check', 'done'), ('exefs', 'start'), ('exefs', 'done'),
|
|
('romfs', 'start'), ('romfs', 'done')])
|
|
self.assertTrue(any(e['ev'] == 'progress' and e['step'] == 'romfs' and e['done'] == e['total'] for e in ev))
|
|
index = json.loads((self.out / 'romfs' / 'index.json').read_text())
|
|
blob = (self.out / 'romfs' / 'romfs.bin').read_bytes()
|
|
for e in index['files']:
|
|
self.assertEqual(blob[e['offset']:e['offset'] + e['size']], self.romfs[e['path']])
|
|
self.assertEqual(e['offset'] % 16, 0)
|
|
flat = (self.out / 'exefs' / 'main').read_bytes()
|
|
self.assertEqual(flat[0x40:0x50].hex(), self.mg.MAIN_BUILD_ID)
|
|
# a second run with the same input skips both steps
|
|
code, ev, _ = self.run_mg('--nup', self.nup, '--out', self.out, '--msys', self.msys, '--until', 'romfs')
|
|
self.assertEqual([e['state'] for e in ev if e['ev'] == 'step' and e['step'] in ('exefs', 'romfs')],
|
|
['skip', 'skip'])
|
|
# --rebuild needs no game files: it checks the unpacked ExeFS and keeps both steps
|
|
self.nup.rename(self.tmp / 'moved.nup')
|
|
code, ev, lines = self.run_mg('--rebuild', '--out', self.out, '--msys', self.msys, '--until', 'romfs')
|
|
self.assertEqual(code, 0, '\n'.join(lines))
|
|
self.assertEqual([e['state'] for e in ev if e['ev'] == 'step' and e['step'] in ('exefs', 'romfs')],
|
|
['skip', 'skip'])
|
|
# the exefs and romfs keys do not depend on the kit; the lift key does
|
|
st = json.loads((self.out / 'build-cache' / 'state.json').read_text())
|
|
self.assertEqual(st['steps']['exefs'], self.mg.step_key('exefs', st['input']))
|
|
self.assertNotIn(self.mg.kit_version(), st['steps']['romfs'])
|
|
|
|
def test_rebuild_without_build(self):
|
|
code, ev, lines = self.run_mg('--rebuild', '--out', self.tmp / 'empty', '--msys', self.msys)
|
|
self.assertEqual(code, 1)
|
|
self.assertTrue(any(e['ev'] == 'error' and 'rebuild' in e['message'] for e in ev))
|
|
|
|
def test_wrong_version_refused(self):
|
|
bad = self.tmp / 'bad.nup'
|
|
write_nup(bad, {'main': nso(b'\x11' * 16), 'sdk': nso(), 'subsdk0': nso(), 'rtld': nso(),
|
|
'main.npdm': b'META' + b'\0' * 0x100}, {'a': b'a'})
|
|
code, ev, _ = self.run_mg('--nup', bad, '--check')
|
|
self.assertEqual(code, 1)
|
|
self.assertTrue(any(e['ev'] == 'error' and '1.1.1' in e['message'] for e in ev))
|
|
|
|
@unittest.skipUnless(shutil.which('cc'), 'needs a C compiler')
|
|
def test_object_cache_survives_a_moved_kit(self):
|
|
"""The launcher keeps each version in its own folder: a kit at a new place reuses every object."""
|
|
kits = []
|
|
for name in ('0.3.10', '0.3.11'):
|
|
kit = self.tmp / 'app' / name / 'runtime'
|
|
kit.mkdir(parents=True)
|
|
(kit / 'h.h').write_text('#define V 1\n')
|
|
(kit / 'a.c').write_text('#include "h.h"\nint a(void) { return V; }\n')
|
|
kits.append(kit.parent)
|
|
obj = self.tmp / 'obj'
|
|
obj.mkdir()
|
|
|
|
def go(kit):
|
|
rt = kit / 'runtime'
|
|
srcs = [(rt / 'a.c', obj / 'rt_a.o')]
|
|
cache = self.mg.ObjectCache(obj / 'objects.json', {'<kit>': kit})
|
|
return self.mg.compile_all(srcs, lambda s_, d: ['cc', '-c', '-I', str(rt), '-o', str(d), str(s_)], cache,
|
|
1, dict(os.environ), salt='cc')
|
|
self.assertEqual(go(kits[0])[:2], (1, 0))
|
|
self.assertEqual(go(kits[1])[:2], (0, 1))
|
|
(kits[1] / 'runtime' / 'h.h').write_text('#define V 2\n') # the new version changed the header
|
|
self.assertEqual(go(kits[1])[:2], (1, 0))
|
|
|
|
def main_with(self, romfs, lift, compile_, rebuild=False):
|
|
"""make_game.main() in this process, with the given step functions in place of the real
|
|
lift and compile (they need real modules and clang). Returns the SystemExit code, or 0."""
|
|
sys.path.insert(0, str(self.layout.pydeps)) # the fake zbic
|
|
saved = (self.mg.step_romfs, self.mg.step_lift, self.mg.step_compile, sys.argv)
|
|
self.mg.step_romfs, self.mg.step_lift, self.mg.step_compile = romfs, lift, compile_
|
|
source = ['--rebuild'] if rebuild else ['--nup', str(self.nup)]
|
|
sys.argv = ['make_game.py', *source, '--out', str(self.out), '--msys', str(self.msys),
|
|
'--until', 'compile'] # the shaders step needs the shader translator
|
|
try:
|
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
self.mg.main()
|
|
return 0
|
|
except SystemExit as e:
|
|
return e.code
|
|
finally:
|
|
self.mg.step_romfs, self.mg.step_lift, self.mg.step_compile, sys.argv = saved
|
|
sys.path.remove(str(self.layout.pydeps))
|
|
|
|
def test_retry_interrupted_lift_after_cache_cleanup(self):
|
|
layout = self.out / 'build-cache' / 'lift' / 'layout.json'
|
|
ran = []
|
|
|
|
def lift(out, cache):
|
|
ran.append('lift')
|
|
layout.parent.mkdir(exist_ok=True)
|
|
layout.write_text('[]')
|
|
|
|
def compile_(out, cache, msys, jobs, romfs_job, update):
|
|
ran.append('compile')
|
|
self.assertTrue(layout.is_file())
|
|
|
|
def interrupted(out, cache):
|
|
lift(out, cache)
|
|
raise SystemExit(1) # layout exists again, but the lift did not finish
|
|
|
|
with mock.patch.object(self.mg, 'WIN', True): # use the fixture's MSYS toolchain check
|
|
self.assertEqual(self.main_with(self.mg.step_romfs, lift, compile_), 0)
|
|
self.nup.unlink()
|
|
ran.clear()
|
|
shutil.rmtree(layout.parent)
|
|
self.assertEqual(self.main_with(self.mg.step_romfs, lift, compile_, rebuild=True), 0)
|
|
self.assertEqual(ran, []) # cleaning the cache alone does not require a compile
|
|
|
|
with mock.patch.object(self.mg, 'kit_version', return_value='updated kit'):
|
|
self.assertEqual(self.main_with(self.mg.step_romfs, interrupted, compile_, rebuild=True), 1)
|
|
self.assertEqual(self.main_with(self.mg.step_romfs, lift, compile_, rebuild=True), 0)
|
|
self.assertEqual(self.main_with(self.mg.step_romfs, lift, compile_, rebuild=True), 0)
|
|
self.assertEqual(ran, ['lift', 'lift', 'compile'])
|
|
|
|
def test_romfs_beside_lift_and_compile(self):
|
|
"""A first build packs the RomFS image while the lift and the compile run; the compile gets the running step
|
|
and waits for it only for the RomFS index."""
|
|
real_romfs, seen = self.mg.step_romfs, {}
|
|
|
|
def romfs(src, out, jobs):
|
|
time.sleep(0.5)
|
|
real_romfs(src, out, jobs)
|
|
|
|
def lift(out, cache):
|
|
seen['lift_beside_romfs'] = not (out / 'romfs' / 'index.json').exists()
|
|
|
|
def compile_(out, cache, msys, jobs, romfs_job=None, update=False):
|
|
seen['job'] = romfs_job is not None
|
|
romfs_job.join()
|
|
seen['image_after_join'] = (out / 'romfs' / 'romfs.bin').is_file()
|
|
(out / self.mg.EXE_NAME).write_bytes(b'MZ')
|
|
self.assertEqual(self.main_with(romfs, lift, compile_), 0)
|
|
self.assertEqual(seen, {'lift_beside_romfs': True, 'job': True, 'image_after_join': True})
|
|
steps = json.loads((self.out / 'build-cache' / 'state.json').read_text())['steps']
|
|
self.assertEqual(set(steps), {'exefs', 'romfs', 'lift', 'compile'})
|
|
|
|
def test_a_repeated_romfs_step_does_not_repeat_the_lift(self):
|
|
"""An install cut short while the RomFS image was packed, its lift already done: the next run packs the
|
|
image again and compiles, but keeps the lift, which reads only the unpacked ExeFS."""
|
|
calls = []
|
|
|
|
def lift(out, cache):
|
|
calls.append('lift')
|
|
(cache / 'lift').mkdir(exist_ok=True)
|
|
(cache / 'lift' / 'layout.json').write_text('[]') # the lifted code is there (make_game.py lift_gone)
|
|
|
|
def compile_(out, cache, msys, jobs, romfs_job=None, update=False):
|
|
calls.append('compile')
|
|
if romfs_job:
|
|
romfs_job.join()
|
|
(out / self.mg.EXE_NAME).write_bytes(b'MZ')
|
|
self.assertEqual(self.main_with(self.mg.step_romfs, lift, compile_), 0)
|
|
self.assertEqual(calls, ['lift', 'compile'])
|
|
state = self.out / 'build-cache' / 'state.json'
|
|
st = json.loads(state.read_text())
|
|
for step in ('romfs', 'compile'): # as the cut run left it: the image and the compile not finished
|
|
del st['steps'][step]
|
|
state.write_text(json.dumps(st))
|
|
(self.out / 'romfs' / 'romfs.bin').unlink()
|
|
self.assertEqual(self.main_with(self.mg.step_romfs, lift, compile_), 0)
|
|
self.assertEqual(calls, ['lift', 'compile', 'compile'])
|
|
self.assertTrue((self.out / 'romfs' / 'romfs.bin').is_file())
|
|
self.assertEqual(set(json.loads(state.read_text())['steps']), {'exefs', 'romfs', 'lift', 'compile'})
|
|
# the ExeFS step still makes the lift run again: the lift reads what it writes
|
|
st = json.loads(state.read_text())
|
|
del st['steps']['exefs']
|
|
state.write_text(json.dumps(st))
|
|
self.assertEqual(self.main_with(self.mg.step_romfs, lift, compile_), 0)
|
|
self.assertEqual(calls, ['lift', 'compile', 'compile', 'lift', 'compile'])
|
|
|
|
def test_shader_progress_reaches_the_launcher(self):
|
|
"""The shaders step turns nx2navi.exe's counts into progress (translation the first half of the bar, the
|
|
driver build the second), sends one every few seconds while nothing is printed, says when the counts stop
|
|
moving, and passes its other lines on to the log."""
|
|
kit = self.tmp / 'kit'
|
|
(kit / 'tools').mkdir(parents=True)
|
|
(kit / 'bin').mkdir()
|
|
self.out.mkdir()
|
|
for name in (self.mg.XLATE_NAME, 'nx2navi_xlate.id'):
|
|
(kit / 'bin' / name).write_bytes(b'current translator')
|
|
(self.out / name).write_bytes(b'old translator')
|
|
(kit / 'tools' / 'prepare_shaders.py').write_text(
|
|
'import time\n'
|
|
'print("reading the inventory", flush=True)\n'
|
|
'time.sleep(0.6)\n'
|
|
'for d in (0, 50, 100):\n'
|
|
' print(f"prepare-shaders: progress translate {d} 100", flush=True)\n'
|
|
' time.sleep(0.05)\n'
|
|
'print("prepare-shaders: progress build 10000 100000", flush=True)\n'
|
|
'time.sleep(0.6)\n'
|
|
'for d in (99998, 99999, 100000):\n'
|
|
' print(f"prepare-shaders: progress build {d} 100000", flush=True)\n')
|
|
for name, value in (('KIT', kit), ('EVENTS', True), ('SHADER_BEAT_S', 0.1), ('SHADER_STALL_S', 0.3)):
|
|
self.addCleanup(setattr, self.mg, name, getattr(self.mg, name))
|
|
setattr(self.mg, name, value)
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
self.mg.step_shaders(self.out)
|
|
for name in (self.mg.XLATE_NAME, 'nx2navi_xlate.id'):
|
|
self.assertEqual((self.out / name).read_bytes(), b'current translator')
|
|
lines = buf.getvalue().splitlines()
|
|
events = [json.loads(ln[2:]) for ln in lines if ln.startswith('@@')]
|
|
self.assertIn('reading the inventory', lines)
|
|
self.assertFalse([ln for ln in lines if ln.startswith('prepare-shaders: progress')])
|
|
self.assertTrue(all(e['ev'] == 'progress' and e['step'] == 'shaders' for e in events))
|
|
fractions = [e['fraction'] for e in events]
|
|
self.assertEqual(fractions, sorted(fractions))
|
|
self.assertEqual(fractions[-1], 1.0)
|
|
details = [e['detail'] for e in events]
|
|
self.assertTrue(any(d.startswith("Reading the game's shaders") for d in details))
|
|
self.assertIn('Translated 50 of 100 shaders', details)
|
|
self.assertGreaterEqual(sum(1 for e in events if e['fraction'] == 0.55), 2) # heartbeats while the build waits
|
|
self.assertTrue(any(d.startswith('Built 10,000 of 100,000') and 'still running' in d for d in details))
|
|
for done in ('99,998', '99,999'):
|
|
event = next(e for e in events if e['detail'].startswith(f'Built {done} of 100,000'))
|
|
self.assertEqual(int(event['fraction'] * 100), 99)
|
|
self.assertEqual([e['fraction'] for e in events if e['detail'] == 'Translated 50 of 100 shaders'][0], 0.25)
|
|
|
|
def test_a_build_only_run_fills_the_whole_bar(self):
|
|
"""The driver cache warm-up after an update only builds: its count runs from 0 to 100% of the bar, where a
|
|
full preparation gives the build the second half after the translation."""
|
|
p = self.mg.shader_progress
|
|
self.assertEqual(p('build', 0, 42142, 0, 1, build_only=True)['fraction'], 0.0)
|
|
self.assertEqual(p('build', 21071, 42142, 0, 600, build_only=True)['fraction'], 0.5)
|
|
self.assertEqual(p('build', 42142, 42142, 0, 1200, build_only=True)['fraction'], 1.0)
|
|
self.assertEqual(p('build', 21071, 42142, 0, 600)['fraction'], 0.75)
|
|
self.assertEqual(p('build', 21071, 42142, 0, 600, build_only=True)['detail'],
|
|
'Built 21,071 of 42,142 shaders for your graphics card')
|
|
# warm_driver_cache runs the game's exe that way: a stand-in exe that prints the counts the game prints
|
|
self.out.mkdir()
|
|
(self.out / 'shader-prepared.bin').write_bytes(b'x')
|
|
exe = self.out / self.mg.EXE_NAME
|
|
exe.write_bytes(b'MZ')
|
|
script = self.tmp / 'warm.py'
|
|
script.write_text('for d in (0, 25, 100):\n print(f"prepare-shaders: progress build {d} 100", flush=True)\n')
|
|
real = self.mg.subprocess.Popen
|
|
|
|
def popen(cmd, **kw):
|
|
self.assertEqual(cmd, [str(exe), '--warm-shaders'])
|
|
return real([sys.executable, str(script)], **kw)
|
|
buf = io.StringIO()
|
|
with mock.patch.object(self.mg, 'EVENTS', True), mock.patch.object(self.mg.subprocess, 'Popen', popen), \
|
|
contextlib.redirect_stdout(buf):
|
|
self.mg.warm_driver_cache(self.out)
|
|
events = [json.loads(ln[2:]) for ln in buf.getvalue().splitlines() if ln.startswith('@@')]
|
|
self.assertEqual([e['fraction'] for e in events if e['detail'].startswith('Built')], [0.0, 0.25, 1.0])
|
|
|
|
def test_shaders_step_packs_the_cache(self):
|
|
"""After the preparation the shaders step folds the loose files of the shader cache into shader-cache.pack
|
|
(tools/pack_shader_cache.py), as the installer did; a kit without the tool leaves the cache as it is."""
|
|
kit = self.tmp / 'kit'
|
|
(kit / 'tools').mkdir(parents=True)
|
|
(kit / 'tools' / 'prepare_shaders.py').write_text('print("prepare-shaders: progress build 1 1", flush=True)\n')
|
|
cache = self.out / 'shader-cache'
|
|
(cache / 'in').mkdir(parents=True)
|
|
(cache / ('ab' * 32 + '.spv')).write_bytes(b'\x03\x02\x23\x07' * 8)
|
|
(cache / 'in' / ('cd' * 32 + '.in')).write_bytes(b'TKSI' * 4)
|
|
self.addCleanup(setattr, self.mg, 'KIT', self.mg.KIT)
|
|
self.mg.KIT = kit
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.mg.step_shaders(self.out)
|
|
self.assertFalse((cache / 'shader-cache.pack').exists()) # no tool in the kit
|
|
shutil.copy(ROOT / 'tools' / 'pack_shader_cache.py', kit / 'tools')
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
self.mg.step_shaders(self.out)
|
|
self.assertTrue((cache / 'shader-cache.pack').is_file())
|
|
self.assertEqual(sorted(p.name for p in cache.iterdir()), ['shader-cache.pack'])
|
|
self.assertIn('shader cache packed', buf.getvalue())
|
|
|
|
def test_carried_update_packs_many_loose_shaders(self):
|
|
"""An update that carries the shaders over packs the cache once it holds PACK_LOOSE_MIN loose translations,
|
|
and leaves fewer loose."""
|
|
kit = self.tmp / 'kit'
|
|
(kit / 'tools').mkdir(parents=True)
|
|
shutil.copy(ROOT / 'tools' / 'pack_shader_cache.py', kit / 'tools')
|
|
cache = self.out / 'shader-cache'
|
|
cache.mkdir(parents=True)
|
|
for i in range(3):
|
|
(cache / (f'{i:02x}' * 32 + '.spv')).write_bytes(b'\x03\x02\x23\x07' * 8)
|
|
for name, value in (('KIT', kit), ('PACK_LOOSE_MIN', 4), ('shader_carry', lambda out: True)):
|
|
self.addCleanup(setattr, self.mg, name, getattr(self.mg, name))
|
|
setattr(self.mg, name, value)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.mg.step_shaders(self.out, update=True)
|
|
self.assertFalse((cache / 'shader-cache.pack').exists()) # 3 loose, under the minimum
|
|
(cache / ('03' * 32 + '.spv')).write_bytes(b'\x03\x02\x23\x07' * 8)
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
self.mg.step_shaders(self.out, update=True)
|
|
self.assertEqual(sorted(p.name for p in cache.iterdir()), ['shader-cache.pack'])
|
|
self.assertIn('stay in use', buf.getvalue())
|
|
self.assertIn('shader cache packed', buf.getvalue())
|
|
|
|
def test_carried_update_warms_the_drivers_cache(self):
|
|
"""An update that carries the shaders over runs the game's --warm-shaders, in the game folder; a run that
|
|
fails is said and does not stop the build, and without a built game or a preparation nothing runs."""
|
|
calls = []
|
|
for name, value in (('shader_carry', lambda out: True),
|
|
('shader_run', lambda cmd, cwd=None, build_only=False: calls.append((cmd, cwd)) or self.rc)):
|
|
self.addCleanup(setattr, self.mg, name, getattr(self.mg, name))
|
|
setattr(self.mg, name, value)
|
|
self.rc = 0
|
|
self.out.mkdir(parents=True, exist_ok=True)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.mg.step_shaders(self.out, update=True)
|
|
self.assertEqual(calls, []) # no exe, no prepared list
|
|
(self.out / self.mg.EXE_NAME).write_bytes(b'MZ')
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.mg.step_shaders(self.out, update=True)
|
|
self.assertEqual(calls, []) # never prepared: nothing to build again
|
|
# a list that says what it was prepared from; the stand-in writes no seed, so there is none to differ from
|
|
(self.out / 'shader-prepared.bin').write_bytes(prepared_list(b'p' * 32, b'a' * 32, b't' * 32))
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
self.mg.step_shaders(self.out, update=True)
|
|
self.assertEqual(calls[0][0][-1], '--no-run') # the seed is written first, to compare the list with
|
|
self.assertEqual(calls[1:], [([str(self.out / self.mg.EXE_NAME), '--warm-shaders'], str(self.out))])
|
|
self.assertIn("driver's cache warm", buf.getvalue())
|
|
self.rc = 3
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
self.mg.step_shaders(self.out, update=True) # no exception: the game warms it itself
|
|
self.assertIn('did not finish (3)', buf.getvalue())
|
|
self.assertEqual(len(calls), 4)
|
|
|
|
def test_prepared_list_says_what_it_was_prepared_from(self):
|
|
"""shader-prepared.bin carries the digests of the two seed files and the translator from version 2 on
|
|
(runtime/prp_file.h); a version 1 list, or a file cut short, says nothing, and no file is None."""
|
|
self.out.mkdir(parents=True, exist_ok=True)
|
|
self.assertIsNone(self.mg.prepared_from(self.out))
|
|
lst = self.out / 'shader-prepared.bin'
|
|
lst.write_bytes(b'NX2NSPRP' + struct.pack('<II', 1, 36) + bytes(36 * 3))
|
|
self.assertEqual(self.mg.prepared_from(self.out), {})
|
|
lst.write_bytes(prepared_list(b'p' * 32, b'a' * 32, b't' * 32)[:60])
|
|
self.assertEqual(self.mg.prepared_from(self.out), {})
|
|
lst.write_bytes(prepared_list(b'p' * 32, b'a' * 32, b't' * 32, records=2))
|
|
self.assertEqual(self.mg.prepared_from(self.out),
|
|
{'programs': '70' * 32, 'answers': '61' * 32, 'translator': '74' * 32})
|
|
|
|
def test_update_prepares_again_when_the_seed_changed(self):
|
|
"""An update keeps the shaders prepared before only when their list was prepared from the seed this kit
|
|
writes: the seed is written first (--no-run) and compared with the list's head. Another seed, or a list that
|
|
does not say what it came from, runs the whole preparation; the same seed with another translator carries
|
|
over as before, and the seed files do not stay in the game folder either way."""
|
|
calls = []
|
|
|
|
def run(cmd, cwd=None, build_only=False):
|
|
calls.append(cmd[-1])
|
|
if cmd[-1] == '--no-run': # the kit's seed, as tools/prepare_shaders.py writes it
|
|
(self.out / 'seed-programs.bin').write_bytes(self.programs)
|
|
(self.out / 'seed-answers.bin').write_bytes(self.answers)
|
|
return 0
|
|
for name, value in (('shader_carry', lambda out: True), ('shader_run', run),
|
|
('pack_shader_cache', lambda out: None)):
|
|
self.addCleanup(setattr, self.mg, name, getattr(self.mg, name))
|
|
setattr(self.mg, name, value)
|
|
self.out.mkdir(parents=True, exist_ok=True)
|
|
(self.out / self.mg.EXE_NAME).write_bytes(b'MZ')
|
|
self.programs, self.answers = b'NX2NSPRG programs 1', b'NX2NSANS answers 1'
|
|
digest = lambda b: hashlib.sha256(b).digest() # noqa: E731
|
|
|
|
def update():
|
|
calls.clear()
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
self.mg.step_shaders(self.out, update=True)
|
|
self.assertFalse((self.out / 'seed-programs.bin').exists() or (self.out / 'seed-answers.bin').exists())
|
|
return buf.getvalue()
|
|
lst = self.out / 'shader-prepared.bin'
|
|
# prepared from this seed: kept, and the driver's cache is warmed
|
|
lst.write_bytes(prepared_list(digest(self.programs), digest(self.answers), b't' * 32))
|
|
self.assertIn('stay in use', update())
|
|
self.assertEqual(calls, ['--no-run', '--warm-shaders'])
|
|
# the same seed, another translator: still kept, the game carries the translations over
|
|
lst.write_bytes(prepared_list(digest(self.programs), digest(self.answers), b'u' * 32))
|
|
self.assertIn('stay in use', update())
|
|
# one byte of the seed's programs differs: everything is prepared again
|
|
self.programs = b'NX2NSPRG programs 2'
|
|
text = update()
|
|
self.assertIn('the shader seed changed since the shaders were prepared: preparing them again', text)
|
|
self.assertNotIn('stay in use', text)
|
|
self.assertEqual(calls, ['--no-run', self.mg.EXE_NAME]) # the seed, then the preparation (its last argument)
|
|
# so does a change to the answers alone
|
|
lst.write_bytes(prepared_list(digest(self.programs), digest(b'other answers'), b't' * 32))
|
|
self.assertIn('preparing them again', update())
|
|
# a list from before it recorded its seed
|
|
lst.write_bytes(b'NX2NSPRP' + struct.pack('<II', 1, 36) + bytes(36))
|
|
self.assertIn('do not say which shader seed', update())
|
|
self.assertEqual(calls, ['--no-run', self.mg.EXE_NAME])
|
|
# a preparation that then records this seed is kept by the next update
|
|
lst.write_bytes(prepared_list(digest(self.programs), digest(self.answers), b't' * 32))
|
|
self.assertIn('stay in use', update())
|
|
|
|
def test_romfs_failure_stops_the_build(self):
|
|
ran, lifting, failed = [], threading.Event(), threading.Event()
|
|
|
|
def romfs(src, out, jobs):
|
|
lifting.wait(5)
|
|
failed.set()
|
|
self.mg.fail('the RomFS image could not be written')
|
|
|
|
def lift(out, cache):
|
|
lifting.set()
|
|
failed.wait(5) # the RomFS step fails while the lift runs
|
|
time.sleep(0.1)
|
|
ran.append('lift')
|
|
|
|
def compile_(out, cache, msys, jobs, romfs_job=None, update=False):
|
|
ran.append('compile')
|
|
self.assertEqual(self.main_with(romfs, lift, compile_), 1)
|
|
self.assertEqual(ran, ['lift'])
|
|
steps = json.loads((self.out / 'build-cache' / 'state.json').read_text())['steps']
|
|
self.assertNotIn('romfs', steps)
|
|
|
|
def test_shaders_key_names_the_translator_the_build_uses(self):
|
|
"""A source checkout has its translator in build/xlate/ (tools/build_xlate.py), not in the kit's bin/: the
|
|
shaders key follows its identity, so a rebuilt translator prepares the shaders again. A kit's bin/ comes first
|
|
when both exist. A kit with no translator has a key that says so (the keys are worked out before the steps
|
|
run, steps_to_run), and the build refuses it with the way to build one when it needs the translator."""
|
|
from unittest import mock
|
|
kit = self.tmp / 'kit'
|
|
(kit / 'tools').mkdir(parents=True)
|
|
(kit / 'tools' / 'prepare_shaders.py').write_text('')
|
|
xlate = kit / 'build' / 'xlate'
|
|
xlate.mkdir(parents=True)
|
|
(xlate / self.mg.XLATE_NAME).write_bytes(b'')
|
|
(xlate / 'nx2navi_xlate.id').write_text('xlate 1\n')
|
|
|
|
def key():
|
|
return self.mg.step_key('shaders', 'game', self.out)
|
|
with mock.patch.object(self.mg, 'KIT', kit):
|
|
first = key()
|
|
(xlate / 'nx2navi_xlate.id').write_text('xlate 2\n')
|
|
self.assertNotEqual(key(), first)
|
|
(kit / 'bin').mkdir()
|
|
(kit / 'bin' / self.mg.XLATE_NAME).write_bytes(b'')
|
|
(kit / 'bin' / 'nx2navi_xlate.id').write_text('xlate 3\n')
|
|
from_bin = key()
|
|
(xlate / 'nx2navi_xlate.id').write_text('xlate 4\n')
|
|
self.assertEqual(key(), from_bin)
|
|
shutil.rmtree(kit / 'bin')
|
|
shutil.rmtree(xlate)
|
|
self.assertTrue(key().endswith('|missing'))
|
|
with contextlib.redirect_stderr(io.StringIO()) as err, self.assertRaises(SystemExit):
|
|
self.mg.xlate_source()
|
|
self.assertIn('tools/build_xlate.py', err.getvalue())
|
|
|
|
def test_prompts_step(self):
|
|
"""With button art in the kit, the last step draws the button prompts (a fake tools/pad_prompts.py), one
|
|
progress event per folder. It runs again only when the art, the bind_ lines or a folder changed, and then no
|
|
other step runs; a step before it that runs does not make it run; a kit without art skips it."""
|
|
from unittest import mock
|
|
kit = self.tmp / 'kit'
|
|
(kit / 'tools').mkdir(parents=True)
|
|
(kit / 'bin').mkdir()
|
|
(kit / 'bin' / self.mg.XLATE_NAME).write_bytes(b'') # the translator the shaders key names (xlate_source)
|
|
(kit / 'bin' / 'nx2navi_xlate.id').write_text('xlate 1\n')
|
|
(kit / 'tools' / 'prepare_shaders.py').write_text('')
|
|
for name in ('prompt_art.py', 'button_icons.py'):
|
|
shutil.copy2(ROOT / 'tools' / name, kit / 'tools' / name)
|
|
(kit / 'tools' / 'pad_prompts.py').write_text(FAKE_PAD_PROMPTS)
|
|
art = kit / 'prompt-art' / 'XGamepad' / 'Default'
|
|
art.mkdir(parents=True)
|
|
(art / 'T_X_A_White.png').write_bytes(b'picture one')
|
|
ran = []
|
|
|
|
def fake(name):
|
|
def step(*args, **kw):
|
|
ran.append(name)
|
|
return step
|
|
|
|
def build():
|
|
"""One build; (the other steps that ran, the prompts step's events, the prompt tool's runs so far)."""
|
|
ran.clear()
|
|
buf = io.StringIO()
|
|
argv = ['make_game.py', '--nup', str(self.nup), '--out', str(self.out), '--msys', str(self.msys),
|
|
'--events']
|
|
with mock.patch.object(sys, 'argv', argv), contextlib.redirect_stdout(buf), \
|
|
contextlib.redirect_stderr(io.StringIO()):
|
|
self.mg.main()
|
|
events = [json.loads(ln[2:]) for ln in buf.getvalue().splitlines() if ln.startswith('@@')]
|
|
log = self.out / 'prompt-runs.txt'
|
|
runs = log.read_text().count('run') if log.exists() else 0
|
|
return sorted(ran), [e for e in events if e.get('step') == 'prompts'], runs
|
|
|
|
def states(events):
|
|
return [e['state'] for e in events if e['ev'] == 'step']
|
|
|
|
sys.path.insert(0, str(self.layout.pydeps)) # the fake zbic
|
|
self.addCleanup(sys.path.remove, str(self.layout.pydeps))
|
|
with mock.patch.multiple(self.mg, KIT=kit, PROMPT_PACKAGES={}, EVENTS=False, step_romfs=fake('romfs'),
|
|
step_lift=fake('lift'), step_compile=fake('compile'), step_scan=fake('scan'), step_shaders=fake('shaders')):
|
|
others, ev, runs = build()
|
|
self.assertEqual((others, runs), (['compile', 'lift', 'romfs', 'scan', 'shaders'], 1))
|
|
self.assertEqual(states(ev), ['start', 'done'])
|
|
fractions = [e['fraction'] for e in ev if e['ev'] == 'progress']
|
|
sets = len(self.mg.prompt_art.OVERLAYS) # two folders per pad style and the keyboard and mouse one
|
|
self.assertEqual(sets, 7)
|
|
self.assertEqual(fractions, [round(i / sets, 4) for i in range(sets + 1)])
|
|
details = [e['detail'] for e in ev if e['ev'] == 'progress']
|
|
self.assertEqual(details[-1], f'Drew {sets} of {sets} sets of button prompts')
|
|
self.assertIn('prompts', json.loads((self.out / 'build-cache' / 'state.json').read_text())['steps'])
|
|
self.assertEqual(build(), ([], [{'ev': 'step', 'step': 'prompts', 'state': 'skip'}], 1))
|
|
# a setting that is no key binding changes nothing; a binding draws the prompts again, and only them
|
|
(self.out / 'nx2navi.ini').write_text('vsync = on\n')
|
|
self.assertEqual(build()[2], 1)
|
|
(self.out / 'nx2navi.ini').write_text('vsync = on\nbind_a = key:f # jump\n')
|
|
others, ev, runs = build()
|
|
self.assertEqual((others, states(ev), runs), ([], ['start', 'done'], 2))
|
|
# a folder gone, or new art: drawn again
|
|
shutil.rmtree(self.out / 'romfs-patches-pc')
|
|
self.assertEqual(build()[2], 3)
|
|
(art / 'T_X_B_White.png').write_bytes(b'picture two')
|
|
self.assertEqual(build()[2], 4)
|
|
# the lift running again makes the compile run, not the shaders (their key names the lift and the
|
|
# compile they depend on, make_game.py UNAFFECTED) nor the prompts
|
|
st = json.loads((self.out / 'build-cache' / 'state.json').read_text())
|
|
del st['steps']['lift']
|
|
(self.out / 'build-cache' / 'state.json').write_text(json.dumps(st))
|
|
others, ev, runs = build()
|
|
self.assertEqual((others, states(ev), runs), (['compile', 'lift'], ['skip'], 4))
|
|
# a kit without art: skipped, the folders an earlier build drew stay
|
|
shutil.rmtree(kit / 'prompt-art')
|
|
self.assertEqual(build(), ([], [{'ev': 'step', 'step': 'prompts', 'state': 'skip'}], 4))
|
|
self.assertTrue((self.out / 'romfs-patches-xbox' / 'manifest.tsv').is_file())
|
|
|
|
def test_an_update_keeps_the_settings_file(self):
|
|
"""The rebuild an update ends with (make_game.py --rebuild under a newer kit: the lift, the compile, the
|
|
shaders step and the button prompts all run again) leaves the game folder's nx2navi.ini as the player has it,
|
|
byte for byte. The build reads the file (the bind_ lines for the prompts, shader_carry for the shaders step)
|
|
and never writes it; so does a second rebuild that finds nothing to do, and one from the game files again.
|
|
The lift and the compile are stand-ins here (the real ones need the game and clang); the check, the ExeFS and
|
|
RomFS steps, the shaders step and the prompts step are the real ones."""
|
|
from unittest import mock
|
|
kit = self.tmp / 'kit'
|
|
for d in ('tools', 'bin', 'recomp'):
|
|
(kit / d).mkdir(parents=True)
|
|
(kit / 'VERSION').write_text('0.7.0\n')
|
|
(kit / 'recomp' / 'gen.py').write_text('# the lifter of 0.7.0\n')
|
|
(kit / 'bin' / self.mg.XLATE_NAME).write_bytes(b'translator')
|
|
(kit / 'bin' / 'nx2navi_xlate.id').write_text('xlate 1\n')
|
|
(kit / 'tools' / 'prepare_shaders.py').write_text('print("prepare-shaders: progress build 1 1", flush=True)\n')
|
|
for name in ('prompt_art.py', 'button_icons.py'):
|
|
shutil.copy2(ROOT / 'tools' / name, kit / 'tools' / name)
|
|
(kit / 'tools' / 'pad_prompts.py').write_text(FAKE_PAD_PROMPTS)
|
|
art = kit / 'prompt-art' / 'XGamepad' / 'Default'
|
|
art.mkdir(parents=True)
|
|
(art / 'T_X_A_White.png').write_bytes(b'picture one')
|
|
ran = []
|
|
|
|
def fake(name):
|
|
def step(*args, **kw):
|
|
ran.append(name)
|
|
return step
|
|
|
|
def build(*source):
|
|
"""One run of make_game.py; the steps that ran, in order."""
|
|
buf = io.StringIO()
|
|
argv = ['make_game.py', *source, '--out', str(self.out), '--msys', str(self.msys), '--events']
|
|
with mock.patch.object(sys, 'argv', argv), contextlib.redirect_stdout(buf), \
|
|
contextlib.redirect_stderr(io.StringIO()):
|
|
self.mg.main()
|
|
events = [json.loads(ln[2:]) for ln in buf.getvalue().splitlines() if ln.startswith('@@')]
|
|
return [e['step'] for e in events if e['ev'] == 'step' and e['state'] == 'start']
|
|
|
|
sys.path.insert(0, str(self.layout.pydeps)) # the fake zbic
|
|
self.addCleanup(sys.path.remove, str(self.layout.pydeps))
|
|
ini = self.out / 'nx2navi.ini'
|
|
with mock.patch.multiple(self.mg, KIT=kit, PROMPT_PACKAGES={}, EVENTS=False, step_lift=fake('lift'),
|
|
step_compile=fake('compile'), step_scan=fake('scan')):
|
|
self.assertEqual(build('--nup', str(self.nup)),
|
|
['check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts', 'scan'])
|
|
self.assertFalse(ini.exists()) # the build makes no settings file: the game's menu does
|
|
ini.write_bytes(PLAYER_INI)
|
|
names = sorted(p.name for p in self.out.iterdir())
|
|
# the update: a kit with another lifter and another version
|
|
(kit / 'VERSION').write_text('0.7.1\n')
|
|
(kit / 'recomp' / 'gen.py').write_text('# the lifter of 0.7.1\n')
|
|
ran.clear()
|
|
steps = build('--rebuild')
|
|
self.assertEqual(steps, ['check', 'lift', 'compile', 'shaders', 'prompts']) # the bind_ lines are new to it
|
|
self.assertEqual(ran, ['lift', 'compile'])
|
|
self.assertEqual(ini.read_bytes(), PLAYER_INI)
|
|
self.assertEqual(sorted(p.name for p in self.out.iterdir()), names) # no copy, no backup, no temporary file
|
|
self.assertEqual(json.loads((self.out / 'build-cache' / 'state.json').read_text())['kit'], '0.7.1')
|
|
self.assertEqual(build('--rebuild'), ['check']) # nothing to do
|
|
self.assertEqual(ini.read_bytes(), PLAYER_INI)
|
|
# from the game files again into the same folder (Rebuild the Game without a build to start from)
|
|
st = json.loads((self.out / 'build-cache' / 'state.json').read_text())
|
|
st['steps'] = {}
|
|
(self.out / 'build-cache' / 'state.json').write_text(json.dumps(st))
|
|
self.assertEqual(build('--nup', str(self.nup)),
|
|
['check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts', 'scan'])
|
|
self.assertEqual(ini.read_bytes(), PLAYER_INI)
|
|
|
|
@unittest.skipIf(os.name == 'nt', 'uses a POSIX script as the compiler')
|
|
def test_largest_source_compiles_first(self):
|
|
src, obj = self.tmp / 'src', self.tmp / 'obj'
|
|
src.mkdir()
|
|
obj.mkdir()
|
|
for name, size in (('small.c', 10), ('big.c', 5000), ('mid.c', 800)):
|
|
(src / name).write_text('/*' + 'x' * size + '*/\n')
|
|
order = self.tmp / 'order.txt'
|
|
cc = self.tmp / 'fakecc'
|
|
cc.write_text(f'#!{sys.executable}\nimport sys\nargs = sys.argv[1:]\n'
|
|
'dep, out, s = args[args.index("-MF") + 1], args[args.index("-o") + 1], args[-1]\n'
|
|
f'open({str(order)!r}, "a").write(s.rsplit("/", 1)[-1] + "\\n")\n'
|
|
'open(out, "w").close(); open(dep, "w").write(out + ": " + s + "\\n")\n')
|
|
cc.chmod(0o755)
|
|
srcs = [(src / n, obj / (n[:-2] + '.o')) for n in ('small.c', 'big.c', 'mid.c')]
|
|
cache = self.mg.ObjectCache(obj / 'objects.json')
|
|
compiled, reused, bad = self.mg.compile_all(srcs, lambda s, d: [str(cc), '-c', '-o', str(d), str(s)], cache,
|
|
1, dict(os.environ))
|
|
self.assertEqual((compiled, bad), (3, []))
|
|
self.assertEqual(order.read_text().split(), ['big.c', 'mid.c', 'small.c'])
|
|
|
|
def fake_compiles(self, count, held=None):
|
|
"""count sources of growing size in a folder, and a stand-in for the compiler process (subprocess.run in
|
|
make_game.py) that writes the object and its dependency file. held: (name, event) makes that one source's
|
|
compile wait for the event. Returns (sources and objects, the stand-in, the object folder)."""
|
|
src, obj = self.tmp / 'src', self.tmp / 'obj'
|
|
src.mkdir()
|
|
obj.mkdir()
|
|
srcs = []
|
|
for i in range(count):
|
|
(src / f'f{i:02d}.c').write_text(f'int f{i};' + ' ' * (10 * i) + '\n')
|
|
srcs.append((src / f'f{i:02d}.c', obj / f'f{i:02d}.o'))
|
|
|
|
def run(cmd, **kw):
|
|
dep, out, source = cmd[cmd.index('-MF') + 1], cmd[cmd.index('-o') + 1], cmd[-1]
|
|
if held and Path(source).name == held[0]:
|
|
held[1].wait(20)
|
|
Path(out).write_bytes(b'object')
|
|
Path(dep).write_text(out.replace('\\', '/') + ': ' + source.replace('\\', '/') + '\n')
|
|
return mock.Mock(returncode=0, stderr='')
|
|
return srcs, run, obj
|
|
|
|
def test_a_cut_compile_keeps_its_finished_objects(self):
|
|
"""The object cache goes to disk every CACHE_SAVE_EVERY objects while the compile runs, so a compile that
|
|
ends in the middle (Cancel ends the process) keeps what it finished; it used to be written only at the end."""
|
|
srcs, run, obj = self.fake_compiles(60)
|
|
on_disk = []
|
|
|
|
class Cut(Exception):
|
|
pass
|
|
|
|
def cut_at_40(done, total):
|
|
path = obj / 'objects.json'
|
|
on_disk.append(len(json.loads(path.read_text())) if path.is_file() else 0)
|
|
if done == 40:
|
|
raise Cut # the process ends here
|
|
|
|
def go(progress=None):
|
|
cache = self.mg.ObjectCache(obj / 'objects.json')
|
|
return self.mg.compile_all(srcs, lambda s, d: ['cc', '-c', '-o', str(d), str(s)], cache, 1,
|
|
dict(os.environ), progress)
|
|
with mock.patch.object(self.mg.subprocess, 'run', run), mock.patch.object(self.mg, 'CACHE_SAVE_PAUSE', 0):
|
|
with self.assertRaises(Cut):
|
|
go(cut_at_40)
|
|
self.assertEqual(on_disk[24:27], [0, 25, 25]) # nothing before the 25th object, then all 25
|
|
self.assertFalse((obj / 'objects.tmp').exists())
|
|
self.assertEqual(go()[:2], (35, 25)) # the next run compiles only the 35 not saved
|
|
self.assertEqual(go()[:2], (0, 60))
|
|
|
|
def test_objects_are_recorded_while_the_largest_file_compiles(self):
|
|
"""The largest file starts first and can compile for minutes: the objects that finish meanwhile are recorded
|
|
and saved, not held back until the largest one is done."""
|
|
release = threading.Event()
|
|
srcs, run, obj = self.fake_compiles(30, held=('f29.c', release)) # f29.c is the largest
|
|
seen = []
|
|
|
|
def progress(done, total):
|
|
path = obj / 'objects.json'
|
|
if not release.is_set() and path.is_file() and len(json.loads(path.read_text())) >= 25:
|
|
seen.append(done)
|
|
release.set() # 25 others are on disk while the largest still compiles
|
|
cache = self.mg.ObjectCache(obj / 'objects.json')
|
|
with mock.patch.object(self.mg.subprocess, 'run', run), mock.patch.object(self.mg, 'CACHE_SAVE_PAUSE', 0):
|
|
compiled, reused, bad = self.mg.compile_all(srcs, lambda s, d: ['cc', '-c', '-o', str(d), str(s)], cache,
|
|
4, dict(os.environ), progress)
|
|
self.assertEqual((compiled, reused, bad), (30, 0, []))
|
|
self.assertTrue(seen, 'nothing was saved before the largest file finished')
|
|
self.assertEqual(len(json.loads((obj / 'objects.json').read_text())), 30)
|
|
|
|
def test_the_default_job_count_follows_the_free_memory(self):
|
|
"""Without --jobs: one compile per processor, capped at 80 % of the free memory over 768 MiB a compile, and
|
|
never fewer than one."""
|
|
gib = 1 << 30
|
|
jobs = self.mg.default_jobs
|
|
self.assertEqual(jobs(24, 64 * gib)[0], 24) # memory to spare: every processor
|
|
self.assertEqual(jobs(16, 8 * gib)[0], 8) # 6.4 GiB at 768 MiB each
|
|
self.assertEqual(jobs(32, 16 * gib)[0], 17)
|
|
self.assertEqual(jobs(4, 15 * gib)[0], 4)
|
|
self.assertEqual(jobs(8, 512 << 20)[0], 1) # too little for one: still one
|
|
self.assertEqual(jobs(8, 0)[0], 1)
|
|
self.assertIn('8.0 GiB of free memory allow 8', jobs(16, 8 * gib)[1])
|
|
import build_game
|
|
self.assertEqual(self.mg.JOB_MEM, build_game.JOB_MEM) # one measured figure, kept in both scripts
|
|
# this machine's figures when none are given; without a reading of the memory, the processors alone
|
|
import hostmem
|
|
with mock.patch.object(self.mg.os, 'cpu_count', return_value=12), \
|
|
mock.patch.object(hostmem, 'available_ram', return_value=3 * gib):
|
|
self.assertEqual(self.mg.default_jobs()[0], 3)
|
|
with mock.patch.object(self.mg.os, 'cpu_count', return_value=12), \
|
|
mock.patch.object(hostmem, 'available_ram', return_value=None):
|
|
self.assertEqual(self.mg.default_jobs(), (12, '12 processors, free memory unknown'))
|
|
|
|
def test_the_build_uses_the_default_job_count(self):
|
|
"""main() without --jobs takes default_jobs and says so; --jobs is taken as given."""
|
|
made_up_game(self.out)
|
|
seen = []
|
|
|
|
def compile_(out, cache, msys, jobs, romfs_job=None, update=False):
|
|
seen.append(jobs)
|
|
self.mg.fail('stop here')
|
|
with mock.patch.object(self.mg, 'default_jobs', lambda: (3, 'stubbed')):
|
|
code, text = self.main_rebuild(lambda out, cache: None, compile_)
|
|
self.assertIn('compiles at a time: 3 (stubbed)', text)
|
|
code, text = self.main_rebuild(lambda out, cache: None, compile_, '--jobs', '5')
|
|
self.assertIn('compiles at a time: 5 (--jobs)', text)
|
|
self.assertEqual(seen, [3, 5])
|
|
|
|
def test_runtime_split(self):
|
|
tainted = self.mg.per_install_names()
|
|
self.assertTrue({'layout.h', 'patches.h', 'romfs_config.h'} <= tainted)
|
|
names = {p.name: self.mg.per_install(p, tainted) for p, _ in self.mg.runtime_sources()}
|
|
for f in ('loader.c', 'memory.c', 'romfs.c', 'patches.c'):
|
|
self.assertTrue(names[f], f)
|
|
self.assertFalse(names['vk_device.c'])
|
|
self.assertLess(sum(names.values()), len(names) // 4) # most of the runtime can come prebuilt
|
|
|
|
def test_parse_deps(self):
|
|
text = 'C:/o/x.o: C:/src/x.c C:/inc/a\\ b.h \\\n C:/inc/c.h\n'
|
|
self.assertEqual(self.mg.parse_deps(text), ['C:/src/x.c', 'C:/inc/a b.h', 'C:/inc/c.h'])
|
|
|
|
def test_copy_if_changed_equal_metadata(self):
|
|
# Windows has no POSIX executable mode and cannot replace read-only destinations.
|
|
modes = (0o666,) if os.name == 'nt' else (0o444, 0o555)
|
|
for mode in modes:
|
|
with self.subTest(mode=oct(mode)):
|
|
src, dst = self.tmp / f'source-{mode}.id', self.tmp / f'installed-{mode}.id'
|
|
for path, data in ((src, b'7' * 64 + b'\n'), (dst, b'1' * 64 + b'\n')):
|
|
path.write_bytes(data)
|
|
os.utime(path, (1, 1)) # Build artifacts can share a normalized modification time.
|
|
path.chmod(mode)
|
|
self.assertEqual(src.stat().st_size, dst.stat().st_size)
|
|
self.assertEqual(src.stat().st_mtime_ns, dst.stat().st_mtime_ns)
|
|
self.assertTrue(self.mg.copy_if_changed(src, dst))
|
|
self.assertEqual(dst.read_bytes(), src.read_bytes())
|
|
self.assertEqual(dst.stat().st_mode & 0o777, src.stat().st_mode & 0o777)
|
|
self.assertFalse(self.mg.copy_if_changed(src, dst))
|
|
|
|
def test_copy_if_changed_makes_no_folder(self):
|
|
"""A network share can refuse the owner-only folder TemporaryDirectory makes: the copy needs a file only, and
|
|
leaves nothing behind, also when the copy fails."""
|
|
out = self.tmp / 'share'
|
|
out.mkdir()
|
|
src, dst = self.tmp / 'new.bin', out / 'installed.bin'
|
|
src.write_bytes(b'new')
|
|
dst.write_bytes(b'old')
|
|
refused = mock.patch.object(self.mg.tempfile, 'TemporaryDirectory', side_effect=PermissionError('refused'))
|
|
with refused, mock.patch.object(self.mg.os, 'mkdir', side_effect=PermissionError('refused')):
|
|
self.assertTrue(self.mg.copy_if_changed(src, dst))
|
|
self.assertEqual(dst.read_bytes(), b'new')
|
|
self.assertEqual([p.name for p in out.iterdir()], ['installed.bin'])
|
|
src.write_bytes(b'newer')
|
|
with mock.patch.object(self.mg.shutil, 'copy2', side_effect=OSError('the share went away')):
|
|
with self.assertRaises(OSError):
|
|
self.mg.copy_if_changed(src, dst)
|
|
self.assertEqual(dst.read_bytes(), b'new') # the installed file is as it was
|
|
self.assertEqual([p.name for p in out.iterdir()], ['installed.bin'])
|
|
|
|
def test_source_size_waits_for_a_share(self):
|
|
"""A share that stops answering for a moment: the source's folder is not there either, and the size is asked
|
|
again. A folder that answers without the file ends the build at once."""
|
|
src = self.tmp / 'lift' / 'a.c'
|
|
src.parent.mkdir()
|
|
src.write_bytes(b'int a;\n')
|
|
real_stat, real_is_dir = Path.stat, Path.is_dir
|
|
down = {'left': 3}
|
|
|
|
def stat(p, *a, **k):
|
|
if p == src and down['left'] > 0:
|
|
raise FileNotFoundError(2, 'The network path was not found', str(p))
|
|
return real_stat(p, *a, **k)
|
|
|
|
def is_dir(p): # each look at the folder is one try nearer to the share's return
|
|
if p == src.parent and down['left'] > 0:
|
|
down['left'] -= 1
|
|
return False
|
|
return real_is_dir(p)
|
|
|
|
lines = []
|
|
with mock.patch.object(Path, 'stat', stat), mock.patch.object(Path, 'is_dir', is_dir), \
|
|
mock.patch.object(self.mg.time, 'sleep') as sleep, mock.patch.object(self.mg, 'say', lines.append):
|
|
self.assertEqual(self.mg.source_size(src), 7)
|
|
self.assertEqual(sleep.call_count, 3) # three tries found the share down
|
|
self.assertEqual(len(lines), 1) # said once, not at every try
|
|
# the share never comes back: the build stops after SOURCE_WAIT and names the drive
|
|
down['left'] = 10 ** 6
|
|
clock = iter(range(0, 10 ** 6, 5))
|
|
with mock.patch.object(Path, 'stat', stat), mock.patch.object(Path, 'is_dir', is_dir), \
|
|
mock.patch.object(self.mg.time, 'sleep'), mock.patch.object(self.mg, 'say'), \
|
|
mock.patch.object(self.mg.time, 'monotonic', lambda: next(clock)), \
|
|
mock.patch.object(self.mg, 'fail', side_effect=SystemExit) as fail:
|
|
with self.assertRaises(SystemExit):
|
|
self.mg.source_size(src)
|
|
self.assertIn('stopped answering', fail.call_args[0][0])
|
|
# the file is gone and its folder answers: no wait
|
|
down['left'] = 0
|
|
src.unlink()
|
|
with mock.patch.object(self.mg.time, 'sleep') as sleep, \
|
|
mock.patch.object(self.mg, 'fail', side_effect=SystemExit) as fail:
|
|
with self.assertRaises(SystemExit):
|
|
self.mg.source_size(src)
|
|
sleep.assert_not_called()
|
|
self.assertIn('lift folder', fail.call_args[0][0])
|
|
|
|
def test_out_path_keeps_a_drive_letter(self):
|
|
"""Windows: --out is made absolute without resolve(), which would turn a mapped drive into its UNC name."""
|
|
with mock.patch.object(self.mg, 'WIN', True), \
|
|
mock.patch.object(Path, 'resolve', side_effect=AssertionError('resolve() was called')):
|
|
got = self.mg.out_path('game')
|
|
self.assertEqual(got, Path(os.path.abspath('game')))
|
|
self.assertTrue(got.is_absolute())
|
|
with mock.patch.object(self.mg, 'WIN', False):
|
|
self.assertEqual(self.mg.out_path(self.tmp / 'x' / '..' / 'game'), (self.tmp / 'game').resolve())
|
|
|
|
@unittest.skipUnless(shutil.which('cc'), 'needs a C compiler')
|
|
def test_object_cache(self):
|
|
src = self.tmp / 'src'
|
|
src.mkdir()
|
|
(src / 'h.h').write_text('#define V 1\n')
|
|
(src / 'a.c').write_text('#include "h.h"\nint a(void) { return V; }\n')
|
|
(src / 'b.c').write_text('int b(void) { return 2; }\n')
|
|
obj = self.tmp / 'obj'
|
|
obj.mkdir()
|
|
srcs = [(src / 'a.c', obj / 'a.o'), (src / 'b.c', obj / 'b.o')]
|
|
|
|
def cmd_for(s, d):
|
|
return ['cc', '-c', '-I', str(src), '-o', str(d), str(s)]
|
|
|
|
def go():
|
|
cache = self.mg.ObjectCache(obj / 'objects.json')
|
|
return self.mg.compile_all(srcs, cmd_for, cache, 2, dict(os.environ), salt='cc')
|
|
self.assertEqual(go()[:2], (2, 0))
|
|
self.assertEqual(go()[:2], (0, 2))
|
|
(src / 'h.h').write_text('#define V 3\n') # a header: only a.c includes it
|
|
self.assertEqual(go()[:2], (1, 1))
|
|
(src / 'b.c').write_text('int b(void) { return syntax error }\n')
|
|
compiled, reused, bad = go()
|
|
self.assertEqual((compiled, len(bad)), (1, 1))
|
|
self.assertNotIn('b.o', json.loads((obj / 'objects.json').read_text()))
|
|
|
|
def test_dlss_beside_the_game(self):
|
|
"""The kit's nvngx_dlss.dll and its notice go beside the game; a newer kit replaces the copy an earlier build
|
|
placed, and a DLSS the player put there is kept."""
|
|
import dlss_pin
|
|
from unittest import mock
|
|
kit, out = self.tmp / 'kit', self.tmp / 'game'
|
|
(kit / 'bin').mkdir(parents=True)
|
|
out.mkdir()
|
|
|
|
def kit_dll(data, when):
|
|
(kit / 'bin' / 'nvngx_dlss.dll').write_bytes(data)
|
|
os.utime(kit / 'bin' / 'nvngx_dlss.dll', (when, when))
|
|
(kit / 'bin' / 'nvngx_dlss-NOTICE.txt').write_text(f'NVIDIA DLSS\n\nsha256 {hashlib.sha256(data).hexdigest()}\n')
|
|
|
|
dll, note = out / 'nvngx_dlss.dll', out / 'nvngx_dlss-NOTICE.txt'
|
|
with mock.patch.object(self.mg, 'KIT', kit):
|
|
self.assertIsNone(self.mg.place_dlss(out)) # a kit without DLSS: nothing
|
|
self.assertFalse(dll.exists())
|
|
kit_dll(b'dlss one', 1_700_000_000)
|
|
self.assertIsNone(self.mg.place_dlss(out))
|
|
self.assertEqual(dll.read_bytes(), b'dlss one')
|
|
self.assertTrue(note.is_file())
|
|
self.assertIsNone(self.mg.place_dlss(out)) # unchanged
|
|
kit_dll(b'dlss two', 1_700_100_000) # a newer kit: the copy the last build placed goes
|
|
self.assertIsNone(self.mg.place_dlss(out))
|
|
self.assertEqual(dll.read_bytes(), b'dlss two')
|
|
self.assertIn(hashlib.sha256(b'dlss two').hexdigest(), note.read_text())
|
|
dll.write_bytes(b"the player's own DLSS") # another version the player put there: kept
|
|
self.assertIn('kept', self.mg.place_dlss(out))
|
|
self.assertEqual(dll.read_bytes(), b"the player's own DLSS")
|
|
# the notice the release scripts write is the one make_game reads
|
|
(self.tmp / 'n.txt').write_text(dlss_pin.notice_text(), encoding='utf-8')
|
|
self.assertEqual(self.mg.notice_sha256(self.tmp / 'n.txt'), dlss_pin.SHA256)
|
|
self.assertEqual((dlss_pin.NAME, dlss_pin.NOTICE), (self.mg.DLSS_DLL, self.mg.DLSS_NOTICE))
|
|
|
|
def test_dlss_library_beside_the_game_on_linux(self):
|
|
"""The kit's libnvidia-ngx-dlss.so.<version> and its notice go beside the game. The name carries the DLSS
|
|
version, so a kit with another version replaces the file an earlier build placed under its old name, and a
|
|
library the player put there, of any version, is kept. Each platform places only its own library."""
|
|
from unittest import mock
|
|
kit, out = self.tmp / 'kit', self.tmp / 'game'
|
|
(kit / 'bin').mkdir(parents=True)
|
|
out.mkdir()
|
|
note = out / 'libnvidia-ngx-dlss-NOTICE.txt'
|
|
|
|
def kit_lib(version, data, when):
|
|
for old in (kit / 'bin').glob('libnvidia-ngx-dlss.so.*'):
|
|
old.unlink()
|
|
lib = kit / 'bin' / f'libnvidia-ngx-dlss.so.{version}'
|
|
lib.write_bytes(data)
|
|
os.utime(lib, (when, when))
|
|
(kit / 'bin' / 'libnvidia-ngx-dlss-NOTICE.txt').write_text(
|
|
f'NVIDIA DLSS {version}\n\nsha256 {hashlib.sha256(data).hexdigest()}\n')
|
|
|
|
def beside(folder=out):
|
|
return sorted(p.name for p in folder.iterdir())
|
|
|
|
with mock.patch.object(self.mg, 'KIT', kit):
|
|
self.assertIsNone(self.mg.place_dlss(out, linux=True)) # a kit without DLSS: nothing
|
|
self.assertEqual(beside(), [])
|
|
(kit / 'bin' / 'nvngx_dlss.dll').write_bytes(b'the Windows DLL')
|
|
self.assertIsNone(self.mg.place_dlss(out, linux=True)) # the Windows DLL is not a Linux build's
|
|
self.assertEqual(beside(), [])
|
|
kit_lib('310.9.1', b'dlss one', 1_700_000_000)
|
|
self.assertIsNone(self.mg.place_dlss(out, linux=True))
|
|
self.assertEqual(beside(), ['libnvidia-ngx-dlss-NOTICE.txt', 'libnvidia-ngx-dlss.so.310.9.1'])
|
|
self.assertEqual((out / 'libnvidia-ngx-dlss.so.310.9.1').read_bytes(), b'dlss one')
|
|
self.assertIn(hashlib.sha256(b'dlss one').hexdigest(), note.read_text())
|
|
self.assertIsNone(self.mg.place_dlss(out, linux=True)) # unchanged
|
|
self.assertEqual(len(beside()), 2)
|
|
kit_lib('310.10.0', b'dlss two', 1_700_100_000) # another version: the earlier build's file goes
|
|
self.assertIsNone(self.mg.place_dlss(out, linux=True))
|
|
self.assertEqual(beside(), ['libnvidia-ngx-dlss-NOTICE.txt', 'libnvidia-ngx-dlss.so.310.10.0'])
|
|
self.assertEqual((out / 'libnvidia-ngx-dlss.so.310.10.0').read_bytes(), b'dlss two')
|
|
self.assertIn(hashlib.sha256(b'dlss two').hexdigest(), note.read_text())
|
|
# a library the player put there first, of another version: kept, and the kit's is not placed beside it
|
|
mine = self.tmp / 'mine'
|
|
mine.mkdir()
|
|
(mine / 'libnvidia-ngx-dlss.so.310.4.0').write_bytes(b"the player's own DLSS")
|
|
self.assertIn('libnvidia-ngx-dlss.so.310.4.0 kept', self.mg.place_dlss(mine, linux=True))
|
|
self.assertEqual(beside(mine), ['libnvidia-ngx-dlss.so.310.4.0'])
|
|
# the same version with other bytes (the player's own build) is kept too
|
|
(mine / 'libnvidia-ngx-dlss.so.310.4.0').unlink()
|
|
(mine / 'libnvidia-ngx-dlss.so.310.10.0').write_bytes(b"the player's own build")
|
|
self.assertIn('kept', self.mg.place_dlss(mine, linux=True))
|
|
self.assertEqual((mine / 'libnvidia-ngx-dlss.so.310.10.0').read_bytes(), b"the player's own build")
|
|
# a player's library put beside a library an earlier build placed: both stay
|
|
(out / 'libnvidia-ngx-dlss.so.310.4.0').write_bytes(b"the player's own DLSS")
|
|
self.assertIn('kept', self.mg.place_dlss(out, linux=True))
|
|
self.assertEqual(len(beside()), 3)
|
|
# and a Windows build takes the DLL, not the library
|
|
win = self.tmp / 'win'
|
|
win.mkdir()
|
|
self.assertIsNone(self.mg.place_dlss(win))
|
|
self.assertEqual(beside(win), ['nvngx_dlss.dll'])
|
|
|
|
def test_dlss_pin_for_linux(self):
|
|
"""The Linux library is pinned beside the Windows DLL, at the same DLSS version with its own size and sha256.
|
|
A file on disk must be the pinned one, a missing one is downloaded into the cache under its own name, and the
|
|
notice names the library and carries the sha256 line make_game.py reads."""
|
|
import dlss_pin
|
|
from unittest import mock
|
|
p = dlss_pin.pin(linux=True)
|
|
self.assertEqual(p.name, f'libnvidia-ngx-dlss.so.{dlss_pin.VERSION}')
|
|
self.assertTrue(p.name.startswith(self.mg.DLSS_SO_PREFIX))
|
|
self.assertFalse(p.notice.startswith(self.mg.DLSS_SO_PREFIX)) # the runtime takes that prefix for the library
|
|
self.assertEqual(p.notice, self.mg.DLSS_SO_NOTICE)
|
|
self.assertEqual(dlss_pin.pin().name, dlss_pin.NAME) # the default is still the Windows DLL
|
|
self.assertNotEqual((p.size, p.sha256), (dlss_pin.SIZE, dlss_pin.SHA256))
|
|
(self.tmp / 'n.txt').write_text(dlss_pin.notice_text(linux=True), encoding='utf-8')
|
|
self.assertEqual(self.mg.notice_sha256(self.tmp / 'n.txt'), p.sha256)
|
|
self.assertIn(p.name, dlss_pin.notice_text(linux=True))
|
|
self.assertNotIn('nvngx_dlss.dll', dlss_pin.notice_text(linux=True))
|
|
data = b'a DLSS library'
|
|
lib, cache = self.tmp / 'file.so', self.tmp / 'dl'
|
|
lib.write_bytes(data)
|
|
fetched = []
|
|
|
|
def download(url, dest, sha256=None, size=None):
|
|
fetched.append((url, Path(dest).name, sha256, size))
|
|
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(dest).write_bytes(data)
|
|
return dest
|
|
|
|
pinned = dict(LINUX_SIZE=len(data), LINUX_SHA256=hashlib.sha256(data).hexdigest(),
|
|
SIZE=len(data) + 1, SHA256=hashlib.sha256(data + b'!').hexdigest())
|
|
with mock.patch.multiple(dlss_pin, **pinned), mock.patch('launcher.net.download', download), \
|
|
contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(dlss_pin.resolve(lib, cache, linux=True), lib)
|
|
with self.assertRaises(SystemExit): # the Windows DLL's pin does not accept the Linux file
|
|
dlss_pin.resolve(lib, cache)
|
|
lib.write_bytes(b'another DLSS!')
|
|
with self.assertRaises(SystemExit):
|
|
dlss_pin.resolve(lib, cache, linux=True)
|
|
with self.assertRaises(SystemExit):
|
|
dlss_pin.resolve(self.tmp / 'missing.so', cache, linux=True)
|
|
self.assertEqual(fetched, [])
|
|
self.assertEqual(dlss_pin.resolve(None, cache, linux=True), cache / p.name)
|
|
self.assertEqual(fetched, [(p.url, p.name, hashlib.sha256(data).hexdigest(), len(data))])
|
|
self.assertEqual(dlss_pin.resolve(None, cache), cache / f'nvngx_dlss-{dlss_pin.VERSION}.dll')
|
|
self.assertEqual(fetched[1][:2], (dlss_pin.URL, f'nvngx_dlss-{dlss_pin.VERSION}.dll'))
|
|
bin_dir = self.tmp / 'bin'
|
|
bin_dir.mkdir()
|
|
dlss_pin.place(cache / p.name, bin_dir, linux=True)
|
|
self.assertEqual((bin_dir / p.notice).read_text(encoding='utf-8'), dlss_pin.notice_text(linux=True))
|
|
self.assertEqual(sorted(x.name for x in bin_dir.iterdir()), sorted([p.notice, p.name]))
|
|
self.assertEqual((bin_dir / p.name).read_bytes(), data)
|
|
|
|
def test_compile_key_ignores_file_time_and_sysroot(self):
|
|
"""The compiler's id leaves its file time out (a tool update that puts the same compiler back keeps every
|
|
object), and the key leaves --sysroot out (the fast compiler's objects are the UCRT64 compiler's)."""
|
|
exe = self.tmp / 'cc.exe'
|
|
shutil.copy2(sys.executable, exe)
|
|
a = self.mg.compiler_ids(exe, dict(os.environ))
|
|
os.utime(exe, (1_700_000_000, 1_700_000_000))
|
|
b = self.mg.compiler_ids(exe, dict(os.environ))
|
|
self.assertEqual(a[0], b[0])
|
|
self.assertNotEqual(a[1], b[1]) # the form up to 0.6.0 had it
|
|
src, dst = self.tmp / 'a.c', self.tmp / 'a.o'
|
|
src.write_text('int a;')
|
|
cache = self.mg.ObjectCache(self.tmp / 'objects.json', {'<kit>': self.tmp / 'kit'})
|
|
plain = ['clang.exe', '-c', '-O1', '-o', str(dst), str(src)]
|
|
fast = ['clang.exe', '--sysroot=C:/msys64/ucrt64', '-c', '-O1', '-o', str(dst), str(src)]
|
|
self.assertEqual(self.mg.object_key(plain, src, dst, cache, 'id'), self.mg.object_key(fast, src, dst, cache, 'id'))
|
|
self.assertNotEqual(self.mg.object_key(plain, src, dst, cache, 'id', sysroot=True),
|
|
self.mg.object_key(fast, src, dst, cache, 'id', sysroot=True))
|
|
|
|
def test_vulkan_headers_go_first_for_the_runtime_only(self):
|
|
"""The folder of the pinned Vulkan headers goes ahead of every other include folder for the runtime's sources,
|
|
so that no folder that also is a system one answers for <vulkan/vulkan.h>; the lifted code's command, which is
|
|
part of its objects' keys, is the one it was."""
|
|
inc = ['-I', 'rt', '-I', '/usr/include']
|
|
with_vk = self.mg.runtime_cmd('clang', 'clang++', inc, 3, first=['-I', 'kit/vulkan'])
|
|
without = self.mg.runtime_cmd('clang', 'clang++', inc, 3)
|
|
guest = (Path('lift/main_0001.c'), Path('obj/main_main_0001.o'))
|
|
self.assertEqual(with_vk(*guest), without(*guest))
|
|
for src in (Path('rt/vk_device.c'), Path('rt/ui/menu.cpp')):
|
|
cmd, plain = with_vk(src, Path('obj/rt_x.o')), without(src, Path('obj/rt_x.o'))
|
|
self.assertEqual(cmd.count('-I'), plain.count('-I') + 1)
|
|
self.assertLess(cmd.index('kit/vulkan'), cmd.index('rt'))
|
|
self.assertLess(cmd.index('kit/vulkan'), cmd.index('/usr/include'))
|
|
|
|
def test_early_check_tries_the_runtime_before_the_long_steps(self):
|
|
"""The check of a Linux build runs the compile step's own command with -fsyntax-only on each source and links
|
|
an empty program against the game's libraries. When files fail it stops with every one of them named (the
|
|
compile step named three of four), and a missing header is told from an old one."""
|
|
srcs = [self.tmp / n for n in ('good.c', 'bad1.c', 'bad2.c', 'bad3.c', 'bad4.cpp')]
|
|
for s in srcs:
|
|
s.write_text('int x;\n')
|
|
ran = []
|
|
|
|
def compiler(errors):
|
|
def run(cmd, **kw):
|
|
ran.append([str(c) for c in cmd])
|
|
name = next((Path(c).name for c in cmd if Path(str(c)).name.startswith(('good', 'bad'))), '')
|
|
bad = name.startswith('bad')
|
|
return subprocess.CompletedProcess(cmd, 1 if bad else 0, '', errors.get(name, 'error: old') if bad else '')
|
|
return run
|
|
|
|
def check(errors, sources):
|
|
ran.clear()
|
|
err = io.StringIO()
|
|
with mock.patch.multiple(self.mg, toolchain=lambda msys: (Path('clang'), Path('clang++'), {}, Path('vma')),
|
|
clang_version=lambda clang, env: 'clang version 18.1.3',
|
|
vulkan_headers=lambda clang, env: ['-I', 'kit/vulkan'],
|
|
runtime_headers=lambda rt: None, EVENTS=False), \
|
|
mock.patch.object(self.mg.subprocess, 'run', compiler(errors)), \
|
|
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err):
|
|
try:
|
|
self.mg.preflight(self.msys, 2, sources)
|
|
return None, err.getvalue()
|
|
except SystemExit:
|
|
return 'stopped', err.getvalue()
|
|
|
|
stopped, text = check({}, srcs)
|
|
self.assertEqual(stopped, 'stopped')
|
|
self.assertIn('did not compile: bad1.c, bad2.c, bad3.c, bad4.cpp', text)
|
|
self.assertIn('4 of the runtime\'s 5 files', text)
|
|
self.assertIn('clang version 18.1.3', text)
|
|
self.assertIn('kit/vulkan', text) # where the Vulkan headers came from
|
|
compiles = [c for c in ran if '-fsyntax-only' in c]
|
|
self.assertEqual(len(compiles), 5)
|
|
for c in compiles:
|
|
self.assertEqual(c[1], '-fsyntax-only')
|
|
self.assertFalse({'-c', '-o'} & set(c))
|
|
self.assertLess(c.index('kit/vulkan'), c.index(str(self.mg.KIT / 'runtime')))
|
|
self.assertEqual(len(ran), 5) # no link is tried once sources failed
|
|
stopped, text = check({'bad1.c': "bad1.c:1:10: fatal error: 'SDL3/SDL.h' file not found"}, srcs[:2])
|
|
self.assertIn('These headers are missing: SDL3/SDL.h', text)
|
|
# every source passes: the empty program is linked against the game's libraries
|
|
stopped, text = check({}, srcs[:1])
|
|
self.assertIsNone(stopped)
|
|
link = ran[-1]
|
|
self.assertNotIn('-fsyntax-only', link)
|
|
self.assertEqual(link[-len(self.mg.LINK_LIBS):], self.mg.LINK_LIBS)
|
|
|
|
def test_pinned_vulkan_headers(self):
|
|
"""tools/vulkan_pin.py keeps the C headers and the licence note of the pinned archive and nothing else, takes
|
|
no other archive, and says the folder holds them only once the last file is written."""
|
|
import vulkan_pin
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode='w:gz') as tar:
|
|
for name, data in (('include/vulkan/vulkan_core.h', b'#define VK_HEADER_VERSION 363\n'),
|
|
('include/vulkan/vulkan.h', b'#include "vulkan_core.h"\n'),
|
|
('include/vk_video/vulkan_video_codecs_common.h', b'\n'),
|
|
('include/vulkan/vulkan.hpp', b'// C++\n'), ('registry/vk.xml', b'<registry/>'),
|
|
('include/vulkan/../../outside.h', b'x'), ('LICENSE.md', b'Apache-2.0 OR MIT\n')):
|
|
info = tarfile.TarInfo(vulkan_pin.TOP + name)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
archive = self.tmp / 'headers.tar.gz'
|
|
archive.write_bytes(buf.getvalue())
|
|
dest = self.tmp / 'toolchain' / 'vulkan-headers'
|
|
with self.assertRaises(SystemExit), contextlib.redirect_stdout(io.StringIO()):
|
|
vulkan_pin.resolve(dest, archive) # not the pinned archive
|
|
self.assertFalse(vulkan_pin.have(dest))
|
|
pinned = (vulkan_pin.SOURCE[0], hashlib.sha256(buf.getvalue()).hexdigest())
|
|
with mock.patch.object(vulkan_pin, 'SOURCE', pinned):
|
|
self.assertEqual(vulkan_pin.resolve(dest, archive), dest)
|
|
files = sorted(f.relative_to(dest).as_posix() for f in dest.rglob('*') if f.is_file())
|
|
self.assertEqual(files, ['LICENSE.md', 'include/vk_video/vulkan_video_codecs_common.h',
|
|
'include/vulkan/vulkan.h', 'include/vulkan/vulkan_core.h', vulkan_pin.STAMP])
|
|
self.assertFalse((self.tmp / 'outside.h').exists() or (self.tmp / 'toolchain' / 'outside.h').exists())
|
|
self.assertTrue(vulkan_pin.have(dest))
|
|
self.assertEqual(vulkan_pin.resolve(dest), dest) # there already: nothing is fetched
|
|
self.assertEqual(vulkan_pin.header_version((dest / 'include' / 'vulkan' / 'vulkan_core.h').read_text()), 363)
|
|
self.assertEqual(vulkan_pin.header_version('#define VK_HEADER_VERSION_COMPLETE 1\n'), 0)
|
|
(dest / vulkan_pin.STAMP).unlink() # a folder cut short while it was written
|
|
self.assertFalse(vulkan_pin.have(dest))
|
|
|
|
def test_embedded_file_as_a_header_of_bytes(self):
|
|
"""tools/build_shaders.py writes the menu font's bytes as a header for compilers without #embed: the numbers
|
|
are the file's bytes, and the header is stale once the file changes."""
|
|
import build_shaders
|
|
rt = self.tmp / 'runtime'
|
|
font = rt / 'ui' / 'fonts' / 'Roboto-Medium.ttf'
|
|
font.parent.mkdir(parents=True)
|
|
data = bytes(range(256)) * 3 + b'\r\n\x00tail'
|
|
font.write_bytes(data)
|
|
header = rt / 'ui' / 'roboto_ttf.h'
|
|
with mock.patch.object(build_shaders, 'ASSEMBLED', {}), contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(build_shaders.stale(rt), [font])
|
|
self.assertEqual(build_shaders.build(rt), 1)
|
|
self.assertEqual(build_shaders.stale(rt), [])
|
|
numbers = ''.join(ln for ln in header.read_text(encoding='utf-8').splitlines() if not ln.startswith('//'))
|
|
self.assertEqual(bytes(int(n) for n in numbers.split(',') if n), data)
|
|
font.write_bytes(data + b'!')
|
|
self.assertEqual(build_shaders.stale(rt), [font])
|
|
menu = (ROOT / 'runtime' / 'ui' / 'menu.cpp').read_text(encoding='utf-8')
|
|
self.assertIn('#ifdef __has_embed\n#embed "fonts/Roboto-Medium.ttf"\n#else\n#include "roboto_ttf.h"\n#endif', menu)
|
|
# the menu font, and the SMAA lookup textures of the anti-aliasing setting (runtime/vk_aa.c)
|
|
self.assertEqual(build_shaders.EMBEDDED, {'ui/fonts/Roboto-Medium.ttf': 'ui/roboto_ttf.h',
|
|
'third_party/smaa/AreaTex.bin': 'smaa_area_tex.h',
|
|
'third_party/smaa/SearchTex.bin': 'smaa_search_tex.h'})
|
|
|
|
def test_objects_of_an_earlier_version_are_taken_over(self):
|
|
"""An entry under a key an earlier version gave the same compile is re-recorded under the new key when its
|
|
files read as they did; a header in the earlier kit's folder is read in this kit's (that folder may be gone)."""
|
|
old, new, lift, obj = self.tmp / 'app' / '0.5.0', self.tmp / 'app' / '0.6.1', self.tmp / 'lift', self.tmp / 'obj'
|
|
for d in (new / 'runtime', lift, obj):
|
|
d.mkdir(parents=True)
|
|
(new / 'runtime' / 'cpu.h').write_text('#define CPU 1\n')
|
|
src, dst = lift / 'main_0000.c', obj / 'main_main_0000.o'
|
|
src.write_text('int f(void) { return 1; }\n')
|
|
dst.write_bytes(b'object')
|
|
sha = lambda p: hashlib.sha256(Path(p).read_bytes()).hexdigest()
|
|
legacy = {'key': 'old-key', 'deps': {str(src).replace('\\', '/'): sha(src),
|
|
str(old / 'runtime' / 'cpu.h').replace('\\', '/'): sha(new / 'runtime' / 'cpu.h')}}
|
|
(obj / 'objects.json').write_text(json.dumps({dst.name: legacy}))
|
|
cache = self.mg.ObjectCache(obj / 'objects.json', {'<kit>': new})
|
|
self.assertEqual(cache.adopt(dst, 'new-key', ['other-key'], [old]), 0) # not a key it knows
|
|
self.assertEqual(cache.adopt(dst, 'new-key', ['old-key'], [old]), 1)
|
|
e = cache.entries[dst.name]
|
|
self.assertEqual(e['key'], 'new-key')
|
|
self.assertIn('<kit>/runtime/cpu.h', e['deps'])
|
|
self.assertTrue(cache.current(dst, 'new-key'))
|
|
# a header that changed since: compiled again
|
|
(obj / 'objects.json').write_text(json.dumps({dst.name: legacy}))
|
|
(new / 'runtime' / 'cpu.h').write_text('#define CPU 2\n')
|
|
cache = self.mg.ObjectCache(obj / 'objects.json', {'<kit>': new})
|
|
self.assertEqual(cache.adopt(dst, 'new-key', ['old-key'], [old]), 0)
|
|
|
|
def test_lifted_level_kept_on_updates(self):
|
|
obj = self.tmp / 'obj'
|
|
obj.mkdir()
|
|
self.assertEqual(self.mg.lifted_level(obj, 3, update=False), 3) # a build from the game files
|
|
self.assertEqual(self.mg.lifted_level(obj, 3, update=True), 3) # nothing recorded, no old cache
|
|
(obj / 'objects.json').write_text(json.dumps({'main_main_0000.o': {'key': 'k', 'deps': {'a.c': 'ab'}}}))
|
|
self.assertEqual(self.mg.lifted_level(obj, 3, update=True), 0) # 0.5.0's cache: compiled for no level
|
|
# 0.5.0's runtime objects left in a cache whose lifted code a later version compiled: not an old cache
|
|
(obj / 'objects.json').write_text(json.dumps({
|
|
'main_main_0000.o': {'key': 'k', 'deps': {'a.c': ['ab', 1, 2]}}, 'rt_amiibo.o': {'key': 'k', 'deps': {'b.c': 'cd'}}}))
|
|
self.assertEqual(self.mg.lifted_level(obj, 3, update=True), 3)
|
|
cache = self.mg.ObjectCache(obj / 'objects.json')
|
|
self.assertEqual(cache.keep_only({'main_main_0000.o'}), 1) # what this build no longer makes
|
|
self.assertEqual(list(cache.entries), ['main_main_0000.o'])
|
|
(obj / 'objects.json').write_text(json.dumps({'main_main_0000.o': {'key': 'k', 'deps': {'a.c': 'ab'}}}))
|
|
(obj / self.mg.LIFTED_BUILD).write_text(json.dumps({'level': 2}))
|
|
self.assertEqual(self.mg.lifted_level(obj, 3, update=True), 2) # as recorded
|
|
self.assertEqual(self.mg.lifted_level(obj, 1, update=True), 1) # never above this processor's
|
|
self.assertEqual(self.mg.lifted_level(obj, 3, update=False), 3)
|
|
|
|
def test_retune_due_when_the_level_differs(self):
|
|
obj = self.tmp / 'obj'
|
|
obj.mkdir()
|
|
with mock.patch.object(self.mg, 'cpu_level', lambda clang, env: 3), mock.patch.object(self.mg, 'toolchain', lambda msys: (None, None, {}, None)):
|
|
self.assertTrue(self.mg.retune_due(obj, None)) # nothing recorded
|
|
(obj / self.mg.LIFTED_BUILD).write_text(json.dumps({'level': 0}))
|
|
self.assertTrue(self.mg.retune_due(obj, None)) # generic objects on a v3 processor
|
|
(obj / self.mg.LIFTED_BUILD).write_text(json.dumps({'level': 3}))
|
|
self.assertFalse(self.mg.retune_due(obj, None))
|
|
|
|
def test_updates_skip_shader_preparation(self):
|
|
"""An update leaves the shaders to the game (shader_carry) only when they were prepared and the cache names the
|
|
translator they were made with: without that the game has nothing to carry them over by."""
|
|
out = self.tmp / 'game'
|
|
(out / 'shader-cache').mkdir(parents=True)
|
|
self.assertFalse(self.mg.shader_carry(out)) # nothing prepared yet: the update prepares
|
|
(out / 'shader-prepared.bin').write_bytes(b'x')
|
|
self.assertFalse(self.mg.shader_carry(out)) # prepared, but the cache names no translator
|
|
(out / 'shader-cache' / 'identities.txt').write_text('# translators\n' + 'ab' * 32 + '\n')
|
|
self.assertTrue(self.mg.shader_carry(out))
|
|
(out / 'nx2navi.ini').write_text('[performance]\nNX2NAVI_SHADER_CARRY = "0"\n')
|
|
self.assertFalse(self.mg.shader_carry(out))
|
|
|
|
def test_cache_from_before_carrying_names_its_translator(self):
|
|
"""Before a new translator goes beside the game, a shader cache with no identities.txt gets one naming the
|
|
translator still there, in the form runtime/gfx_shader.c reads (ids_parse): '#' lines, then 64 hex digits."""
|
|
out = self.tmp / 'game'
|
|
out.mkdir()
|
|
old = 'B72C60033CE90FC475EF5B4D397555DDA9A52AD0FDF480B8A4E292ED925A6DE7'
|
|
(out / 'nx2navi_xlate.id').write_text(old + '\r\n')
|
|
self.mg.note_cache_translator(out) # no cache: nothing to name
|
|
self.assertFalse((out / 'shader-cache' / 'identities.txt').exists())
|
|
(out / 'shader-cache').mkdir()
|
|
self.mg.note_cache_translator(out)
|
|
ids = out / 'shader-cache' / 'identities.txt'
|
|
lines = ids.read_bytes().decode('ascii').split('\n')
|
|
self.assertTrue(lines[0].startswith('#'))
|
|
self.assertEqual(lines[1], old.lower())
|
|
self.assertEqual(self.mg.cache_identities(out), [old.lower()])
|
|
(out / 'nx2navi_xlate.id').write_text('cd' * 32) # a file the game keeps is never written over
|
|
self.mg.note_cache_translator(out)
|
|
self.assertEqual(self.mg.cache_identities(out), [old.lower()])
|
|
ids.unlink()
|
|
(out / 'nx2navi_xlate.id').write_text('not an identity')
|
|
self.mg.note_cache_translator(out)
|
|
self.assertFalse(ids.exists())
|
|
|
|
def test_dlssg_beside_the_game(self):
|
|
"""DLSS Frame Generation's files go beside the game only as a whole set, with the licence texts; the release
|
|
script (tools/make_launcher.py) and the build agree on the set."""
|
|
import streamline_pin
|
|
from unittest import mock
|
|
kit, out = self.tmp / 'kit', self.tmp / 'game'
|
|
src = kit / 'bin' / 'dlssg'
|
|
src.mkdir(parents=True)
|
|
out.mkdir()
|
|
self.assertEqual(streamline_pin.FILES, self.mg.DLSSG_FILES)
|
|
with mock.patch.object(self.mg, 'KIT', kit):
|
|
for n in self.mg.DLSSG_FILES[:-1]:
|
|
(src / n).write_bytes(n.encode())
|
|
self.assertEqual(self.mg.place_dlssg(out), 0) # nvngx_dlssg.dll missing: nothing is placed
|
|
self.assertFalse((out / 'nx2navi_dlssg.dll').exists())
|
|
(src / 'nvngx_dlssg.dll').write_bytes(b'ngx')
|
|
(src / 'streamline-LICENSE.txt').write_text('MIT')
|
|
self.assertEqual(self.mg.place_dlssg(out), len(self.mg.DLSSG_FILES) + 1)
|
|
for n in self.mg.DLSSG_FILES:
|
|
self.assertEqual((out / n).read_bytes(), (src / n).read_bytes())
|
|
self.assertEqual((out / 'streamline-LICENSE.txt').read_text(), 'MIT')
|
|
|
|
def test_streamline_pin(self):
|
|
"""The Streamline files come out of the SDK archive checked one by one; the archive goes once they are out and
|
|
a filled cache needs no download; an SDK folder on disk must hold the pinned files; bin/dlssg/ gets the set
|
|
make_game.py places, the two licence texts and a notice with every file's sha256."""
|
|
import streamline_pin
|
|
from unittest import mock
|
|
files = {n: f'{n} of the SDK'.encode() for n in streamline_pin.SDK_FILES}
|
|
pins = {n: hashlib.sha256(d).hexdigest() for n, d in files.items()}
|
|
cache = self.tmp / 'dl'
|
|
cache.mkdir()
|
|
archive = cache / f'streamline-sdk-v{streamline_pin.VERSION}.zip'
|
|
with zipfile.ZipFile(archive, 'w') as z:
|
|
for n, d in files.items():
|
|
z.writestr(n, d)
|
|
z.writestr('bin/x64/sl.nis.dll', b'a plugin the game does not load')
|
|
fetched = []
|
|
|
|
def download(url, dest, sha256=None, size=None): # the archive is already where net.download writes it
|
|
fetched.append(url)
|
|
self.assertEqual(Path(dest), archive)
|
|
return dest
|
|
|
|
with mock.patch.dict(streamline_pin.SDK_FILES, pins), mock.patch('launcher.net.download', download):
|
|
sdk = streamline_pin.resolve(None, cache)
|
|
self.assertEqual(fetched, [streamline_pin.URL])
|
|
self.assertFalse(archive.exists())
|
|
self.assertEqual(streamline_pin.wrong_files(sdk), [])
|
|
self.assertFalse((sdk / 'bin' / 'x64' / 'sl.nis.dll').exists())
|
|
self.assertEqual(streamline_pin.resolve(None, cache), sdk) # filled: no second download
|
|
self.assertEqual(len(fetched), 1)
|
|
self.assertEqual(streamline_pin.resolve(sdk, cache), sdk) # a folder on disk with the pinned files
|
|
(sdk / 'bin' / 'x64' / 'sl.common.dll').write_bytes(b'another Streamline')
|
|
with self.assertRaises(SystemExit):
|
|
streamline_pin.resolve(sdk, cache)
|
|
(sdk / 'bin' / 'x64' / 'sl.common.dll').write_bytes(files['bin/x64/sl.common.dll'])
|
|
with zipfile.ZipFile(archive, 'w') as z: # an archive whose file differs is refused
|
|
for n, d in files.items():
|
|
z.writestr(n, b'changed' if n == 'license.txt' else d)
|
|
with self.assertRaises(SystemExit):
|
|
streamline_pin.extract(archive, self.tmp / 'x')
|
|
dll = self.tmp / 'nx2navi_dlssg.dll'
|
|
dll.write_bytes(b'our frame generation shim')
|
|
dest = self.tmp / 'bin' / 'dlssg'
|
|
streamline_pin.place(dll, sdk, dest)
|
|
self.assertEqual(sorted(p.name for p in dest.iterdir()),
|
|
sorted((*streamline_pin.FILES, *streamline_pin.TEXTS, streamline_pin.NOTICE)))
|
|
self.assertEqual((dest / 'streamline-LICENSE.txt').read_bytes(), files['license.txt'])
|
|
note = (dest / streamline_pin.NOTICE).read_text(encoding='utf-8')
|
|
self.assertIn(f'Streamline {streamline_pin.VERSION} SDK', note)
|
|
self.assertIn(f'nx2navi_dlssg.dll sha256 {hashlib.sha256(dll.read_bytes()).hexdigest()}', note)
|
|
self.assertIn(f'nvngx_dlssg.dll sha256 {pins["bin/x64/nvngx_dlssg.dll"]}', note)
|
|
|
|
@unittest.skipIf(os.name == 'nt', 'a script stands in for windres.exe')
|
|
def test_compile_resources(self):
|
|
# The toolchain, the kit and the game under folders with spaces, as under C:\Users\Ali Sayed. windres must
|
|
# start gcc itself (--use-temp-file) on a line that names no path but gcc's: the .rc by its bare name from
|
|
# runtime/, no -I, -D or -U.
|
|
msys, obj = self.tmp / 'tool chain' / 'ucrt64', self.out / 'build-cache' / 'obj'
|
|
windres = msys / 'bin' / 'windres.exe'
|
|
windres.parent.mkdir(parents=True)
|
|
windres.write_text(f'#!{sys.executable}\n' + FAKE_WINDRES)
|
|
windres.chmod(0o755)
|
|
obj.mkdir(parents=True)
|
|
rt = self.tmp / 'the kit' / 'runtime'
|
|
rt.mkdir(parents=True)
|
|
for name in ('nx2navi.rc', 'nx2navi.manifest', 'nx2navi.ico'):
|
|
shutil.copy2(ROOT / 'runtime' / name, rt / name)
|
|
rco = self.mg.compile_resources(msys, rt, obj, dict(os.environ))
|
|
self.assertEqual((rco, rco.read_bytes()), (obj / 'nx2navi_rc.o', b'coff'))
|
|
seen = json.loads(windres.with_suffix('.json').read_text())
|
|
args = seen['argv']
|
|
self.assertEqual(seen['cwd'], str(rt))
|
|
self.assertIn('--use-temp-file', args)
|
|
self.assertIn(f'--preprocessor={msys / "bin" / "gcc.exe"}', args)
|
|
self.assertEqual([a for a in args if a.startswith('--preprocessor-arg=')],
|
|
['--preprocessor-arg=-E', '--preprocessor-arg=-xc', '--preprocessor-arg=-DRC_INVOKED'])
|
|
self.assertEqual(args[args.index('-i') + 1], 'nx2navi.rc')
|
|
self.assertFalse([a for a in args if a.startswith(('-I', '--include-dir', '-D', '-U'))])
|
|
# the temporary files went to a folder of the build cache, which is gone with the two windres left there
|
|
self.assertEqual(seen['tmp'], seen['temp'])
|
|
self.assertEqual(Path(seen['tmp']).parent, obj)
|
|
self.assertEqual(sorted(p.name for p in obj.iterdir()), ['nx2navi_rc.o'])
|
|
# the same resource again: the object keeps its time, so the link key does not change and nothing relinks
|
|
before = rco.stat().st_mtime_ns
|
|
time.sleep(0.02)
|
|
self.mg.compile_resources(msys, rt, obj, dict(os.environ))
|
|
self.assertEqual(rco.stat().st_mtime_ns, before)
|
|
# a failing windres stops the build with its message
|
|
err = io.StringIO()
|
|
with self.assertRaises(SystemExit), contextlib.redirect_stderr(err):
|
|
self.mg.compile_resources(msys, rt, obj, dict(os.environ, FAKE_WINDRES_FAIL='1'))
|
|
self.assertIn('windres failed on runtime/nx2navi.rc: windres: preprocessing failed.', err.getvalue())
|
|
self.assertEqual(sorted(p.name for p in obj.iterdir()), ['nx2navi_rc.o'])
|
|
|
|
def test_a_cut_romfs_image_does_not_count_as_used_space(self):
|
|
"""The RomFS step removes the romfs.bin.part of a run that was cut short before it measures the free space:
|
|
on a disk with just enough room for the image, the retry passes the check as the first run did."""
|
|
import collections
|
|
mb = 1 << 20
|
|
|
|
class Src:
|
|
romfs = [(f'f{i}.bin', i) for i in range(3)]
|
|
|
|
def romfs_size(self, ref):
|
|
return mb
|
|
|
|
def romfs_chunks(self, ref):
|
|
yield bytes([ref]) * mb
|
|
budget = 3 * mb + (1 << 30) + mb # the image, the margin the step asks for, and 1 MB to spare
|
|
usage = collections.namedtuple('usage', 'total used free')
|
|
|
|
def disk_usage(path): # a disk of that size that holds only the game folder
|
|
held = sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
|
|
return usage(budget, held, budget - held)
|
|
romfs = self.out / 'romfs'
|
|
romfs.mkdir(parents=True)
|
|
(romfs / 'romfs.bin.part').write_bytes(bytes(2 * mb)) # what the cut run left
|
|
with mock.patch.object(self.mg.shutil, 'disk_usage', disk_usage), \
|
|
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
self.mg.step_romfs(Src(), self.out, 2)
|
|
self.assertEqual((romfs / 'romfs.bin').stat().st_size, 3 * mb)
|
|
self.assertFalse((romfs / 'romfs.bin.part').exists())
|
|
# a disk that is too small even so still stops the step, and says by how much
|
|
(romfs / 'romfs.bin').unlink()
|
|
budget -= 2 * mb
|
|
err = io.StringIO()
|
|
with mock.patch.object(self.mg.shutil, 'disk_usage', disk_usage), \
|
|
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err), \
|
|
self.assertRaises(SystemExit):
|
|
self.mg.step_romfs(Src(), self.out, 2)
|
|
self.assertIn('GB free for the RomFS image', err.getvalue())
|
|
|
|
def test_a_damaged_dump_names_the_file(self):
|
|
"""A dump whose table of contents is whole but one data byte differs (a copy that went wrong): the RomFS
|
|
step ends with an error that names the file, which the launcher shows, and leaves no partial image."""
|
|
raw = bytearray(self.nup.read_bytes())
|
|
raw[raw.find(self.romfs['dir/b.bin']) + 100] ^= 0xFF
|
|
self.nup.write_bytes(raw)
|
|
events, lines = [], []
|
|
run = build.Run(build.command(self.layout, ['--nup', self.nup, '--out', self.out, '--msys', self.msys,
|
|
'--until', 'romfs']), events.append, lines.append)
|
|
self.assertEqual(run.wait(), 1)
|
|
errors = [e['message'] for e in events if e['ev'] == 'error']
|
|
self.assertEqual(len(errors), 1, lines)
|
|
self.assertIn('the dump is damaged: dir/b.bin: size or sha256 differs', errors[0])
|
|
self.assertIn('dump the game again', errors[0])
|
|
self.assertNotIn('file 6', errors[0]) # the dump's own number for the file
|
|
self.assertEqual(run.error, errors[0]) # what the launcher's job shows as its error
|
|
self.assertFalse(any('Traceback' in ln for ln in lines))
|
|
self.assertEqual(sorted(p.name for p in (self.out / 'romfs').iterdir()), [])
|
|
state = json.loads((self.out / 'build-cache' / 'state.json').read_text())
|
|
self.assertNotIn('romfs', state['steps'])
|
|
|
|
def test_an_unexpected_error_reaches_the_launcher_with_its_message(self):
|
|
"""An exception make_game.py does not report itself ends the build as fail() ends it: an error event with
|
|
the exception's message, not only a traceback and "the build stopped (exit code 1)"."""
|
|
made_up_game(self.out)
|
|
|
|
def broken(out, cache):
|
|
raise ValueError('the patch table has no line 12')
|
|
|
|
def full(out, cache):
|
|
raise OSError(28, 'No space left on device', str(out / 'build-cache' / 'lift' / 'main_0001.c'))
|
|
with mock.patch.object(self.mg, 'EVENTS', False): # --events sets it: back to what it was afterwards
|
|
code, text = self.main_rebuild(broken, None, '--events')
|
|
self.assertEqual(code, 1)
|
|
errors = [json.loads(ln[2:]) for ln in text.splitlines() if ln.startswith('@@') and '"error"' in ln]
|
|
self.assertEqual([e['message'] for e in errors],
|
|
['the build stopped: ValueError: the patch table has no line 12'])
|
|
self.assertIn('Traceback (most recent call last)', text) # the log still has where it came from
|
|
self.assertIn('error: the build stopped: ValueError', text)
|
|
code, text = self.main_rebuild(full, None, '--events')
|
|
self.assertEqual(code, 1)
|
|
self.assertIn('error: the build stopped: the disk is full (', text)
|
|
self.assertIn('main_0001.c)', text)
|
|
self.assertEqual(self.mg.error_text(FileNotFoundError(2, 'No such file or directory', 'x.bin')),
|
|
'No such file or directory: x.bin')
|
|
self.assertEqual(self.mg.error_text(MemoryError()), 'MemoryError')
|
|
|
|
def test_a_game_folder_outside_the_basic_plane_compiles(self):
|
|
"""modules.c holds the game folder's paths as C strings. A character outside the Basic Multilingual Plane
|
|
(an emoji, a rare Han character) is written as itself in UTF-8, not as the two surrogate escapes JSON's
|
|
default makes of it, which C refuses."""
|
|
fairy, han = chr(0x1F9DA), chr(0x20BB7)
|
|
out = self.tmp / f'Games {fairy}' / f'V\u00edctor \u30bc\u30eb\u30c0 {han}ya'
|
|
lift, gen = self.tmp / 'lift', self.tmp / 'gen'
|
|
for d in (out / 'exefs', lift, gen):
|
|
d.mkdir(parents=True)
|
|
for name in self.mg.EXEFS_FILES:
|
|
(out / 'exefs' / name).write_bytes(b'x')
|
|
(lift / 'layout.json').write_text(json.dumps([{'name': 'main'}]))
|
|
text = self.mg.modules_c(lift, out, gen).read_text(encoding='utf-8')
|
|
import check_content
|
|
self.assertIn(check_content.GENERATED_MARKER, text.splitlines()[0])
|
|
self.assertIn('generated from game code', check_content.problems_for(gen / 'modules.c')) # refused in a commit
|
|
for half in ('\\ud83e', '\\ud842'): # the first surrogate halves of the two characters
|
|
self.assertNotIn(half, text.lower())
|
|
want = 'const char rt_default_exefs[] = "' + str(out / 'exefs').replace('\\', '\\\\') + '";'
|
|
self.assertIn(want, text)
|
|
self.assertEqual(self.mg.c_string('a"b\\c\n' + fairy), '"a\\"b\\\\c\\n' + fairy + '"')
|
|
cc = shutil.which('clang') or shutil.which('cc') or shutil.which('gcc')
|
|
env = dict(os.environ)
|
|
if not cc and (Path(r'C:\msys64\ucrt64\bin') / 'clang.exe').is_file(): # make_game.py's default --msys
|
|
cc = r'C:\msys64\ucrt64\bin\clang.exe'
|
|
env['PATH'] = r'C:\msys64\ucrt64\bin' + os.pathsep + env['PATH']
|
|
if not cc:
|
|
self.skipTest('the text is right; compiling it needs a C compiler')
|
|
|
|
def compiles(source, *include):
|
|
obj = source.with_suffix('.o')
|
|
r = subprocess.run([cc, '-c', '-std=gnu11', '-w', *include, '-o', str(obj), str(source)],
|
|
capture_output=True, text=True, errors='replace', env=env)
|
|
self.assertEqual(r.returncode, 0, r.stderr[-2000:])
|
|
self.assertIn(str(out / 'exefs').encode('utf-8'), obj.read_bytes()) # the path's own bytes are in it
|
|
# the strings alone, which any C compiler takes: the runtime's headers want clang
|
|
strings = [ln for ln in text.splitlines() if ln.startswith('const char rt_')]
|
|
self.assertEqual(len(strings), 4)
|
|
(self.tmp / 'strings.c').write_text('\n'.join(strings) + '\n', encoding='utf-8')
|
|
compiles(self.tmp / 'strings.c')
|
|
said = subprocess.run([cc, '--version'], capture_output=True, text=True, errors='replace', env=env).stdout
|
|
if 'clang' in said: # and the file as the build compiles it, with the runtime's headers
|
|
(gen / 'layout.h').write_text('#define MOD_MAIN_BASE 0x7000000000ull\n'
|
|
'#define MOD_MAIN_SIZE 0x1000000ull\n')
|
|
compiles(gen / 'modules.c', '-I', str(ROOT / 'runtime'), '-I', str(gen))
|
|
|
|
def test_the_disk_space_of_every_step_is_checked_first(self):
|
|
"""Before its first long step the build adds up what its steps will write into the game folder and stops
|
|
when the disk cannot hold it, naming each part and how much is missing; it looked at the RomFS image's
|
|
space only, and only when that step began."""
|
|
import collections
|
|
mg, gib = self.mg, 1 << 30
|
|
usage = collections.namedtuple('usage', 'total used free')
|
|
self.out.mkdir()
|
|
first = mg.space_needs(['exefs', 'romfs', 'lift', 'compile', 'shaders'], self.out, 26_240_000_000)
|
|
self.assertEqual([n for _, n in first], [26_240_000_000 + gib, 2 * gib, 4 * gib])
|
|
(self.out / 'build-cache' / 'lift').mkdir(parents=True)
|
|
(self.out / 'build-cache' / 'lift' / 'layout.json').write_text('[]')
|
|
self.assertEqual([n for _, n in mg.space_needs(['lift', 'compile', 'shaders'], self.out, carried=True)], [gib])
|
|
self.assertEqual(mg.space_needs(['shaders'], self.out, carried=True), [])
|
|
self.assertEqual(mg.space_needs([], self.out), [])
|
|
# a shader cache that was begun is not asked for the whole preparation's space again
|
|
(self.out / 'shader-cache').mkdir()
|
|
self.assertEqual([n for _, n in mg.space_needs(['shaders'], self.out)], [4 * gib])
|
|
(self.out / 'shader-cache' / '0123.spv').write_bytes(b'')
|
|
self.assertEqual(mg.space_needs(['shaders'], self.out), [])
|
|
(self.out / 'shader-cache' / '0123.spv').unlink()
|
|
(self.out / 'shader-cache' / 'shader-cache.pack').write_bytes(b'')
|
|
self.assertEqual(mg.space_needs(['shaders'], self.out), [])
|
|
shutil.rmtree(self.out / 'shader-cache')
|
|
|
|
def check(free, needs):
|
|
err = io.StringIO()
|
|
with mock.patch.object(mg.shutil, 'disk_usage', lambda p: usage(500 * gib, 0, free)), \
|
|
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err):
|
|
try:
|
|
mg.check_space(self.out, needs)
|
|
return ''
|
|
except SystemExit:
|
|
return err.getvalue()
|
|
self.assertEqual(check(40 * gib, first), '')
|
|
text = check(30 * gib, first)
|
|
self.assertIn('has 32.2 GB free and this build needs 33.8 GB there', text)
|
|
self.assertIn('27.3 GB for the RomFS image, 2.1 GB for the build cache', text)
|
|
self.assertIn('4.3 GB for the prepared shaders. Free 1.5 GB more on that disk', text)
|
|
self.assertEqual(check(0, []), '') # nothing to write: nothing asked
|
|
# what a cut run left of the image is the RomFS step's to remove: it counts as free
|
|
image = [('the RomFS image', 10 << 20)]
|
|
self.assertIn('needs 0.0 GB there', check(19 << 19, image)) # 9.5 MiB free for 10 MiB
|
|
(self.out / 'romfs').mkdir()
|
|
(self.out / 'romfs' / 'romfs.bin.part').write_bytes(bytes(1 << 20))
|
|
self.assertEqual(check(19 << 19, image), '')
|
|
|
|
def test_an_update_without_room_stops_before_its_lift(self):
|
|
import collections
|
|
made_up_game(self.out)
|
|
usage = collections.namedtuple('usage', 'total used free')
|
|
ran = []
|
|
with mock.patch.object(self.mg.shutil, 'disk_usage', lambda p: usage(10 ** 12, 0, 200 << 20)):
|
|
code, text = self.main_rebuild(lambda out, cache: ran.append('lift'), lambda *a, **k: ran.append('compile'),
|
|
'--until', 'compile')
|
|
self.assertEqual((code, ran), (1, []))
|
|
self.assertIn('has 0.2 GB free and this build needs 2.1 GB there: 2.1 GB for the build cache', text)
|
|
with mock.patch.object(self.mg.shutil, 'disk_usage', lambda p: usage(10 ** 12, 0, 50 << 30)):
|
|
code, text = self.main_rebuild(lambda out, cache: ran.append('lift'), lambda *a, **k: ran.append('compile'),
|
|
'--until', 'compile')
|
|
self.assertEqual((code, ran), (0, ['lift', 'compile']))
|
|
|
|
def test_the_steps_a_build_will_run(self):
|
|
mg = self.mg
|
|
made_up_game(self.out)
|
|
state = json.loads((self.out / 'build-cache' / 'state.json').read_text())
|
|
ident = state['input']
|
|
self.assertEqual(mg.steps_to_run(state, ident, self.out, 'compile'), ['lift', 'compile'])
|
|
for name in ('lift', 'compile'):
|
|
state['steps'][name] = mg.step_key(name, ident, self.out)
|
|
self.assertEqual(mg.steps_to_run(state, ident, self.out, 'compile'), [])
|
|
del state['steps']['romfs'] # the lift reads nothing of the RomFS step: it does not follow it
|
|
self.assertEqual(mg.steps_to_run(state, ident, self.out, 'compile'), ['romfs', 'compile'])
|
|
self.assertEqual(mg.steps_to_run({'steps': {}}, ident, self.out, 'romfs'), ['exefs', 'romfs'])
|
|
|
|
def test_the_build_tools_are_tried_before_the_long_steps(self):
|
|
"""On Windows a build whose compile step will run first compiles and links an empty program with the
|
|
installed tools; tools that do not work stop it there with what is wrong and how to mend it."""
|
|
mg = self.mg
|
|
work = self.tmp / 'check'
|
|
seen = []
|
|
|
|
def compiler(compile_rc=0, link_rc=0, stderr='', start=None):
|
|
def run(cmd, **kw):
|
|
seen.append(cmd)
|
|
if start:
|
|
raise start
|
|
if '-c' in cmd:
|
|
self.assertTrue(Path(cmd[-1]).is_file()) # the test program is there to compile
|
|
return mock.Mock(returncode=compile_rc, stderr=stderr)
|
|
return mock.Mock(returncode=link_rc, stderr=stderr)
|
|
return run
|
|
|
|
def probe(**kw):
|
|
err = io.StringIO()
|
|
with mock.patch.object(mg.subprocess, 'run', compiler(**kw)), \
|
|
mock.patch.object(mg, 'toolchain', lambda msys: (msys / 'bin' / 'clang.exe', msys / 'bin' /
|
|
'clang++.exe', dict(os.environ), msys)), \
|
|
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err):
|
|
try:
|
|
self.real_probe(self.msys, work)
|
|
return ''
|
|
except SystemExit:
|
|
return err.getvalue()
|
|
self.assertEqual(probe(), '')
|
|
self.assertEqual([Path(c[0]).name for c in seen], ['clang.exe', 'clang++.exe'])
|
|
self.assertTrue(set(mg.LINK_LIBS) <= set(seen[1])) # linked against what the game links
|
|
self.assertFalse(work.exists())
|
|
text = probe(compile_rc=1, stderr="clang: error: unable to execute command: program not executable\n")
|
|
self.assertIn('clang.exe cannot compile a test program (clang: error: unable to execute command', text)
|
|
self.assertIn(f'delete the folder {self.msys.parent} and try again', text)
|
|
text = probe(link_rc=1, stderr='ld.lld: error: unable to find library -lSDL3\n'
|
|
'ld.lld: error: unable to find library -lopus\nclang++: error: linker failed\n')
|
|
self.assertIn(f'the build tools cannot link the game: {self.msys / "lib"} lacks -lSDL3, -lopus.', text)
|
|
text = probe(link_rc=1, stderr='ld.lld: error: undefined symbol: WinMain\n')
|
|
self.assertIn('the linker says: ld.lld: error: undefined symbol: WinMain', text)
|
|
text = probe(start=PermissionError(13, 'Access is denied'))
|
|
self.assertIn('clang.exe does not start (Access is denied)', text)
|
|
self.assertIn('security software', text)
|
|
self.assertFalse(work.exists())
|
|
# the build calls it on Windows when the compile step will run, and not for a build that only draws prompts
|
|
made_up_game(self.out)
|
|
called = []
|
|
with mock.patch.object(mg, 'probe_toolchain', lambda msys, work: called.append(work)), \
|
|
mock.patch.object(mg, 'WIN', True):
|
|
self.main_rebuild(lambda out, cache: None, lambda *a, **k: None, '--until', 'compile')
|
|
self.assertEqual(called, [self.out / 'build-cache' / 'toolchain-check'])
|
|
self.main_rebuild(lambda out, cache: None, lambda *a, **k: None, '--until', 'compile')
|
|
self.assertEqual(len(called), 1) # the second build had nothing to compile
|
|
|
|
def main_rebuild(self, lift, compile_, *more):
|
|
"""make_game.main() --rebuild in this process on self.out, with the given functions in place of the lift and
|
|
the compile. Returns (the SystemExit code or 0, what it printed)."""
|
|
buf = io.StringIO()
|
|
argv = ['make_game.py', '--rebuild', '--out', str(self.out), '--msys', str(self.msys), *more]
|
|
with mock.patch.object(self.mg, 'step_lift', lift), mock.patch.object(self.mg, 'step_compile', compile_), \
|
|
mock.patch.object(self.mg, 'keep_awake', lambda: None), mock.patch.object(sys, 'argv', argv), \
|
|
contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
|
try:
|
|
self.mg.main()
|
|
return 0, buf.getvalue()
|
|
except SystemExit as e:
|
|
return e.code, buf.getvalue()
|
|
|
|
def test_an_update_that_stops_in_the_compile_keeps_the_game(self):
|
|
"""An update whose lift ran again drops the compile step's record before the compile runs. When the compile
|
|
then stops, the folder still holds the exe of the build before and the unpacked game files: the launcher
|
|
counts it as installed (build.installed), not as a folder to install into with the dump."""
|
|
made_up_game(self.out)
|
|
self.assertEqual(build.built_version(self.out), '0.6.1')
|
|
|
|
def stop(*a, **k):
|
|
self.mg.fail('3 files did not compile')
|
|
code, _ = self.main_rebuild(lambda out, cache: None, stop)
|
|
self.assertEqual(code, 1)
|
|
st = json.loads((self.out / 'build-cache' / 'state.json').read_text())
|
|
# the shaders' record stays as the lift left it: the step's key names what it depends on (UNAFFECTED)
|
|
self.assertEqual(sorted(s for s in st['steps'] if s != 'shaders'), ['exefs', 'lift', 'romfs'])
|
|
self.assertEqual(build.built_version(self.out), '') # no finished build on record
|
|
self.assertTrue(build.can_rebuild(self.out))
|
|
self.assertTrue(build.installed(self.out))
|
|
(self.out / 'ocarina.exe').unlink() # a first install that stopped there has nothing to play
|
|
self.assertFalse(build.installed(self.out))
|
|
self.assertFalse(build.installed(''))
|
|
self.assertFalse(build.installed(None))
|
|
self.assertFalse(build.installed(self.tmp / 'no such folder'))
|
|
|
|
|
|
# ---------------------------------------------------------------- the check of the game files
|
|
|
|
def logic_digest(path):
|
|
"""sha256 of a Python file's code without what cannot change what it does: docstrings, comment lines, blank
|
|
lines and trailing spaces. The same on every Python version, as it reads only where the docstrings are."""
|
|
import ast
|
|
text = Path(path).read_text(encoding='utf-8')
|
|
skip = set()
|
|
for node in ast.walk(ast.parse(text)):
|
|
body = getattr(node, 'body', None)
|
|
if (isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and body
|
|
and isinstance(body[0], ast.Expr) and isinstance(getattr(body[0], 'value', None), ast.Constant)
|
|
and isinstance(body[0].value.value, str)):
|
|
skip.update(range(body[0].lineno, body[0].end_lineno + 1))
|
|
lines = [ln.rstrip() for n, ln in enumerate(text.splitlines(), 1) if n not in skip]
|
|
return hashlib.sha256('\n'.join(ln for ln in lines if ln and not ln.lstrip().startswith('#')).encode()).hexdigest()
|
|
|
|
|
|
# make_game.SCAN_TOOL, and tools/romfs_check.py's logic_digest when that value was last looked at. A change to the
|
|
# script's logic fails FileCheckTest.test_the_file_checks_logic_is_the_recorded_one, which asks: can the change alter
|
|
# what the check finds or writes for some game file? If it can, give SCAN_TOOL a new value (every install then checks
|
|
# its game files again, about four minutes) and record both here. If it cannot, record only the new digest.
|
|
SCAN_LOGIC = ('55ea0c150aaf9dac93a2e6fd3b4ca11e1c572b5eee8b6bd74960535964384634',
|
|
'758faaaaca013ed78f21359270cb92196e7f3ffd3aa30a2d06a9edfa0de1a502')
|
|
|
|
|
|
class FakeImage:
|
|
"""What tools/romfs_check.py problem() reads of the packed game data, held in memory: files by path, and the
|
|
game's dictionaries by id."""
|
|
|
|
def __init__(self, files, dicts=()):
|
|
self.files = files
|
|
self.dicts = {d.dict_id(): d for d in dicts}
|
|
|
|
def read(self, entry):
|
|
return self.files[entry['path']][:entry['size']]
|
|
|
|
def decompressor(self, dict_id):
|
|
import zstandard
|
|
return zstandard.ZstdDecompressor(dict_data=self.dicts[dict_id]) if dict_id else zstandard.ZstdDecompressor()
|
|
|
|
|
|
class FileCheckTest(Tmp):
|
|
"""tools/romfs_check.py reads each file in pieces of PIECE bytes. The piece size is a matter of speed only:
|
|
what the check says of a file is the same with the 4 MiB pieces of 0.7.1 and the 64 KiB pieces of now."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
try:
|
|
import zstandard # noqa: F401
|
|
except ImportError:
|
|
self.skipTest('needs zstandard')
|
|
import romfs_check
|
|
self.rc = romfs_check
|
|
|
|
SOME, SAME = object(), object() # cases()
|
|
|
|
@staticmethod
|
|
def old_problem(rc, image, entry, piece=1 << 22):
|
|
"""The check as 0.7.1 made it: 4 MiB pieces, and no look at whether the frame had ended."""
|
|
import zstandard
|
|
data = image.read(entry)
|
|
if len(data) != entry['size']:
|
|
return 'shorter than the index says'
|
|
if not entry['path'].endswith('.zs'):
|
|
return 'nothing but zeros' if len(data) >= 16 and not any(data) else None
|
|
if data[:4] != rc.ZSTD_MAGIC:
|
|
return 'no compressed frame at its start'
|
|
try:
|
|
params = zstandard.get_frame_parameters(data)
|
|
if params.dict_id and params.dict_id not in image.dicts:
|
|
return None
|
|
out = image.decompressor(params.dict_id).decompressobj()
|
|
size = 0
|
|
for at in range(0, len(data), piece):
|
|
size += len(out.decompress(data[at:at + piece]))
|
|
except zstandard.ZstdError as e:
|
|
return f'does not decompress ({e})'
|
|
if params.content_size and size != params.content_size:
|
|
return f'decompresses to {size} bytes, not {params.content_size}'
|
|
return None
|
|
|
|
def cases(self):
|
|
"""(name, path, bytes, what the check must say) of made-up files: frames of about 8 MiB packed, which span
|
|
several pieces at either size, and small ones. SOME: the check must find the file damaged, whatever its
|
|
words; SAME: only that both piece sizes say the same."""
|
|
import random
|
|
import zstandard
|
|
z = zstandard.ZstdCompressor(level=3)
|
|
twelve = bytes(i % 12 for i in range(256)) # bytes of 12 values: packs to about half
|
|
big_raw = random.Random(1).randbytes(16 << 20).translate(twelve)
|
|
big = z.compress(big_raw)
|
|
self.assertGreater(len(big), 6 << 20)
|
|
small = z.compress(random.Random(2).randbytes(300 << 10).translate(twelve))
|
|
mid = len(big) // 2
|
|
samples = [bytes([65 + i % 7]) * 40 + f'sample {i} of the dictionary'.encode() * 3 for i in range(400)]
|
|
dic = zstandard.train_dictionary(4096, samples)
|
|
with_dict = zstandard.ZstdCompressor(level=3, dict_data=dic).compress(b''.join(samples[:50]))
|
|
no_size = zstandard.ZstdCompressor(level=3, write_content_size=False).compress(big_raw[:1 << 20])
|
|
some, same, span = self.SOME, self.SAME, 300 << 10
|
|
return dic, [
|
|
('whole, small', 'Tex/a.bntx.zs', small, None),
|
|
('whole, large', 'Tex/b.bntx.zs', big, None),
|
|
('zeros before the frame', 'Tex/c.bntx.zs', bytes(16) + small, 'no compressed frame at its start'),
|
|
('damaged at the start', 'Tex/d.bntx.zs', big[:4] + bytes(60) + big[64:], some),
|
|
('damaged after the start', 'Tex/e.bntx.zs', big[:64] + bytes(span) + big[64 + span:], some),
|
|
('damaged in the middle', 'Tex/f.bntx.zs', big[:mid] + bytes(span) + big[mid + span:], some),
|
|
('damaged near the end', 'Tex/g.bntx.zs', big[:-9000] + bytes(4096) + big[-9000 + 4096:], some),
|
|
# a few changed bytes inside a block can decompress to other bytes of the same length
|
|
('a little damage in the middle', 'Tex/v.bntx.zs', big[:mid] + bytes(64) + big[mid + 64:], same),
|
|
('cut in the middle', 'Tex/h.bntx.zs', big[:mid], some),
|
|
('cut at 4 MiB', 'Tex/i.bntx.zs', big[:4 << 20], some),
|
|
('cut at 64 KiB', 'Tex/j.bntx.zs', big[:64 << 10], some),
|
|
('cut by one byte', 'Tex/k.bntx.zs', big[:-1], some),
|
|
('small, cut', 'Tex/l.bntx.zs', small[:len(small) // 2], some),
|
|
('all zeros', 'Model/m.bfres.zs', bytes(1 << 20), 'no compressed frame at its start'),
|
|
('all zeros, not packed', 'Param/n.bgyml', bytes(64), 'nothing but zeros'),
|
|
('not packed, fine', 'Param/o.bgyml', b'YB' + bytes(62), None),
|
|
('not packed, a few zeros', 'Param/p.bgyml', bytes(8), None),
|
|
('only the magic', 'Tex/q.bntx.zs', big[:4], some),
|
|
('with a dictionary', 'Pack/r.pack.zs', with_dict, None),
|
|
('with a dictionary, damaged', 'Pack/s.pack.zs', with_dict[:40] + bytes(40) + with_dict[80:], some),
|
|
('no size in its frame', 'Tex/t.bntx.zs', no_size, same),
|
|
('no size in its frame, damaged', 'Tex/u.bntx.zs', no_size[:9000] + bytes(4096) + no_size[13096:], same),
|
|
('no size in its frame, cut', 'Tex/w.bntx.zs', no_size[:len(no_size) // 2], same),
|
|
]
|
|
|
|
def test_the_piece_size_does_not_change_the_result(self):
|
|
rc = self.rc
|
|
dic, cases = self.cases()
|
|
self.assertEqual(rc.PIECE, 1 << 16)
|
|
for dicts in ((dic,), ()): # the game's dictionaries there, and a frame naming one the game lacks
|
|
image = FakeImage({path: data for _, path, data, _ in cases}, dicts)
|
|
for name, path, data, want in cases:
|
|
if not dicts and 'dictionary' not in name:
|
|
continue
|
|
entry = {'path': path, 'size': len(data)}
|
|
was = self.old_problem(rc, image, entry)
|
|
with mock.patch.object(rc, 'PIECE', 1 << 22):
|
|
large = rc.problem(image, entry)
|
|
small = rc.problem(image, entry)
|
|
self.assertEqual((large, small), (was, was), name)
|
|
if want is self.SOME:
|
|
if dicts or 'dictionary' not in name: # a frame naming a dictionary the game lacks is not judged
|
|
self.assertTrue(was, f'{name}: not found damaged')
|
|
elif want is not self.SAME:
|
|
self.assertEqual(was, want, name)
|
|
# an index that says more bytes than the image holds
|
|
image = FakeImage({'Tex/a.bntx.zs': cases[0][2]})
|
|
self.assertEqual(rc.problem(image, {'path': 'Tex/a.bntx.zs', 'size': len(cases[0][2]) + 1}),
|
|
'shorter than the index says')
|
|
|
|
def test_bytes_after_the_frame_do_not_depend_on_the_piece_size(self):
|
|
"""A whole frame with other bytes after it. The check stops at the frame's end at either piece size. As
|
|
0.7.1 made it, such a file passed when the frame ended in the file's last piece and failed when another
|
|
piece followed, so the answer hung on the piece size; the game data has no such file (the check reports
|
|
the same four files on every copy, none for this reason)."""
|
|
rc = self.rc
|
|
_, cases = self.cases()
|
|
small, big = cases[0][2], cases[1][2]
|
|
tails = {'Tex/a.bntx.zs': small + bytes(200 << 10), # past the next 64 KiB piece
|
|
'Tex/b.bntx.zs': big + bytes(5 << 20), # past the next 4 MiB piece
|
|
'Tex/c.bntx.zs': small + small} # a second frame
|
|
image = FakeImage(tails)
|
|
for path, data in tails.items():
|
|
entry = {'path': path, 'size': len(data)}
|
|
with mock.patch.object(rc, 'PIECE', 1 << 22):
|
|
self.assertIsNone(rc.problem(image, entry), path)
|
|
self.assertIsNone(rc.problem(image, entry), path)
|
|
entry = {'path': 'Tex/b.bntx.zs', 'size': len(tails['Tex/b.bntx.zs'])}
|
|
self.assertIn('multiple times', self.old_problem(rc, image, entry))
|
|
self.assertIn('multiple times', self.old_problem(rc, image, {'path': 'Tex/a.bntx.zs',
|
|
'size': len(tails['Tex/a.bntx.zs'])}, 1 << 16))
|
|
|
|
def test_the_scan_key_is_the_one_installs_have(self):
|
|
"""The scan step's key no longer follows the script's bytes: it is what 0.7.0 and 0.7.1 wrote into
|
|
state.json, so an install that passed the check does not run it again because the script was edited."""
|
|
import make_game
|
|
out = self.tmp / 'game'
|
|
out.mkdir()
|
|
ident = 'nup C:\\dumps\\game.nup 28000000000 1760000000'
|
|
released = '55ea0c150aaf9dac93a2e6fd3b4ca11e1c572b5eee8b6bd74960535964384634' # sha256 of 0.7.1's script
|
|
self.assertEqual(make_game.step_key('scan', ident, out), f'scan-1|{ident}|{released}|missing')
|
|
(out / 'romfs-check.json').write_text('{}')
|
|
self.assertEqual(make_game.step_key('scan', ident, out), f'scan-1|{ident}|{released}|written')
|
|
|
|
def test_the_file_checks_logic_is_the_recorded_one(self):
|
|
import make_game
|
|
now = (make_game.SCAN_TOOL, logic_digest(ROOT / 'tools' / 'romfs_check.py'))
|
|
self.assertEqual(now, SCAN_LOGIC,
|
|
"tools/romfs_check.py's logic changed. If the change can alter what the check finds or "
|
|
'writes, give make_game.SCAN_TOOL a new value, so that installs check their game files '
|
|
'again; either way record the new pair in SCAN_LOGIC (tests/launcher_test.py).')
|
|
# the digest follows the code, not its comments, docstrings or blank lines
|
|
a = self.tmp / 'a.py'
|
|
a.write_text('"""Doc."""\nX = 1\n\n\ndef f():\n """Doc."""\n # why\n return X \n')
|
|
b = self.tmp / 'b.py'
|
|
b.write_text('"""Another."""\n# a comment\nX = 1\n\ndef f():\n """Another\n one."""\n return X\n')
|
|
c = self.tmp / 'c.py'
|
|
c.write_text('"""Doc."""\nX = 2\n\n\ndef f():\n """Doc."""\n # why\n return X \n')
|
|
self.assertEqual(logic_digest(a), logic_digest(b))
|
|
self.assertNotEqual(logic_digest(a), logic_digest(c))
|
|
|
|
|
|
# make_game.PROMPT_TOOLS, and the logic_digest of the three scripts that draw the button prompts when that value was
|
|
# last looked at, as SCAN_LOGIC for the file check: a change to their logic fails
|
|
# PromptToolsTest.test_the_prompt_tools_logic_is_the_recorded_one. If the change can alter what the tools write, give
|
|
# PROMPT_TOOLS a new value (every install then draws its prompts again) and record both here; if it cannot, record
|
|
# only the new digests.
|
|
PROMPT_LOGIC = ('ad59f2c4533f6ea2630b916ef5f3695f72daffe9b2b7663eea4168a4f08e4249',
|
|
{'pad_prompts.py': '780c246b5fde7a0e9dbf3f59ad16372e1737592add6f2f2d9825bee6df4e296f',
|
|
'prompt_art.py': '87edacabcddb4952f5862373815d7d46a05c67f350e0734025ae4d2f83b12038',
|
|
'button_icons.py': '3804e371a030965bc9eb4226f333b611651f25efaa67ee523b404edc3257818a'})
|
|
|
|
|
|
def load_pad_prompts():
|
|
"""tools/pad_prompts.py as a module. Where fontTools is not installed, the names the tool and
|
|
tools/button_icons.py import from it get empty stand-ins for the import: the tests here use the tool's reading
|
|
of the game files only, which needs none of them. The module is not left in sys.modules."""
|
|
import types
|
|
stubs = {}
|
|
try:
|
|
import fontTools # noqa: F401
|
|
except ImportError:
|
|
for name, classes in (('fontTools', ()), ('fontTools.pens', ()), ('fontTools.ttLib', ('TTFont',)),
|
|
('fontTools.pens.basePen', ('BasePen',)), ('fontTools.pens.boundsPen', ('BoundsPen',)),
|
|
('fontTools.pens.recordingPen', ('RecordingPen',)),
|
|
('fontTools.pens.reverseContourPen', ('ReverseContourPen',)),
|
|
('fontTools.pens.t2CharStringPen', ('T2CharStringPen',)),
|
|
('fontTools.pens.transformPen', ('TransformPen',))):
|
|
stubs[name] = types.ModuleType(name)
|
|
for cls in classes:
|
|
setattr(stubs[name], cls, type(cls, (), {}))
|
|
with mock.patch.dict(sys.modules, stubs):
|
|
for name in ('pad_prompts', 'button_icons'):
|
|
sys.modules.pop(name, None)
|
|
import pad_prompts
|
|
return pad_prompts
|
|
|
|
|
|
class PromptToolsTest(Tmp):
|
|
"""tools/pad_prompts.py reads the game's files in pieces: the same bytes as in one call, without the minutes one
|
|
call took on the 244 MB texture archive. And the prompts step's key no longer follows the scripts' bytes."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
try:
|
|
import zstandard # noqa: F401
|
|
self.pp = load_pad_prompts()
|
|
except ImportError as e:
|
|
self.skipTest(f'needs zstandard and Pillow: {e}')
|
|
|
|
def test_a_file_unpacks_to_the_same_bytes_in_pieces(self):
|
|
import random
|
|
import zstandard
|
|
pp = self.pp
|
|
seven = bytes(i % 7 for i in range(256))
|
|
big = random.Random(5).randbytes(24 << 20).translate(seven) # packs to several pieces
|
|
small = b'a small file ' * 100
|
|
samples = [bytes([65 + i % 7]) * 40 + f'sample {i} of the dictionary'.encode() * 3 for i in range(400)]
|
|
dic = zstandard.train_dictionary(4096, samples)
|
|
z = zstandard.ZstdCompressor(level=3)
|
|
with_dict = zstandard.ZstdCompressor(level=3, dict_data=dic).compress(b''.join(samples[:50]))
|
|
no_size = zstandard.ZstdCompressor(level=3, write_content_size=False).compress(big[:3 << 20])
|
|
|
|
def whole(raw, d=None): # as the tool read a file before: the whole file in one call
|
|
return zstandard.ZstdDecompressor(dict_data=d).decompressobj().decompress(raw)
|
|
packed = z.compress(big)
|
|
self.assertGreater(len(packed), 4 * pp.PIECE)
|
|
for name, raw, d in (('large', packed, None), ('small', z.compress(small), None), ('empty', z.compress(b''), None),
|
|
('with a dictionary', with_dict, dic), ('no size in its frame', no_size, None),
|
|
('bytes after the frame', z.compress(small) + bytes(300 << 10), None),
|
|
('two frames', z.compress(small) + packed, None)):
|
|
self.assertEqual(pp.unpack(raw, d), whole(raw, d), name)
|
|
with mock.patch.object(pp, 'PIECE', 1000):
|
|
self.assertEqual(pp.unpack(raw, d), whole(raw, d), name)
|
|
self.assertEqual(pp.unpack(packed), big)
|
|
with self.assertRaises(zstandard.ZstdError):
|
|
pp.unpack(packed[:64] + bytes(300 << 10) + packed[64 + (300 << 10):])
|
|
# the tool's reader of extracted game files: plain files, packed ones, and one packed with a dictionary of
|
|
# the game's dictionary archive
|
|
root = self.tmp / 'romfs'
|
|
for path, data in (('Font2/plain.bin', small), ('UI/big.bntx.zs', packed), ('UI/dict.bntx.zs', with_dict),
|
|
(pp.DICTIONARIES, z.compress(sarc({'zs.zsdic': dic.as_bytes()})))):
|
|
(root / path).parent.mkdir(parents=True, exist_ok=True)
|
|
(root / path).write_bytes(data)
|
|
romfs = pp.Romfs(root)
|
|
self.assertEqual(romfs.load('Font2/plain.bin'), (small, None))
|
|
self.assertEqual(romfs.load('UI/big.bntx.zs'), (big, None))
|
|
content, used = romfs.load('UI/dict.bntx.zs')
|
|
self.assertEqual((content, used.dict_id()), (b''.join(samples[:50]), dic.dict_id()))
|
|
|
|
def test_the_prompts_key_is_the_one_installs_have(self):
|
|
import make_game
|
|
kit = self.tmp / 'kit'
|
|
art = kit / 'prompt-art' / 'XGamepad' / 'Default'
|
|
art.mkdir(parents=True)
|
|
(art / 'T_X_A_White.png').write_bytes(b'picture')
|
|
out = self.tmp / 'game'
|
|
out.mkdir()
|
|
ident = 'nup C:\\dumps\\game.nup 28000000000 1760000000'
|
|
released = 'ad59f2c4533f6ea2630b916ef5f3695f72daffe9b2b7663eea4168a4f08e4249' # the scripts of 0.7.1
|
|
with mock.patch.object(make_game, 'KIT', kit):
|
|
pictures = make_game.tree_digest(kit / 'prompt-art', ('**/*.png',))
|
|
binds = make_game.sha256_bytes(b'[]')
|
|
self.assertEqual(make_game.step_key('prompts', ident, out),
|
|
f'prompts-1|{ident}|{pictures}|{released}|{binds}|missing')
|
|
|
|
def test_the_prompt_tools_logic_is_the_recorded_one(self):
|
|
import make_game
|
|
now = (make_game.PROMPT_TOOLS, {name: logic_digest(ROOT / 'tools' / name) for name in PROMPT_LOGIC[1]})
|
|
self.assertEqual(now, PROMPT_LOGIC,
|
|
'The logic of the tools that draw the button prompts changed. If the change can alter what '
|
|
'they write, give make_game.PROMPT_TOOLS a new value, so that installs draw their prompts '
|
|
'again; either way record the new pair in PROMPT_LOGIC (tests/launcher_test.py).')
|
|
|
|
|
|
# ---------------------------------------------------------------- jobs
|
|
|
|
FAKE_MAKE_GAME = r'''
|
|
import json, sys, time
|
|
def ev(**k): print('@@' + json.dumps(k), flush=True)
|
|
if '--fail' in open(sys.argv[0] + '.mode').read():
|
|
ev(ev='step', step='check', state='start'); ev(ev='error', message='this is not game version 1.1.1'); sys.exit(1)
|
|
mode = open(sys.argv[0] + '.mode').read()
|
|
for s in ('check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts') + (('scan',) if '--scan' in mode else ()):
|
|
ev(ev='step', step=s, state='start')
|
|
if s == 'compile':
|
|
ev(ev='progress', step=s, done=5, total=10, unit='files'); ev(ev='phase', step=s, phase='link')
|
|
print('working on', s, flush=True)
|
|
ev(ev='step', step=s, state='done', seconds=0.1)
|
|
ev(ev='end', exe='x')
|
|
'''
|
|
|
|
|
|
class JobTest(Tmp):
|
|
def make_ctx(self, mode):
|
|
app = self.tmp / 'app'
|
|
(app / 'tools').mkdir(parents=True)
|
|
(app / 'VERSION').write_text('0.3.10\n')
|
|
script = app / 'tools' / 'make_game.py'
|
|
script.write_text(FAKE_MAKE_GAME)
|
|
(app / 'tools' / 'make_game.py.mode').write_text(mode)
|
|
ctx = Context(Layout(root=self.tmp / 'root', app=app))
|
|
return ctx
|
|
|
|
def patch_tools(self):
|
|
saved = toolchain.load_lock, toolchain.is_current, toolchain.ensure_zbic
|
|
toolchain.load_lock = lambda layout, version='dev': {'packages': []}
|
|
toolchain.is_current = lambda layout, lock: True
|
|
toolchain.ensure_zbic = lambda *a, **k: None
|
|
self.addCleanup(lambda: setattr(toolchain, 'load_lock', saved[0]) or
|
|
setattr(toolchain, 'is_current', saved[1]) or setattr(toolchain, 'ensure_zbic', saved[2]))
|
|
|
|
def test_install_job(self):
|
|
self.patch_tools()
|
|
ctx = self.make_ctx('ok')
|
|
j = jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game').run_inline()
|
|
self.assertEqual(j.result, 'ok', j.error)
|
|
self.assertEqual([s.state for s in j.steps], [jobs.SKIPPED] + [jobs.DONE] * 6 + [jobs.SKIPPED, jobs.DONE]) # tools ... shaders, scan (the fake build has no such step), ready
|
|
self.assertEqual(j.step('check').value, 'Version 1.1.1')
|
|
self.assertTrue(any('working on lift' in ln for ln in j.lines))
|
|
self.assertEqual(j.overall(), 1.0)
|
|
self.assertIsNone(j.step('prompts')) # a version without button art draws no prompts: no row
|
|
|
|
def test_install_job_with_button_art(self):
|
|
"""A version with button art shows the prompts step as a row of its own, after the shaders."""
|
|
self.patch_tools()
|
|
ctx = self.make_ctx('ok')
|
|
(ctx.layout.app / 'prompt-art').mkdir()
|
|
j = jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game').run_inline()
|
|
self.assertEqual(j.result, 'ok', j.error)
|
|
self.assertEqual([s.id for s in j.steps][-4:], ['shaders', 'prompts', 'scan', 'ready'])
|
|
self.assertEqual(j.step('prompts').state, jobs.DONE)
|
|
self.assertEqual([s.id for s in jobs.RebuildJob(ctx, self.tmp / 'game').steps][-3:], ['shaders', 'prompts', 'scan'])
|
|
|
|
def test_failed_job(self):
|
|
self.patch_tools()
|
|
ctx = self.make_ctx('--fail')
|
|
j = jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game').run_inline()
|
|
self.assertEqual(j.result, 'failed')
|
|
self.assertIn('1.1.1', j.error)
|
|
self.assertEqual(j.step('check').state, jobs.FAILED)
|
|
|
|
def scan_job(self, mode, report):
|
|
"""An install whose fake build ran (mode 'ok --scan') or kept (mode 'ok') the check of the game files, with
|
|
report as the romfs-check.json the check left in the game folder."""
|
|
self.patch_tools()
|
|
ctx = self.make_ctx(mode)
|
|
game = self.tmp / 'game'
|
|
game.mkdir()
|
|
if report is not None:
|
|
(game / 'romfs-check.json').write_text(json.dumps(report))
|
|
j = jobs.InstallJob(ctx, self.tmp / 'g.nup', game).run_inline()
|
|
self.assertEqual(j.result, 'ok', j.error)
|
|
ctx._file.flush()
|
|
return j, (ctx.layout.logs / 'launcher.log').read_text(encoding='utf-8')
|
|
|
|
def test_the_file_check_result_is_shown(self):
|
|
"""What the check of the game files found reaches the player: the count in the scan row, a sentence for the
|
|
home screen and a line in the log. The files flagged on every copy are told as expected, with nothing to
|
|
do; nothing tells the player to dump again."""
|
|
known = sorted(jobs.SCAN_EXPECTED)
|
|
textures = [p for p in known if p.startswith('Tex/')]
|
|
report = {'status': 'pass', 'files': 186587, 'damaged': {p: 'no compressed frame at its start' for p in known},
|
|
'repaired': {p: 'Tex/Other.bntx.zs' for p in textures},
|
|
'not_repaired': [p for p in known if p not in textures]}
|
|
j, log = self.scan_job('ok --scan', report)
|
|
self.assertEqual(j.step('scan').state, jobs.DONE)
|
|
self.assertEqual(j.step('scan').value, '4 Flagged, 3 Stand-Ins')
|
|
self.assertEqual(j.note, 'The file check flagged 4 game files, the same as in every copy of the game: 3 textures '
|
|
'got a stand-in, and nothing needs doing.')
|
|
self.assertIn('file check: The file check flagged 4 game files', log)
|
|
for text in (j.note, j.step('scan').value):
|
|
self.assertNotIn('dump', text.lower())
|
|
|
|
def test_other_damaged_files_are_counted_and_pointed_to(self):
|
|
report = {'damaged': {'Tex/A_Nrm.bntx.zs': 'no compressed frame at its start', 'Model/B.bfres.zs': 'x'},
|
|
'repaired': {'Tex/A_Nrm.bntx.zs': 'Tex/C_Nrm.bntx.zs'}, 'not_repaired': ['Model/B.bfres.zs']}
|
|
j, _ = self.scan_job('ok --scan', report)
|
|
self.assertEqual(j.step('scan').value, '2 Flagged, 1 Stand-In')
|
|
self.assertIn('flagged 2 game files as damaged: 1 texture got a stand-in', j.note)
|
|
self.assertIn('romfs-check.json', j.note)
|
|
self.assertNotIn('dump', j.note.lower())
|
|
self.assertEqual(jobs.scan_summary(['Model/B.bfres.zs'], 0)[1],
|
|
'The file check flagged 1 game file as damaged: none got a stand-in, so what uses them still '
|
|
'loads. romfs-check.json in the game folder lists them.')
|
|
|
|
def test_a_clean_or_kept_file_check_says_nothing_on_home(self):
|
|
j, _ = self.scan_job('ok --scan', {'damaged': {}, 'repaired': {}, 'not_repaired': []})
|
|
self.assertTrue(j.step('scan').value.startswith('Done'))
|
|
self.assertEqual(j.note, '')
|
|
shutil.rmtree(self.tmp / 'game')
|
|
shutil.rmtree(self.tmp / 'app')
|
|
# the check was done by an earlier build (the fake build has no scan step): the row has the count, home nothing
|
|
j, _ = self.scan_job('ok', {'damaged': {p: 'x' for p in jobs.SCAN_EXPECTED}, 'repaired': {}})
|
|
self.assertEqual(j.step('scan').state, jobs.SKIPPED)
|
|
self.assertEqual(j.step('scan').value, 'Unchanged · 4 Flagged, 0 Stand-Ins')
|
|
self.assertEqual(j.note, '')
|
|
self.assertIsNone(build.scan_report(self.tmp / 'no game'))
|
|
(self.tmp / 'game' / 'romfs-check.json').write_text('not json')
|
|
self.assertIsNone(build.scan_report(self.tmp / 'game'))
|
|
|
|
def test_home_shows_the_file_check_note(self):
|
|
App, a = mock_app(self, self.tmp / 'game')
|
|
a.job = mock.Mock(kind='install', result='ok', error='', after=None,
|
|
note='The file check flagged 4 game files, the same as in every copy of the game: 3 '
|
|
'textures got a stand-in, and nothing needs doing.')
|
|
a.settings['keep_cache'] = True
|
|
with mock.patch('launcher.ui.app.winsys.keep_awake'):
|
|
App.job_finished(a)
|
|
self.assertEqual(a.message, 'The game is built. Select Play. ' + a.job.note)
|
|
a.job.note = ''
|
|
App.job_finished(a)
|
|
self.assertEqual(a.message, 'The game is built. Select Play.')
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'Windows priority classes')
|
|
def test_the_build_and_what_it_starts_run_below_normal_priority(self):
|
|
"""build.Run starts the build below normal priority, and the processes the build starts (the compilers)
|
|
get that class from it, so a build on every processor leaves the PC usable."""
|
|
script = self.tmp / 'priority.py'
|
|
script.write_text(
|
|
'import ctypes, subprocess, sys\n'
|
|
'k = ctypes.windll.kernel32\n'
|
|
'k.GetCurrentProcess.restype = ctypes.c_void_p\n'
|
|
'k.GetPriorityClass.argtypes = [ctypes.c_void_p]\n'
|
|
'print(sys.argv[1], hex(k.GetPriorityClass(k.GetCurrentProcess())), flush=True)\n'
|
|
'if sys.argv[1] == "build":\n'
|
|
' subprocess.run([sys.executable, __file__, "compiler"])\n')
|
|
lines = []
|
|
run = build.Run([sys.executable, str(script), 'build'], lambda ev: None, lines.append)
|
|
self.assertEqual(run.wait(), 0, lines)
|
|
self.assertEqual(lines, ['build 0x4000', 'compiler 0x4000']) # BELOW_NORMAL_PRIORITY_CLASS
|
|
|
|
def test_the_build_is_read_to_its_end_when_a_handler_fails(self):
|
|
"""A line handler that raises (the log on a full disk) must not end the thread that reads the build's
|
|
output: the build would stop at a full pipe and never exit."""
|
|
child = [sys.executable, '-c',
|
|
'import json\n'
|
|
'for i in range(20000):\n'
|
|
' print("line", i, "x" * 60)\n'
|
|
'print("@@[1]")\n'
|
|
'print("@@" + json.dumps({"ev": "end"}))\n']
|
|
events = []
|
|
|
|
def on_line(line):
|
|
raise OSError(28, 'No space left on device')
|
|
|
|
def on_event(ev):
|
|
events.append(ev)
|
|
raise RuntimeError('a drawing error')
|
|
run = build.Run(child, on_event, on_line)
|
|
try:
|
|
code = run.proc.wait(timeout=60)
|
|
except subprocess.TimeoutExpired:
|
|
run.cancel()
|
|
self.fail('the build never ended: nobody read its output any more')
|
|
run.wait()
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(events, [{'ev': 'end'}])
|
|
self.assertEqual(run.dropped, 20002) # every line, the "@@" line that is no event, and the event
|
|
|
|
def test_a_log_that_cannot_be_written_loses_only_the_line(self):
|
|
ctx = self.make_ctx('ok')
|
|
real = ctx._file
|
|
self.addCleanup(real.close)
|
|
ctx._file = mock.Mock()
|
|
ctx._file.write.side_effect = OSError(28, 'No space left on device')
|
|
ctx.log('a line while the disk is full')
|
|
j = jobs.Job(ctx, [])
|
|
j.log('a line of the build') # what the build's reader thread calls
|
|
self.assertEqual(list(j.lines), ['a line of the build'])
|
|
ctx._file = real
|
|
ctx.log('the disk has room again')
|
|
real.close()
|
|
ctx.log('a line after the file was closed')
|
|
text = (ctx.layout.logs / 'launcher.log').read_text(encoding='utf-8')
|
|
self.assertIn('the disk has room again', text)
|
|
self.assertNotIn('while the disk is full', text)
|
|
|
|
def test_an_install_goes_on_from_the_unpacked_game_files(self):
|
|
"""Begin Installation into a folder whose game files an earlier run unpacked builds on from them (--rebuild
|
|
for this processor's level), so a moved or deleted dump is not asked for; without them it reads the dump."""
|
|
ctx = self.make_ctx('ok')
|
|
game = self.tmp / 'game'
|
|
first = jobs.InstallJob(ctx, self.tmp / 'g.nup', game).args()
|
|
self.assertEqual(first[:2], ['--nup', str(self.tmp / 'g.nup')])
|
|
made_up_game(game, steps=('exefs', 'romfs'), exe=False)
|
|
again = jobs.InstallJob(ctx, self.tmp / 'g.nup', game).args()
|
|
self.assertEqual(again[:2], ['--rebuild', '--retune'])
|
|
self.assertNotIn('--nup', again)
|
|
(game / 'romfs' / 'romfs.bin').unlink() # the image is gone: only the dump brings it back
|
|
self.assertEqual(jobs.InstallJob(ctx, self.tmp / 'g.nup', game).args()[0], '--nup')
|
|
|
|
def test_a_reader_sees_each_step_as_one_change_left_it(self):
|
|
"""The worker, the build's reader thread and the window change a job while the window reads it. Under
|
|
job.locked() a step's state comes with the value and fraction that belong to it, every change counts in
|
|
rev (the window draws again when rev changed, and learns from it that the job ended), and the output's
|
|
last lines can be copied while lines arrive."""
|
|
ctx = self.make_ctx('ok')
|
|
self.addCleanup(ctx._file.close)
|
|
j = jobs.Job(ctx, ['lift', 'compile'])
|
|
stop = threading.Event()
|
|
wrong, errors = [], []
|
|
|
|
def work():
|
|
while not stop.is_set():
|
|
for sid in ('lift', 'compile'):
|
|
j.begin(sid, 'starting')
|
|
j.progress(sid, 0.5, 'half')
|
|
j.finish(sid)
|
|
j.log(f'working on {sid}')
|
|
with j.locked():
|
|
j.step(sid).state, j.step(sid).value, j.step(sid).fraction = jobs.PENDING, 'Waiting', 0.0
|
|
|
|
def read():
|
|
try:
|
|
while not stop.is_set():
|
|
with j.locked():
|
|
seen = [(s.state, s.value, s.fraction) for s in j.steps]
|
|
for state, value, fraction in seen:
|
|
ok = {jobs.PENDING: value == 'Waiting' and fraction == 0.0,
|
|
jobs.RUNNING: value.endswith('%') and value == f'{int(fraction * 100)}%',
|
|
jobs.DONE: value.startswith('Done') and fraction == 1.0}[state]
|
|
if not ok:
|
|
wrong.append((state, value, fraction))
|
|
j.last_lines(6)
|
|
except Exception as e: # noqa: BLE001 - whatever a reader meets is the failure
|
|
errors.append(e)
|
|
|
|
threads = [threading.Thread(target=work), threading.Thread(target=read), threading.Thread(target=read)]
|
|
for t in threads:
|
|
t.start()
|
|
time.sleep(0.5)
|
|
stop.set()
|
|
for t in threads:
|
|
t.join(30)
|
|
self.assertEqual((wrong[:3], errors), ([], []))
|
|
|
|
counted = jobs.Job(ctx, [])
|
|
touchers = [threading.Thread(target=lambda: [counted._touch() for _ in range(20000)]) for _ in range(4)]
|
|
for t in touchers:
|
|
t.start()
|
|
for t in touchers:
|
|
t.join(60)
|
|
self.assertEqual(counted.rev, 80000) # no change was lost
|
|
try:
|
|
from launcher.ui.app import App
|
|
except ImportError:
|
|
return
|
|
a = mock.MagicMock() # the job screen's rows, from a real job
|
|
a.job = jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game')
|
|
a.job.begin('tools')
|
|
a.job.progress('tools', 0.25)
|
|
rows = App.rows_job(a)
|
|
self.assertEqual((rows[0].kind, rows[0].value, rows[0].fraction), ('progress', '25%', 0.25))
|
|
self.assertEqual([r.kind for r in rows].count('divider'), 1) # after the game data, as before
|
|
self.assertEqual(rows[-1].kind, 'action') # Cancel
|
|
|
|
|
|
# ---------------------------------------------------------------- one launcher, one job
|
|
|
|
# Another launcher process: it takes the lock file argv[2] as argv[3], says so and holds it until its input closes.
|
|
HOLDER = ('import sys; sys.path.insert(0, sys.argv[1]); from launcher import locks; '
|
|
'lock = locks.FileLock(sys.argv[2], sys.argv[3]); print("held", flush=True); sys.stdin.read()')
|
|
|
|
|
|
class LockTest(Tmp):
|
|
"""launcher/locks.py: one process works in a launcher folder at a time, and in it one job on the same thing."""
|
|
|
|
make_ctx, patch_tools = JobTest.make_ctx, JobTest.patch_tools
|
|
|
|
def holder(self, layout, what='the launcher window'):
|
|
"""A second process holding layout's lock as what, until the test ends or it is killed."""
|
|
p = subprocess.Popen([sys.executable, '-c', HOLDER, str(ROOT), str(layout.instance_lock), what],
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
|
|
|
|
def end():
|
|
p.kill()
|
|
p.wait()
|
|
p.stdin.close()
|
|
p.stdout.close()
|
|
self.addCleanup(end)
|
|
self.assertEqual(p.stdout.readline().strip(), 'held')
|
|
return p
|
|
|
|
def test_a_second_launcher_is_refused_and_told_who_holds_the_folder(self):
|
|
other = self.holder(self.layout)
|
|
with self.assertRaises(locks.Busy) as refused:
|
|
locks.FileLock(self.layout.instance_lock, 'the launcher window', where=self.layout.root)
|
|
self.assertIn(f'the launcher window (process {other.pid}, since ', str(refused.exception))
|
|
self.assertIn(str(self.layout.root), str(refused.exception))
|
|
try:
|
|
from launcher.ui import app
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
said = []
|
|
with mock.patch.object(app, 'tell', said.append), mock.patch.object(app.tk, 'Tk') as window, \
|
|
mock.patch.object(app.winsys, 'dpi_aware'), mock.patch.object(app.winsys, 'app_id'), \
|
|
mock.patch.object(app, 'single_instance', lambda layout, wait: locks.FileLock(
|
|
layout.instance_lock, 'the launcher window', where=layout.root)): # without the wait
|
|
self.assertEqual(app.main(['--root', str(self.layout.root)]), 0)
|
|
window.assert_not_called() # no second window
|
|
self.assertEqual(len(said), 1)
|
|
self.assertIn(f'NX2navi is already running: the launcher window (process {other.pid}', said[0])
|
|
|
|
def test_the_lock_goes_when_its_process_dies(self):
|
|
other = self.holder(self.layout)
|
|
other.kill() # no clean exit: the operating system lets go of the lock
|
|
other.wait()
|
|
mine = locks.FileLock(self.layout.instance_lock, 'the launcher window', wait=10)
|
|
self.addCleanup(mine.release)
|
|
# the window's own jobs share its lock, and it stays the window's until the window lets go of it
|
|
job = locks.FileLock(self.layout.instance_lock, 'a job')
|
|
job.release()
|
|
job.release() # letting go twice lets go once
|
|
self.assertIn(locks._key(self.layout.instance_lock), locks._files)
|
|
mine.release()
|
|
self.assertNotIn(locks._key(self.layout.instance_lock), locks._files)
|
|
|
|
def test_a_folder_that_cannot_hold_a_lock_file_refuses_nobody(self):
|
|
gone = self.tmp / 'no such folder' / 'launcher.lock'
|
|
with locks.FileLock(gone, 'the launcher window'), locks.FileLock(gone, 'another'):
|
|
self.assertFalse(gone.exists())
|
|
|
|
def test_a_job_does_not_run_while_another_process_holds_the_folder(self):
|
|
self.patch_tools()
|
|
ctx = self.make_ctx('ok')
|
|
self.addCleanup(ctx._file.close)
|
|
other = self.holder(ctx.layout, '"rebuild" from the command line')
|
|
j = jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game').run_inline()
|
|
self.assertEqual(j.result, 'failed')
|
|
self.assertIn(f'"rebuild" from the command line (process {other.pid}', j.error)
|
|
self.assertIn('try again', j.error)
|
|
self.assertEqual({s.state for s in j.steps}, {jobs.PENDING}) # nothing of it ran
|
|
self.assertFalse(any('working on' in ln for ln in j.lines))
|
|
check = jobs.Job(ctx, []) # a job that changes no file (the update check) runs beside it
|
|
check.run = lambda: None
|
|
self.assertEqual(check.run_inline().result, 'ok')
|
|
other.stdin.close() # the other process ends
|
|
other.wait()
|
|
j = jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game').run_inline()
|
|
self.assertEqual(j.result, 'ok', j.error)
|
|
self.assertEqual(locks._files, {}) # and the job let go of the folder when it ended
|
|
|
|
def test_two_jobs_on_one_folder(self):
|
|
self.patch_tools()
|
|
ctx = self.make_ctx('ok')
|
|
self.addCleanup(ctx._file.close)
|
|
started, go = threading.Event(), threading.Event()
|
|
|
|
class Slow(jobs.RebuildJob):
|
|
def run(self):
|
|
started.set()
|
|
go.wait(30)
|
|
|
|
first = Slow(ctx, self.tmp / 'game').start()
|
|
self.addCleanup(go.set)
|
|
self.assertTrue(started.wait(30))
|
|
second = jobs.RebuildJob(ctx, self.tmp / 'game').run_inline()
|
|
self.assertEqual(second.result, 'failed')
|
|
self.assertEqual(second.error, 'the rebuild of the game is still running: wait for it to end, or cancel it')
|
|
self.assertEqual({s.state for s in second.steps}, {jobs.PENDING})
|
|
install = jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game').run_inline()
|
|
self.assertEqual(install.result, 'failed') # another kind of job on the same folder
|
|
self.assertIn('the rebuild of the game is still running', install.error)
|
|
|
|
class Download(jobs.Job): # what the background download claims: the app folder, not the game
|
|
claims = ('app',)
|
|
task = 'the background download of 9.9.9'
|
|
|
|
def run(self):
|
|
pass
|
|
|
|
self.assertEqual(Download(ctx, []).run_inline().result, 'ok')
|
|
elsewhere = Context(Layout(root=self.tmp / 'other root', app=ctx.layout.app))
|
|
self.addCleanup(elsewhere._file.close)
|
|
self.assertEqual(jobs.InstallJob(elsewhere, self.tmp / 'g.nup', self.tmp / 'game 2').run_inline().result, 'ok')
|
|
go.set()
|
|
for _ in range(300):
|
|
if first.result is not None:
|
|
break
|
|
time.sleep(0.1)
|
|
self.assertEqual(first.result, 'ok')
|
|
again = jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game').run_inline()
|
|
self.assertEqual(again.result, 'ok', again.error) # the first job let go when it ended
|
|
self.assertEqual((locks._files, locks._claims), ({}, {}))
|
|
|
|
def test_a_job_the_launcher_was_stopped_in_is_told_at_the_next_start(self):
|
|
"""A job that writes into the game folder leaves data/job-running.json while it runs. Ended, also by an
|
|
error, it removes it; a launcher stopped in the middle leaves it, and the next start says which job did not
|
|
finish, once."""
|
|
self.patch_tools()
|
|
ctx = self.make_ctx('ok')
|
|
self.addCleanup(ctx._file.close)
|
|
marker = ctx.layout.data / jobs.MARKER
|
|
during = []
|
|
|
|
class Watched(jobs.RebuildJob):
|
|
def run(self):
|
|
during.append(json.loads(marker.read_text(encoding='utf-8')))
|
|
|
|
self.assertEqual(Watched(ctx, self.tmp / 'game').run_inline().result, 'ok')
|
|
self.assertEqual((during[0]['task'], during[0]['kind'], during[0]['game_dir']),
|
|
('the rebuild of the game', 'rebuild', str(self.tmp / 'game')))
|
|
self.assertFalse(marker.exists())
|
|
self.assertIsNone(jobs.interrupted(ctx))
|
|
|
|
class Failing(jobs.RebuildJob):
|
|
def run(self):
|
|
raise build.BuildError('the build stopped')
|
|
|
|
self.assertEqual(Failing(ctx, self.tmp / 'game').run_inline().result, 'failed')
|
|
self.assertFalse(marker.exists()) # it ended and said so on screen
|
|
check = jobs.Job(ctx, [])
|
|
self.assertFalse(check._mark()) # a job that writes nothing there leaves none
|
|
self.assertTrue(jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game')._mark()) # stopped before its end
|
|
self.assertTrue(marker.exists())
|
|
self.assertEqual(jobs.interrupted(ctx)['task'], 'the installation of the game')
|
|
self.assertFalse(marker.exists())
|
|
self.assertIsNone(jobs.interrupted(ctx)) # told once
|
|
marker.write_text('{"task": ') # cut while it was written
|
|
self.assertIsNone(jobs.interrupted(ctx))
|
|
self.assertFalse(marker.exists())
|
|
|
|
def test_the_window_says_which_job_did_not_finish(self):
|
|
game = made_up_game(self.tmp / 'game')
|
|
App, a = mock_app(self, game)
|
|
a.args.selftest = False
|
|
a.ctx.zstd_missing = ''
|
|
self.assertTrue(jobs.RebuildJob(a.ctx, game)._mark())
|
|
App.start(a)
|
|
a.show.assert_called_once_with('home')
|
|
self.assertIn('stopped during the rebuild of the game', a.message)
|
|
self.assertIn('Rebuild the Game finishes', a.message)
|
|
a.ctx._file.flush()
|
|
self.assertIn('the launcher was stopped during the rebuild of the game',
|
|
(self.layout.logs / 'launcher.log').read_text(encoding='utf-8'))
|
|
App, a = mock_app(self, self.tmp / 'no game yet') # an install that was stopped: first run
|
|
a.args.selftest = False
|
|
a.ctx.zstd_missing = ''
|
|
self.assertTrue(jobs.InstallJob(a.ctx, self.tmp / 'g.nup', self.tmp / 'no game yet')._mark())
|
|
App.start(a)
|
|
a.show.assert_called_once_with('first_run')
|
|
self.assertIn('stopped during the installation of the game', a.message)
|
|
self.assertIn('Begin Installation goes on', a.message)
|
|
App, a = mock_app(self, game) # the next start has nothing to tell
|
|
a.args.selftest = False
|
|
a.ctx.zstd_missing = ''
|
|
App.start(a)
|
|
self.assertEqual(a.message, '')
|
|
|
|
def test_a_failed_job_lets_go_of_the_folder(self):
|
|
self.patch_tools()
|
|
ctx = self.make_ctx('--fail')
|
|
self.addCleanup(ctx._file.close)
|
|
self.assertEqual(jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game').run_inline().result, 'failed')
|
|
self.assertEqual((locks._files, locks._claims), ({}, {}))
|
|
|
|
def test_the_command_line_takes_the_folders_lock(self):
|
|
from launcher import cli
|
|
ctx = self.make_ctx('ok')
|
|
ctx._file.close()
|
|
root = ['--root', str(ctx.layout.root)]
|
|
ran = []
|
|
|
|
def run(a, ctx, held):
|
|
ran.append((a.cmd, held is not None and locks._key(ctx.layout.instance_lock) in locks._files))
|
|
ctx._file.close()
|
|
return 0
|
|
err = io.StringIO()
|
|
with mock.patch.object(cli, 'run', run), contextlib.redirect_stderr(err):
|
|
self.assertEqual(cli.main(root + ['tools']), 0)
|
|
self.assertEqual(ran, [('tools', True)]) # held while the command runs
|
|
self.assertEqual(locks._files, {}) # and let go of after
|
|
other = self.holder(ctx.layout)
|
|
self.assertEqual(cli.main(root + ['rebuild']), 1)
|
|
self.assertEqual(cli.main(root + ['install', '--dump', 'd.nup', '--game', str(self.tmp / 'game')]), 1)
|
|
self.assertEqual(len(ran), 1) # neither ran, and no setting was written
|
|
self.assertEqual(cli.main(root + ['check']), 0) # the update check changes nothing: it runs
|
|
self.assertEqual(ran[-1], ('check', False))
|
|
self.assertIn(f'error: the launcher window (process {other.pid}', err.getvalue())
|
|
self.assertIn('try again', err.getvalue())
|
|
|
|
def test_the_command_line_update_refuses_while_the_game_runs(self):
|
|
"""`update` rebuilds the game after it switched versions: started while the game runs, it would leave the new
|
|
version with the old build. It refuses before it downloads anything, as the window's Updates row does."""
|
|
import argparse
|
|
from launcher import cli
|
|
ctx = mock.MagicMock()
|
|
ctx.settings = {'game_dir': str(self.tmp / 'game')}
|
|
err = io.StringIO()
|
|
with mock.patch('launcher.cli.build.game_running', return_value=True) as running, \
|
|
mock.patch('launcher.cli.jobs.CheckJob') as check, \
|
|
mock.patch('launcher.cli.jobs.UpdateDownloadJob') as download, contextlib.redirect_stderr(err):
|
|
self.assertEqual(cli.run(argparse.Namespace(cmd='update'), ctx, mock.MagicMock()), 1)
|
|
running.assert_called_once_with(str(self.tmp / 'game'))
|
|
check.assert_not_called()
|
|
download.assert_not_called()
|
|
self.assertIn('error: the game is running', err.getvalue())
|
|
|
|
|
|
# ---------------------------------------------------------------- the icon
|
|
|
|
class SavesTest(unittest.TestCase):
|
|
"""launcher/saves.py: where the game keeps its save, the save folders it recognises, and the import."""
|
|
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp(prefix='nx2navi-saves-'))
|
|
self.game = self.tmp / 'game'
|
|
self.game.mkdir()
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
def files(self, folder, names):
|
|
for n, data in names.items():
|
|
(folder / n).parent.mkdir(parents=True, exist_ok=True)
|
|
(folder / n).write_bytes(data)
|
|
return folder
|
|
|
|
def test_account_folder_as_the_game_names_it(self):
|
|
(self.game / 'profile').mkdir()
|
|
lo, hi = 0x1122334455667788, 0x99aabbccddeeff00
|
|
(self.game / 'profile' / 'account.uid').write_bytes(lo.to_bytes(8, 'little') + hi.to_bytes(8, 'little'))
|
|
self.assertEqual(saves.account_dir(self.game).name, f'{hi:016x}{lo:016x}') # fs_host.c storage_init
|
|
fresh = self.tmp / 'fresh'
|
|
acct = saves.account_dir(fresh) # a game that never ran: the account is made
|
|
self.assertEqual(len((fresh / 'profile' / 'account.uid').read_bytes()), 16)
|
|
self.assertEqual(saves.account_dir(fresh), acct)
|
|
|
|
def test_save_folders_found(self):
|
|
plain = self.files(self.tmp / 'backup', {'save.dat': b'1', 'option.dat': b'2'})
|
|
self.assertEqual(saves.find_save(plain)[0], plain)
|
|
emu = self.files(self.tmp / 'emu', {'0000000000000000/' + 'a' * 32 + '/0100000000010000/save.dat': b'1'})
|
|
self.assertEqual(saves.find_save(emu)[0].name, '0100000000010000')
|
|
other = self.files(self.tmp / 'other', {'profile/save/' + 'b' * 32 + '/save.dat': b'1', 'ocarina.exe': b''})
|
|
self.assertEqual(saves.find_save(other)[0].name, 'b' * 32)
|
|
self.assertEqual(saves.find_save(other / 'profile')[0].name, 'b' * 32)
|
|
(self.tmp / 'empty').mkdir()
|
|
self.assertIsNone(saves.find_save(self.tmp / 'empty')[0])
|
|
|
|
def test_import_backs_up_and_clears_the_journal(self):
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'save.dat': b'old'})
|
|
tx = self.game / 'profile' / 'save-tx'
|
|
self.files(tx, {'ready-3/save.dat': b'newer', 'work/save.dat': b'w'})
|
|
src = self.files(self.tmp / 'mine', {'save.dat': b'imported', 'sub/extra.bin': b'x'})
|
|
r = saves.import_save(self.game, src, log=lambda m: None)
|
|
self.assertEqual(r['files'], 2)
|
|
self.assertEqual((acct / 'save.dat').read_bytes(), b'imported')
|
|
self.assertEqual((acct / 'sub' / 'extra.bin').read_bytes(), b'x')
|
|
self.assertEqual(list(tx.iterdir()), []) # the game loads the imported save
|
|
self.assertEqual((r['backup'] / 'save.dat').read_bytes(), b'newer') # the save the game would have loaded
|
|
self.assertTrue((self.game / 'saves' / 'README.txt').is_file())
|
|
self.assertTrue((src / 'save.dat').is_file()) # a picked folder is left as it was
|
|
|
|
def test_one_slot_replaces_only_that_slot(self):
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'account.sav': b'a', 'option.sav': b'o', 'slot_00/progress.sav': b'old0',
|
|
'slot_01/progress.sav': b'old1', 'slot_01/extra.sav': b'e'})
|
|
src = self.files(self.tmp / 'slot_01', {'slot_01/progress.sav': b'new1', 'slot_01/caption.sav': b'c1'}) # nested
|
|
r = saves.import_save(self.game, src, log=lambda m: None)
|
|
self.assertEqual(r['slot'], 'slot_01')
|
|
self.assertEqual((acct / 'slot_01' / 'progress.sav').read_bytes(), b'new1')
|
|
self.assertEqual((acct / 'slot_01' / 'caption.sav').read_bytes(), b'c1')
|
|
self.assertFalse((acct / 'slot_01' / 'extra.sav').exists()) # the slot is replaced, not mixed
|
|
self.assertFalse((acct / 'progress.sav').exists()) # the game reads it only inside a slot
|
|
for name, data in (('slot_00/progress.sav', b'old0'), ('account.sav', b'a'), ('option.sav', b'o')):
|
|
self.assertEqual((acct / name).read_bytes(), data)
|
|
self.assertEqual((r['backup'] / 'slot_01' / 'progress.sav').read_bytes(), b'old1')
|
|
|
|
def test_a_slot_under_another_name_is_the_first_slot(self):
|
|
src = self.files(self.tmp / 'my save', {'progress.sav': b'p', 'caption.sav': b'c'})
|
|
r = saves.import_save(self.game, src, log=lambda m: None) # a game that never ran has no save to keep
|
|
acct = saves.account_dir(self.game)
|
|
self.assertEqual(r['slot'], 'slot_00')
|
|
self.assertIsNone(r['backup'])
|
|
self.assertEqual((acct / 'slot_00' / 'progress.sav').read_bytes(), b'p')
|
|
|
|
def test_a_slot_clears_a_slot_left_at_the_top_of_the_save(self):
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'progress.sav': b'stray', 'caption.sav': b'stray', 'option.sav': b'o'})
|
|
src = self.files(self.tmp / 'slot_02', {'progress.sav': b'p'})
|
|
r = saves.import_save(self.game, src, log=lambda m: None)
|
|
self.assertFalse((acct / 'progress.sav').exists())
|
|
self.assertFalse((acct / 'caption.sav').exists())
|
|
self.assertEqual((acct / 'option.sav').read_bytes(), b'o')
|
|
self.assertEqual((acct / 'slot_02' / 'progress.sav').read_bytes(), b'p')
|
|
self.assertEqual((r['backup'] / 'progress.sav').read_bytes(), b'stray')
|
|
|
|
def test_a_whole_save_replaces_the_save(self):
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'slot_00/progress.sav': b'old', 'slot_03/progress.sav': b'old3', 'option.sav': b'o'})
|
|
src = self.files(self.tmp / 'whole', {'account.sav': b'a', 'option.sav': b'n', 'slot_00/progress.sav': b'new'})
|
|
r = saves.import_save(self.game, src, log=lambda m: None)
|
|
self.assertIsNone(r['slot'])
|
|
self.assertEqual((acct / 'slot_00' / 'progress.sav').read_bytes(), b'new')
|
|
self.assertEqual((acct / 'option.sav').read_bytes(), b'n')
|
|
self.assertFalse((acct / 'slot_03').exists())
|
|
|
|
def test_a_backup_comes_back_without_its_journal(self):
|
|
src = self.files(self.tmp / 'backup 2026-11-09 05.42.06', {'option.sav': b'o', 'slot_00/progress.sav': b'p',
|
|
'save-tx/work/option.sav': b'w'})
|
|
saves.import_save(self.game, src, log=lambda m: None)
|
|
acct = saves.account_dir(self.game)
|
|
self.assertEqual((acct / 'slot_00' / 'progress.sav').read_bytes(), b'p')
|
|
self.assertFalse((acct / 'save-tx').exists())
|
|
|
|
def test_import_from_the_saves_folder(self):
|
|
d = saves.ensure_saves_dir(self.game)
|
|
self.assertIsNone(saves.waiting(self.game)[0]) # README.txt alone is no save
|
|
self.files(d, {'emu/0000000000000000/' + 'c' * 32 + '/0100000000010000/save.dat': b'emu'})
|
|
self.assertIsNotNone(saves.waiting(self.game)[0])
|
|
r = saves.import_save(self.game, log=lambda m: None)
|
|
self.assertIsNone(r['backup']) # there was no save yet
|
|
self.assertEqual((saves.account_dir(self.game) / 'save.dat').read_bytes(), b'emu')
|
|
left = sorted(e.name for e in d.iterdir())
|
|
self.assertEqual(left[0], 'README.txt')
|
|
self.assertTrue(left[1].startswith('imported '))
|
|
self.assertIsNone(saves.waiting(self.game)[0]) # not imported twice
|
|
self.files(d, {'save.dat': b'loose'}) # files put straight into saves/
|
|
r = saves.import_save(self.game, log=lambda m: None)
|
|
self.assertEqual((saves.account_dir(self.game) / 'save.dat').read_bytes(), b'loose')
|
|
self.assertEqual((r['backup'] / 'save.dat').read_bytes(), b'emu')
|
|
self.assertIsNone(saves.waiting(self.game)[0])
|
|
|
|
def test_picking_the_saves_folder_takes_what_waits_there(self):
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'save.dat': b'mine'})
|
|
d = saves.ensure_saves_dir(self.game)
|
|
with self.assertRaises(saves.SaveError): # README.txt alone is no save
|
|
saves.import_save(self.game, d, log=lambda m: None)
|
|
self.assertEqual((acct / 'save.dat').read_bytes(), b'mine')
|
|
self.files(d, {'save.dat': b'put there'})
|
|
r = saves.import_save(self.game, d, log=lambda m: None)
|
|
self.assertEqual(sorted(p.name for p in acct.iterdir()), ['save.dat']) # not README.txt or a backup
|
|
self.assertEqual((acct / 'save.dat').read_bytes(), b'put there')
|
|
self.assertEqual((r['backup'] / 'save.dat').read_bytes(), b'mine')
|
|
|
|
def test_an_interrupted_commit_is_backed_up(self):
|
|
# runtime/fs_host.c save_recover: with no account folder and no finished commit, save-tx/new is the save, and
|
|
# without it save-tx/old; the import keeps that only copy
|
|
for half in ('new', 'old'):
|
|
with self.subTest(half=half):
|
|
shutil.rmtree(self.game, ignore_errors=True)
|
|
self.game.mkdir()
|
|
acct = saves.account_dir(self.game)
|
|
tx = self.game / 'profile' / 'save-tx'
|
|
self.files(tx, {f'{half}/save.dat': b'only copy'})
|
|
src = self.files(self.tmp / f'mine-{half}', {'save.dat': b'imported'})
|
|
r = saves.import_save(self.game, src, log=lambda m: None)
|
|
self.assertEqual((acct / 'save.dat').read_bytes(), b'imported')
|
|
self.assertEqual((r['backup'] / 'save.dat').read_bytes(), b'only copy')
|
|
self.assertEqual((r['backup'] / 'save-tx' / half / 'save.dat').read_bytes(), b'only copy')
|
|
|
|
def test_a_failed_swap_keeps_the_old_save(self):
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'save.dat': b'old'})
|
|
src = self.files(self.tmp / 'mine', {'save.dat': b'imported'})
|
|
real = saves.os.replace
|
|
|
|
def replace(a, b):
|
|
if Path(a).name == 'import': # the imported save going into place: antivirus holds a file
|
|
raise PermissionError(5, 'Access is denied')
|
|
return real(a, b)
|
|
saves.os.replace = replace
|
|
try:
|
|
with self.assertRaises(PermissionError):
|
|
saves.import_save(self.game, src, log=lambda m: None)
|
|
finally:
|
|
saves.os.replace = real
|
|
self.assertEqual((acct / 'save.dat').read_bytes(), b'old')
|
|
|
|
def test_a_failed_swap_keeps_the_finished_commit(self):
|
|
# the account folder cannot be moved aside (a file in it held open): the newer finished commit stays in
|
|
# save-tx, where the game loads it first, instead of going into the backup
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'save.dat': b'older'})
|
|
tx = self.game / 'profile' / 'save-tx'
|
|
self.files(tx, {'ready-4/save.dat': b'newest'})
|
|
src = self.files(self.tmp / 'mine', {'save.dat': b'imported'})
|
|
real = saves.os.replace
|
|
|
|
def replace(a, b):
|
|
if Path(a) == acct:
|
|
raise PermissionError(32, 'The process cannot access the file')
|
|
return real(a, b)
|
|
saves.os.replace = replace
|
|
try:
|
|
with self.assertRaises(PermissionError):
|
|
saves.import_save(self.game, src, log=lambda m: None)
|
|
finally:
|
|
saves.os.replace = real
|
|
self.assertEqual((tx / 'ready-4' / 'save.dat').read_bytes(), b'newest')
|
|
self.assertEqual((acct / 'save.dat').read_bytes(), b'older')
|
|
self.assertFalse((tx / 'import').exists())
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'the running check is Windows only')
|
|
def test_a_read_only_exe_is_not_a_running_game(self):
|
|
exe = self.game / 'ocarina.exe'
|
|
exe.write_bytes(b'MZ')
|
|
os.chmod(exe, stat.S_IREAD)
|
|
self.assertFalse(build.game_running(self.game))
|
|
self.assertFalse(saves.game_running(self.game))
|
|
self.assertTrue(os.access(exe, os.W_OK))
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'the running check is Windows only')
|
|
def test_only_a_sharing_violation_is_a_running_game(self):
|
|
"""A running exe refuses a write open with a sharing violation (32); access denied (5) on an exe that is not
|
|
read-only (an access list, Controlled Folder Access) is no running game, and is logged when asked."""
|
|
exe = self.game / 'ocarina.exe'
|
|
exe.write_bytes(b'MZ')
|
|
answers = []
|
|
with mock.patch('launcher.build._write_open_error', side_effect=lambda p: answers.pop(0)) as opened:
|
|
answers[:] = [32]
|
|
self.assertTrue(build.game_running(self.game))
|
|
answers[:] = [0]
|
|
self.assertFalse(build.game_running(self.game))
|
|
answers[:] = [5]
|
|
lines = []
|
|
self.assertFalse(build.game_running(self.game, log=lines.append))
|
|
self.assertEqual(len(lines), 1)
|
|
self.assertIn('cannot be opened for writing (access denied)', lines[0])
|
|
self.assertEqual(opened.call_count, 3)
|
|
os.chmod(exe, stat.S_IREAD) # read-only: cleared, then the open is tried again
|
|
answers[:] = [5, 32]
|
|
self.assertTrue(build.game_running(self.game))
|
|
self.assertTrue(os.access(exe, os.W_OK))
|
|
os.chmod(exe, stat.S_IREAD)
|
|
answers[:] = [5, 5]
|
|
lines = []
|
|
self.assertFalse(build.game_running(self.game, log=lines.append))
|
|
self.assertEqual(len(lines), 1)
|
|
self.assertEqual(answers, [])
|
|
self.assertEqual(build._write_open_error(exe), 0) # the real call on a file nothing holds
|
|
sleeper = self.game / 'sleeper.exe' # and on a running exe
|
|
if not compiled_sleeper(sleeper, 3000, 0):
|
|
self.skipTest('needs a C compiler')
|
|
proc = winsys.start_game(sleeper)
|
|
try:
|
|
self.assertEqual(build._write_open_error(sleeper), build.ERROR_SHARING_VIOLATION)
|
|
self.assertTrue(build.game_running(self.game, exe='sleeper.exe'))
|
|
finally:
|
|
proc.kill()
|
|
proc.wait(10)
|
|
self.assertFalse(build.game_running(self.game, exe='sleeper.exe'))
|
|
|
|
def test_read_only_files_import_writable(self):
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'save.dat': b'old'})
|
|
src = self.files(self.tmp / 'mine', {'save.dat': b'imported'})
|
|
os.chmod(src / 'save.dat', stat.S_IREAD)
|
|
os.chmod(acct / 'save.dat', stat.S_IREAD) # the save it replaces: deleted after the swap all the same
|
|
try:
|
|
saves.import_save(self.game, src, log=lambda m: None)
|
|
self.assertTrue(os.access(acct / 'save.dat', os.W_OK))
|
|
self.assertEqual([p.name for p in (self.game / 'profile' / 'save-tx').iterdir()], [])
|
|
finally:
|
|
os.chmod(src / 'save.dat', stat.S_IREAD | stat.S_IWRITE)
|
|
|
|
def test_another_installs_finished_commit_is_its_save(self):
|
|
other = self.files(self.tmp / 'other', {'profile/save/' + 'b' * 32 + '/save.dat': b'older',
|
|
'profile/save-tx/ready-2/save.dat': b'newest'})
|
|
self.assertEqual(saves.find_save(other)[0].name, 'ready-2')
|
|
|
|
def test_refusals(self):
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'save.dat': b'1'})
|
|
with self.assertRaises(saves.SaveError):
|
|
saves.import_save(self.game, self.game / 'profile', log=lambda m: None) # its own save
|
|
big = self.tmp / 'big'
|
|
big.mkdir()
|
|
for i in range(saves.MAX_FILES + 1):
|
|
(big / f'{i}.txt').write_bytes(b'')
|
|
with self.assertRaises(saves.SaveError):
|
|
saves.import_save(self.game, big, log=lambda m: None)
|
|
self.assertEqual((acct / 'save.dat').read_bytes(), b'1') # nothing changed
|
|
|
|
@staticmethod
|
|
def game_lock(path):
|
|
"""The profile lock as the game takes it (runtime/platform plat_lock_file): Windows opens the file without
|
|
sharing, elsewhere flock. Returns a function that lets go of it, or None when it is refused."""
|
|
if os.name == 'nt':
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
k = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
k.CreateFileW.restype = wintypes.HANDLE
|
|
k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p,
|
|
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
|
k.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
h = k.CreateFileW(str(path), 0xC0000000, 0, None, 4, 0x80, None)
|
|
if h in (None, wintypes.HANDLE(-1).value):
|
|
return None
|
|
return lambda: k.CloseHandle(h)
|
|
import fcntl
|
|
f = open(path, 'a+b')
|
|
try:
|
|
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
f.close()
|
|
return None
|
|
return f.close
|
|
|
|
def test_no_import_while_the_game_holds_its_profile(self):
|
|
"""The import takes the game's own profile lock for the time it replaces the save: a running game refuses
|
|
it with nothing changed, and a game that starts in the middle of an import is turned away by the lock
|
|
instead of mounting a save that is half replaced."""
|
|
acct = saves.account_dir(self.game)
|
|
self.files(acct, {'save.dat': b'old'})
|
|
src = self.files(self.tmp / 'theirs', {'save.dat': b'new'})
|
|
lock = self.game / 'profile' / 'instance.lock'
|
|
let_go = self.game_lock(lock) # the game runs
|
|
self.assertIsNotNone(let_go)
|
|
with self.assertRaises(saves.SaveError) as refused:
|
|
saves.import_save(self.game, src, log=lambda m: None)
|
|
self.assertIn('the game is running', str(refused.exception))
|
|
self.assertEqual((acct / 'save.dat').read_bytes(), b'old')
|
|
self.assertFalse(saves.saves_dir(self.game).exists()) # no backup was made, nothing staged
|
|
self.assertFalse((self.game / 'profile' / 'save-tx' / 'import').exists())
|
|
let_go() # the game has ended
|
|
started = []
|
|
real_copytree = shutil.copytree
|
|
|
|
def copytree(a, b, *args, **kw): # the game is started during the backup's copy
|
|
started.append(self.game_lock(lock))
|
|
return real_copytree(a, b, *args, **kw)
|
|
with mock.patch.object(shutil, 'copytree', copytree):
|
|
r = saves.import_save(self.game, src, log=lambda m: None)
|
|
self.assertEqual(started, [None]) # and is turned away
|
|
self.assertEqual((acct / 'save.dat').read_bytes(), b'new')
|
|
self.assertEqual((r['backup'] / 'save.dat').read_bytes(), b'old')
|
|
let_go = self.game_lock(lock) # after the import the game starts as ever
|
|
self.assertIsNotNone(let_go)
|
|
let_go()
|
|
|
|
|
|
class SaveShareTest(unittest.TestCase):
|
|
"""launcher/saves.py export_save and import_zip: one zip with the save and a manifest, for a bug report or another
|
|
player, and the checks an import makes before it replaces the save."""
|
|
|
|
SAVE = {'account.sav': b'acct', 'option.sav': b'opt', 'slot_00/progress.sav': b'p0', 'slot_00/caption.sav': b'c0',
|
|
'slot_02/progress.sav': b'p2'}
|
|
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp(prefix='nx2navi-share-'))
|
|
self.game = self.tmp / 'game'
|
|
self.out = self.tmp / 'out'
|
|
self.out.mkdir()
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
def files(self, folder, names):
|
|
for n, data in names.items():
|
|
(folder / n).parent.mkdir(parents=True, exist_ok=True)
|
|
(folder / n).write_bytes(data)
|
|
return folder
|
|
|
|
def game_with_save(self, game, names=None):
|
|
self.files(saves.account_dir(game), names or self.SAVE)
|
|
return game
|
|
|
|
def zip_of(self, members, manifest=None):
|
|
"""A zip with members {name: bytes} and a manifest (export_save's keys unless given; None: none)."""
|
|
path = self.tmp / f'made-{len(list(self.tmp.glob("made-*")))}.zip'
|
|
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
|
|
if manifest is not False:
|
|
m = manifest or {'format': saves.FORMAT, 'format_version': 1, 'title_id': '0400486024dec000',
|
|
'game_version': '1.1.1', 'nx2navi': '0.6.1', 'exported': '2026-11-09T17:30:05Z'}
|
|
z.writestr(saves.MANIFEST, json.dumps(m))
|
|
for n, data in members.items():
|
|
z.writestr(n, data)
|
|
return path
|
|
|
|
def test_export_writes_one_zip_of_the_newest_save(self):
|
|
self.game_with_save(self.game)
|
|
tx = self.game / 'profile' / 'save-tx'
|
|
self.files(tx, {'ready-7/option.sav': b'newer', 'ready-7/slot_00/progress.sav': b'p0 newer',
|
|
'work/option.sav': b'not committed'})
|
|
self.files(self.game, {'logs/nx2navi.log': b'log', 'nx2navi.ini': b'[graphics]'})
|
|
r = saves.export_save(self.game, self.out, version='0.6.1', log=lambda m: None)
|
|
self.assertRegex(r['path'].name, r'^NX2navi-save-\d{4}-\d\d-\d\d_\d\d-\d\d-\d\d\.zip$')
|
|
self.assertEqual(r['extras'], [])
|
|
self.assertEqual([p.name for p in self.out.iterdir()], [r['path'].name]) # no .part left behind
|
|
with zipfile.ZipFile(r['path']) as z:
|
|
names = sorted(z.namelist())
|
|
self.assertEqual(names, ['manifest.json', 'save/option.sav', 'save/slot_00/progress.sav'])
|
|
self.assertEqual(z.read('save/option.sav'), b'newer') # the finished commit the game would load
|
|
m = json.loads(z.read('manifest.json'))
|
|
self.assertEqual((m['format'], m['format_version'], m['title_id'], m['game_version'], m['nx2navi'],
|
|
m['files'], m['bug_report']), (saves.FORMAT, 1, '0400486024dec000', '1.1.1', '0.6.1', 2,
|
|
False))
|
|
self.assertRegex(m['exported'], r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$')
|
|
r2 = saves.export_save(self.game, self.out, log=lambda m: None) # the same second: a name of its own
|
|
self.assertNotEqual(r2['path'], r['path'])
|
|
|
|
def test_a_bug_report_adds_the_log_and_settings_and_never_keys(self):
|
|
self.game_with_save(self.game, dict(self.SAVE, **{'prod.keys': b'secret', 'sub/title.keys': b'secret'}))
|
|
self.files(self.game, {'logs/nx2navi.log': b'the log', 'logs/nx2navi.1.log': b'older', 'nx2navi.ini': b'[x]',
|
|
'prod.keys': b'secret'})
|
|
r = saves.export_save(self.game, self.out, bug_report=True, log=lambda m: None)
|
|
self.assertTrue(r['path'].name.endswith('-bug-report.zip'))
|
|
self.assertEqual(r['extras'], ['nx2navi.log', 'nx2navi.ini'])
|
|
with zipfile.ZipFile(r['path']) as z:
|
|
names = z.namelist()
|
|
self.assertEqual(z.read('bug-report/nx2navi.log'), b'the log')
|
|
self.assertEqual(z.read('bug-report/nx2navi.ini'), b'[x]')
|
|
self.assertTrue(json.loads(z.read('manifest.json'))['bug_report'])
|
|
self.assertFalse(any(b'secret' in z.read(n) for n in names))
|
|
self.assertFalse([n for n in names if 'keys' in n])
|
|
self.assertEqual(len([n for n in names if n.startswith('save/')]), len(self.SAVE))
|
|
|
|
def test_a_bug_report_keeps_the_private_settings_values_out(self):
|
|
"""The private settings' values (a room's secret, a relay and a name that an earlier version's co-op left in
|
|
the file) are not in the nx2navi.ini a bug report carries; every other byte of the file is."""
|
|
ini = (b'\xef\xbb\xbf[coop]\r\ncoop = 1\r\n Coop_Room = H7K3WM-BCDFGH-234567\r\ncoop_server=relay.example:24680\r\n'
|
|
b'# coop_name = in a comment\r\ncoop_name =\r\ncoop_accept = 1a2b3c4d\nfps_limit = 60')
|
|
self.game_with_save(self.game, self.SAVE)
|
|
self.files(self.game, {'logs/nx2navi.log': b'the log', 'nx2navi.ini': ini})
|
|
r = saves.export_save(self.game, self.out, bug_report=True, log=lambda m: None)
|
|
with zipfile.ZipFile(r['path']) as z:
|
|
got = z.read('bug-report/nx2navi.ini')
|
|
self.assertEqual(got, b'\xef\xbb\xbf[coop]\r\ncoop = 1\r\n Coop_Room = (set, not shown)\r\n'
|
|
b'coop_server= (set, not shown)\r\n# coop_name = in a comment\r\ncoop_name =\r\n'
|
|
b'coop_accept = (set, not shown)\nfps_limit = 60')
|
|
for secret in (b'H7K3WM', b'relay.example', b'1a2b3c4d'):
|
|
self.assertNotIn(secret, got)
|
|
self.assertEqual(saves.scrub_settings(b'[x]'), b'[x]')
|
|
self.assertEqual(saves.scrub_settings(b''), b'')
|
|
|
|
def test_no_save_no_export(self):
|
|
with self.assertRaises(saves.SaveError):
|
|
saves.export_save(self.game, self.out, log=lambda m: None) # a game that never ran
|
|
self.assertFalse((self.game / 'profile' / 'account.uid').exists()) # and the export made no account
|
|
saves.account_dir(self.game).mkdir(parents=True)
|
|
with self.assertRaises(saves.SaveError):
|
|
saves.export_save(self.game, self.out, log=lambda m: None) # an account without files
|
|
self.assertEqual(list(self.out.iterdir()), [])
|
|
|
|
def test_a_bug_report_without_a_save_holds_the_log_and_settings(self):
|
|
"""A game that stops before its first save: the bug report export writes its log and settings alone."""
|
|
with self.assertRaises(saves.SaveError):
|
|
saves.export_save(self.game, self.out, bug_report=True, log=lambda m: None) # no log either: nothing
|
|
self.assertFalse(self.out.exists() and list(self.out.iterdir()))
|
|
self.files(self.game, {'logs/nx2navi.log': b'FATAL: it stopped', 'nx2navi.ini': b'[x]', 'prod.keys': b'secret'})
|
|
with self.assertRaises(saves.SaveError):
|
|
saves.export_save(self.game, self.out, log=lambda m: None) # not a bug report: a save or nothing
|
|
for account in (False, True): # a game that never made its account, and an account without files
|
|
if account:
|
|
saves.account_dir(self.game).mkdir(parents=True)
|
|
with self.subTest(account=account):
|
|
r = saves.export_save(self.game, self.out, bug_report=True, version='0.7.1', log=lambda m: None)
|
|
self.assertRegex(r['path'].name, r'^NX2navi-log-.*-bug-report( \(\d+\))?\.zip$') # (2): the same second
|
|
self.assertEqual((r['files'], r['extras']), (0, ['nx2navi.log', 'nx2navi.ini']))
|
|
with zipfile.ZipFile(r['path']) as z:
|
|
self.assertEqual(sorted(z.namelist()), ['bug-report/nx2navi.ini', 'bug-report/nx2navi.log',
|
|
'manifest.json'])
|
|
self.assertEqual(z.read('bug-report/nx2navi.log'), b'FATAL: it stopped')
|
|
m = json.loads(z.read('manifest.json'))
|
|
self.assertEqual((m['format'], m['files'], m['bug_report'], m['nx2navi']),
|
|
(saves.FORMAT, 0, True, '0.7.1'))
|
|
with self.assertRaises(saves.SaveError): # nobody's save: an import says so and changes nothing
|
|
saves.import_zip(self.game_with_save(self.tmp / f'other{account}'), r['path'], log=lambda m: None)
|
|
self.assertFalse(list(self.out.glob('*.part')))
|
|
|
|
def test_a_shared_save_replaces_the_save_and_keeps_a_backup(self):
|
|
other = self.game_with_save(self.tmp / 'other')
|
|
shared = saves.export_save(other, self.out, bug_report=True, version='0.6.1', log=lambda m: None)['path']
|
|
self.game_with_save(self.game, {'option.sav': b'mine', 'slot_05/progress.sav': b'mine5'})
|
|
acct = saves.account_dir(self.game)
|
|
self.files(self.game / 'profile' / 'save-tx', {'ready-2/option.sav': b'mine newer'})
|
|
r = saves.import_save(self.game, shared, log=lambda m: None)
|
|
self.assertEqual(r['manifest']['nx2navi'], '0.6.1')
|
|
self.assertIsNone(r['slot'])
|
|
got = {p.relative_to(acct).as_posix(): p.read_bytes() for p in acct.rglob('*') if p.is_file()}
|
|
self.assertEqual(got, self.SAVE) # every slot: slot_05 is gone too
|
|
self.assertFalse((acct / 'nx2navi.log').exists()) # the bug report files stay out
|
|
self.assertEqual((r['backup'] / 'option.sav').read_bytes(), b'mine newer')
|
|
self.assertEqual(r['backup'].parent, self.game / 'saves')
|
|
self.assertEqual(list((self.game / 'profile' / 'save-tx').iterdir()), [])
|
|
self.assertTrue(shared.is_file()) # a picked zip is left where it was
|
|
|
|
def test_a_zip_in_the_saves_folder_is_taken_first(self):
|
|
self.game_with_save(self.game)
|
|
shared = saves.export_save(self.game_with_save(self.tmp / 'other', {'option.sav': b'theirs'}), self.out,
|
|
log=lambda m: None)['path']
|
|
d = saves.ensure_saves_dir(self.game)
|
|
self.files(d, {'loose.sav': b'x'})
|
|
shutil.copy2(shared, d / shared.name)
|
|
src, _ = saves.waiting(self.game)
|
|
self.assertEqual(src, d / shared.name)
|
|
r = saves.import_save(self.game, log=lambda m: None)
|
|
self.assertEqual((saves.account_dir(self.game) / 'option.sav').read_bytes(), b'theirs')
|
|
self.assertFalse((saves.account_dir(self.game) / shared.name).exists()) # not copied in as a file
|
|
done = [e for e in d.iterdir() if e.name.startswith('imported ')]
|
|
self.assertEqual([p.name for p in done[0].iterdir()], [shared.name]) # moved out: not imported twice
|
|
self.assertEqual(saves.waiting(self.game)[0], d) # the loose file waits on its own
|
|
self.assertIsNotNone(r['backup'])
|
|
# picking the saves folder itself takes what waits there, as the row does
|
|
shutil.copy2(shared, d / 'again.zip')
|
|
r = saves.import_save(self.game, d, log=lambda m: None)
|
|
self.assertEqual(r['source'].name, 'again.zip')
|
|
|
|
def test_an_import_in_the_launcher_drops_the_game_menus_waiting_import(self):
|
|
self.game_with_save(self.game)
|
|
self.files(self.game / 'profile', {'save-import/option.sav': b'from the menu',
|
|
'save-import.part/option.sav': b'half'})
|
|
self.assertIsNotNone(saves.pending_import(self.game))
|
|
shared = saves.export_save(self.game_with_save(self.tmp / 'other'), self.out, log=lambda m: None)['path']
|
|
saves.import_save(self.game, shared, log=lambda m: None)
|
|
self.assertIsNone(saves.pending_import(self.game))
|
|
self.assertFalse((self.game / 'profile' / 'save-import.part').exists())
|
|
|
|
def test_zips_that_are_refused(self):
|
|
acct = saves.account_dir(self.game_with_save(self.game))
|
|
good = {'save/option.sav': b'o', 'save/slot_00/progress.sav': b'p'}
|
|
manifest = {'format': saves.FORMAT, 'format_version': 1, 'title_id': '0400486024dec000'}
|
|
notzip = self.tmp / 'not.zip'
|
|
notzip.write_bytes(b'PK not really')
|
|
cases = {
|
|
'not a zip': notzip,
|
|
'no manifest': self.zip_of(good, manifest=False),
|
|
'another format': self.zip_of(good, dict(manifest, format='something-else')),
|
|
'a newer format': self.zip_of(good, dict(manifest, format_version=2)),
|
|
'no format version': self.zip_of(good, {'format': saves.FORMAT, 'title_id': '0400486024dec000'}),
|
|
'another game': self.zip_of(good, dict(manifest, title_id='0100000000010000')),
|
|
'a path outside the save': self.zip_of(dict(good, **{'save/../../evil.sav': b'x'}), manifest),
|
|
'an absolute path': self.zip_of(dict(good, **{'save//abs.sav': b'x'}), manifest),
|
|
'a drive': self.zip_of(dict(good, **{'save/C:/evil.sav': b'x'}), manifest),
|
|
'a backslash': self.zip_of(dict(good, **{'save/..\\evil.sav': b'x'}), manifest),
|
|
'not this game': self.zip_of({'save/save.dat': b'x', 'save/user/0001.bin': b'y'}, manifest),
|
|
'no save': self.zip_of({'bug-report/nx2navi.log': b'l'}, manifest),
|
|
}
|
|
for why, path in cases.items():
|
|
with self.subTest(why=why):
|
|
with self.assertRaises(saves.SaveError):
|
|
saves.import_save(self.game, path, log=lambda m: None)
|
|
self.assertEqual((acct / 'option.sav').read_bytes(), self.SAVE['option.sav'])
|
|
self.assertFalse((self.tmp / 'evil.sav').exists())
|
|
self.assertFalse((self.game / 'saves').exists() and
|
|
[e for e in (self.game / 'saves').iterdir() if e.name.startswith('backup ')])
|
|
|
|
def test_a_link_in_the_zip_is_refused(self):
|
|
acct = saves.account_dir(self.game_with_save(self.game))
|
|
path = self.zip_of({'save/option.sav': b'o'})
|
|
with zipfile.ZipFile(path, 'a') as z:
|
|
info = zipfile.ZipInfo('save/slot_00/progress.sav')
|
|
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
|
z.writestr(info, '../../../outside')
|
|
with self.assertRaises(saves.SaveError):
|
|
saves.import_save(self.game, path, log=lambda m: None)
|
|
self.assertEqual((acct / 'option.sav').read_bytes(), self.SAVE['option.sav'])
|
|
|
|
def test_a_damaged_zip_changes_nothing(self):
|
|
acct = saves.account_dir(self.game_with_save(self.game))
|
|
path = self.zip_of({'save/option.sav': b'theirs' * 200, 'save/slot_00/progress.sav': b'p' * 300})
|
|
data = bytearray(path.read_bytes())
|
|
at = data.index(b'save/option.sav') + len('save/option.sav') + 4 # inside the first member's data
|
|
data[at] ^= 0xff
|
|
path.write_bytes(bytes(data))
|
|
with self.assertRaises(saves.SaveError):
|
|
saves.import_save(self.game, path, log=lambda m: None)
|
|
self.assertEqual((acct / 'option.sav').read_bytes(), self.SAVE['option.sav'])
|
|
self.assertFalse((self.game / 'profile' / 'save-tx' / 'import').exists())
|
|
|
|
def test_the_zip_of_the_games_menu_imports(self):
|
|
# runtime/save_share.c writes the same layout with stored and deflated members; Python's reader takes both
|
|
path = self.tmp / 'from-the-game.zip'
|
|
with zipfile.ZipFile(path, 'w') as z:
|
|
z.writestr(saves.MANIFEST, json.dumps({'format': saves.FORMAT, 'format_version': 1, 'exported_by': 'game',
|
|
'title_id': '0400486024DEC000', 'game_version': '1.1.1'}))
|
|
z.writestr('save/option.sav', b'stored', zipfile.ZIP_STORED)
|
|
z.writestr('save/slot_01/progress.sav', b'deflated' * 50, zipfile.ZIP_DEFLATED)
|
|
r = saves.import_save(self.game, path, log=lambda m: None) # a game that never ran: no backup
|
|
self.assertIsNone(r['backup'])
|
|
acct = saves.account_dir(self.game)
|
|
self.assertEqual((acct / 'slot_01' / 'progress.sav').read_bytes(), b'deflated' * 50)
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'the shell\'s known folders are Windows only')
|
|
def test_the_default_export_folder(self):
|
|
d = saves.default_export_dir()
|
|
self.assertTrue(d.is_dir())
|
|
self.assertIn(d, (winsys.known_folder('downloads'), winsys.known_folder('desktop'), Path.home()))
|
|
|
|
|
|
class IconTest(unittest.TestCase):
|
|
def test_navi(self):
|
|
try:
|
|
from launcher.ui import art
|
|
except ImportError:
|
|
self.skipTest('needs numpy')
|
|
self.assertEqual(art.navi(2).shape, (42, 44, 4))
|
|
data = art.navi_ico()
|
|
count = struct.unpack_from('<H', data, 4)[0]
|
|
sizes = [data[6 + 16 * i] or 256 for i in range(count)]
|
|
self.assertEqual(sizes[0], 16)
|
|
self.assertIn(256, sizes)
|
|
off = struct.unpack_from('<I', data, 6 + 12)[0]
|
|
self.assertEqual(struct.unpack_from('<Iii', data, off), (40, 16, 32)) # a bitmap entry, not PNG
|
|
big = struct.unpack_from('<I', data, 6 + 16 * (count - 1) + 12)[0]
|
|
self.assertEqual(data[big:big + 4], b'\x89PNG')
|
|
|
|
|
|
class LookTest(Tmp):
|
|
"""Customize Launcher: the colour schemes, the background picture and the sounds."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
try:
|
|
from launcher.ui import art, picture, sound
|
|
except ImportError:
|
|
self.skipTest('needs numpy')
|
|
self.art, self.picture, self.sound = art, picture, sound
|
|
|
|
def test_every_colour_scheme_has_every_colour(self):
|
|
art = self.art
|
|
self.assertEqual(art.theme_palette('forest'), art.BASE) # the first scheme is the colours as written
|
|
for name, t in art.THEMES.items():
|
|
pal = art.theme_palette(name)
|
|
self.assertEqual(set(pal), set(art.BASE), name)
|
|
for key, value in pal.items():
|
|
self.assertRegex(value, r'^#[0-9a-f]{6}$', f'{name} {key}')
|
|
self.assertTrue(t['label'])
|
|
grouped = [k for keys in art.GROUPS.values() for k in keys]
|
|
self.assertEqual(len(grouped), len(set(grouped)), 'a colour in two groups')
|
|
self.assertLessEqual(set(grouped), set(art.BASE))
|
|
|
|
def test_set_theme_changes_the_palette_in_place(self):
|
|
art = self.art
|
|
held = art.PALETTE
|
|
try:
|
|
self.assertEqual(art.set_theme('lake'), 'lake')
|
|
self.assertIs(art.PALETTE, held)
|
|
self.assertNotEqual(held['panel'], art.BASE['panel'])
|
|
self.assertEqual(held['salmon'], art.BASE['salmon']) # a warning stays a warning in every scheme
|
|
self.assertEqual(art.set_theme('no such scheme'), 'forest')
|
|
self.assertEqual(dict(held), art.BASE)
|
|
self.assertEqual(art.set_theme(None), 'forest') # launcher.json without the key
|
|
finally:
|
|
art.set_theme('forest')
|
|
|
|
def test_recolor_keeps_light_and_dark(self):
|
|
import colorsys
|
|
art = self.art
|
|
|
|
def hls(c):
|
|
return colorsys.rgb_to_hls(*(int(c[i:i + 2], 16) / 255 for i in (1, 3, 5)))
|
|
for c in ('#7a5c32', '#2a2019', '#d69127'):
|
|
h, l, _s = hls(art.recolor(c, 200))
|
|
self.assertAlmostEqual(h * 360, 200, delta=4)
|
|
self.assertAlmostEqual(l, hls(c)[1], delta=0.01)
|
|
self.assertEqual(art.recolor('#7a5c32', None), '#7a5c32')
|
|
|
|
def test_a_picture_is_cut_to_the_windows_shape(self):
|
|
import numpy as np
|
|
pic = self.picture
|
|
wide = np.zeros((100, 400, 3), dtype=np.uint8)
|
|
wide[:, 150:250] = 255 # the middle of a wide picture
|
|
out = pic.cover(wide, 160, 100)
|
|
self.assertEqual(out.shape, (100, 160, 3))
|
|
self.assertGreater(out[:, 40:120].mean(), 200) # its middle fills most of the result
|
|
tall = np.zeros((400, 100, 3), dtype=np.uint8)
|
|
tall[150:250] = 255
|
|
self.assertEqual(pic.cover(tall, 160, 100).shape, (100, 160, 3))
|
|
self.assertGreater(pic.cover(tall, 160, 100).mean(), 120)
|
|
big = np.full((2048, 3200, 3), 77, dtype=np.uint8) # shrunk to half: averaged, the grey stays
|
|
self.assertTrue((pic.cover(big, 1600, 1024) == 77).all())
|
|
|
|
def test_the_kept_background(self):
|
|
import numpy as np
|
|
art, pic = self.art, self.picture
|
|
src = self.tmp / 'mine.png'
|
|
img = np.zeros((90, 300, 4), dtype=np.float32)
|
|
img[..., 0], img[..., 3] = 1.0, 1.0 # red
|
|
src.write_bytes(art.png_bytes(img))
|
|
store = self.tmp / 'background.npy'
|
|
if pic.Image is None and os.name != 'nt':
|
|
self.skipTest('needs Pillow, or Tk with a display')
|
|
fitted = pic.keep(src, store)
|
|
self.assertEqual(fitted.shape, (pic.KEEP_H, pic.KEEP_W, 3))
|
|
back = pic.kept(store, 1000, 640)
|
|
self.assertEqual(back.shape, (640, 1000, 3))
|
|
self.assertEqual(str(back.dtype), 'float32')
|
|
self.assertAlmostEqual(float(back[..., 0].min()), 1.0, places=3)
|
|
self.assertAlmostEqual(float(back[..., 1:].max()), 0.0, places=3)
|
|
# the backdrop with it: the picture darkened by the dimming, solid everywhere
|
|
plain = art.backdrop(200, 128, 1.0, pic.kept(store, 200, 128), 0.0)
|
|
dimmed = art.backdrop(200, 128, 1.0, pic.kept(store, 200, 128), 0.8)
|
|
self.assertEqual(plain.shape, (128, 200, 4))
|
|
self.assertGreater(plain[64, 100, 0], 0.8) # the corners' shade reaches in a little
|
|
self.assertLess(dimmed[64, 100, 0], 0.35)
|
|
self.assertEqual(float(dimmed[..., 3].min()), 1.0)
|
|
# what is not a kept background, and what is no picture
|
|
self.assertIsNone(pic.kept(self.tmp / 'none.npy', 100, 64))
|
|
store.write_bytes(b'not numpy')
|
|
self.assertIsNone(pic.kept(store, 100, 64))
|
|
text = self.tmp / 'notes.txt'
|
|
text.write_text('no picture')
|
|
with self.assertRaises(pic.PictureError):
|
|
pic.keep(text, store)
|
|
with self.assertRaises(pic.PictureError):
|
|
pic.load(self.tmp / 'gone.png')
|
|
|
|
def test_the_custom_scheme(self):
|
|
art = self.art
|
|
self.assertEqual(art.custom_palette({}), art.BASE) # nothing picked yet: the first scheme's colours
|
|
slots = art.CUSTOM_SLOTS
|
|
self.assertEqual(list(slots), ['background', 'panel', 'buttons'])
|
|
parts = [k for _, keys, _ in slots.values() for k in keys]
|
|
self.assertEqual(len(parts), len(set(parts)), 'a colour in two parts')
|
|
self.assertEqual(set(parts), set(art.GROUPS['scene'] + art.GROUPS['wood'] + art.GROUPS['accent']))
|
|
pal = art.custom_palette({'background': '#3A7BD5', 'panel': '#203040', 'buttons': '#802020'})
|
|
self.assertEqual(pal['tint_mid'], '#3a7bd5') # the picked colours exactly where they stand in
|
|
self.assertEqual(pal['panel'], '#203040')
|
|
self.assertEqual(pal['label_mid'], '#802020')
|
|
only = art.custom_palette({'buttons': '#802020'}) # Button Color: the buttons and nothing else
|
|
self.assertLessEqual({k for k in art.BASE if only[k] != art.BASE[k]}, set(slots['buttons'][1]))
|
|
for k in ('label_top', 'label_bottom', 'value', 'orange', 'orange_light'): # the orange choices too
|
|
self.assertNotEqual(only[k], art.BASE[k], k)
|
|
for k in ('panel', 'desc', 'wood_1', 'window', 'tint_mid'): # not the panels, the title band, the backdrop
|
|
self.assertEqual(only[k], art.BASE[k], k)
|
|
self.assertEqual(round(art.hls(only['orange'])[0] * 360), 0) # the choices in the picked hue, lighter
|
|
self.assertGreater(art.hls(only['orange'])[1], art.hls(only['label_mid'])[1])
|
|
panel = art.custom_palette({'panel': '#203040'})
|
|
self.assertLessEqual({k for k in art.BASE if panel[k] != art.BASE[k]}, set(slots['panel'][1]))
|
|
self.assertEqual(panel['label_mid'], art.BASE['label_mid'])
|
|
for slot, (ref, keys, _) in slots.items():
|
|
for k in keys: # lighter than the picked colour where the first scheme's is lighter
|
|
if art.hls(art.BASE[k])[1] > art.hls(art.BASE[ref])[1] + 0.02:
|
|
self.assertGreater(art.hls(pal[k])[1], art.hls(pal[ref])[1], k)
|
|
self.assertGreater(art.luminance(pal['text']), 0.6) # light text on dark buttons
|
|
light = art.custom_palette({'buttons': '#f0e0a0'})
|
|
self.assertLess(art.luminance(light['text']), 0.3) # and dark text on light ones
|
|
self.assertGreater(art.luminance(light['desc_text']), 0.6) # the description box's text stays as it was
|
|
light = art.custom_palette({'panel': '#f0e0a0'})
|
|
self.assertLess(art.luminance(light['desc_text']), 0.3) # dark on a light panel
|
|
self.assertGreater(art.luminance(light['text']), 0.6)
|
|
self.assertEqual(art.custom_palette({'buttons': 'red', 'background': None}), art.BASE)
|
|
held = art.PALETTE
|
|
try:
|
|
self.assertEqual(art.set_theme('custom', {'buttons': '#802020', 'other': '#000000'}), 'custom')
|
|
self.assertEqual(held['label_mid'], '#802020')
|
|
self.assertEqual(art.CUSTOM, {'background': art.BASE['tint_mid'], 'panel': art.BASE['panel'],
|
|
'buttons': '#802020'})
|
|
art.set_theme('custom', {})
|
|
self.assertEqual(dict(held), art.BASE)
|
|
finally:
|
|
art.set_theme('forest', {})
|
|
|
|
def test_the_colour_square(self):
|
|
import numpy as np
|
|
art = self.art
|
|
sq = art.color_square(40, 30, 0.0) # red
|
|
self.assertEqual(sq.shape, (30, 40, 4))
|
|
self.assertTrue(np.allclose(sq[0, -1, :3], [1, 0, 0], atol=0.05)) # top right: the full colour
|
|
self.assertLess(sq[-1, :, :3].max(), 0.05) # the bottom row: black
|
|
self.assertLess(np.ptp(sq[0, 0, :3]), 0.05) # left: grey
|
|
bar = art.hue_bar(60, 4)
|
|
self.assertTrue(np.allclose(bar[0, 20, :3], [0, 1, 0], atol=0.1)) # a third of the way: green
|
|
self.assertTrue(np.allclose(bar[0, 40, :3], [0, 0, 1], atol=0.1)) # two thirds: blue
|
|
|
|
def test_a_typed_hex_code(self):
|
|
try:
|
|
from launcher.ui.app import App
|
|
except ImportError:
|
|
self.skipTest('needs tkinter')
|
|
parse = App.parse_hex
|
|
self.assertEqual(parse('#3A7BD5'), (58, 123, 213))
|
|
self.assertEqual(parse(' 3a7bd5 '), (58, 123, 213))
|
|
self.assertEqual(parse('#37d'), (0x33, 0x77, 0xdd))
|
|
self.assertIsNone(parse('#3A7BD'))
|
|
self.assertIsNone(parse('blue'))
|
|
|
|
def test_the_picture_starts_below_the_band_and_is_never_stretched(self):
|
|
import numpy as np
|
|
art, pic = self.art, self.picture
|
|
store = self.tmp / 'background.npy'
|
|
old = np.zeros((1024, 1600, 3), dtype=np.uint8) # a copy an older launcher kept for the window
|
|
old[:, :800, 0] = 255 # left half red: a circle stays a circle
|
|
np.save(store, old)
|
|
self.assertEqual(pic.kept_shape(store), (1024, 1600))
|
|
back = pic.kept(store, 1000, 580) # cut to the new shape, not squeezed
|
|
self.assertEqual(back.shape, (580, 1000, 3))
|
|
self.assertGreater(back[:, 490, 0].min(), 0.95)
|
|
self.assertLess(back[:, 510, 0].max(), 0.05)
|
|
img = art.backdrop(200, 128, 1.0, np.ones((116, 200, 3), dtype=np.float32), 0.0, top=12)
|
|
self.assertLess(img[6, 100, :3].max(), 0.2) # under the band: the scheme's colour
|
|
self.assertGreater(img[20, 100, :3].min(), 0.7) # from the band's end on: the picture
|
|
|
|
def test_ppm(self):
|
|
import numpy as np
|
|
px = bytes(range(18))
|
|
a = self.picture.read_ppm(b'P6\n# a comment\n3 2\n255\n' + px)
|
|
self.assertEqual(a.shape, (2, 3, 3))
|
|
self.assertEqual(a.tobytes(), px)
|
|
self.assertEqual(a.dtype, np.uint8)
|
|
with self.assertRaises(self.picture.PictureError):
|
|
self.picture.read_ppm(b'P5\n1 1\n255\n\0')
|
|
|
|
@staticmethod
|
|
def bwav(channels, rate=48000):
|
|
"""A .bwav of channels given as (codec, pan, samples, data): the header, 0x4c bytes a channel, the data."""
|
|
out = bytearray(b'BWAV' + struct.pack('<HHIHH', 0xfeff, 1, 0, 0, len(channels)))
|
|
at = 0x10 + 0x4c * len(channels)
|
|
for codec, pan, count, data in channels:
|
|
info = bytearray(0x4c)
|
|
struct.pack_into('<HHIII', info, 0, codec, pan, rate, count, count)
|
|
struct.pack_into('<I', info, 0x30, at)
|
|
out += info
|
|
at += len(data)
|
|
for _codec, _pan, _count, data in channels:
|
|
out += data
|
|
return bytes(out)
|
|
|
|
def game_sounds(self, names=('Do', 'Cancel')):
|
|
"""A dump folder whose RomFS holds the given interface sounds; decoded into this layout's cache."""
|
|
dump = self.tmp / 'dump'
|
|
(dump / 'exefs').mkdir(parents=True, exist_ok=True)
|
|
(dump / 'exefs' / 'main').write_bytes(b'')
|
|
stream = dump / 'romfs' / 'Sound' / 'Resource' / 'Stream'
|
|
stream.mkdir(parents=True, exist_ok=True)
|
|
loud = struct.pack('<4h', 0, 16000, -16000, 8000)
|
|
for name in names:
|
|
(stream / f'SE_Sys_{name}00.bwav').write_bytes(self.bwav([(0, 0, 4, loud), (0, 1, 4, loud)]))
|
|
return gamefiles.extract_sounds(self.layout, '', str(dump))
|
|
|
|
def test_the_games_interface_sounds(self):
|
|
sound = self.sound
|
|
# one PCM16 channel in the middle: both sides, at 0.707
|
|
mono = self.bwav([(0, 2, 3, struct.pack('<3h', 1000, -2000, 3000))])
|
|
self.assertEqual(gamefiles.bwav_to_pcm(mono), (48000, [(707, 707), (-1414, -1414), (2121, 2121)]))
|
|
# two DSP-ADPCM channels, left and right: a frame's header byte (coefficient pair 0, all zero here, shift 8)
|
|
# and its four-bit samples 1, -1, 7 make 256, -256, 1792
|
|
frame = bytes([0x08, 0x1f, 0x70]) + bytes(5)
|
|
both = self.bwav([(1, 0, 3, frame), (1, 1, 2, frame)])
|
|
self.assertEqual(gamefiles.bwav_to_pcm(both), (48000, [(256, 256), (-256, -256), (1792, 0)]))
|
|
for bad in (b'', b'RIFF' + bytes(60), mono[:0x30], self.bwav([(7, 2, 3, bytes(6))]), mono[:-2]):
|
|
self.assertIsNone(gamefiles.bwav_to_pcm(bad))
|
|
# from a dump folder into data/cache/sounds, and from there into the effects: the decide sound only here
|
|
got = self.game_sounds(('Do',))
|
|
(self.tmp / 'dump' / 'romfs' / 'Sound' / 'Resource' / 'Stream' / 'SE_Sys_Cancel00.bwav').write_bytes(b'no sound')
|
|
self.assertEqual(set(got), {'select'})
|
|
self.assertEqual(gamefiles.cached_sounds(self.layout), got)
|
|
with mock.patch.object(sound.os, 'name', 'posix'), mock.patch.object(sound.shutil, 'which', return_value=None):
|
|
s = sound.Sounds(self.layout.cache, 'high')
|
|
self.assertEqual(set(s.game), {'select'})
|
|
wav = s.wav('select') # the game's sound: stereo at its rate, at the level
|
|
self.assertEqual(wav[:4] + wav[8:16], b'RIFFWAVEfmt ')
|
|
self.assertEqual(struct.unpack_from('<I', wav, 4)[0], len(wav) - 8)
|
|
self.assertEqual(struct.unpack_from('<HHI', wav, 20), (1, 2, 48000))
|
|
peak = max(abs(v) for v in struct.unpack_from('<8h', wav, 44))
|
|
self.assertAlmostEqual(peak / 32767, sound.LEVELS['high'], delta=0.01)
|
|
self.assertFalse(sound.Sounds(self.tmp / 'empty', 'high').game)
|
|
|
|
def test_only_the_games_sounds_play_at_low_unless_set(self):
|
|
sound = self.sound
|
|
self.assertEqual(settings.DEFAULTS['sounds'], 'low')
|
|
self.assertEqual(sound.DEFAULT_LEVEL, 'low')
|
|
self.assertEqual(set(gamefiles.SOUNDS), set(sound.EFFECTS))
|
|
empty = self.tmp / 'empty'
|
|
self.game_sounds()
|
|
with mock.patch.object(sound.os, 'name', 'posix'), mock.patch.object(sound.shutil, 'which',
|
|
return_value='/usr/bin/aplay'), \
|
|
mock.patch.object(sound.subprocess, 'Popen') as popen:
|
|
quiet = sound.Sounds(empty, 'high') # before the game has been read: nothing to play
|
|
quiet.play('select')
|
|
quiet.play('back')
|
|
popen.assert_not_called()
|
|
quiet.cache = self.layout.cache
|
|
self.assertTrue(quiet.load_game()) # the game's sounds, once decoded
|
|
quiet.play('select')
|
|
self.assertEqual(popen.call_count, 1)
|
|
s = sound.Sounds(self.layout.cache) # no level given, as a new launcher.json has it: low
|
|
self.assertEqual(s.level, 'low')
|
|
s.play('select')
|
|
self.assertEqual(popen.call_count, 2)
|
|
s.set_level('off') # the player turned them off
|
|
s.play('select')
|
|
self.assertEqual(popen.call_count, 2)
|
|
s.set_level('nonsense') # a level launcher.json cannot hold: the default
|
|
self.assertEqual(s.level, 'low')
|
|
s.play('back')
|
|
s.play('move') # the cursor has no sound
|
|
s.play('no such effect')
|
|
self.assertEqual(popen.call_count, 3)
|
|
with open(popen.call_args[0][0][1], 'rb') as f:
|
|
self.assertEqual(f.read(4), b'RIFF')
|
|
peak = max(abs(v) for v in struct.unpack_from('<8h', s.wav('back'), 44))
|
|
self.assertAlmostEqual(peak / 32767, sound.LEVELS['low'], delta=0.01)
|
|
|
|
def test_the_look_settings_survive_and_fall_back(self):
|
|
path = self.tmp / 'launcher.json'
|
|
s = settings.Settings(path)
|
|
self.assertEqual((s['theme'], s['background'], s['background_dim'], s['animations'], s['sounds']),
|
|
('forest', '', 40, True, 'low'))
|
|
s['theme'], s['sounds'], s['background_dim'] = 'shadow', 'high', 60
|
|
s.save()
|
|
t = settings.Settings(path)
|
|
self.assertEqual((t['theme'], t['sounds'], t['background_dim']), ('shadow', 'high', 60))
|
|
path.write_text(json.dumps({'theme': 5, 'animations': 'yes', 'background_dim': '40'}))
|
|
u = settings.Settings(path) # values of the wrong kind: the defaults
|
|
self.assertEqual((u['theme'], u['animations'], u['background_dim']), ('forest', True, 40))
|
|
|
|
|
|
# ---------------------------------------------------------------- the window
|
|
|
|
def have_display():
|
|
if os.name == 'nt':
|
|
return True
|
|
if not os.environ.get('DISPLAY'):
|
|
return False
|
|
try:
|
|
import tkinter # noqa: F401
|
|
import numpy # noqa: F401
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
|
|
@unittest.skipUnless(have_display(), 'needs tkinter, numpy and a display')
|
|
class SteamTest(Tmp):
|
|
"""launcher/steam.py on made-up Steam files: the binary shortcuts file read and written back unchanged, our
|
|
shortcut added beside another and updated in place, its id as Steam computes it, the accounts that signed in."""
|
|
|
|
def other(self):
|
|
from launcher import steam
|
|
exe = '"C:\\Games\\Other\\other.exe"'
|
|
entry = [[steam.T_INT, 'appid', struct.pack('<I', steam.appid(exe, 'Other'))], [steam.T_STR, 'AppName', 'Other'],
|
|
[steam.T_STR, 'Exe', exe], [steam.T_STR, 'StartDir', '"C:\\Games\\Other\\"'],
|
|
[steam.T_STR, 'icon', ''], [steam.T_INT, 'LastPlayTime', struct.pack('<I', 1234)],
|
|
[steam.T_STR, 'sortas', ''], [steam.T_MAP, 'tags', [[steam.T_STR, '0', 'Favorite']]]]
|
|
return steam.vdf_dump([[steam.T_MAP, 'shortcuts', [[steam.T_MAP, '0', entry]]]])
|
|
|
|
def test_round_trip_add_and_update(self):
|
|
from launcher import steam
|
|
b = self.other()
|
|
self.assertEqual(b[-4:], b'\x08\x08\x08\x08') # tags, the shortcut, shortcuts, the file
|
|
self.assertEqual(steam.vdf_dump(steam.vdf_load(b)), b)
|
|
self.assertFalse(steam.has_shortcut(b))
|
|
new, sid = steam.add_shortcut(b, '"C:\\NX2navi\\python\\pythonw.exe"', '"C:\\NX2navi"',
|
|
'"C:\\NX2navi\\data\\steam_play.pyw"', 'C:\\grid\\x_icon.png')
|
|
self.assertEqual(sid, steam.appid('"C:\\NX2navi\\python\\pythonw.exe"', steam.NAME))
|
|
self.assertTrue(sid & 0x80000000)
|
|
self.assertTrue(new.startswith(b[:-2])) # the other shortcut stays byte for byte
|
|
self.assertTrue(steam.has_shortcut(new))
|
|
items = steam.vdf_load(new)[0][2]
|
|
self.assertEqual([k for _, k, _ in items], ['0', '1'])
|
|
ours = {k: v for _, k, v in items[1][2]}
|
|
self.assertEqual(ours['AppName'], 'The Legend of Zelda Ocarina of Time')
|
|
self.assertEqual(struct.unpack('<I', ours['appid'])[0], sid)
|
|
self.assertEqual(ours['LaunchOptions'], '"C:\\NX2navi\\data\\steam_play.pyw"')
|
|
self.assertEqual(ours['AllowOverlay'], struct.pack('<I', 1))
|
|
again, _ = steam.add_shortcut(new, '"D:\\NX2navi\\python\\pythonw.exe"', '"D:\\NX2navi"', '', 'x')
|
|
items = steam.vdf_load(again)[0][2]
|
|
self.assertEqual(len(items), 2) # moved launcher: the same shortcut, updated
|
|
self.assertEqual({k: v for _, k, v in items[1][2]}['StartDir'], '"D:\\NX2navi"')
|
|
with self.assertRaises(steam.SteamError):
|
|
steam.vdf_load(b[:-5])
|
|
|
|
def test_new_file_and_accounts(self):
|
|
from launcher import steam
|
|
new, _ = steam.add_shortcut(b'', '"x.exe"', '"."', '', '')
|
|
self.assertTrue(steam.has_shortcut(new))
|
|
root = self.tmp / 'Steam'
|
|
for a in ('0', '1046310423', '22'):
|
|
(root / 'userdata' / a / 'config').mkdir(parents=True)
|
|
(root / 'config').mkdir()
|
|
(root / 'config' / 'loginusers.vdf').write_text('"users"\n{\n\t"76561199006576151"\n\t{\n\t}\n}\n')
|
|
self.assertEqual(steam.accounts(root), [root / 'userdata' / '1046310423' / 'config'])
|
|
(root / 'config' / 'loginusers.vdf').unlink()
|
|
self.assertEqual(steam.accounts(root), sorted([root / 'userdata' / '1046310423' / 'config',
|
|
root / 'userdata' / '22' / 'config']))
|
|
|
|
|
|
class WhilePlayingTest(Tmp):
|
|
def test_the_games_restart_does_not_bring_the_launcher_back(self):
|
|
try:
|
|
from launcher.ui import app
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
App, GameRun = app.App, app.GameRun
|
|
a = mock.MagicMock()
|
|
a.settings = {'game_dir': str(self.tmp)}
|
|
a.game_run = GameRun(mock.MagicMock(returncode=0, pid=11), str(self.tmp), time.time())
|
|
a.game_run.proc.poll.return_value = 0 # the process started here has ended
|
|
with mock.patch('launcher.ui.app.saves.game_running', return_value=True), \
|
|
mock.patch('launcher.ui.app.winsys.game_copy', return_value=None) as copy:
|
|
App.watch_game(a) # but the restarted game runs from the same exe
|
|
copy.assert_called_once_with(self.tmp / 'ocarina.exe', skip=11)
|
|
a.root.deiconify.assert_not_called()
|
|
a.root.after.assert_called_once()
|
|
with mock.patch('launcher.ui.app.saves.game_running', return_value=False):
|
|
App.watch_game(a)
|
|
a.root.deiconify.assert_called_once()
|
|
|
|
def test_the_restarted_copy_is_followed_for_its_exit_code(self):
|
|
"""The game's Restart ends the process the launcher started with code 0 and starts the exe again: the launcher
|
|
follows that copy (winsys.game_copy) and reports the code it ends with, and its FATAL line."""
|
|
try:
|
|
from launcher.ui import app
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
App, GameRun = app.App, app.GameRun
|
|
log = self.tmp / 'logs' / 'nx2navi.log'
|
|
log.parent.mkdir()
|
|
log.write_text('[host] gfx: window closed; restarting\n', encoding='utf-8')
|
|
a = mock.MagicMock()
|
|
a.message = ''
|
|
a.settings = {'game_dir': str(self.tmp)}
|
|
a.install_staged.return_value = False
|
|
a.game_run = GameRun(mock.MagicMock(returncode=0, pid=11), str(self.tmp), time.time() - 60)
|
|
a.game_run.proc.poll.return_value = 0
|
|
copy = mock.MagicMock(returncode=None, pid=12, ended=False)
|
|
copy.poll.return_value = None
|
|
with mock.patch('launcher.ui.app.saves.game_running', return_value=True), \
|
|
mock.patch('launcher.ui.app.winsys.game_copy', return_value=copy):
|
|
App.watch_game(a) # the copy is found and followed
|
|
App.watch_game(a) # it runs: no further look for one
|
|
self.assertIs(a.game_run.proc, copy)
|
|
a.root.deiconify.assert_not_called()
|
|
log.write_text('[host] boot\nFATAL: vk: the GPU device was lost (seen at: q). The run ends here\n',
|
|
encoding='utf-8') # the copy's own log (main.c log_open)
|
|
copy.returncode = 72
|
|
copy.poll.return_value = 72
|
|
with mock.patch('launcher.ui.app.saves.game_running', return_value=False):
|
|
App.watch_game(a)
|
|
a.ctx.log.assert_called_once_with('the game ended with code 72 after restarting itself; its log says: FATAL: '
|
|
'vk: the GPU device was lost (seen at: q). The run ends here')
|
|
self.assertIn('(code 72): vk: the GPU device was lost', a.message)
|
|
self.assertIn('[launcher] the game ended with code 72 (the graphics device was lost',
|
|
log.read_text(encoding='utf-8'))
|
|
self.assertIsNone(a.game_run)
|
|
|
|
def test_a_restarted_copy_that_could_not_be_followed_ends_with_its_code_unknown(self):
|
|
"""A copy the launcher could not find (it ended before the next look): its code is not known, so neither
|
|
launcher.log nor the game's log says code 0, and its FATAL line is still shown."""
|
|
try:
|
|
from launcher.ui import app
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
App, GameRun = app.App, app.GameRun
|
|
log = self.tmp / 'logs' / 'nx2navi.log'
|
|
log.parent.mkdir()
|
|
|
|
def run(text):
|
|
log.write_text(text, encoding='utf-8')
|
|
a = mock.MagicMock()
|
|
a.message = ''
|
|
a.settings = {'game_dir': str(self.tmp)}
|
|
a.install_staged.return_value = False
|
|
a.game_run = GameRun(mock.MagicMock(returncode=0, pid=11), str(self.tmp), time.time() - 60)
|
|
a.game_run.proc.poll.return_value = 0
|
|
with mock.patch('launcher.ui.app.saves.game_running', return_value=True), \
|
|
mock.patch('launcher.ui.app.winsys.game_copy', return_value=None):
|
|
App.watch_game(a)
|
|
with mock.patch('launcher.ui.app.saves.game_running', return_value=False):
|
|
App.watch_game(a)
|
|
return a
|
|
|
|
a = run('[host] log: x\n[host] perf: the last line of play\n')
|
|
a.ctx.log.assert_called_once_with('the game ended after restarting itself; the exit code of its last run is '
|
|
'not known')
|
|
self.assertEqual(a.message, '')
|
|
text = log.read_text(encoding='utf-8')
|
|
self.assertIn('[launcher] the game ended with code unknown (the game had restarted itself;', text)
|
|
self.assertNotIn('code 0', text)
|
|
a = run('[host] log: x\nFATAL: fs: another copy of the game is running\n')
|
|
a.ctx.log.assert_called_once_with('the game ended after restarting itself; its log says: FATAL: fs: another '
|
|
'copy of the game is running')
|
|
self.assertIn("A copy started by the game's Restart ended with a fatal error: fs: another copy", a.message)
|
|
self.assertNotIn('[launcher]', log.read_text(encoding='utf-8')) # the FATAL line says it already
|
|
|
|
def test_the_games_settings_file_is_left_alone_while_it_runs(self):
|
|
"""The game writes nx2navi.ini when a menu setting changes, so the launcher's Discord Status switch (the one
|
|
setting of that file the Settings screen has) changes nothing while the game runs and says why; the mods'
|
|
switches refuse the same way (switch_mod)."""
|
|
try:
|
|
from launcher.ui.app import App
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
game = self.tmp / 'game'
|
|
game.mkdir()
|
|
(game / 'nx2navi.ini').write_text('[discord]\ndiscord_rpc = 1\n', encoding='utf-8')
|
|
a = mock.MagicMock()
|
|
a.message = ''
|
|
a.game_busy.return_value = True
|
|
row = App.discord_row(a, str(game))
|
|
before = (game / 'nx2navi.ini').read_bytes()
|
|
row.on(False)
|
|
self.assertEqual((game / 'nx2navi.ini').read_bytes(), before)
|
|
self.assertTrue(row.get()) # the switch still shows what the file says
|
|
self.assertIn('Quit the game first', a.message)
|
|
a.game_busy.return_value = False
|
|
row.on(False)
|
|
self.assertEqual(gameini.get(game, 'discord_rpc'), '0')
|
|
self.assertFalse(row.get())
|
|
self.assertEqual(a.message, '')
|
|
a.game_busy.return_value = True # a mod's switch while the game runs
|
|
a.settings = {'game_dir': str(game)}
|
|
App.switch_mod(a, mock.Mock(), False)
|
|
self.assertEqual(gameini.get(game, 'MODS_DISABLED'), '')
|
|
self.assertIn('Quit the game first', a.mods_message)
|
|
|
|
def ended(self, code, log_text, started=None):
|
|
"""watch_game after the game ended with code, its log holding log_text; started is the time Play started it,
|
|
a minute ago unless given; (the stand-in window, the log)."""
|
|
from launcher.ui.app import App, GameRun
|
|
log = self.tmp / 'logs' / 'nx2navi.log'
|
|
log.parent.mkdir(exist_ok=True)
|
|
if log_text is not None:
|
|
log.write_text(log_text, encoding='utf-8')
|
|
a = mock.MagicMock()
|
|
a.message = ''
|
|
a.settings = {'game_dir': str(self.tmp)}
|
|
a.game_dir.return_value = str(self.tmp)
|
|
proc = mock.MagicMock(returncode=code)
|
|
proc.poll.return_value = code
|
|
a.game_run = GameRun(proc, str(self.tmp), time.time() - 60 if started is None else started)
|
|
a.install_staged.return_value = False
|
|
with mock.patch('launcher.ui.app.saves.game_running', return_value=False):
|
|
App.watch_game(a)
|
|
return a, log
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'Windows processes')
|
|
def test_a_game_copy_is_found_by_its_exe_file(self):
|
|
"""winsys.game_copy finds a running process by its image file, not by its name, and reads the code it ended
|
|
with after it ended. A sleeper compiled here stands in for the game, under a name that no game runner
|
|
watches."""
|
|
mine, other = self.tmp / 'mine' / 'sleeper.exe', self.tmp / 'other' / 'sleeper.exe'
|
|
if not compiled_sleeper(mine, 4000, 72):
|
|
self.skipTest('needs a C compiler')
|
|
other.parent.mkdir()
|
|
shutil.copy(mine, other)
|
|
proc = winsys.start_game(mine)
|
|
try:
|
|
copy = winsys.game_copy(mine)
|
|
self.assertIsNotNone(copy)
|
|
self.assertEqual(copy.pid, proc.pid)
|
|
self.assertIsNone(copy.poll())
|
|
self.assertIsNone(winsys.game_copy(mine, skip=proc.pid))
|
|
self.assertIsNone(winsys.game_copy(other)) # the same name in another folder: another install
|
|
upper = winsys.game_copy(Path(str(mine).upper())) # Windows paths ignore case
|
|
self.assertEqual(upper.pid, proc.pid)
|
|
proc.wait(30)
|
|
self.assertEqual(upper.poll(), 72)
|
|
self.assertEqual(copy.poll(), 72)
|
|
self.assertTrue(copy.ended)
|
|
self.assertIsNone(copy.handle)
|
|
finally:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
proc.wait(10)
|
|
|
|
def test_a_lost_device_shows_the_games_fatal_line(self):
|
|
"""Exit code 72 (runtime/vk_device.c vk_device_lost_check) follows a FATAL line as 70 and 71 do; when its
|
|
watchdog ended the run before that line, the code's own text says what happened."""
|
|
try:
|
|
from launcher.ui import app
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
fatal = 'vk: the GPU device was lost (seen at: q). The run ends here'
|
|
a, log = self.ended(72, f'[host] x\nFATAL: {fatal}\n[host] fatal: memory commit: this process 9000 MB\n')
|
|
self.assertEqual(a.message, f'The game ended with a fatal error (code 72): {fatal}. Its log is '
|
|
'logs\\nx2navi.log in the game folder.')
|
|
a.ctx.log.assert_called_once_with(f'the game ended with code 72; its log says: FATAL: {fatal}')
|
|
self.assertIn('[launcher] the game ended with code 72 (the graphics device was lost',
|
|
log.read_text(encoding='utf-8'))
|
|
self.assertTrue(app.exit_code_text(72).startswith('72 (the graphics device was lost'))
|
|
a, _ = self.ended(72, '[host] vk: device fault: none\n')
|
|
self.assertIn('The game ended with code 72 (the graphics device was lost', a.message)
|
|
|
|
def test_a_fatal_line_after_code_0_is_the_restarted_copys(self):
|
|
"""No process of the game writes a FATAL line and exits with 0: after code 0 such a line is a copy's that the
|
|
game's Restart started and that ended before the launcher saw it, and the player is told. A log older than
|
|
the start is an earlier run's and tells nothing."""
|
|
try:
|
|
from launcher.ui import app # noqa: F401
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
a, log = self.ended(0, '[host] x\nFATAL: vk: the GPU device was lost (seen at: x)\n')
|
|
self.assertEqual(a.message, "A copy started by the game's Restart ended with a fatal error: vk: the GPU device "
|
|
'was lost (seen at: x). Its log is logs\\nx2navi.log in the game folder.')
|
|
a.ctx.log.assert_called_once_with('the game ended after restarting itself; its log says: FATAL: vk: the GPU '
|
|
'device was lost (seen at: x)')
|
|
self.assertNotIn('[launcher]', log.read_text(encoding='utf-8'))
|
|
old = time.time() - 3600
|
|
log.write_text('FATAL: an earlier run started without the launcher\n', encoding='utf-8')
|
|
os.utime(log, (old, old))
|
|
a, _ = self.ended(0, None, started=time.time())
|
|
self.assertEqual(a.message, '')
|
|
a.ctx.log.assert_called_once_with('the game ended with code 0')
|
|
|
|
def test_a_log_cut_at_its_cap_says_nothing_of_its_end(self):
|
|
"""A log that reached its 64 MiB cap (runtime/main.c log_sink) wrote its end lines to stderr only: it is
|
|
not taken for a run without its own end."""
|
|
try:
|
|
from launcher.ui import app
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
a, log = self.ended(0, '[host] perf: x\n[host] log: the file reached 64 MiB, the rest of this run goes to '
|
|
'stderr only\n')
|
|
self.assertIsNone(app.log_has_own_exit(self.tmp))
|
|
a.ctx.log.assert_called_once_with('the game ended with code 0')
|
|
self.assertNotIn('[launcher]', log.read_text(encoding='utf-8'))
|
|
|
|
def play_from_steam(self, code, log_text, lock_held=False):
|
|
"""play_only (--play, the Steam shortcut) for a game that ends with code, its log then holding log_text, with
|
|
the launcher folder's lock held by another process when lock_held; (the context, the stand-in for tell, the
|
|
log). self.start_game is the stand-in for winsys.start_game."""
|
|
from launcher.ui import app
|
|
(self.tmp / 'ocarina.exe').write_bytes(b'MZ')
|
|
log = self.tmp / 'logs' / 'nx2navi.log'
|
|
log.parent.mkdir(exist_ok=True)
|
|
log.write_text(log_text, encoding='utf-8')
|
|
ctx = mock.MagicMock()
|
|
ctx.settings = {'game_dir': str(self.tmp)}
|
|
ctx.layout = self.layout
|
|
proc = mock.MagicMock(returncode=code, pid=5)
|
|
proc.poll.return_value = code
|
|
told = []
|
|
|
|
def tell(text): # what the logs hold when the box comes, which waits for OK
|
|
told.append((text, log.read_text(encoding='utf-8'), list(ctx.log.call_args_list)))
|
|
held = mock.patch('launcher.ui.app.locks.FileLock', side_effect=locks.Busy('the launcher window is using x')) \
|
|
if lock_held else contextlib.nullcontext()
|
|
with mock.patch('launcher.ui.app.winsys.start_game', return_value=proc) as self.start_game, \
|
|
mock.patch('launcher.ui.app.saves.game_running', return_value=False), \
|
|
mock.patch('launcher.ui.app.mods.prepare', return_value=[]), \
|
|
mock.patch('launcher.ui.app.build.can_rebuild', return_value=False), \
|
|
mock.patch('launcher.ui.app.time.sleep'), \
|
|
mock.patch('launcher.ui.app.tell', side_effect=tell), held:
|
|
self.assertEqual(app.play_only(ctx), 0)
|
|
return ctx, told, log
|
|
|
|
def test_play_from_steam_waits_for_a_job_in_the_game_folder(self):
|
|
"""The Steam shortcut does not start the game while the window or a command runs a job in the game folder
|
|
(another process holds the launcher folder's lock, and the job's marker is there): the game would be the build
|
|
the job replaces. A marker that a stopped launcher left, with the lock free, stops nothing, nor does the window
|
|
open without a job."""
|
|
try:
|
|
from launcher.ui import app # noqa: F401
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
marker = self.layout.data / jobs.MARKER
|
|
marker.write_text(json.dumps({'task': 'the rebuild of the game', 'kind': 'rebuild'}), encoding='utf-8')
|
|
ctx, told, _ = self.play_from_steam(0, '[host] x\n', lock_held=True)
|
|
self.start_game.assert_not_called()
|
|
self.assertEqual([t[0] for t in told],
|
|
['NX2navi is busy with the rebuild of the game. Start the game when it is done.'])
|
|
ctx.log.assert_called_once_with('play from Steam: not started: the launcher is busy with the rebuild of the '
|
|
'game')
|
|
self.assertTrue(marker.is_file()) # the job's marker stays
|
|
ctx, told, _ = self.play_from_steam(0, '[host] gfx: window closed; exiting\n')
|
|
self.start_game.assert_called_once() # the lock is free: the marker is a leftover
|
|
self.assertEqual(told, [])
|
|
marker.unlink()
|
|
ctx, told, _ = self.play_from_steam(0, '[host] gfx: window closed; exiting\n', lock_held=True)
|
|
self.start_game.assert_called_once() # the window is open, with no job
|
|
self.assertEqual(told, [])
|
|
|
|
def test_an_end_of_a_game_started_from_steam_is_told(self):
|
|
"""The Steam shortcut's game ends as Play's does: launcher.log and the game's log say how, and an end worth it
|
|
(a FATAL line, 70, 71 or 72, a Windows status) gets a message box, as no window shows it."""
|
|
try:
|
|
from launcher.ui import app # noqa: F401
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
ctx, told, log = self.play_from_steam(70, '[host] x\nFATAL: fs: another copy of the game is running\n')
|
|
self.assertEqual(len(told), 1)
|
|
text, game_log, calls = told[0]
|
|
self.assertEqual(text, 'The game ended with a fatal error (code 70): fs: another copy of the game is running. '
|
|
'Its log is logs\\nx2navi.log in the game folder.')
|
|
self.assertIn('[launcher] the game ended with code 70', game_log)
|
|
self.assertIn(mock.call('play from Steam: the game ended with code 70; its log says: FATAL: fs: another copy '
|
|
'of the game is running'), calls)
|
|
_, told, _ = self.play_from_steam(0xC0000005, '[host] x\n')
|
|
self.assertEqual([t[0] for t in told], ['The game ended with code 0xC0000005 (access violation). Its log is '
|
|
'logs\\nx2navi.log in the game folder.'])
|
|
ctx, told, log = self.play_from_steam(0, '[host] x\n[host] gfx: window closed; exiting\n')
|
|
self.assertEqual(told, [])
|
|
ctx.log.assert_called_with('play from Steam: the game ended with code 0')
|
|
ctx, told, log = self.play_from_steam(1, '[host] perf: the last line\n') # Task Manager's End task
|
|
self.assertEqual(told, [])
|
|
ctx.log.assert_called_with('play from Steam: the game ended with code 1')
|
|
self.assertIn('[launcher] the game ended with code 1 at', log.read_text(encoding='utf-8'))
|
|
|
|
def test_a_fatal_exit_shows_the_games_fatal_line(self):
|
|
"""Exit codes 70 and 71 follow a FATAL line in the game's log: the home screen and launcher.log name it."""
|
|
try:
|
|
from launcher.ui import app
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
fatal = 'vk: vkQueueSubmit2 of 3 held submissions up to value 83931 failed (-4)'
|
|
a, log = self.ended(70, f'[host] log: x\nFATAL: an earlier one\n[host] more\nFATAL: {fatal}\n'
|
|
'[host] fatal: memory commit: this process 9000 MB, the system 30000 of 32000 MB\n')
|
|
self.assertEqual(a.message, f'The game ended with a fatal error (code 70): {fatal}. Its log is '
|
|
'logs\\nx2navi.log in the game folder.')
|
|
a.ctx.log.assert_called_once_with(f'the game ended with code 70; its log says: FATAL: {fatal}')
|
|
self.assertIn('[launcher] the game ended with code 70', log.read_text(encoding='utf-8'))
|
|
# the crash report's exit code, and a line longer than the home screen shows
|
|
long = 'access violation (0xc0000005) at host 0x7ff6 (nx2navi+0x1234), reading address 0x10 ' + 'x' * 300
|
|
a, log = self.ended(71, f'FATAL: {long}\n a line of the report\n')
|
|
self.assertIn('(code 71): access violation (0xc0000005)', a.message)
|
|
self.assertIn('…. Its log is', a.message)
|
|
self.assertLess(len(a.message), app.FATAL_SHOWN + 120)
|
|
self.assertIn(long, a.ctx.log.call_args[0][0]) # the launcher's log has the whole line
|
|
# a log that still ends with an earlier run's end has no FATAL line of this run
|
|
a, _ = self.ended(70, None)
|
|
self.assertIn('The game ended with code 70 (a fatal error', a.message)
|
|
# any other exit code reads no FATAL line; no log at all is no error
|
|
a, log = self.ended(0xC0000005, f'FATAL: {fatal}\n')
|
|
self.assertEqual(a.message, 'The game ended with code 0xC0000005 (access violation). Its log is '
|
|
'logs\\nx2navi.log in the game folder.')
|
|
log.unlink()
|
|
self.assertEqual(app.last_fatal(self.tmp), '')
|
|
self.assertEqual(app.last_fatal(''), '')
|
|
|
|
def test_an_end_with_code_0_is_logged_and_noted_without_the_games_exit_line(self):
|
|
"""A run ended with code 0 goes into launcher.log; when the game's log has no line of the game's own end (the
|
|
window's close, its ExitProcess, a FATAL line), the end is noted in that log too."""
|
|
try:
|
|
from launcher.ui import app
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
a, log = self.ended(0, '[host] log: x\n[host] gfx: window closed; exiting\n[host] perf: after it\n')
|
|
a.ctx.log.assert_called_once_with('the game ended with code 0')
|
|
self.assertNotIn('[launcher]', log.read_text(encoding='utf-8'))
|
|
a, log = self.ended(0, '[host] log: x\n[host] perf: frame 39660: the last line of play\n')
|
|
a.ctx.log.assert_called_once_with('the game ended with code 0, and its log has no line of its own end')
|
|
text = log.read_text(encoding='utf-8')
|
|
self.assertIn("[launcher] the game ended with code 0 (the log has no line of the game's own end", text)
|
|
self.assertIs(app.log_has_own_exit(self.tmp), False) # the note ends the search for the next run
|
|
log.write_text('FATAL: x\n', encoding='utf-8')
|
|
self.assertIs(app.log_has_own_exit(self.tmp), True)
|
|
log.write_text('[host] guest ExitProcess at main+0x10; exiting\n', encoding='utf-8')
|
|
self.assertIs(app.log_has_own_exit(self.tmp), True)
|
|
log.unlink()
|
|
self.assertIsNone(app.log_has_own_exit(self.tmp))
|
|
a, _ = self.ended(0, None) # no log: logged as an end with code 0, nothing noted
|
|
a.ctx.log.assert_called_once_with('the game ended with code 0')
|
|
|
|
def test_the_fatal_line_is_looked_for_in_the_logs_end(self):
|
|
try:
|
|
from launcher.ui import app
|
|
except ImportError as e:
|
|
self.skipTest(f'no window toolkit: {e}')
|
|
log = self.tmp / 'logs' / 'nx2navi.log'
|
|
log.parent.mkdir()
|
|
log.write_bytes(b'FATAL: far back\n' + b'[host] a line\n' * 40000 + b'FATAL: near the end \xff\n[host] last\n')
|
|
self.assertGreater(log.stat().st_size, app.FATAL_TAIL)
|
|
self.assertEqual(app.last_fatal(self.tmp), 'near the end \ufffd')
|
|
log.write_bytes(b'FATAL: far back\n' + b'[host] a line\n' * 40000)
|
|
self.assertEqual(app.last_fatal(self.tmp), '')
|
|
|
|
|
|
class MemorySettings(dict):
|
|
"""The launcher's settings (launcher/settings.py) without their file."""
|
|
|
|
def save(self):
|
|
pass
|
|
|
|
|
|
def mock_app(test, game):
|
|
"""launcher.ui.app.App and a stand-in for its instance, for calling one of its methods without a window: the
|
|
settings name the game folder, the context and layout are real ones in the test's folder, nothing is staged,
|
|
no check has run and no message is set. Skips the test without the window toolkit."""
|
|
try:
|
|
from launcher.ui.app import App
|
|
except ImportError as e:
|
|
test.skipTest(f'no window toolkit: {e}')
|
|
a = mock.MagicMock()
|
|
a.settings = MemorySettings({'game_dir': str(game), 'auto_check': False})
|
|
a.args = mock.Mock(rolled_back='', uninstall=False, continue_update=None)
|
|
a.ctx = Context(test.layout)
|
|
test.addCleanup(a.ctx._file.close)
|
|
a.layout = test.layout
|
|
a.install_staged.return_value = False
|
|
a.bg.ready.return_value = None
|
|
a.bg.staging.return_value = False
|
|
a.release = a.check = None
|
|
a.check_error = a.message = ''
|
|
a.checked_at = 0
|
|
return App, a
|
|
|
|
|
|
class BeginInstallTest(Tmp):
|
|
def test_a_cut_romfs_image_does_not_block_the_install(self):
|
|
"""Begin Installation removes the romfs.bin.part an install that was cut short left before it measures the
|
|
free space, so the 34 GB it asks for are not short by the part's size."""
|
|
import collections
|
|
game = self.tmp / 'game'
|
|
part = game / 'romfs' / 'romfs.bin.part'
|
|
part.parent.mkdir(parents=True)
|
|
part.write_bytes(b'cut short')
|
|
usage = collections.namedtuple('usage', 'total used free')
|
|
|
|
def disk_usage(path): # 40 GB free without the part, 20 GB with it
|
|
return usage(10 ** 12, 0, 20 * 10 ** 9 if part.exists() else 40 * 10 ** 9)
|
|
App, a = mock_app(self, game)
|
|
a.settings['dump'] = str(self.tmp / 'game.nup')
|
|
a.settings['desktop_shortcut'] = False
|
|
a.game_dir.return_value = str(game)
|
|
with mock.patch('launcher.ui.app.shutil.disk_usage', disk_usage):
|
|
App.begin_install(a)
|
|
self.assertFalse(part.exists())
|
|
job = a.run_job.call_args[0][0]
|
|
self.assertIsInstance(job, jobs.InstallJob)
|
|
self.assertEqual(job.game_dir, str(game))
|
|
|
|
def test_the_first_run_screen_counts_a_cut_romfs_image_as_free(self):
|
|
from launcher.ui import app
|
|
game = self.tmp / 'game'
|
|
(game / 'romfs').mkdir(parents=True)
|
|
self.assertEqual(app.partial_image_gb(game), 0.0)
|
|
(game / 'romfs' / 'romfs.bin.part').write_bytes(bytes(2500000))
|
|
self.assertEqual(app.partial_image_gb(game), 0.0025)
|
|
App, a = mock_app(self, game)
|
|
a.settings['dump'] = str(self.tmp / 'game.nup')
|
|
a.game_dir.return_value = str(game)
|
|
with mock.patch.object(app, 'free_gb', lambda p: 5.0), mock.patch.object(app, 'partial_image_gb', lambda p: 26.0):
|
|
rows = {r.label: r for r in App.rows_first_run(a) if r.label}
|
|
self.assertIn('31 GB free', rows['Install Folder'].desc[0])
|
|
self.assertIn('(31 GB free)', rows['Begin Installation'].desc[0])
|
|
|
|
def test_a_game_folder_whose_path_is_too_long_is_refused_first(self):
|
|
"""Windows and the build tools stop at 260 characters for a file's whole path, and the game folder's own
|
|
files need 128 below it: a deeper folder is refused at Begin Installation and by the install job with the
|
|
lengths, where the build stopped in the middle with a file "not found". No limit elsewhere."""
|
|
from launcher import paths
|
|
most = paths.WINDOWS_PATH_MAX - 1 - paths.LONGEST_IN_GAME
|
|
fits = 'C:\\' + 'g' * (most - 3)
|
|
deep = fits + 'g'
|
|
with mock.patch.object(paths.os, 'name', 'nt'), \
|
|
mock.patch.object(paths.os.path, 'abspath', lambda p: p): # as Windows gives these paths back
|
|
self.assertEqual(paths.long_path_problem(fits), '')
|
|
self.assertEqual(paths.long_path_problem(''), '')
|
|
said = paths.long_path_problem(deep)
|
|
self.assertIn(f'is {most + 1} characters long', said)
|
|
self.assertIn(f'at most {most} characters', said)
|
|
with mock.patch.object(paths.os, 'name', 'posix'):
|
|
self.assertEqual(paths.long_path_problem('/' + 'g' * 4000), '')
|
|
if os.name != 'nt':
|
|
return
|
|
App, a = mock_app(self, deep)
|
|
a.settings['dump'] = str(self.tmp / 'game.nup')
|
|
a.game_dir.return_value = deep
|
|
App.begin_install(a)
|
|
a.run_job.assert_not_called()
|
|
self.assertTrue(a.message.startswith('The path of the game folder is'))
|
|
ctx = JobTest.make_ctx(self, 'ok')
|
|
self.addCleanup(ctx._file.close)
|
|
j = jobs.InstallJob(ctx, self.tmp / 'g.nup', deep).run_inline()
|
|
self.assertEqual(j.result, 'failed')
|
|
self.assertIn('characters long', j.error)
|
|
self.assertEqual(j.step('tools').state, jobs.PENDING) # before the build tools are fetched
|
|
|
|
|
|
class UpdateTextTest(Tmp):
|
|
def test_the_updates_row_does_not_promise_a_few_minutes(self):
|
|
"""An update's rebuild took players 24 to 45 minutes: the Updates row says what it depends on instead of
|
|
"in a few minutes"."""
|
|
from launcher.ui import app
|
|
App, a = mock_app(self, self.tmp / 'game')
|
|
a.release = mock.Mock(version='9.9.9', notes='')
|
|
texts = [' '.join(App.update_desc(a)['text']), App.staged_desc(a, {'version': '9.9.9', 'notes': ''})['note']]
|
|
for text in texts:
|
|
self.assertNotIn('in a few minutes', text)
|
|
self.assertIn(app.UPDATE_TAKES, text)
|
|
self.assertIn('saves and settings stay', text)
|
|
self.assertIn('half an hour or more', app.UPDATE_TAKES)
|
|
|
|
|
|
class ForcedCheckTest(Tmp):
|
|
def test_one_forced_update_check_a_minute(self):
|
|
"""Selecting the Updates row (or F5) again within a minute of the last check starts no request: 25 of them
|
|
in 90 seconds used up GitHub's hourly allowance for a player."""
|
|
from launcher.ui import app
|
|
App, a = mock_app(self, self.tmp / 'game')
|
|
a.screen = 'home'
|
|
with mock.patch('launcher.ui.app.jobs.CheckJob') as check:
|
|
a.check_started = time.time() - 10
|
|
App.check_now(a, force=True)
|
|
check.assert_not_called()
|
|
self.assertEqual(a.message, 'NX2navi looked for updates less than a minute ago.')
|
|
a.show.assert_called_once_with('home', a.focus)
|
|
a.check_started = time.time() - app.FORCED_CHECK_PAUSE - 1
|
|
App.check_now(a, force=True)
|
|
check.assert_called_once()
|
|
self.assertGreater(a.check_started, time.time() - 5)
|
|
# the launcher's own checks (at start, every hour) are not held back by it
|
|
a.check, a.check_started = None, time.time() - 10
|
|
App.check_now(a)
|
|
self.assertEqual(check.call_count, 2)
|
|
|
|
|
|
class StartScriptTest(Tmp):
|
|
"""launcher/start.pyw when the version cannot be imported: with a version to go back to it ends with code 86 at
|
|
once and shows no message box, so the stub's six-second wait sees the code and rolls back."""
|
|
|
|
def release(self, ver, broken=False):
|
|
app = self.tmp / 'root' / 'app' / ver / 'launcher'
|
|
(app / 'ui').mkdir(parents=True)
|
|
shutil.copy(ROOT / 'launcher' / 'start.pyw', app / 'start.pyw')
|
|
(app / '__init__.py').write_text('')
|
|
(app / 'ui' / '__init__.py').write_text('')
|
|
(app / 'ui' / 'app.py').write_text("raise RuntimeError('this version is broken')\n" if broken else
|
|
'def main():\n return 0\n')
|
|
return app / 'start.pyw'
|
|
|
|
def load(self, script):
|
|
import importlib.machinery
|
|
import importlib.util
|
|
loader = importlib.machinery.SourceFileLoader('nx2navi_start_script', str(script))
|
|
mod = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader))
|
|
loader.exec_module(mod) # not as __main__: nothing starts
|
|
return mod
|
|
|
|
def test_which_version_the_stub_goes_back_to(self):
|
|
root = self.tmp / 'root'
|
|
s = self.load(self.release('0.7.2', broken=True))
|
|
self.assertEqual(s.rollback_target(str(root)), '') # no previous.txt
|
|
(root / 'app' / 'previous.txt').write_text('0.7.1\n')
|
|
self.assertEqual(s.rollback_target(str(root)), '') # it names a folder that is not there
|
|
self.release('0.7.1')
|
|
self.assertEqual(s.rollback_target(str(root)), '0.7.1')
|
|
(root / 'app' / 'previous.txt').write_bytes(b'\xef\xbb\xbf 0.7.1 \r\n') # as the stub reads the file
|
|
self.assertEqual(s.rollback_target(str(root)), '0.7.1')
|
|
self.assertTrue(s.quiet_exit(str(root), [], 1.0))
|
|
self.assertTrue(s.quiet_exit(str(root), ['--continue-update', '0.7.1'], 1.0))
|
|
self.assertFalse(s.quiet_exit(str(root), [], 5.5)) # the stub no longer waits: the box
|
|
self.assertFalse(s.quiet_exit(str(root), [], float('inf')))
|
|
self.assertFalse(s.quiet_exit(str(root), ['--play'], 1.0)) # Steam started it: no stub to roll back
|
|
(root / 'app' / 'previous.txt').write_text('0.7.2\n') # this version itself: nothing to go back to
|
|
self.assertEqual(s.rollback_target(str(root)), '')
|
|
self.assertFalse(s.quiet_exit(str(root), [], 1.0))
|
|
if os.name == 'nt':
|
|
self.assertLess(s.process_age(), 3600)
|
|
else:
|
|
self.assertEqual(s.process_age(), float('inf'))
|
|
|
|
def test_a_broken_version_ends_with_86_and_no_box(self):
|
|
root = self.tmp / 'root'
|
|
script = self.release('0.7.2', broken=True)
|
|
self.release('0.7.1')
|
|
(root / 'app' / 'previous.txt').write_text('0.7.1\n')
|
|
env = dict(os.environ, NX2NAVI_LAUNCHER_ROOT=str(root))
|
|
try: # a message box would wait for a click: the timeout is the test that there is none
|
|
r = subprocess.run([sys.executable, str(script)], env=env, capture_output=True, text=True, timeout=30)
|
|
except subprocess.TimeoutExpired:
|
|
self.fail('start.pyw did not end: it waits in a message box')
|
|
self.assertEqual(r.returncode, 86, r.stderr)
|
|
log = (root / 'data' / 'logs' / 'crash.log').read_text(encoding='utf-8')
|
|
self.assertIn('this version is broken', log)
|
|
|
|
def test_a_version_that_starts_returns_its_code(self):
|
|
root = self.tmp / 'root'
|
|
script = self.release('0.7.2')
|
|
r = subprocess.run([sys.executable, str(script)], env=dict(os.environ, NX2NAVI_LAUNCHER_ROOT=str(root)),
|
|
capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertFalse((root / 'data' / 'logs' / 'crash.log').exists())
|
|
|
|
|
|
class UnfinishedUpdateTest(Tmp):
|
|
"""A game whose last update stopped in the compile (state.json without the compile step, the exe of the build
|
|
before still there): the window opens on home and says how to finish, and an update goes on with the game."""
|
|
|
|
def app(self, game):
|
|
return mock_app(self, game)
|
|
|
|
def test_the_window_opens_on_home(self):
|
|
game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift'))
|
|
App, a = self.app(game)
|
|
App.start(a)
|
|
a.show.assert_called_once_with('home')
|
|
a.find_dumps.assert_not_called()
|
|
(game / 'ocarina.exe').unlink() # nothing to play: an install that never finished asks for the dump
|
|
App, a = self.app(game)
|
|
App.start(a)
|
|
a.show.assert_called_once_with('first_run')
|
|
|
|
def test_an_update_goes_on_with_the_game(self):
|
|
game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift'))
|
|
App, a = self.app(game)
|
|
a.args.continue_update = '0.6.1'
|
|
App.start(a)
|
|
job = a.run_job.call_args[0][0]
|
|
self.assertIsInstance(job, jobs.ContinueUpdateJob)
|
|
self.assertEqual(job.game_dir, str(game)) # not '', which skips the rebuild with "No game yet"
|
|
|
|
def test_home_says_how_to_finish(self):
|
|
game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift'))
|
|
App, a = self.app(game)
|
|
rows = {r.label: r for r in App.rows_home(a) if r.label}
|
|
self.assertEqual(rows['Rebuild the Game'].value, 'Not Finished')
|
|
self.assertTrue(any('Rebuild the Game finishes it' in d and 'dump is not needed' in d for d in rows['Play'].desc))
|
|
made_up_game(game) # a finished build of an earlier version
|
|
rows = {r.label: r for r in App.rows_home(a) if r.label}
|
|
self.assertEqual(rows['Rebuild the Game'].value, 'Built with 0.6.1')
|
|
|
|
|
|
class WindowTest(Tmp):
|
|
def test_selftest(self):
|
|
r = subprocess.run([sys.executable, '-m', 'launcher', '--root', str(self.tmp / 'w'), '--selftest'],
|
|
cwd=ROOT, capture_output=True, text=True, timeout=180)
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertIn('19 screens drawn', r.stdout)
|
|
|
|
|
|
# ---------------------------------------------------------------- mods
|
|
|
|
class ModsTest(Tmp):
|
|
"""The mods folder as the launcher puts it in order for the game (launcher/mods.py) and the build takes its code
|
|
in (tools/make_game.py, tools/mod_files.py)."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
from launcher import mods
|
|
self.mods = mods
|
|
self.game = self.tmp / 'game'
|
|
made_up_game(self.game)
|
|
self.folder = self.game / 'mods'
|
|
self.folder.mkdir()
|
|
|
|
def file(self, rel, text='x'):
|
|
p = self.folder / rel
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(text)
|
|
return p
|
|
|
|
def tree(self):
|
|
return sorted(p.relative_to(self.folder).as_posix() for p in self.folder.rglob('*') if p.is_file())
|
|
|
|
def test_downloads_are_put_where_the_game_looks(self):
|
|
with zipfile.ZipFile(self.folder / 'Zipped.zip', 'w') as z: # a zip holding the mod's own folder
|
|
z.writestr('Zipped/romfs/Actor/a.bfres', 'a')
|
|
z.writestr('Zipped/README.md', 'r')
|
|
with zipfile.ZipFile(self.folder / 'Escape.zip', 'w') as z:
|
|
z.writestr('../outside.txt', 'e')
|
|
self.file('Wrapped/readme.txt') # wrapped twice, a readme on each level
|
|
self.file('Wrapped/Wrapped v2/readme.txt')
|
|
self.file('Wrapped/Wrapped v2/Wrapped/romfs/UI/b.txt')
|
|
self.file('romfs/Actor/c.bfres') # a mod's romfs straight in the mods folder
|
|
self.file('atmosphere/contents/0400486024DEC000/romfs/d.txt') # an Atmosphere layout: a mod as it is
|
|
self.file('Options/Option A/romfs/e') # a download with options: left alone
|
|
self.file('Options/Option B/romfs/e')
|
|
done = self.mods.prepare(self.game)
|
|
self.assertEqual(self.tree(), [
|
|
'Escape.zip', 'Options/Option A/romfs/e', 'Options/Option B/romfs/e', 'Unnamed mod/romfs/Actor/c.bfres',
|
|
'Wrapped/readme (2).txt', 'Wrapped/readme.txt', 'Wrapped/romfs/UI/b.txt', 'Zipped.zip',
|
|
'Zipped/README.md', 'Zipped/romfs/Actor/a.bfres',
|
|
'atmosphere/contents/0400486024DEC000/romfs/d.txt'])
|
|
self.assertFalse((self.game / 'outside.txt').exists())
|
|
self.assertTrue(any(line.startswith('Escape.zip was not unpacked') for line in done))
|
|
self.assertEqual(self.mods.prepare(self.game), [line for line in done if 'Escape.zip' in line]) # once
|
|
found = {m.name: m for m in self.mods.scan(self.game)}
|
|
self.assertEqual(sorted(n for n, m in found.items() if m.usable),
|
|
['Unnamed mod', 'Wrapped', 'Zipped', 'atmosphere'])
|
|
self.assertIn('options', found['Options'].problem)
|
|
self.assertEqual(found['Zipped'].files, ['actor/a.bfres'])
|
|
|
|
def test_what_the_game_cannot_use_is_named(self):
|
|
self.file('Plugin/exefs/subsdk9')
|
|
self.file('Other/atmosphere/contents/01007EF00011E000/romfs/x')
|
|
self.file('Readme only/readme.txt')
|
|
self.file('Old patch/exefs/0123456789ABCDEF0123456789ABCDEF.ips', 'PATCHEOF')
|
|
self.file('Patch/exefs/E0F3CCFAF50065AE4EA01EB5AEB13B1A.ips', 'PATCHEOF')
|
|
found = {m.name: m for m in self.mods.scan(self.game)}
|
|
self.assertIn('code module', found['Plugin'].problem)
|
|
self.assertIn('01007EF00011E000', found['Other'].problem)
|
|
self.assertIn('nothing the game reads', found['Readme only'].problem)
|
|
self.assertIn('another version', found['Old patch'].problem)
|
|
self.assertEqual((found['Patch'].problem, found['Patch'].patches), ('', 1))
|
|
|
|
def test_switches_and_order_are_the_games_settings(self):
|
|
for name in ('beta', 'Alpha', ' Lead'):
|
|
self.file(f'{name}/romfs/same.txt', name)
|
|
self.assertEqual([m.name for m in self.mods.scan(self.game)], [' Lead', 'Alpha', 'beta']) # strcmp
|
|
self.mods.set_enabled(self.game, 'Alpha', False)
|
|
self.mods.move(self.game, 'beta', -2)
|
|
self.mods.set_enabled(self.game, ' Lead', False)
|
|
self.mods.set_enabled(self.game, 'Alpha', True)
|
|
self.assertEqual(gameini.get(self.game, 'MODS_ORDER'), 'beta| Lead|Alpha')
|
|
self.assertEqual(gameini.get(self.game, 'MODS_DISABLED'), ' Lead')
|
|
self.assertIn('mods_disabled = " Lead"', (self.game / 'nx2navi.ini').read_text()) # as runtime/cfg.c quotes
|
|
found = self.mods.scan(self.game)
|
|
self.assertEqual([(m.name, m.enabled) for m in found], [('beta', True), (' Lead', False), ('Alpha', True)])
|
|
self.assertEqual(self.mods.overlaps(found), {'beta': [('Alpha', 1, True)], 'Alpha': [('beta', 1, False)]})
|
|
|
|
def test_names_windows_cannot_keep_are_refused_in_an_archive(self):
|
|
"""A file of a mod's archive called after a device (CON.pchtxt), ending in a dot or a space, or holding a
|
|
colon cannot be kept under its name on Windows: unpacked anyway it is lost, renamed or a hidden stream of
|
|
another file, and the game misses it without a word. The archive is refused with the file's name; on
|
|
Linux, whose game reads such names, it passes."""
|
|
problem = self.mods.windows_name_problem
|
|
for name in ('CON', 'con.pchtxt', 'NUL.tar.gz', 'Com1', 'LPT9.txt', 'aux .txt', 'trailing.', 'trailing ',
|
|
'file.txt:stream', 'what?.bfres', 'tab\there'):
|
|
self.assertTrue(problem(name), name)
|
|
for name in ('console.txt', 'COM10.txt', 'a.b.c', '.hidden', 'nul_tex.bntx.zs', 'Actor', 'my mod (2)',
|
|
'Ünïcödé'):
|
|
self.assertEqual(problem(name), '', name)
|
|
safe = self.mods._safe_members
|
|
safe(['romfs/Actor/a.bfres', 'romfs/Actor/', 'readme.txt'], windows=True)
|
|
for bad in ('exefs/CON.pchtxt', 'romfs/nul/a.txt', 'romfs/Actor/foo.', 'romfs/Actor /a.txt',
|
|
'romfs/file.txt:stream', 'romfs\\AUX.bin'):
|
|
with self.assertRaises(self.mods.ModError) as refused:
|
|
safe(['romfs/ok.txt', bad], windows=True)
|
|
self.assertIn(f'({bad})', str(refused.exception))
|
|
safe(['romfs/ok.txt', bad], windows=False) # names as any other elsewhere
|
|
with self.assertRaises(self.mods.ModError): # leaving the folder is refused everywhere
|
|
safe(['../outside.txt'], windows=False)
|
|
with zipfile.ZipFile(self.folder / 'Device.zip', 'w') as z:
|
|
z.writestr('exefs/CON.pchtxt', '@stop\n')
|
|
z.writestr('romfs/a.txt', 'a')
|
|
done = self.mods.prepare(self.game)
|
|
if os.name == 'nt':
|
|
self.assertEqual(len(done), 1)
|
|
self.assertIn('Device.zip was not unpacked: it holds a file Windows cannot keep under its name '
|
|
'(exefs/CON.pchtxt): Windows keeps that name for a device', done[0])
|
|
self.assertEqual(self.tree(), ['Device.zip']) # nothing of it was unpacked
|
|
with self.assertRaises(self.mods.ModError): # Add a Mod refuses the same archive
|
|
self.mods.add(self.game, self.folder / 'Device.zip')
|
|
self.assertEqual(self.tree(), ['Device.zip'])
|
|
else:
|
|
self.assertEqual(self.tree(), ['Device.zip', 'Device/exefs/CON.pchtxt', 'Device/romfs/a.txt'])
|
|
|
|
def test_a_picked_archive_or_folder_is_added(self):
|
|
src = self.tmp / 'Downloads'
|
|
src.mkdir()
|
|
with zipfile.ZipFile(src / 'Picked.zip', 'w') as z:
|
|
z.writestr('romfs/a.txt', 'a')
|
|
(src / 'Folder Mod' / 'Inner' / 'exefs').mkdir(parents=True)
|
|
(src / 'Folder Mod' / 'Inner' / 'exefs' / 'E0F3CCFAF50065AE.ips').write_text('PATCHEOF')
|
|
(src / 'romfs').mkdir()
|
|
(src / 'romfs' / 'b.txt').write_text('b')
|
|
self.assertEqual(self.mods.add(self.game, src / 'Picked.zip').name, 'Picked')
|
|
self.assertEqual(self.mods.add(self.game, src / 'Picked.zip').name, 'Picked 2')
|
|
self.assertEqual(self.mods.add(self.game, src / 'Folder Mod').name, 'Folder Mod')
|
|
self.assertEqual(self.mods.add(self.game, src / 'romfs').name, 'Unnamed mod')
|
|
self.assertEqual(self.tree(), ['Folder Mod/exefs/E0F3CCFAF50065AE.ips', 'Picked 2/romfs/a.txt',
|
|
'Picked/romfs/a.txt', 'Unnamed mod/romfs/b.txt'])
|
|
self.assertTrue((src / 'Picked.zip').is_file()) # the player's download stays where it was
|
|
with self.assertRaises(self.mods.ModError):
|
|
self.mods.add(self.game, src / 'Folder Mod' / 'Inner' / 'exefs' / 'E0F3CCFAF50065AE.ips')
|
|
self.assertEqual(list(self.game.glob(self.mods.STAGING + '*')), []) # nothing stays beside the mods folder
|
|
|
|
def test_a_mod_that_does_not_arrive_whole_leaves_nothing(self):
|
|
"""A copy that stops halfway (a full disk, the launcher closed) or an archive that cannot be unpacked
|
|
leaves no folder in the mods folder, which the game would load as a mod, and none beside it."""
|
|
src = self.tmp / 'Downloads' / 'Big Mod'
|
|
(src / 'romfs').mkdir(parents=True)
|
|
for n in range(4):
|
|
(src / 'romfs' / f'{n}.txt').write_text('x')
|
|
self.file('Kept/romfs/k.txt')
|
|
real_copytree = shutil.copytree
|
|
for stop in (OSError(28, 'No space left on device'), KeyboardInterrupt()):
|
|
copied = []
|
|
|
|
def copy(a, b, stop=stop, copied=copied):
|
|
if len(copied) == 2:
|
|
raise stop
|
|
copied.append(b)
|
|
return shutil.copy2(a, b)
|
|
|
|
def copytree(a, b, *args, copy=copy, **kw): # shutil.copytree with a copy that stops at the third file
|
|
if args or kw: # shutil's own call for a folder inside, which hands the copy on
|
|
return real_copytree(a, b, *args, **kw)
|
|
return real_copytree(a, b, copy_function=copy)
|
|
with mock.patch.object(shutil, 'copytree', copytree), self.assertRaises(type(stop)):
|
|
self.mods.add(self.game, src)
|
|
self.assertEqual(len(copied), 2) # two files had arrived
|
|
self.assertEqual(self.tree(), ['Kept/romfs/k.txt'])
|
|
self.assertEqual(list(self.game.glob(self.mods.STAGING + '*')), [])
|
|
(src.parent / 'Broken.zip').write_bytes(b'PK\x03\x04 not a zip')
|
|
with self.assertRaises(self.mods.ModError):
|
|
self.mods.add(self.game, src.parent / 'Broken.zip')
|
|
self.assertEqual(self.tree(), ['Kept/romfs/k.txt'])
|
|
self.assertEqual([m.name for m in self.mods.scan(self.game)], ['Kept'])
|
|
self.assertEqual(self.mods.add(self.game, src).name, 'Big Mod') # and the same mod goes in whole after
|
|
self.assertEqual(len(self.tree()), 5)
|
|
|
|
def test_a_mod_is_put_together_beside_the_mods_folder(self):
|
|
"""While a mod is copied the mods folder does not hold it; it moves in under a name that is free then; and
|
|
what a stopped launcher left beside the mods folder long ago is removed."""
|
|
src = self.tmp / 'Downloads' / 'Late'
|
|
(src / 'romfs').mkdir(parents=True)
|
|
(src / 'romfs' / 'a.txt').write_text('a')
|
|
left = self.game / self.mods.STAGING
|
|
(left / 'mod' / 'romfs').mkdir(parents=True)
|
|
old = time.time() - 2 * self.mods.STAGING_STALE
|
|
os.utime(left, (old, old))
|
|
fresh = self.game / (self.mods.STAGING + ' 2') # another add still at work
|
|
fresh.mkdir()
|
|
seen = []
|
|
real_copytree = shutil.copytree
|
|
|
|
def copytree(a, b, *args, **kw):
|
|
if not seen: # the first call; shutil calls it per folder
|
|
seen.append((Path(b), self.tree()))
|
|
(self.folder / 'Late' / 'romfs').mkdir(parents=True) # a folder of that name comes meanwhile
|
|
return real_copytree(a, b, *args, **kw)
|
|
with mock.patch.object(shutil, 'copytree', copytree):
|
|
target = self.mods.add(self.game, src)
|
|
self.assertEqual(seen[0][1], []) # nothing in the mods folder while copying
|
|
self.assertEqual(seen[0][0].parent.parent, self.game) # it was put together beside it
|
|
self.assertEqual(target.name, 'Late 2')
|
|
self.assertEqual(self.tree(), ['Late 2/romfs/a.txt']) # the folder that came holds no file
|
|
self.assertFalse(left.exists())
|
|
self.assertEqual(list(self.game.glob(self.mods.STAGING + '*')), [fresh])
|
|
|
|
def test_code_waits_for_a_build_that_saw_it(self):
|
|
import make_game
|
|
cheat = self.file('Cheats/cheats/E0F3CCFAF50065AE.txt', '[Moon jump]\n04000000 00000000 D503201F\n')
|
|
self.file('Textures/romfs/a.txt')
|
|
self.assertEqual(self.mods.code_waiting(self.game), ['Cheats'])
|
|
self.mods.set_enabled(self.game, 'Cheats', False)
|
|
self.assertEqual(self.mods.code_waiting(self.game), []) # a mod switched off waits for nothing
|
|
self.mods.set_enabled(self.game, 'Cheats', True)
|
|
(self.game / 'build-cache' / 'mod-code.json').write_text(json.dumps(make_game.mod_code(self.game)))
|
|
self.assertEqual(self.mods.code_waiting(self.game), []) # the last build took it up
|
|
cheat.write_text('[Moon jump]\n04000000 00000000 D65F03C0\n')
|
|
self.assertEqual(self.mods.code_waiting(self.game), ['Cheats']) # edited since
|
|
|
|
def test_code_cheats_take_mains_base_from_the_layout(self):
|
|
"""recomp/layout.py alone decides the modules' bases: tools/cheats.py decodes main's pc-relative code at the
|
|
base the layout gives main, the first module of its order, and follows it when it changes."""
|
|
import importlib
|
|
import cheats
|
|
import layout
|
|
self.assertEqual(layout.ORDER[0], 'main')
|
|
self.assertEqual(cheats.BASE, layout.FIRST_BASE)
|
|
self.addCleanup(importlib.reload, cheats)
|
|
with mock.patch.object(layout, 'FIRST_BASE', 0x7100000000):
|
|
self.assertEqual(importlib.reload(cheats).BASE, 0x7100000000)
|
|
|
|
def test_the_mods_code_is_in_the_lift_key_and_not_the_shaders_key(self):
|
|
import make_game
|
|
kit = self.tmp / 'kit'
|
|
for d in ('tools', 'recomp', 'runtime', 'bin'):
|
|
(kit / d).mkdir(parents=True)
|
|
for f in ('tools/prepare_shaders.py', 'tools/cheats.py', 'tools/exefs_patches.py', 'tools/mod_files.py',
|
|
'recomp/patches-v111.txt', 'bin/' + make_game.XLATE_NAME, 'bin/nx2navi_xlate.id', 'VERSION'):
|
|
(kit / f).write_text('1\n')
|
|
|
|
def keys():
|
|
return {s: make_game.step_key(s, 'game', self.game) for s in ('lift', 'compile', 'shaders')}
|
|
with mock.patch.object(make_game, 'KIT', kit):
|
|
plain = keys()
|
|
self.file('Textures/romfs/a.txt')
|
|
self.assertEqual(keys(), plain) # files of the game's data: no build
|
|
self.assertNotIn('|mods ', plain['lift'])
|
|
self.file('Patch/exefs/E0F3CCFAF50065AE.ips', 'PATCHEOF')
|
|
with_patch = keys()
|
|
self.assertTrue(with_patch['lift'].startswith(plain['lift'] + '|mods '))
|
|
self.assertNotEqual(with_patch['compile'], plain['compile'])
|
|
self.assertEqual(with_patch['shaders'], plain['shaders'])
|
|
self.assertEqual(list(make_game.mod_code(self.game)), ['Patch/exefs/E0F3CCFAF50065AE.ips'])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main(verbosity=2)
|