From d17a2f1d92b9b6269c142c6f7df438f78eb46898 Mon Sep 17 00:00:00 2001 From: Nicholas Gower Date: Sun, 8 Nov 2026 12:24:29 -0800 Subject: [PATCH 1/6] feat: add the Hookshot MCP server for scripted in-game testing Add scripts/hookshot_mcp.py, a stdio MCP server using the official Python SDK. It launches Hookshot, drives HOS_INPUT_LIVE, requests on-demand screenshots and returns them inline. Game-side seams: HOS_INPUT_SCRIPT_ONLY, analog stick tokens (LX/LY/RX/RY), !quit, HOOKSHOT_NO_ENV_FILE, RX_VK_SHOT_FILE and RX_SCREENSHOT_FILE. Tests: tests/test_mcp_server.py plus script-only and analog cases in the input fixture. Docs include MCP host configuration and the new switches. Assisted-by: DeepSeek V4.1 Flash Co-Authored-By: DeepSeek V4.1 Flash --- .github/workflows/source-regressions.yml | 5 +- CONTRIBUTING.md | 4 +- docs/design.md | 3 + docs/development.md | 6 +- docs/mcp.md | 172 +++++ gpu/render/rx_core.cpp | 36 +- gpu/vk/VULKAN.md | 5 +- gpu/vk/vk_replay.cpp | 62 +- host/hos_main.c | 3 + host/nn_hid.c | 30 +- scripts/hookshot_mcp.py | 894 +++++++++++++++++++++++ tests/input_background_test.c | 24 + tests/run_all.py | 1 + tests/test_input_background.py | 6 +- tests/test_mcp_server.py | 261 +++++++ 15 files changed, 1486 insertions(+), 26 deletions(-) create mode 100644 docs/mcp.md create mode 100644 scripts/hookshot_mcp.py create mode 100644 tests/test_mcp_server.py diff --git a/.github/workflows/source-regressions.yml b/.github/workflows/source-regressions.yml index 2c7c0f56..6ee49022 100644 --- a/.github/workflows/source-regressions.yml +++ b/.github/workflows/source-regressions.yml @@ -51,9 +51,10 @@ jobs: uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.12' - # (the one package a test imports: nx2/test_nup_codec.py's zstandard. The runner itself needs none) + # (the packages the tests import: nx2/test_nup_codec.py's zstandard and tests/test_mcp_server.py's MCP SDK; + # the runner itself needs none) - name: Python packages for the tests - run: python -m pip install --disable-pip-version-check --no-deps zstandard==0.25.0 + run: python -m pip install --disable-pip-version-check zstandard==0.25.0 mcp==2.3.0 - name: Compilers and the SPIR-V validator if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y g++ clang spirv-tools diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eef9a591..419e25e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,6 +142,7 @@ window, including one started to update a game, uses Automatic again. | `python -I nx2/build.py --standalone --jobs 4` | Compile `work/sa/build/hookshot.exe` and `hookshot_vk.dll`. | | `powershell -File scripts/run-vk.ps1 -Window -Seconds 120` | Run the standalone build with the Vulkan back end and take screenshots in `work/runs//shots`. | | `python -I scripts/prof.py ` | Read a profile sampled with `HOS_SAMPLE` and `HOS_SAMPLE_FILE`. | +| `python -I scripts/hookshot_mcp.py --check` | The MCP server for automated in-game tests (needs the `mcp` package; see [docs/mcp.md](docs/mcp.md)). | Use a separate `--work` folder or `OOT_WORK` if you keep more than one dump around. The lifted C and every build are local artifacts; they are never committed. @@ -163,7 +164,8 @@ failure's output. Name a check to run only the ones whose path contains it (`pyt Some checks compile a small C or C++ fixture and need a compiler (`g++`, `gcc` or `clang++` on PATH), the SPIR-V validator (`spirv-val`), the SPIR-V or Vulkan headers (`HOOKSHOT_SPIRV_HEADERS` / `HOOKSHOT_VULKAN_HEADERS`, or the CPM -cache in `RX_CPM` or `build-linux/deps/cpm`), or the `zstandard` package. A check whose tool is missing is skipped and +cache in `RX_CPM` or `build-linux/deps/cpm`), or a Python package (`zstandard`, and `mcp` for the MCP server's test). +A check whose tool is missing is skipped and says so; CI passes `--strict`, which fails it instead. The skip list at the top of `tests/run_all.py` names the checks that cannot run in CI (a GPU, the game, a Visual Studio prompt) and the few that run on Linux only; `--no-skip` runs them anyway. diff --git a/docs/design.md b/docs/design.md index 6c701642..f1542ddf 100644 --- a/docs/design.md +++ b/docs/design.md @@ -145,6 +145,9 @@ id (`hos_game_is_mkw`); OoT gets its own once its pacer is found, never by addre - **The production build** (`nx2/test_dll.py`, when a `hookshot_game.dll` is built). - **In the game**: `scripts/run-vk.ps1` screenshots, the flash detector, frame dumps (`RX_VK_DUMP*`), the sampled profiler (`HOS_SAMPLE` + `scripts/prof.py`), and Eglantine as the A/B reference for what a frame should look like. +- **Scripted in-game tests** (`scripts/hookshot_mcp.py`, `docs/mcp.md`): an MCP agent launches a run, sends timed pad + input through `HOS_INPUT_LIVE` / `HOS_INPUT_SCRIPT_ONLY`, and gets on-demand PNGs through `RX_VK_SHOT_FILE` + (`RX_SCREENSHOT_FILE` on the WebGPU fallback). ## 8. Rules diff --git a/docs/development.md b/docs/development.md index 4bf6fe61..f3b9df2d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,6 +31,7 @@ Run everything with `python -I` from this folder. | `python -I nx2/test_leaf.py [--count 3000 --states 4] [--calls]` | random whole functions (or call trees), Unicorn vs recompiled native code on the real image | | `python -I nx2/lift.py --standalone --write` then `python -I nx2/build.py --standalone --jobs 4` | the standalone game: `work/sa/build/hookshot.exe` + `hookshot_vk.dll` | | `powershell -File scripts/run-vk.ps1 [-Window] [-Seconds 120] [-Name x]` | a muted test run on Vulkan, with screenshots in `work/runs//shots` | +| `python -I scripts/hookshot_mcp.py` | the MCP server for automated in-game tests: scripted input and on-demand screenshots ([docs/mcp.md](mcp.md)) | | `python -I scripts/prof.py ` | a profile sampled with `HOS_SAMPLE=` + `HOS_SAMPLE_FILE=`, named from the linker maps | The dump can also come from `OOT_DUMP` (its folder) or `OOT_EXEFS`; `OOT_WORK` moves the work folder. @@ -41,7 +42,10 @@ The built game's own arguments (`image.bin`, `--romfs`, `--user`, `--trace`, `-- Scripted test input (`HOS_INPUT_SCRIPT` and `HOS_INPUT_LIVE`) is applied before the window-focus check. A log saying the game window is not in front does not mean those presses were discarded. `HOS_HEADLESS` and `HOS_WINDOW_NOACTIVATE` change window creation, not this input routing. The game must still reach a state that reads -and responds to the supplied controller input. +and responds to the supplied controller input. `HOS_INPUT_SCRIPT_ONLY=1` leaves out the physical pad, keyboard and +mouse entirely, so a connected controller cannot disturb a test; analog sticks are written as `LX=`, `LY=`, `RX=` and +`RY=` tokens. `scripts/hookshot_mcp.py` wraps this, the screenshot request file and the game log in an MCP server (see +[docs/mcp.md](mcp.md)). Physical keyboard and mouse input stays with the foreground window. Physical controllers can also play in the background when the existing "Controller in background" option or `HOS_INPUT_BACKGROUND=1` enables them; diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..488ddd9d --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,172 @@ +# Automated tests through MCP + +`scripts/hookshot_mcp.py` is a [Model Context Protocol](https://modelcontextprotocol.io) server for automated in-game +tests. It launches a built Hookshot, drives the same timed controller input a test script uses, and asks the renderer +for a screenshot on demand. Screenshots come back as MCP image content, so an agent can look at the game after every +step. + +The server is a separate Python process. It is not linked into `hookshot.exe` and needs no game files of its own. + +## Install + +The MCP server uses the official Python SDK (Python 3.10 or newer): + +```powershell +python -m pip install "mcp==2.3.0" +python -I scripts/hookshot_mcp.py --check +``` + +`--check` prints the executable, image, romfs, save folder and run folder it would use, and exits non-zero if one is +missing. + +The defaults are the standalone build: + +| What | Default | +|---|---| +| Executable | `work/sa/build/hookshot.exe` (or `hookshot` on Linux) | +| Image | `work/sa/image/image.bin` | +| RomFS | the dump recorded in `work/dump.txt`, plus `/romfs` | +| Saves | `work/mcp/user` | +| Run files | `work/mcp/runs/-/` | +| Shader/pipeline caches | `work/mcp/cache/` | + +For a game folder made by HookshotBuilder or `nx2/make_game.py`, pass `--game-dir`, or set +`HOOKSHOT_MCP_GAME_DIR`. Individual paths can be set with `--exe`, `--image`, `--romfs`, `--user` or their +`HOOKSHOT_MCP_*` environment variables. + +## MCP host configuration + +OpenCode (`opencode.json`): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "hookshot": { + "type": "local", + "command": ["python", "-I", "C:/path/to/Hookshot/scripts/hookshot_mcp.py"], + "enabled": true, + "environment": { + "HOOKSHOT_MCP_GAME_DIR": "C:/Games/Hookshot" + } + } + } +} +``` + +Claude Desktop: + +```json +{ + "mcpServers": { + "hookshot": { + "command": "python", + "args": ["-I", "C:/path/to/Hookshot/scripts/hookshot_mcp.py", "--game-dir", "C:/Games/Hookshot"] + } + } +} +``` + +The script finds the repository from its own location, so the host's working directory does not matter. Use an +absolute path to the script. + +## Tools + +| Tool | What it does | +|---|---| +| `launch` | Start a run. Headless, muted, Vulkan and script-only by default. | +| `stop` | Ask the game to quit (`!quit`), then terminate if it does not. Safe when nothing is running. | +| `status` | Process state, renderer, last screenshot and recent log lines. | +| `press` | Press or hold Switch buttons: `A B X Y L R ZL ZR PLUS MINUS LS RS LEFT UP RIGHT DOWN`, plus the stick direction names `LUP` ... `RRIGHT`. | +| `stick` | Move the left or right analog stick with `x`/`y` in `-1..1` (positive y is up). | +| `sequence` | Queue a timed multi-step input sequence in one live input line. | +| `screenshot` | Capture the current presented frame as a PNG (1280x720 on Vulkan), inline and on disk. | +| `wait` | Wait for wall time while the game runs. | +| `logs` | Return recent log lines, optionally filtered. | + +Resources: `hookshot://log`, `hookshot://last-screenshot` and `hookshot://status`. + +A typical session: + +```text +launch(wait_ready=true) +press(["PLUS"], hold_ms=200) +wait(800) +screenshot() +sequence([{"at_ms": 0, "buttons": ["A"], "hold_ms": 200}]) +stop() +``` + +`press` waits for the game to acknowledge the live input line, then for the hold plus `settle_ms`. For a long hold +where screenshots are wanted in the middle, pass `wait=false`, then call `wait` and `screenshot` at the chosen times. + +## Launch options + +| Option | Meaning | +|---|---| +| `game_dir`, `exe`, `image`, `romfs`, `user` | Paths for this run. | +| `run_dir`, `cache_dir` | Where the log/screenshots and the shader caches go. | +| `window`, `focus`, `window_size` | Show the game window; headless otherwise. | +| `audio` | Play sound. Off by default. | +| `backend` | `auto`, `vulkan` or `webgpu`. | +| `script_only` | Ignore physical pads, keyboard and mouse (default true). | +| `frame_clock` | Advance scripted input at 60 presents per second instead of wall time. | +| `exclusive` | Do not merge physical pads while a press is active. | +| `env_file` | Read `hookshot_env.txt` first (the server then overrides its own control switches). | +| `fresh_user` | Use a save folder inside this run instead of `work/mcp/user`. | +| `extra_env` | Extra switches such as `RX_VK_FLASH=1`. The server's control switches are reserved. | +| `force` | Start even if another `hookshot` process is detected. | +| `wait_ready` | Wait for the first screenshot before `launch` returns. | + +## How it works + +The server sets these for the game: + +```text +HOS_INPUT_LIVE=/input.txt +HOS_INPUT_SCRIPT_ONLY=1 +HOS_INPUT_SCRIPT_BACKGROUND=1 +HOS_INPUT_EXCLUSIVE=1 +HOS_HEADLESS=1 (unless window=true) +HOS_AUDIO=0 (unless audio=true) +RX_BACKEND=vulkan (or webgpu) +HOOKSHOT_NO_ENV_FILE=1 +RX_VK_SHOT_FILE=/shot.request +RX_SCREENSHOT_FILE=/shot.request +``` + +`press`, `stick` and `sequence` append lines in the game's existing script syntax. Analog values use the `LX=`, `LY=`, +`RX=` and `RY=` tokens added for this server. A screenshot writes the requested output path into `shot.request`; the +Vulkan renderer (or the WebGPU fallback) deletes the request, writes the PNG on its next present, and logs +`vk: present written to ` (or `rx: screenshot ...`). The server waits for that line, returns the image, and +reads the present/frame number from it. + +`HOOKSHOT_NO_ENV_FILE=1` stops the game from applying `hookshot_env.txt` a second time. The server reads that file +first and then applies its own control switches, so useful test switches survive without letting a stale +`HOS_HEADLESS` or `HOS_INPUT_LIVE` take over. + +The game's own `hookshot_options.ini` still applies: frame generation, resolution and similar settings can change what +a screenshot looks like. Use a dedicated game folder or a clean options file when a test must be reproducible. + +## Safety and limits + +- Keep game files, saves and screenshots out of git. The default run folder is under `work/`, which is ignored. +- Only one game runs at a time; `launch` refuses when another `hookshot` process is found unless `force=true`. +- The server uses the game's ANSI file APIs: run, screenshot and game paths should be ASCII. +- A screenshot is the game's presented frame, not the game window. Renderer overlays (the Insert menu, the FPS + counter) are not in it. +- Input is controller input. The keyboard and mouse mappings are not simulated. +- Live input is applied when the game next reads the controller; a loading screen can delay a queued press. +- A cold launch can take a minute or two while shaders are prepared. `wait_ready` has its own timeout; call it again + if the first call times out. +- The server is developer tooling in `scripts/`, not a file shipped with the game. + +## Game-free test + +```powershell +python -I -B tests/test_mcp_server.py +``` + +The test speaks MCP to the server in memory, checks the input-line encoding and environment handling, and runs a +fake Python game that answers live input and screenshot requests exactly as the host does. It needs the `mcp` package +but no game build, GPU or game files. diff --git a/gpu/render/rx_core.cpp b/gpu/render/rx_core.cpp index dde25884..5f9041cb 100644 --- a/gpu/render/rx_core.cpp +++ b/gpu/render/rx_core.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -1589,11 +1590,31 @@ void report_period() { period_frames = 0; } /// RX_SCREENSHOT=: the presented image every 5 s as /frame_.png (synchronous readback: debugging only). +/// RX_SCREENSHOT_FILE=: the next presented image written to the path in that file, which is deleted (the MCP +/// server and scripted tests use this for a screenshot on demand). std::atomic screenshot_interval{-1}; // (rx_set_screenshot_ms at the end of this file) void screenshot(Image* im) { static const char* dir = getenv("RX_SCREENSHOT"); + static const char* request_file = getenv("RX_SCREENSHOT_FILE"); + static char requested[1024]; + static bool have_request; + if (!have_request && request_file) { + if (FILE* f = std::fopen(request_file, "rb")) { + if (std::fgets(requested, sizeof requested, f)) { + size_t n = std::strlen(requested); + while (n && (requested[n - 1] == '\n' || requested[n - 1] == '\r' || requested[n - 1] == ' ' || + requested[n - 1] == '\t')) + requested[--n] = 0; + have_request = n > 0; + } + std::fclose(f); + } + std::remove(request_file); + if (have_request) hos_log("rx: screenshot requested: %s", requested); + } static int64_t last; - if (!dir || !im || (im->format != WGPUTextureFormat_RGBA8Unorm && im->format != WGPUTextureFormat_RGBA8UnormSrgb)) + if ((!dir && !have_request) || !im || + (im->format != WGPUTextureFormat_RGBA8Unorm && im->format != WGPUTextureFormat_RGBA8UnormSrgb)) return; // RX_SCREENSHOT_MS=: another interval; RX_SCREENSHOT_AFTER=: none before that if (screenshot_interval < 0) @@ -1601,8 +1622,8 @@ void screenshot(Image* im) { static const int64_t after = getenv("RX_SCREENSHOT_AFTER") ? atoll(getenv("RX_SCREENSHOT_AFTER")) * 1000000000LL : 0; static const int64_t start = hos_now_ns(); const int64_t now = hos_now_ns(); - if (now - start < after) return; - if (last && now - last < screenshot_interval) return; + if (!have_request && now - start < after) return; + if (!have_request && last && now - last < screenshot_interval) return; last = now; wait_submitted(); // (the frame's batches are on the submit thread) const size_t stride = ((size_t)im->width * 4 + 255) & ~size_t(255), size = stride * im->height; @@ -1640,8 +1661,13 @@ void screenshot(Image* im) { if (done.status == WGPUMapAsyncStatus_Success) { const auto* px = static_cast(wgpuBufferGetConstMappedRange(buffer, 0, size)); static int n; - char path[512]; - std::snprintf(path, sizeof path, "%s/frame_%03d.png", dir, n++); + char path[1024]; + if (have_request) { + std::snprintf(path, sizeof path, "%s", requested); + have_request = false; + } else { + std::snprintf(path, sizeof path, "%s/frame_%03d.png", dir ? dir : "", n++); + } // the image is drawn with alpha that the display ignores: store it opaque std::vector copy(px, px + size); for (size_t i = 3; i < copy.size(); i += 4) copy[i] = 255; diff --git a/gpu/vk/VULKAN.md b/gpu/vk/VULKAN.md index a730c320..db97cb0e 100644 --- a/gpu/vk/VULKAN.md +++ b/gpu/vk/VULKAN.md @@ -139,5 +139,6 @@ Designed after totk-pc-recomp's runtime/vk_*.c (MIT) and Eden's renderer_vulkan, - **Frame interpolation** (`vk_framegen.cpp`, `RX_VK_FRAMEGEN=1`): the WebGPU renderer's, ported to GLSL (`shaders/`, compiled with glslang): made frames between the game's, shown evenly by a display thread. - **Headless**: without a window presents render offscreen; `RX_VK_SHOTS=` writes every `RX_VK_SHOT_EVERY`-th - as a PNG. `work/run-vk.ps1` runs it (refuses while Siege runs, stops past `-MaxGB` of working set); - `work/vksum.py ` summarises the log. + as a PNG. `RX_VK_SHOT_FILE=` writes the next present to the path the file names, then deletes the + request (the MCP server and scripted tests use this). `work/run-vk.ps1` runs it (refuses while Siege runs, stops past + `-MaxGB` of working set); `work/vksum.py ` summarises the log. diff --git a/gpu/vk/vk_replay.cpp b/gpu/vk/vk_replay.cpp index e6b315d9..aa2da1b7 100644 --- a/gpu/vk/vk_replay.cpp +++ b/gpu/vk/vk_replay.cpp @@ -2285,6 +2285,27 @@ VmaAllocation made_buf_alloc = nullptr; void* made_map = nullptr; VmaAllocation shot_image_alloc = nullptr; +// (RX_VK_SHOT_FILE: a test asks for one PNG of the next present. The file holds the output path and is deleted when +// read; a path waiting for a present with no picture stays here until one comes.) (replay_mu) +bool shot_requested = false; +char shot_request_path[1024]; +/// Reads RX_VK_SHOT_FILE when it appeared. Returns true with the path when a request was consumed. +bool read_shot_request(char* path, size_t cap) { + const char* file = getenv("RX_VK_SHOT_FILE"); + if (!file) return false; + FILE* f = fopen(file, "rb"); + if (!f) return false; + bool got = false; + if (fgets(path, (int)cap, f)) { + size_t n = strlen(path); + while (n && (path[n - 1] == '\n' || path[n - 1] == '\r' || path[n - 1] == ' ' || path[n - 1] == '\t')) path[--n] = 0; + got = n > 0; + } + fclose(f); + std::remove(file); + return got; +} + bool offscreen_init() { if (off_image) return true; VkImageCreateInfo ci{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; @@ -2588,6 +2609,7 @@ void do_present(const nvn_tex* t, const int crop[4]) { } static const char* shots = getenv("RX_VK_SHOTS"); static const int shot_every = getenv("RX_VK_SHOT_EVERY") ? std::max(1, atoi(getenv("RX_VK_SHOT_EVERY"))) : 300; + static const char* shot_file = getenv("RX_VK_SHOT_FILE"); VkImage src = VK_NULL_HANDLE; VkImageView direct_view = VK_NULL_HANDLE; uint32_t sw = 0, sh = 0; @@ -2649,7 +2671,15 @@ void do_present(const nvn_tex* t, const int crop[4]) { } ts_anchor_ns = host->ticks() * 32; dump_present(presents); - if (src && (dev.offscreen || shots) && offscreen_init()) { + // (RX_VK_SHOT_FILE: a test asks for one present as a PNG. When a readback is already in flight, the request + // waits for the next present; nothing is lost.) + static struct { bool on; uint64_t value; int64_t presents; std::string path; } pending{}; + if (!pending.on && !shot_requested && shot_file && read_shot_request(shot_request_path, sizeof shot_request_path)) { + shot_requested = true; + logf("vk: screenshot requested at present %lld: %s", (long long)presents, shot_request_path); + } + const bool take_request = shot_requested && !pending.on; + if (src && (dev.offscreen || shots || take_request) && offscreen_init()) { if (dev.offscreen) { image_barrier(r.cmd, off_image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); blit_crop(r.cmd, src, sw, sh, crop, off_image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, @@ -2658,7 +2688,7 @@ void do_present(const nvn_tex* t, const int crop[4]) { // (RX_VK_SHOT_FROM / _TO: every present in that range too) static const int64_t shot_from = getenv("RX_VK_SHOT_FROM") ? atoll(getenv("RX_VK_SHOT_FROM")) : -1; static const int64_t shot_to = getenv("RX_VK_SHOT_TO") ? atoll(getenv("RX_VK_SHOT_TO")) : -1; - shot = shots && (presents % shot_every == 0 || (presents >= shot_from && presents <= shot_to)); + shot = take_request || (shots && (presents % shot_every == 0 || (presents >= shot_from && presents <= shot_to))); // (RX_VK_SHOT_GO=: every present while that file is there too, a test's run of frames) static const char* shot_go = getenv("RX_VK_SHOT_GO"); if (shots && shot_go && !shot) { @@ -2668,7 +2698,7 @@ void do_present(const nvn_tex* t, const int crop[4]) { // (RX_VK_SHOT_MADE=1: the frame interpolation made before it too, written beside it as vk_N_made.png) static const bool shot_made = getenv("RX_VK_SHOT_MADE") != nullptr; uint32_t mw = 0, mh = 0; - made_src = shot && shot_made && interpolated ? framegen_made_image(&mw, &mh) : VK_NULL_HANDLE; + made_src = shot && shots && shot_made && interpolated ? framegen_made_image(&mw, &mh) : VK_NULL_HANDLE; if (made_src) { static VkImage made_image = VK_NULL_HANDLE; static VmaAllocation made_alloc = nullptr; @@ -2764,15 +2794,14 @@ void do_present(const nvn_tex* t, const int crop[4]) { // (a shot is read once the GPU reached its frame, at a later present, and written on a thread of its own: // waiting for the GPU and encoding the PNG there took the replay ~570 ms a shot, and frequent shots // halved the frame rate test runs measured. RX_VK_SHOT_MADE shots are taken at once, as before.) - static struct { bool on; uint64_t value; int64_t presents; } pending{}; - auto take = [&](uint64_t at_value, int64_t at_present) { + auto take = [&](uint64_t at_value, int64_t at_present, std::string path) { wait_value(at_value); const uint8_t* const sm = (const uint8_t*)shot_maps[at_present & 1]; auto px = std::make_shared>(sm, sm + (size_t)kShotW * kShotH * 4); // (RX_VK_DUMP_BLACK=: a shot with 12-90% pure black pixels in a lit picture, or a green cast over it, // dumps the next frame, n times at most: the glitches some scenes show now and then) static int black_left = getenv("RX_VK_DUMP_BLACK") ? atoi(getenv("RX_VK_DUMP_BLACK")) : 0; - if (black_left > 0) { + if (black_left > 0 && shots) { const uint8_t* q0 = px->data(); uint64_t black = 0, white = 0, sum = 0, ch[3] = {}; const uint64_t n = (uint64_t)kShotW * kShotH; @@ -2805,7 +2834,7 @@ void do_present(const nvn_tex* t, const int crop[4]) { // (RX_VK_FLASH=1, with RX_VK_SHOT_EVERY=1: a frame that differs from both its neighbours in a part of the // picture where they agree is a one-frame flash: the three are written as flash__{a,b,c}.png and // the next frame is dumped (a few times). The other shots are written every 300th present only.) - static const bool flash = getenv("RX_VK_FLASH") && atoi(getenv("RX_VK_FLASH")) != 0; + static const bool flash = shots && getenv("RX_VK_FLASH") && atoi(getenv("RX_VK_FLASH")) != 0; if (flash) { constexpr uint32_t W = 64, H = 36; auto small = std::make_shared>(W * H * 3); @@ -2890,20 +2919,29 @@ void do_present(const nvn_tex* t, const int crop[4]) { sa = sb, pa = pb, sb = small, pb = px, present_b = at_present; if (at_present % 300) return; } - std::string path = std::string(shots) + "/vk_" + std::to_string(1000000 + at_present).substr(1) + ".png"; + if (path.empty()) { + if (!shots) return; + path = std::string(shots) + "/vk_" + std::to_string(1000000 + at_present).substr(1) + ".png"; + } std::thread([px, path, at_present] { write_png(path.c_str(), px->data(), kShotW, kShotH); logf("vk: present %lld written to %s", (long long)at_present, path.c_str()); }).detach(); }; - if (pending.on && (shot || completed() >= pending.value)) take(pending.value, pending.presents), pending.on = false; + if (pending.on && (shot || completed() >= pending.value)) { + take(pending.value, pending.presents, pending.path); + pending.on = false; + pending.path.clear(); + } if (shot && made_src && made_map) { - take(value, presents); + take(value, presents, take_request ? std::string(shot_request_path) : std::string()); + if (take_request) shot_requested = false; char path[1024]; - snprintf(path, sizeof path, "%s/vk_%06lld_made.png", shots, (long long)presents); + snprintf(path, sizeof path, "%s/vk_%06lld_made.png", shots ? shots : "", (long long)presents); write_png(path, (const uint8_t*)made_map, kShotW, kShotH); } else if (shot) { - pending = {true, value, presents}; + pending = {true, value, presents, take_request ? std::string(shot_request_path) : std::string()}; + if (take_request) shot_requested = false; } log_frame_stats(); } diff --git a/host/hos_main.c b/host/hos_main.c index 2a58e268..44149196 100644 --- a/host/hos_main.c +++ b/host/hos_main.c @@ -348,6 +348,9 @@ static const char *saves_dir(const char *beside) { HOS_ENV_FILE= reads that file instead: a scripted or A/B test run brings its own switches and leaves the player's hookshot_env.txt as it is. */ static void load_hookshot_env(void) { + /* (hookshot) HOOKSHOT_NO_ENV_FILE=1: a test harness sets every switch itself (the MCP server merges this file + first and then applies its own values, so the file must not override them a second time). */ + if (getenv("HOOKSHOT_NO_ENV_FILE")) return; char path[MAX_PATH + 32]; const char *other = getenv("HOS_ENV_FILE"); if (other && *other) { diff --git a/host/nn_hid.c b/host/nn_hid.c index 64180e08..364357bc 100644 --- a/host/nn_hid.c +++ b/host/nn_hid.c @@ -20,7 +20,9 @@ HOS_INPUT_SCRIPT (test runs): timed presses added to the input, ":