Skip to content

Control bridge: drive a running game over a local socket, with a CLI - #926

Closed
hckrman101 wants to merge 6 commits into
NotRexed:mainfrom
hckrman101:control-bridge
Closed

hckrman101 wants to merge 6 commits into
NotRexed:mainfrom
hckrman101:control-bridge

Conversation

@hckrman101

@hckrman101 hckrman101 commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

The bridge, CLI, docs and tests are complete: guest memory access (ro/rw), save and slot listing with launch --save/--slot, the Windows named pipe (compiled with llvm-mingw and tested over a real pipe in the windows-2022 CI job), and live end-to-end runs against the real game on Linux (below).

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 scraping hookshot_log.txt (the vk: N presents, F fps; a frame: D draws line: ~2 draws is the intro, 15-70 menus, ~5 loading, hundreds in play). Screenshots come from RX_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 unless HOS_CONTROL=<socket path> is set.
  • Small hooks: 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 to MkwVkRenderer (frame_info, shot, setting, setting_set), and MKWVK_API_VERSION 4 -> 5.
  • scripts/hookshot_ctl.py: the client, standard library only (python -I), with a launch helper.
  • docs/control.md: the protocol, the security model and CLI examples, linked from docs/development.md.

Protocol

Newline-delimited JSON: one request object a line (at most 64 KB), one reply a line that echoes the request's id and carries the current frame. Requests may be answered out of order; lines with an "event" key are subscription events.

command what
status frame, fps, draws a frame, state (boot/intro/menu/loading/play, from the draw counts with 300 ms hysteresis), window size, version
press / stick / sequence buttons and sticks for N frames, from the first controller read after the request; sequence takes steps or HOS_INPUT_SCRIPT's syntax; exclusive keeps the player's own devices out; the reply when the input ends, with its frames
release every queued input let go
shot a 1280x720 PNG of the first present after the request (or a given frame), written to a path (default a run folder by the socket); not inline
wait present, frame, in_play, state, loading_start, loading_end, change, with a timeout
option_get / option_set the Insert menu's settings by ini key or row name
subscribe / unsubscribe presents (throttled), state changes, log lines matching a filter
quit reply, then a clean exit; the socket is removed

Errors 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. launch makes a run folder (socket, log, screenshots) and prints the socket's path; the other commands find it through $HOS_CONTROL.

export HOS_CONTROL=$(python -I scripts/hookshot_ctl.py launch ~/Games/Hookshot --data /tmp/oot-test --headless)

# The title and file select need A presses; press A until the game is in play
until python -I scripts/hookshot_ctl.py wait in_play --timeout 3 >/dev/null; do
  python -I scripts/hookshot_ctl.py press A --frames 8
done
python -I scripts/hookshot_ctl.py status
#   frame 5321  fps 59.8  state play  draws 2410  window 1280x720  <version>

python -I scripts/hookshot_ctl.py shot before.png              # the next present, as a PNG; prints its path

Input is timed in frames. --exclusive keeps the player's own controller out while it runs:

python -I scripts/hookshot_ctl.py press A --frames 8           # returns once A is let go
python -I scripts/hookshot_ctl.py press ZL+A
python -I scripts/hookshot_ctl.py stick L 0 1 --frames 120 --exclusive   # walk forward for 2 s
python -I scripts/hookshot_ctl.py sequence '0:A/8,60:A/8'      # A now, A again a second later
python -I scripts/hookshot_ctl.py sequence '0:LUP/2000,2:A' --hos   # HOS_INPUT_SCRIPT's seconds and ms
python -I scripts/hookshot_ctl.py release                      # let go of anything still queued

Waiting on the game instead of sleeping, and the Insert menu's settings:

python -I scripts/hookshot_ctl.py wait present --frames 30     # 30 more presents
python -I scripts/hookshot_ctl.py wait loading_end --timeout 60
python -I scripts/hookshot_ctl.py wait state --state menu

python -I scripts/hookshot_ctl.py option get                   # every setting, with its range and text
python -I scripts/hookshot_ctl.py option get text_speed
python -I scripts/hookshot_ctl.py option set "FPS counter" true

Watching, raw requests, JSON output and quitting:

python -I scripts/hookshot_ctl.py watch --events present,state --every 60 --count 10
python -I scripts/hookshot_ctl.py watch --log "vk:" --count 5  # log lines that contain the text
python -I scripts/hookshot_ctl.py --json status | jq .fps
python -I scripts/hookshot_ctl.py raw '{"cmd": "status"}'
python -I scripts/hookshot_ctl.py quit

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:

