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..c03ffe48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,6 +142,8 @@ 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)). | +| `python -I -B scripts/pr_check.py --base main` | A read-only pre-PR check: game-derived paths, LF endings, commit format and the full test suite. | 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 +165,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. @@ -186,11 +189,13 @@ gigabytes and are derived from the game. ### When you add a fix or a check - Add a game-free test where the behavior can be tested without the dump. The existing tests are small Python files - under `tests/` or `nx2/` that read source files and run them, or compile a tiny C++ fixture. -- Wire the test into `.github/workflows/source-regressions.yml`: add a `run:` step, and add the test file and every - source file it reads to both the `push` and `pull_request` `paths` lists. Without the paths, the check does not run - when the code changes. + under `tests/` or `nx2/` that read source files and run them, or compile a tiny C++ fixture. A new `test_*.py` file + is picked up by itself; no workflow or `paths:` list has to be edited. +- If the test needs a compiler, headers or a Python package, add it to `ARGS` or `MODULES` in `tests/run_all.py`; the + runner skips a missing tool, and CI's `--strict` makes that a failure. - Keep the test fast enough for the 10-minute CI job, and make it pass on both Ubuntu and Windows where it can. +- Before opening a pull request, run `python -I -B scripts/pr_check.py --base main`. It is read-only and checks the + changed paths, line endings, commit subjects and trailers, then runs the game-free suite. - If the fix is in the renderer or the runtime, say what you saw before and after, on which GPU and driver, and in which part of the game. 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..28f57c54 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,6 +31,8 @@ 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 -B scripts/pr_check.py --base main` | a read-only pre-PR audit: forbidden paths, LF endings, commit format and trailers, then the game-free suite | | `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 +43,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..11162b6f --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,232 @@ +# 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`, V2): servers live under `mcp.servers`, and a server is connected unless it is `disabled`. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "servers": { + "hookshot": { + "type": "local", + "command": ["python", "-I", "C:/path/to/Hookshot/scripts/hookshot_mcp.py"], + "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. | +| `settings` | Every setting the Insert menu shows, the cheat rows when the cheat menu is on, and the lists they use. | +| `set_setting` | Change one of the player's settings by its key, as the menu does; kept in `hookshot_options.ini`. | +| `cheat` | Act on a cheat row (Gameplay → Cheats) directly: toggles, time, teleport, age, pouch and inventory. | + +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}]) +settings() +set_setting("text_speed", 3) +cheat("heart_protection", 1) +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. | +| `cheats` | Turn the cheat menu on (`HOS_OOT_CHEATS=1`) so `cheat` works. Off by default, as the game is. | +| `force` | Start even if another `hookshot` process is detected. | +| `wait_ready` | Wait for the first screenshot before `launch` returns. | + +## Settings and cheats + +`settings()` reads the Insert menu: every row of its Graphics, Gameplay and Controls tabs (the row's key, its label, the +value, its range, and the text the player sees), and — when the cheat menu is on — the Cheats page's rows with the +teleport destinations, the pouch's items and the inventory's items and counts. The keys are the names +`hookshot_options.ini` uses. + +`set_setting(name, value)` goes through the menu's own setter: it clamps to the row's range, applies a quality preset or a +control layout the way the menu does, marks the settings Custom, and writes `hookshot_options.ini`. A row whose text says +"(restart)" keeps its value but takes effect at the next launch, exactly as when a player changes it. + +```text +settings() # the rows, their values and their ranges +set_setting("frame_generation", 0) # frame generation off +set_setting("resolution", 1) # a fixed 100%, not Auto +set_setting("text_speed", 3) # instant dialogue +set_setting("show_fps", 1) +``` + +`cheat(name, value=0, item=None)` acts on the cheat menu directly. The game keeps that menu behind `HOS_OOT_CHEATS=1`, so +the run must be started with `launch(cheats=true)` (or with the setting in `hookshot_env.txt`, which the server reads). + +| Cheat | What to pass | +|---|---| +| `heart_protection`, `no_clip_flight`, `free_camera` | `value` 1 on, 0 off | +| `mark_position`, `return_to_mark`, `switch_age` | nothing else | +| `set_time` | `value`, the hour 0 to 23 | +| `teleport` | `item`, a destination's name or index (from `settings()`) | +| `pouch` | `item`, a pouch item, and `value`, the amount to add or remove (-10 to 10) | +| `inventory` | `item`, an inventory item, and `value`, the count wanted (-1 unlocks or relocks it) | + +```text +launch(cheats=true, wait_ready=true) +cheat("heart_protection", 1) +cheat("set_time", 18) +cheat("teleport", item="Kakariko Village") +cheat("inventory", item="Hookshot") # unlocked +cheat("inventory", item="Bombs", value=20) # twenty bombs +cheat("pouch", item="Arrows", value=5) +``` + +The server writes these as live input lines (`!settings`, `!setting `, `!cheat `) and +the game answers in its log, which the server reads back. Settings and cheats are therefore the Vulkan back end's: the +WebGPU fallback has no Insert menu and `settings()` says so. A cheat acts on the game in play; at the title screen there is +no player to give hearts or a destination to travel to yet. + +## 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) +HOS_OOT_CHEATS=1 (when cheats=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` returns on the first presented frame, which + on a cold cache can be a black boot frame; call it again, or `wait` and `screenshot` until the title appears, before + asserting on the picture. `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. + +`tests/test_shot_request.py` compiles the renderer's `read_shot_request()` and checks the request-file rules, and +`tests/test_docs.py` checks this page's tool/resource tables against the server. Both are part of +`python -I -B tests/run_all.py --strict`. 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..cddb95c3 100644 --- a/gpu/vk/VULKAN.md +++ b/gpu/vk/VULKAN.md @@ -139,5 +139,7 @@ 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; `tests/test_shot_request.py` compiles the request parser from + `vk_replay.cpp` and checks the trimming, one-shot and buffer-edge rules). `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_api.h b/gpu/vk/vk_api.h index ab699f59..fda72d64 100644 --- a/gpu/vk/vk_api.h +++ b/gpu/vk/vk_api.h @@ -15,7 +15,7 @@ extern "C" { #endif -#define MKWVK_API_VERSION 9 +#define MKWVK_API_VERSION 10 enum { HOS_OPT_CHEAT_HEARTS = 32, HOS_OPT_CHEAT_FLIGHT, HOS_OPT_CHEAT_ACTION, HOS_OPT_CHEAT_CAMERA }; /* (hookshot) the Controls tab's motion rows (host/pad_sdl.c reads them, vk_replay.cpp's option answers them): Motion controls on or off, the horizontal's and the vertical's sensitivity in percent, and each turned around */ @@ -162,6 +162,11 @@ typedef struct MkwVkRenderer { /* (hookshot, the Insert menu) a setting changed from the game's side, kept in the options file: 7 the mouse's sensitivity in percent (the - and = keys, host/nn_hid.c) */ void (*option_set)(int id, int value); + /* (hookshot, tests) the Insert menu's settings and cheats for the MCP server (gpu/vk/vk_menu.cpp): the rows as + "mcp: ..." text lines, and a set by key or name (host/nn_hid.c's "!settings", "!setting" and "!cheat") */ + int (*settings_dump)(char *out, int cap); + int (*setting_set)(const char *key, int value); + int (*cheat)(const char *name, int value, int extra); } MkwVkRenderer; /* the DLL's one export: null when Vulkan is not usable (the executable then falls back to WebGPU) */ diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index fa8e940d..36bdeaa0 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -529,7 +529,24 @@ std::atomic protect_hearts{-1}; // -1 disables the shortcut, 0 enables it on the ground, and 1 means flying. std::atomic player_flight{-1}; std::atomic free_camera{0}; -std::atomic cheat_action{0}; +// (the cheat actions the game takes: a queue, so two Enters in one frame, or an MCP "!cheat" right after another, are both +// taken rather than the second overwriting the first) +std::atomic cheat_actions[8]; +std::atomic cheat_head{0}, cheat_tail{0}; +void queue_cheat_action(int action) { + const unsigned tail = cheat_tail.load(std::memory_order_relaxed); + const unsigned next = (tail + 1) % (unsigned)std::size(cheat_actions); + if (next == cheat_head.load(std::memory_order_acquire)) return; // (full: a player cannot get here) + cheat_actions[tail].store(action, std::memory_order_relaxed); + cheat_tail.store(next, std::memory_order_release); +} +int take_cheat_action() { + const unsigned head = cheat_head.load(std::memory_order_relaxed); + if (head == cheat_tail.load(std::memory_order_acquire)) return 0; + const int action = cheat_actions[head].load(std::memory_order_relaxed); + cheat_head.store((head + 1) % (unsigned)std::size(cheat_actions), std::memory_order_release); + return action; +} int clock_hour = 12; int pouch_item = 0; int pouch_delta = 1; @@ -1679,7 +1696,7 @@ bool menu_is_open() { return open_now.load(std::memory_order_relaxed); } int menu_heart_protection() { return protect_hearts.load(std::memory_order_relaxed); } int menu_player_flight() { return cheats_enabled ? player_flight.load(std::memory_order_relaxed) : -1; } int menu_free_camera() { return free_camera.load(std::memory_order_relaxed); } -int menu_take_cheat_action() { return cheat_action.exchange(0, std::memory_order_relaxed); } +int menu_take_cheat_action() { return take_cheat_action(); } void menu_note_present_sizes(uint32_t picture_w, uint32_t picture_h, uint32_t window_w, uint32_t window_h) { const auto c = [](uint32_t v) { return (uint64_t)std::min(v, 0xffffu); }; @@ -1747,6 +1764,172 @@ void menu_option_set(int id, int value) { save(); } +// ---- (hookshot, tests) the settings and cheats the MCP server drives (scripts/hookshot_mcp.py, docs/mcp.md) ---- +// The same rows the Insert menu shows, read and set from outside the window: a live input "!settings" dump, "!setting +// " and "!cheat " (host/nn_hid.c). The lines are logged, so the server reads them back +// from the game's log. Values go through the menu's own setters: the same clamps, presets, bindings and options file. +namespace { +void dump_put(char* out, int cap, int& n, const char* line) { + const int len = (int)strlen(line); + if (n + len + 2 > cap) return; // (the caller's buffer is sized for the whole dump; a line that no longer fits is dropped) + memcpy(out + n, line, (size_t)len); + n += len; + out[n++] = '\n'; + out[n] = 0; +} +} + +int menu_settings_dump(char* out, int cap) { + if (!out || cap < 32) return 0; + int n = 0; + out[0] = 0; + char line[512]; + dump_put(out, cap, n, "mcp: settings begin"); + for (const Row& r : rows) { + if (!r.value) continue; // (the Controls and Cheats rows open pages: not settings) + snprintf(line, sizeof line, "mcp: value|%s|%s|%s|%d|%d|%d|%d|%s", ini_key(r), r.name, + r.tab == TAB_GRAPHICS ? "graphics" : r.tab == TAB_GAMEPLAY ? "gameplay" : "controls", *r.value, r.lo, + r.hi, r.step, r.text ? r.text(*r.value) : ""); + dump_put(out, cap, n, line); + } + dump_put(out, cap, n, "mcp: settings end"); + dump_put(out, cap, n, cheats_enabled ? "mcp: cheats on" : "mcp: cheats off"); + if (cheats_enabled) { + const int hearts = protect_hearts.load(std::memory_order_relaxed) == 1 ? 1 : 0; + const int flight = player_flight.load(std::memory_order_relaxed) >= 0 ? 1 : 0; + const int camera = free_camera.load(std::memory_order_relaxed) ? 1 : 0; + dump_put(out, cap, n, "mcp: cheats begin"); + snprintf(line, sizeof line, "mcp: cheatrow|heart_protection|Heart protection|toggle|%d|%s", hearts, tx_onoff(hearts)); + dump_put(out, cap, n, line); + snprintf(line, sizeof line, "mcp: cheatrow|no_clip_flight|No-clip flight|toggle|%d|%s", flight, tx_onoff(flight)); + dump_put(out, cap, n, line); + dump_put(out, cap, n, "mcp: cheatrow|mark_position|Mark position|action|0|"); + dump_put(out, cap, n, "mcp: cheatrow|return_to_mark|Return to mark|action|0|"); + snprintf(line, sizeof line, "mcp: cheatrow|set_time|Set time|choice|%d|%02d:00", clock_hour, clock_hour); + dump_put(out, cap, n, line); + snprintf(line, sizeof line, "mcp: cheatrow|pouch_item|Inventory item|choice|%d|%s", pouch_item, pouch_items[pouch_item]); + dump_put(out, cap, n, line); + snprintf(line, sizeof line, "mcp: cheatrow|change_quantity|Change quantity|choice|%d|%+d", pouch_delta, pouch_delta); + dump_put(out, cap, n, line); + snprintf(line, sizeof line, "mcp: cheatrow|free_camera|Free camera|toggle|%d|%s", camera, tx_onoff(camera)); + dump_put(out, cap, n, line); + snprintf(line, sizeof line, "mcp: cheatrow|teleport|Teleport|choice|%d|%s", teleport_choice, teleport_names[teleport_choice]); + dump_put(out, cap, n, line); + dump_put(out, cap, n, "mcp: cheatrow|switch_age|Switch age|action|0|"); + dump_put(out, cap, n, "mcp: cheatrow|inventory|Inventory|action|0|"); + dump_put(out, cap, n, "mcp: cheats end"); + dump_put(out, cap, n, "mcp: pouch begin"); + for (int i = 0; i < kPouchItems; i++) { + snprintf(line, sizeof line, "mcp: pouch|%d|%s", i, pouch_items[i]); + dump_put(out, cap, n, line); + } + dump_put(out, cap, n, "mcp: pouch end"); + dump_put(out, cap, n, "mcp: teleports begin"); + for (int i = 0; i < kTeleports; i++) { + snprintf(line, sizeof line, "mcp: teleport|%d|%s", i, teleport_names[i]); + dump_put(out, cap, n, line); + } + dump_put(out, cap, n, "mcp: teleports end"); + dump_put(out, cap, n, "mcp: items begin"); + for (int i = 0; i < kItems; i++) { + snprintf(line, sizeof line, "mcp: item|%d|%s|%d|%d", i, item_names[i], (int)item_kinds[i], + item_counts[i].load(std::memory_order_relaxed)); + dump_put(out, cap, n, line); + } + dump_put(out, cap, n, "mcp: items end"); + } + dump_put(out, cap, n, "mcp: dump end"); + return n; +} + +int menu_setting_set(const char* key, int value) { + for (Row& r : rows) { + if (!r.value || !key || strcmp(key, ini_key(r))) continue; + if (r.value == &opts.output_res) { // (its value is the size's id, not a step: the file's own mapping) + opts.output_res = output_choice(value); + menu_refresh = true; + save(); + return opts.output_res; + } + int v = std::min(std::max(value, r.lo), r.hi); + if (r.value == &opts.framegen && v == 1) v = 2; // (1 is not a count of made frames: menu_init says 2 for it) + set_row(r, v, 0); // (the menu's own setter: presets, bindings, the Custom mark and the options file) + menu_refresh = true; + return *r.value; + } + return -1; +} + +int menu_cheat(const char* name, int value, int extra) { + if (!cheats_enabled) return -2; // (the Cheats page is off: HOS_OOT_CHEATS=1 at start) + if (!name) return -1; + if (!strcmp(name, "heart_protection")) { + protect_hearts.store(value ? 1 : 0, std::memory_order_relaxed); + return value ? 1 : 0; + } + if (!strcmp(name, "no_clip_flight")) { + player_flight.store(value ? 0 : -1, std::memory_order_relaxed); + return value ? 1 : 0; + } + if (!strcmp(name, "free_camera")) { + free_camera.store(value ? 1 : 0, std::memory_order_relaxed); + return value ? 1 : 0; + } + if (!strcmp(name, "mark_position")) { queue_cheat_action(2); return 2; } + if (!strcmp(name, "return_to_mark")) { queue_cheat_action(3); return 3; } + if (!strcmp(name, "switch_age")) { queue_cheat_action(999); return 999; } + if (!strcmp(name, "set_time")) { + if (value < 0 || value > 23) return -3; + queue_cheat_action(100 + value); + return value; + } + if (!strcmp(name, "pouch_item")) { + if (value < 0 || value >= kPouchItems) return -3; + pouch_item = value; + return value; + } + if (!strcmp(name, "change_quantity")) { + if (value < -10 || value > 10 || value == 0) return -3; + pouch_delta = value; + return value; + } + if (!strcmp(name, "teleport")) { + if (value < 0 || value >= kTeleports) return -3; + teleport_choice = value; + queue_cheat_action(1000 + value); + return value; + } + if (!strcmp(name, "pouch")) { // (item, amount: the menu's Inventory item and Change quantity, then Enter) + if (value < 0 || value >= kPouchItems || extra < -10 || extra > 10 || extra == 0) return -3; + pouch_item = value; + pouch_delta = extra; + queue_cheat_action(200 + value * 32 + extra + 10); + return extra; + } + if (!strcmp(name, "inventory")) { // (item, the count wanted; -1: unlock when locked, relock when held) + if (value < 0 || value >= kItems) return -3; + const int have = item_counts[value].load(std::memory_order_relaxed); + if (have < 0) return -3; // (the game side has not reported the counts yet) + if (extra < 0) { // (Enter: unlock a locked item, relock a held one) + queue_cheat_action(OOT_CHEAT_ITEM_ACTION + value * 256 + 200); + return have > 0 ? 0 : 1; + } + int op; + if (item_kinds[value] == 0) { + if ((have > 0) == (extra > 0)) return extra; + op = 200; + } else { + const int delta = std::clamp(extra - have, -64, 63); + if (!delta) return extra; + op = delta + 64; + } + queue_cheat_action(OOT_CHEAT_ITEM_ACTION + value * 256 + op); + return extra; + } + return -1; +} + + /// A list page's line (from the top of the list, headings included) to its item: -1 a section's heading int list_item(int line, const TeleportSection* sec, int sections) { int section = 0; @@ -2027,10 +2210,10 @@ void menu_tick() { changed = true; } if (press[7] && cheat_selected >= 2 && cheat_selected <= 4) { - cheat_action.store(cheat_selected == 4 ? 100 + clock_hour : cheat_selected); + queue_cheat_action(cheat_selected == 4 ? 100 + clock_hour : cheat_selected); } if (press[7] && cheat_selected == 6) { - cheat_action.store(200 + pouch_item * 32 + pouch_delta + 10); + queue_cheat_action(200 + pouch_item * 32 + pouch_delta + 10); } if (press[7] && cheat_selected == 7) { free_camera.store(!free_camera.load()); @@ -2043,7 +2226,7 @@ void menu_tick() { if (press[7] && cheat_selected == 8) page = 3, teleport_reveal(), changed = true; if (press[7] && cheat_selected == 10) page = 4, item_reveal(), changed = true; if (press[7] && cheat_selected == 9) { - cheat_action.store(999); // (host/hos_main.c OOT_AGE_ACTION) + queue_cheat_action(999); // (host/hos_main.c OOT_AGE_ACTION) open_now = false; changed = true; } @@ -2072,8 +2255,8 @@ void menu_tick() { item_choice = item_sections.s[section].first; changed = true; } - if (press[7]) cheat_action.store(OOT_CHEAT_ITEM_ACTION + item_choice * 256 + 200); - else if (press[3] || press[4]) cheat_action.store(OOT_CHEAT_ITEM_ACTION + item_choice * 256 + 64 + (press[4] ? 1 : -1)); + if (press[7]) queue_cheat_action(OOT_CHEAT_ITEM_ACTION + item_choice * 256 + 200); + else if (press[3] || press[4]) queue_cheat_action(OOT_CHEAT_ITEM_ACTION + item_choice * 256 + 64 + (press[4] ? 1 : -1)); if (changed) item_reveal(); } if (changed) redraw_menu(scale); @@ -2083,7 +2266,7 @@ void menu_tick() { teleport_scroll, teleport_choice, press, changed); if (press[5]) page = 2, changed = true; else if (press[7]) { - cheat_action.store(1000 + teleport_choice); + queue_cheat_action(1000 + teleport_choice); open_now = false, page = 2; // (back to the game: the map change starts with an event) changed = true; } else { diff --git a/gpu/vk/vk_menu.h b/gpu/vk/vk_menu.h index 7f0264dc..193e28d6 100644 --- a/gpu/vk/vk_menu.h +++ b/gpu/vk/vk_menu.h @@ -58,6 +58,17 @@ bool menu_is_open(); bool resolution_auto(); // (the game's dynamic resolution: Resolution Auto) int resolution_scale(); // (the fixed path's scale in 1/10000: the Resolution row's, as a twelfth of the output) void menu_option_set(int id, int value); // (from the game's side: 7 the mouse's sensitivity, the - and = keys) + +// ---- (hookshot, tests) the settings and cheat rows the MCP server drives (scripts/hookshot_mcp.py, docs/mcp.md) ---- +/// Every row of the Insert menu and, when the cheat menu is on (HOS_OOT_CHEATS=1), its rows, the pouch's items, the +/// teleports and the inventory, as "mcp: ..." text lines (the server reads them from the game's log). Returns the bytes +/// written; 0 when there is no menu. +int menu_settings_dump(char* out, int cap); +/// Set a setting by its options-file key, as the menu's own setter does. Returns the value applied, -1 for an unknown key. +int menu_setting_set(const char* key, int value); +/// A cheat row named as the server names it, with a value and an extra (the pouch's or the inventory's item and amount). +/// Returns the value applied: -1 unknown name, -2 the cheat menu is off, -3 a value out of its range. +int menu_cheat(const char* name, int value, int extra); int menu_heart_protection(); int menu_player_flight(); int menu_free_camera(); diff --git a/gpu/vk/vk_replay.cpp b/gpu/vk/vk_replay.cpp index e6b315d9..da5024f8 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(); } @@ -3741,6 +3779,9 @@ void fill_replay_table(MkwVkRenderer& t) { id == HOS_OPT_DLSS_JITTER_PHASES ? (int)dlss_jitter_size(2) : 0; }; t.option_set = menu_option_set; + t.settings_dump = menu_settings_dump; + t.setting_set = menu_setting_set; + t.cheat = menu_cheat; t.nvn_register_sampler = register_sampler_queued; t.nvn_set_texture_pool_size = set_texture_pool_size; t.nvn_texture_released = texture_released_of; 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..0493e010 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, ":