From 0eeae7b47aa6935f1bb6ebf37f6d7c5ba2aff65b Mon Sep 17 00:00:00 2001 From: monsterlag <64719697+monsterlag@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:47:53 -0500 Subject: [PATCH 1/2] fix(builder): explain unavailable selected game input Assisted-by: OpenAI Codex --- nx2/make_game.py | 6 +- tests/test_missing_nup.py | 146 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 tests/test_missing_nup.py diff --git a/nx2/make_game.py b/nx2/make_game.py index ef5e237aa..60c41e8c9 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -937,7 +937,11 @@ def main(a=None): good = os.path.join(out, ".hookshot_romfs") check_cache_paths(out, (game, good, kept, romfs)) # (an update of the builder over the same .nup: nothing to read again, only what the build needs. Issues 21 and 28) - st = os.stat(a.nup) + try: + st = os.stat(a.nup) + except OSError as e: + fail("Could not access your .nup file %s: %s. Check that the file is still there, reconnect its drive if needed, " + "or choose the file again in Hookshot Builder." % (a.nup, e)) stamp = "unpacked by this builder: %s %d" % (os.path.basename(a.nup), st.st_size) same = False if os.path.exists(good) and os.path.isdir(romfs) and os.path.isdir(kept): diff --git a/tests/test_missing_nup.py b/tests/test_missing_nup.py new file mode 100644 index 000000000..68169f988 --- /dev/null +++ b/tests/test_missing_nup.py @@ -0,0 +1,146 @@ +"""Missing selected packages use the builder's expected-error protocol, without game data or tools.""" +import contextlib +import errno +import importlib.util +import io +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = Path(os.environ.get("HOOKSHOT_MISSING_NUP_TEST_SOURCE", ROOT / "nx2/make_game.py")) +# The source override changes only make_game.py; its ordinary, stdlib-only toolchain import stays current. +sys.path.insert(0, str(ROOT / "third_party/a64-kit/tools")) +spec = importlib.util.spec_from_file_location("make_game_missing_nup", SOURCE) +make_game = importlib.util.module_from_spec(spec) +spec.loader.exec_module(make_game) + + +class MissingNupTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="hookshot-missing-nup-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.assertEqual(self.root.parent, Path(tempfile.gettempdir()).resolve()) + self.package = self.root / "selected game \u6f22 [1].nup" + self.out, self.work = self.root / "output", self.root / "work" + (self.out / "romfs").mkdir(parents=True) + (self.out / "romfs/asset").write_bytes(b"authored asset") + (self.out / ".hookshot_exefs").mkdir() + (self.out / ".hookshot_exefs/main").write_bytes(b"authored code sentinel") + (self.out / "user/save").mkdir(parents=True) + (self.out / "user/save/sentinel").write_bytes(b"authored save sentinel") + (self.out / ".hookshot_romfs").write_text("previous authored package\n", encoding="utf-8") + self.before = self.files() + self.events = [] + + def files(self): + return {p.relative_to(self.out).as_posix(): p.read_bytes() for p in self.out.rglob("*") if p.is_file()} + + def invoke(self, stat_error=None): + actual_stat = os.stat + + def stat(path, *args, **kwargs): + if os.fspath(path) == str(self.package) and stat_error is not None: + raise stat_error + return actual_stat(path, *args, **kwargs) + + def run(command, *args, **kwargs): + self.assertEqual(command, make_game.PY + ["nx2/nup.py", str(self.package), "--extract", str(self.out / ".game")]) + self.events.append("extract") + raise SystemExit(0) # Stop at the real extraction request; never run a tool or read a package. + + def modules(path): + self.assertEqual(path, str(self.work / "exefs")) + self.events.append("cached modules") + raise SystemExit(0) + + argv = [str(SOURCE), "--jobs", "1", "--nup", str(self.package), "--out", str(self.out), "--work", str(self.work)] + output = io.StringIO() + with contextlib.ExitStack() as stack: + for name in ("check_windows_sdk", "check_cpu", "stop_stray_compilers", "drop_stale_objects", "no_throttle"): + stack.enter_context(mock.patch.object(make_game, name)) + stack.enter_context(mock.patch("ctypes.WinDLL", create=True)) # No Windows power-state change. + blocked = [stack.enter_context(mock.patch.object(make_game.subprocess, "Popen", side_effect=AssertionError("process launched"))), + stack.enter_context(mock.patch.object(os, "system", side_effect=AssertionError("shell launched"))), + stack.enter_context(mock.patch.object(os, "startfile", create=True, side_effect=AssertionError("file launched")))] + stack.enter_context(mock.patch.object(make_game, "run", side_effect=run)) + stack.enter_context(mock.patch.object(make_game, "check_modules", side_effect=modules)) + stack.enter_context(mock.patch.object(os, "stat", side_effect=stat)) + stack.enter_context(mock.patch.object(sys, "argv", argv)) + stack.enter_context(contextlib.redirect_stdout(output)) + stack.enter_context(contextlib.redirect_stderr(output)) + with self.assertRaises(SystemExit) as stopped: + make_game.cli() # Actual argument parsing, main's package branch, fail(), and CLI exception handling. + for launch in blocked: + launch.assert_not_called() + return stopped.exception.code, output.getvalue() + + def expected_error(self, stat_error=None): + code, text = self.invoke(stat_error) + self.assertEqual(code, 1, text) + self.assertIn("@@error Could not access your .nup file", text) + self.assertIn(str(self.package), text) + self.assertIn("choose the file again", text) + self.assertIn("reconnect", text) + self.assertNotIn("Traceback", text) + self.assertNotIn("error it did not expect", text) + if stat_error is not None: + self.assertIn(str(stat_error), text) + self.assertEqual(self.events, []) + self.assertEqual(self.files(), self.before) + + def test_missing_file_with_unicode_spaces_and_brackets(self): + self.expected_error() + + def test_missing_parent_directory(self): + self.package = self.root / "disconnected drive" / "selected.nup" + self.expected_error() + + def test_saved_selection_removed_before_build(self): + self.package.write_bytes(b"authored temporary selection") + self.package.unlink() + self.expected_error() + + def test_denied_stat_preserves_os_reason(self): + self.expected_error(PermissionError(errno.EACCES, "authored stat denial", str(self.package))) + + def test_other_stat_error_preserves_os_reason(self): + self.expected_error(OSError(errno.EIO, "authored device unavailable", str(self.package))) + + def test_existing_package_still_requests_extraction(self): + self.package.write_bytes(b"authored package sentinel") + code, text = self.invoke() + self.assertEqual(code, 0, text) + self.assertEqual(self.events, ["extract"]) + self.assertNotIn("@@error", text) + self.assertEqual(self.files(), self.before) + + def test_existing_package_still_reuses_matching_cache(self): + self.package = self.root / "existing cached game [1].nup" + self.package.write_bytes(b"authored package sentinel") + stamp = "unpacked by this builder: %s %d\n" % (self.package.name, self.package.stat().st_size) + (self.out / ".hookshot_romfs").write_text(stamp, encoding="utf-8") + self.before = self.files() + code, text = self.invoke() + self.assertEqual(code, 0, text) + self.assertEqual(self.events, ["cached modules"]) + self.assertIn("reading it again is skipped", text) + self.assertNotIn("@@error", text) + self.assertEqual(self.files(), self.before) + self.assertEqual((self.work / "exefs/main").read_bytes(), b"authored code sentinel") + + def test_programming_error_still_uses_unexpected_error_handler(self): + code, text = self.invoke(RuntimeError("authored unexpected failure")) + self.assertEqual(code, 1, text) + self.assertIn("Traceback", text) + self.assertIn("error it did not expect: RuntimeError: authored unexpected failure", text) + self.assertEqual(self.events, []) + + +if __name__ == "__main__": + unittest.main() From 537555277b615a2fff5bfb415e6b3b6040c1f9f1 Mon Sep 17 00:00:00 2001 From: monsterlag <64719697+monsterlag@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:58:11 -0500 Subject: [PATCH 2/2] test: align output sizing with the render-target pool floor Assisted-by: OpenAI Codex --- tests/test_output_resolution.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_output_resolution.py b/tests/test_output_resolution.py index 92620438e..e1f0b89d2 100644 --- a/tests/test_output_resolution.py +++ b/tests/test_output_resolution.py @@ -38,7 +38,8 @@ def main(): const unsigned w = atoi(argv[2]), h = atoi(argv[3]); assert(output_mode() == mode); assert(hos_output_size(0) == w && hos_output_size(1) == h); - assert(hos_rt_pool_size(305ull << 20) == ((mode == 2 ? 687ull : 305ull) << 20)); + // PR #1202 raised the default floor to 512 MiB; 4K still keeps its larger scaled pool. + assert(hos_rt_pool_size(305ull << 20) == ((mode == 2 ? 687ull : 512ull) << 20)); assert(kLastOutput == 3); assert(output_choice(-1) == 0 && output_choice(4) == 0); assert(step_output_choice(3, -1) == 3);