export HOS_CONTROL=$(python -I scripts/hookshot_ctl.py launch ~/Games/Hookshot --headless \
    --save ~/oot-saves/5a3c9e1f0b7d24680000000000000001 --slot 03)   # slot_03 alone: Continue loads it
python -I scripts/hookshot_ctl.py saves                        # the save folder, its files, newest slot first
python -I scripts/hookshot_ctl.py slots                        # one line a slot; * the newest (Continue's)
python -I scripts/hookshot_ctl.py slots --thumbs thumbs/       # each slot's file-select picture, slot_NN.jpg

Guest memory, when the game was started with HOS_CONTROL_MEM=ro (reads) or rw (reads and writes), e.g. launch ... --env HOS_CONTROL_MEM=ro:

python -I scripts/hookshot_ctl.py mem read 0x80000000 8                 # the game image's first bytes, as hex
python -I scripts/hookshot_ctl.py mem read 0x1000000000 16 --format u32 # four little-endian words
python -I scripts/hookshot_ctl.py mem read 0x0 4                        # exit 1: out_of_range
python -I scripts/hookshot_ctl.py mem write 0x1008000000 2a000000       # rw only; logged in hookshot_log.txt

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:

# copy a save folder into the run's data (the source is only read); --slot keeps one slot, so Continue loads it
export HOS_CONTROL=$(python -I scripts/hookshot_ctl.py launch ~/Games/Hookshot --save ~/my-saves/kokiri --slot 03 --headless)

python -I scripts/hookshot_ctl.py saves                         # the save folder the game uses, account.sav, option.sav, each slot
python -I scripts/hookshot_ctl.py slots                         # one line per slot, newest first, * = what Continue loads
python -I scripts/hookshot_ctl.py slots --thumbs /tmp/thumbs    # also writes each slot's file-select picture, slot_NN.jpg
python -I scripts/hookshot_ctl.py --json slots | jq '.[0]'

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:

hs() { python -I scripts/hookshot_ctl.py "$@"; }
export HOS_CONTROL=$(hs launch ~/Games/Hookshot --data /tmp/oot-test --headless)
until hs wait in_play --timeout 3 >/dev/null; do hs press A; done &&
hs stick L 0 1 --frames 90 --exclusive &&
hs wait present --frames 10 &&
hs shot after-walk.png &&
hs quit

Security model

  • Opt-in: no socket unless HOS_CONTROL is set; the log says once when it is on.
  • Local only: a Unix domain socket (Linux, created 0600, the umask set around bind) or a named pipe (Windows, a DACL for the current user alone, remote clients refused); never TCP. A client of another user is turned away (SO_PEERCRED). A non-socket file in the way is left alone; a stale socket is removed.
  • The game never waits for a client: a dedicated thread does the I/O; the controller's read takes one short lock never held over I/O; a silent client is dropped once 8 MB waits for it; a client that leaves mid-command has its presses let go.
  • In this base commit, guest memory is not served (mem_* and symbols reply unavailable). Opt-in ro/rw memory 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, analog LX= 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 --save from a copy of a save with --slot 02; the game went from the title straight into play with slot 02, then saves, slots, slots --thumbs (a 40,843-byte JPEG) and quit. 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 of fps_counter; a mem read of the image's first bytes (the same twice); a read at 0x0 refused as out_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.py builds host/control.c with 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.py runs 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.py runs the CLI against a fake bridge on a named pipe.

  • The game built and linked with the bridge (host/control.c compiles clean under gcc and clang).

Not tested

  • The bridge inside a real Windows game build, and launch on Windows (it needs a real hookshot.exe). The named pipe itself is tested in the windows-2022 CI job.
  • Refusing remote clients on Windows (it needs a second machine).
  • The optional caption fields of saves/slots (scene name, map path, thumbnail) were verified only on Kokiri Forest saves. They appear only when caption.sav matches the layout. The player's name, hearts and play time aren't decoded (planned).
  • The state is a heuristic from draw counts; scenes with unusual counts could be misread.
  • Named game values (scene, Link's position, …) for mem: planned, not in this PR.

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.
@NotRexed

NotRexed commented Nov 9, 2026

Copy link
Copy Markdown
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.)

@NotRexed NotRexed closed this Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants