From a8e8c2ec6124e4ea40da0039344072b98a1cb082 Mon Sep 17 00:00:00 2001 From: "nathanael.ries" <1423199+nathanaelries@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:08:09 -0600 Subject: [PATCH] Fix guard-house render-target pool and texture handles --- host/nn_applet.c | 7 ++- host/nvn_native.c | 6 ++- tests/test_nvn_texture_handle.py | 91 ++++++++++++++++++++++++++++++++ tests/test_rt_pool_minimum.py | 91 ++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 tests/test_nvn_texture_handle.py create mode 100644 tests/test_rt_pool_minimum.py diff --git a/host/nn_applet.c b/host/nn_applet.c index a8554dc06..9c90d9526 100644 --- a/host/nn_applet.c +++ b/host/nn_applet.c @@ -123,14 +123,17 @@ uint32_t hos_output_size(int height) { return output_mode() == 2 ? (height ? 216 the 4K output ran out of it. Scaled with the output's pixels, or the widescreen picture's when it has more (host/hos_main.c: hos_wide_out, the output's height at the screen's shape: 7680 x 2160 for a 32:9 screen at the 4K output, twice the pixels the pool was scaled for, and the game crashed as Kokiri Forest loaded, a null pointer or "texture handle ids -1", issue 1031); - HOS_RT_POOL_MB= over it */ + Castle Town's guard-house conversation/camera test crashed with 305 MB at 2560 x 1440 and passed with 512 MB. + Keep at least 512 MB by default; larger scaled pools and an explicit HOS_RT_POOL_MB= still win. */ uint64_t hos_rt_pool_size(uint64_t game) { const char *e = getenv("HOS_RT_POOL_MB"); extern volatile uint64_t hos_wide_out; const uint64_t wide = hos_wide_out, wide_px = (wide >> 32) * (wide & 0xffffffffULL); uint64_t px = (uint64_t)hos_output_size(0) * hos_output_size(1); if (wide_px > px) px = wide_px; - const uint64_t v = e ? strtoull(e, NULL, 0) << 20 : px > 2560 * 1440 ? (game * px / (2560 * 1440) + 0xfffff) & ~0xfffffULL : game; + const uint64_t scaled = px > 2560 * 1440 ? (game * px / (2560 * 1440) + 0xfffff) & ~0xfffffULL : game; + const uint64_t minimum = 512ULL << 20; + const uint64_t v = e ? strtoull(e, NULL, 0) << 20 : scaled < minimum ? minimum : scaled; static int logged; if (!logged++) hos_log("applet: render target pool %llu MB (the game's %llu MB)", (unsigned long long)(v >> 20), (unsigned long long)(game >> 20)); return v; diff --git a/host/nvn_native.c b/host/nvn_native.c index 7f8d117cb..5e84958c6 100644 --- a/host/nvn_native.c +++ b/host/nvn_native.c @@ -202,8 +202,10 @@ NV(nvnDeviceGetImageHandle) { RET((uint64_t)I(1)); } /* combined handle = texture id (20 bits) | sampler id << 20: the Maxwell layout, which the shaders' handle reads expect */ NV(nvnDeviceGetTextureHandle) { const int t = I(1), s = I(2); - if (t < 0 || t >= (1 << 20) || s < 0 || s >= (1 << 12)) die("texture handle ids %d/%d", t, s); - RET((uint64_t)t | (uint64_t)s << 20); + /* The SDK sign-extends both ids before combining them, and texture 0 always means no handle. + A missing texture can have id -1 (Castle Town's guard house): it stays the all-ones sentinel, + rather than stopping the game here. Keep the signed ids, including their upper bits. */ + RET(t ? (uint64_t)t | (uint64_t)s << 20 : 0); } NV(nvnDeviceGetTexelFetchHandle) { RET((uint64_t)I(1)); } NV(nvnDeviceGetSeparateTextureHandle) { RET((uint64_t)I(1)); } diff --git a/tests/test_nvn_texture_handle.py b/tests/test_nvn_texture_handle.py new file mode 100644 index 000000000..a75457870 --- /dev/null +++ b/tests/test_nvn_texture_handle.py @@ -0,0 +1,91 @@ +"""Exercise the production handle constructor with SDK-observed boundary values. + +Castle Town's guard house passes texture -1 and sampler 264. The SDK returns +UINT64_MAX; the former range check stopped the game. Texture zero is special: +it returns zero even with a nonzero or negative sampler. Fixtures are authored +constants; this test contains no game code and needs no dump or GPU. +""" +import argparse +import importlib.util +from pathlib import Path +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cc", help="C compiler (otherwise clang, gcc or cc from PATH)") + parser.add_argument("--source", type=Path, default=ROOT / "host/nvn_native.c") + args = parser.parse_args() + spec = importlib.util.spec_from_file_location("null_pool", ROOT / "tests/test_nvn_null_pool.py") + helpers = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helpers) + body = helpers.block(args.source.read_text(), "NV(nvnDeviceGetTextureHandle)") + fixture = r'''#include +#include +#include +#include +typedef struct { uint64_t x[32]; } Ctx; +#define I(i) ((int)c->x[i]) +#define RET(v) (c->x[0] = (uint64_t)(v)) +#define NV(name) static void n_##name(Ctx *c, void *unused) +static void die(const char *fmt, ...) { (void)fmt; exit(86); } +''' + body + r''' +static const struct { uint64_t texture, sampler, expected; } cases[] = { + {0xffffffff, 264, UINT64_MAX}, + {0, 264, 0}, + {0, 0xffffffff, 0}, + {1, 264, 0x10800001}, + {0xfffff, 0xfff, 0xffffffff}, + {0x100000, 264, 0x10900000}, + {0xfffffffe, 264, UINT64_MAX - 1}, + {1, 0xffffffff, UINT64_C(0xfffffffffff00001)}, + {0x80000000, 0, UINT64_C(0xffffffff80000000)}, + {1, 0x80000000, UINT64_C(0xfff8000000000001)}, + {0xfeedface00000000, 264, 0}, + {0x12345678ffffffff, 264, UINT64_MAX}, + {1, 0xfeedface00000108, 0x10800001}, +}; +int main(int argc, char **argv) { + if (argc != 2) return 2; + unsigned i = (unsigned)atoi(argv[1]); + if (i >= sizeof cases / sizeof *cases) return 2; + Ctx c, before; + memset(&c, 0xa5, sizeof c); + c.x[1] = cases[i].texture; + c.x[2] = cases[i].sampler; + before = c; + n_nvnDeviceGetTextureHandle(&c, NULL); + if (c.x[0] != cases[i].expected) { + fprintf(stderr, "case %u: got %016llx, expected %016llx\n", i, + (unsigned long long)c.x[0], (unsigned long long)cases[i].expected); + return 1; + } + if (memcmp(c.x + 1, before.x + 1, sizeof c.x - sizeof c.x[0])) return 1; + return 0; +} +''' + compiler = shutil.which(args.cc) if args.cc else next( + (path for name in ("clang", "gcc", "cc") if (path := shutil.which(name))), None) + if not compiler: + raise SystemExit("No C compiler found (pass --cc)") + with tempfile.TemporaryDirectory(prefix="nvn-texture-handle-") as directory: + path = Path(directory) + source, binary = path / "fixture.c", path / "fixture.exe" + source.write_text(fixture) + subprocess.run([compiler, "-std=c11", "-O2", str(source), "-o", str(binary)], check=True) + failed = [] + for case in range(13): + result = subprocess.run([str(binary), str(case)], capture_output=True, text=True, timeout=5) + if result.returncode: + failed.append((case, result.returncode, result.stderr.strip())) + if failed: + raise SystemExit("FAIL: " + repr(failed)) + print("PASS: guard-house sentinel, empty handles, signed ids and 32-bit arguments (13 cases)") + + +if __name__ == "__main__": + main() diff --git a/tests/test_rt_pool_minimum.py b/tests/test_rt_pool_minimum.py new file mode 100644 index 000000000..5b304fde6 --- /dev/null +++ b/tests/test_rt_pool_minimum.py @@ -0,0 +1,91 @@ +"""Compile the production render-target pool sizing function without game data. + +The 305 MiB default crashed in Castle Town's guard house; 512 MiB resolved the +reported conversation/camera reproduction. Keep existing 4K/ultrawide growth +and explicit HOS_RT_POOL_MB overrides, including overrides below the floor. +""" +import argparse +import importlib.util +from pathlib import Path +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cc", help="C compiler (otherwise clang, gcc or cc from PATH)") + parser.add_argument("--source", type=Path, default=ROOT / "host/nn_applet.c") + args = parser.parse_args() + spec = importlib.util.spec_from_file_location("null_pool", ROOT / "tests/test_nvn_null_pool.py") + helpers = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helpers) + body = helpers.block(args.source.read_text(), "uint64_t hos_rt_pool_size(uint64_t game)") + fixture = r'''#include +#include +#include +#include +#define MIB (UINT64_C(1) << 20) +volatile uint64_t hos_wide_out; +static unsigned output_width, output_height; +static const char *override; +static uint32_t hos_output_size(int height) { return height ? output_height : output_width; } +static void hos_log(const char *fmt, ...) { (void)fmt; } +static const char *fixture_env(const char *name) { + if (strcmp(name, "HOS_RT_POOL_MB")) exit(2); + return override; +} +#define getenv fixture_env +''' + body + r''' +#define WIDE(w, h) ((uint64_t)(h) << 32 | (w)) +static const struct { + unsigned width, height; + uint64_t wide, game_mib; + const char *env; + uint64_t expected_mib; +} cases[] = { + {2560, 1440, 0, 305, NULL, 512}, + {3840, 2160, 0, 305, NULL, 687}, + {2560, 1440, WIDE(3440, 1440), 305, NULL, 512}, + {2560, 1440, WIDE(5120, 1440), 305, NULL, 610}, + {3840, 2160, WIDE(7680, 2160), 305, NULL, 1373}, + {2560, 1440, WIDE(1920, 1080), 305, NULL, 512}, + {2560, 1440, 0, 305, "256", 256}, + {3840, 2160, WIDE(7680, 2160), 305, "768", 768}, + {2560, 1440, 0, 305, "0x200", 512}, + {2560, 1440, 0, 305, "0", 0}, + {2560, 1440, 0, 768, NULL, 768}, + {3840, 2160, 0, 768, NULL, 1728}, +}; +int main(void) { + int failed = 0; + for (unsigned i = 0; i < sizeof cases / sizeof *cases; i++) { + output_width = cases[i].width; output_height = cases[i].height; + hos_wide_out = cases[i].wide; override = cases[i].env; + uint64_t got = hos_rt_pool_size(cases[i].game_mib * MIB); + if (got != cases[i].expected_mib * MIB) { + fprintf(stderr, "case %u: got %llu bytes, expected %llu MiB\n", i, + (unsigned long long)got, (unsigned long long)cases[i].expected_mib); + failed = 1; + } + } + return failed; +} +''' + compiler = shutil.which(args.cc) if args.cc else next( + (path for name in ("clang", "gcc", "cc") if (path := shutil.which(name))), None) + if not compiler: + raise SystemExit("No C compiler found (pass --cc)") + with tempfile.TemporaryDirectory(prefix="rt-pool-minimum-") as directory: + path = Path(directory) + source, binary = path / "fixture.c", path / "fixture.exe" + source.write_text(fixture) + subprocess.run([compiler, "-std=c11", "-O2", str(source), "-o", str(binary)], check=True) + subprocess.run([str(binary)], check=True, timeout=5) + print("PASS: 512 MiB floor, 4K/ultrawide growth and explicit overrides (12 cases)") + + +if __name__ == "__main__": + main()