Repository navigation
Control bridge: drive a running game over a local socket, with a CLI - #926
Closed
hckrman101 wants to merge 6 commits into
Closed
hckrman101 wants to merge 6 commits into
hckrman101 wants to merge 6 commits into
Conversation
HOS_CONTROL=<socket path> opens a local socket (a Unix domain socket on Linux, a named pipe on Windows) that a tool, test or coding agent drives the running game through, instead of writing presses into a polled file (HOS_INPUT_LIVE) and scraping the log for the frame rate and draw counts. Newline-delimited JSON: one request object a line, one reply a line with the id echoed and the current frame. Commands: status, press, stick, sequence, release, shot, wait (for a frame or a game state), option_get, option_set, subscribe and quit. Inputs are timed in frames and applied in the controller's read; shots are taken at a present and written to a path. Off unless HOS_CONTROL is set; the socket is created 0600, a client of another user is turned away, and there is no TCP. mem_* and symbols reply that guest memory is not served. scripts/hookshot_ctl.py is a stdlib-only client with a launch helper. docs/control.md has the protocol, the security model and examples, linked from docs/development.md. tests/test_control.py drives host/control.c with a fake game over a real socket; tests/test_hookshot_ctl.py drives the CLI against a fake bridge.
HOS_CONTROL_MEM turns on guest memory over the control socket, separate from HOS_CONTROL: ro (reads) or rw (reads and writes); r and read are aliases for ro, readwrite for rw, case does not matter, and any other value logs once and leaves it off. mem_read and mem_read_many read one or several ranges (the many-variant in one pass, so they are consistent), mem_write writes bytes, each at most 1 MB a request, hex or base64. Addresses are bounds-checked against the runtime's own bookkeeping (host/hos_mem.c hos_mem_region: the image, the system region in use, the heap, the address regions); an address outside it is an error, never a fault. Reads and writes run on the game thread at a controller read, and every write is logged. This is a debugging tool, not a sandbox: reads expose all of the game's memory, including values the runtime keeps for itself, and a write can corrupt or crash the game and the runtime and the corruption can be saved, so a throwaway save should be used. The socket is already this user's only, so a client gains no memory a same-user process could not already reach. scripts/hookshot_ctl.py gains mem read and mem write; docs/control.md documents the levels and the model. Named game values (mem get, symbols) are left for later. watch --log without --events streams the log lines alone.
host/control.c's Windows half builds clean with the CI's llvm-mingw at -Wall -Wextra, and a few things in it are fixed: a client that connected before ConnectNamedPipe was asked is told from a failed one, a cancelled read finishes before its OVERLAPPED goes, a refused client gets the busy reply (its instance is closed a little later so the reply is read), and quit's reply is read before the process ends (a bounded FlushFileBuffers). A pipe has no folder, so relative shot paths and shots/ go to a folder named after the pipe in the user's temporary folder; on Linux the run folder is the socket's, as before. tests/control_test.c gets a small platform layer and runs the same cases over a named pipe on Windows, and checks the pipe's access control there: another first instance of the name and a second bridge on it are refused, and the DACL read back through a client's handle is protected with one ACE for the current user. tests/test_control.py no longer skips Windows. The client reads a pipe without a blocking reader (PeekNamedPipe), waits for a busy pipe, and tests/test_hookshot_ctl.py's fake bridge serves a named pipe on Windows.
saves lists the save folder the game uses, from the path nn_fs mounts its saves at (host/nn_fs.c hos_fs_save_root): account.sav and option.sav, and each slot_NN with its files, sizes and modification times, newest first (the one Continue loads is marked). It is read on the bridge's thread and never locks the game; a slot being written may show a size or time between two writes. When a slot's caption.sav has the layout seen in this game's saves, its scene and map names and where its picture (a JPEG) is are added. No opt-in is needed: it lists files and reads no game memory. hookshot_ctl.py: launch --save FOLDER [--slot NN] copies a save folder into the run's data folder before the game starts (one slot alone with --slot, so Continue loads it; the source is only read, and a destination that already holds saves is refused; Linux only). saves prints the folder; slots prints a line a slot, --json the slot list, and --thumbs DIR writes each slot's picture.
hckrman101
marked this pull request as ready for review
November 9, 2026 02:30
Owner
|
Thank you for this! It changes how the game runs at a level that needs a maintainer design decision first, so I'm closing it to keep the queue clear: needs maintainer design decision, reopen after discussion. (A local control socket that can read and write guest memory is a new interface: needs a design discussion first.) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
Tools that drive a running game today write presses into a file the game polls (
HOS_INPUT_LIVE) and read its state by scrapinghookshot_log.txt(thevk: N presents, F fps; a frame: D drawsline: ~2 draws is the intro, 15-70 menus, ~5 loading, hundreds in play). Screenshots come fromRX_VK_SHOTS. It is all polled and timed in wall-clock seconds.This adds a local control bridge inside the game process and a small CLI client, so tests, scripts and coding agents can drive a running game through one connection with replies, timed in frames.
host/control.c/host/control.h: the bridge. Off unlessHOS_CONTROL=<socket path>is set.host/nn_hid.c(the bridge's presses, in the controller's read),host/hos_main.c(start),host/hos_thread.c(log lines for subscribers),gpu/vk/vk_replay.cpp(shots at a present, with their own image and readback, never waited for in the replay),gpu/vk/vk_menu.cpp(the Insert menu's settings by index),host/rx_backend.c(the wrappers).gpu/vk/vk_api.h: four functions appended toMkwVkRenderer(frame_info,shot,setting,setting_set), andMKWVK_API_VERSION4 -> 5.scripts/hookshot_ctl.py: the client, standard library only (python -I), with alaunchhelper.docs/control.md: the protocol, the security model and CLI examples, linked fromdocs/development.md.Protocol
Newline-delimited JSON: one request object a line (at most 64 KB), one reply a line that echoes the request's
idand carries the currentframe. Requests may be answered out of order; lines with an"event"key are subscription events.statuspress/stick/sequencesequencetakes steps orHOS_INPUT_SCRIPT's syntax;exclusivekeeps the player's own devices out; the reply when the input ends, with its framesreleaseshotwaitpresent,frame,in_play,state,loading_start,loading_end,change, with a timeoutoption_get/option_setsubscribe/unsubscribequitErrors are replies too and the connection goes on after them.
CLI examples
Start a game with the bridge on, then drive it from a shell.
launchmakes a run folder (socket, log, screenshots) and prints the socket's path; the other commands find it through$HOS_CONTROL.Input is timed in frames.
--exclusivekeeps the player's own controller out while it runs:Waiting on the game instead of sleeping, and the Insert menu's settings:
Watching, raw requests, JSON output and quitting:
Saves: start from a copy of one slot of a save folder (Linux; the source is only read), then look at what the game has:
Guest memory, when the game was started with
HOS_CONTROL_MEM=ro(reads) orrw(reads and writes), e.g.launch ... --env HOS_CONTROL_MEM=ro:A note on first starts: while shaders are prepared the game reads no controller, so presses sent then time out (exit 3). Wait for the first frame first, with a long timeout, since preparing shaders on a fresh build can take minutes:
python -I scripts/hookshot_ctl.py wait frame --frame 1 --timeout 900.Saves: start from a known save, and see what the game has on disk:
Exit status: 0 done, 1 an error reply, 2 no game or bad usage, 3 a timeout, so a script can
&&the steps. A whole test then reads like this:Security model
HOS_CONTROLis set; the log says once when it is on.SO_PEERCRED). A non-socket file in the way is left alone; a stale socket is removed.mem_*andsymbolsreplyunavailable). Opt-inro/rwmemory access is a follow-up commit on this branch.Relation to #764
#764 adds an MCP server with its own game-side seams (
HOS_INPUT_SCRIPT_ONLY, analogLX=tokens,!quit,RX_VK_SHOT_FILE). This PR does not depend on it, and its hooks sit apart from that PR's hunks. Some of its ideas appear here in the bridge's own form (axis tokens, an exclusive mode, quit, a shot of the next present to a path). An MCP server could become a thin client of this bridge: one socket in place of a polled file, a log scraper and a shot-request file.Tested (Linux)
Live saves check against the real game:
launch --savefrom a copy of a save with--slot 02; the game went from the title straight into play with slot 02, thensaves,slots,slots --thumbs(a 40,843-byte JPEG) andquit. The source folder was byte-identical afterwards.Live end-to-end against the real game (headless, a throwaway save,
HOS_CONTROL_MEM=rw): launch; status; A presses until play (reached at frame 760); press, stick and sequence (exclusive) with frame-exact replies; shots before and after; release, wait,--json, raw; watch (presents, and log lines with--log); option get, option set and get offps_counter; a mem read of the image's first bytes (the same twice); a read at0x0refused asout_of_range; a heap write that read back exactly and was then restored; quit, with the socket removed. Every write and setting change was logged.tests/test_control.pybuildshost/control.cwith a fake game (HOS_CONTROL_NO_GAME) and drives it over a real Unix socket: every command; malformed JSON, unknown commands, bad arguments, an oversized line then a valid one; a client that disconnects mid-press (the press is released), a half request then a disconnect, too many clients, a client that never reads (the game is not held); the socket's 0600 mode, a non-socket file in the way, quit. It is also clean under AddressSanitizer and UndefinedBehaviorSanitizer.tests/test_hookshot_ctl.py: the CLI against a fake bridge (each command's request and output,--json, the exit codes,watch,launch).tests/run_all.py: 107 passed, 0 failed, 6 skipped (the usual ones). Both new checks are registered and run on Linux and Windows.Windows:
host/control.c(both halves) and the touched host files (hos_mem.c,rx_backend.c,nn_hid.c,hos_thread.c,hos_main.c) compile clean with CI's llvm-mingw (20261104, clang 23) at-Wall -Wextra. On the windows-2022 job,tests/test_control.pyruns the same cases over a real named pipe and checks its access control: another first instance of the name and a second bridge on it are refused, and the pipe's DACL is protected with a single ACE for the current user.tests/test_hookshot_ctl.pyruns the CLI against a fake bridge on a named pipe.The game built and linked with the bridge (
host/control.ccompiles clean under gcc and clang).Not tested
launchon Windows (it needs a realhookshot.exe). The named pipe itself is tested in the windows-2022 CI job.captionfields ofsaves/slots(scene name, map path, thumbnail) were verified only on Kokiri Forest saves. They appear only whencaption.savmatches the layout. The player's name, hearts and play time aren't decoded (planned).mem: planned, not in this PR.