feat(control): stream OoT state through the control bridge #2292
Open
mooki000 wants to merge 5 commits from
mooki000/play-harness into main
pull from: mooki000/play-harness
merge into: MagicBeanDev:main
MagicBeanDev:main
MagicBeanDev:github/pr/2893
MagicBeanDev:github/pr/2890
MagicBeanDev:github/pr/2887
MagicBeanDev:github/pr/2878
MagicBeanDev:github/pr/2875
MagicBeanDev:github/pr/2874
MagicBeanDev:github/pr/2873
MagicBeanDev:github/pr/2871
MagicBeanDev:github/pr/2756
MagicBeanDev:github/pr/2897
MagicBeanDev:github/pr/2896
MagicBeanDev:github/pr/2718
MagicBeanDev:github/pr/2842
MagicBeanDev:github/pr/2691
MagicBeanDev:github/pr/2895
MagicBeanDev:github/pr/2743
MagicBeanDev:github/pr/2765
MagicBeanDev:github/pr/2708
MagicBeanDev:github/pr/2732
MagicBeanDev:github/pr/2755
MagicBeanDev:github/pr/2817
MagicBeanDev:github/pr/2876
MagicBeanDev:release-please--branches--main
MagicBeanDev:release-please--branches--main--release-notes
MagicBeanDev:github/pr/2778
MagicBeanDev:github/pr/2888
MagicBeanDev:github/pr/2866
MagicBeanDev:github/pr/2877
MagicBeanDev:github/pr/2800
MagicBeanDev:github/pr/2829
MagicBeanDev:github/pr/2850
MagicBeanDev:github/pr/2889
MagicBeanDev:github/pr/2882
MagicBeanDev:github/pr/2860
MagicBeanDev:github/pr/2849
MagicBeanDev:github/pr/2886
MagicBeanDev:github/pr/2794
MagicBeanDev:github/pr/2840
MagicBeanDev:github/pr/2830
MagicBeanDev:github/pr/2834
MagicBeanDev:github/pr/2719
MagicBeanDev:github/pr/2742
MagicBeanDev:github/pr/2741
MagicBeanDev:github/pr/2820
MagicBeanDev:github/pr/2771
MagicBeanDev:github/pr/2859
MagicBeanDev:github/pr/2858
MagicBeanDev:github/pr/2823
MagicBeanDev:github/pr/2757
MagicBeanDev:github/pr/2769
MagicBeanDev:github/pr/2844
MagicBeanDev:github/pr/2843
MagicBeanDev:github/pr/2673
MagicBeanDev:github/pr/2855
MagicBeanDev:github/pr/2739
MagicBeanDev:github/pr/2807
MagicBeanDev:github/pr/2851
MagicBeanDev:github/pr/2789
MagicBeanDev:github/pr/2814
MagicBeanDev:github/pr/2818
MagicBeanDev:github/pr/2774
MagicBeanDev:github/pr/2772
MagicBeanDev:github/pr/2750
MagicBeanDev:github/pr/2786
MagicBeanDev:github/pr/2785
MagicBeanDev:github/pr/2782
MagicBeanDev:github/pr/2779
MagicBeanDev:github/pr/2775
MagicBeanDev:github/pr/2706
MagicBeanDev:github/pr/2766
MagicBeanDev:github/pr/2747
MagicBeanDev:github/pr/2759
MagicBeanDev:github/pr/2745
MagicBeanDev:github/pr/2740
MagicBeanDev:github/pr/2733
MagicBeanDev:github/pr/2725
MagicBeanDev:github/pr/2727
MagicBeanDev:github/pr/2728
MagicBeanDev:github/pr/2729
MagicBeanDev:github/pr/2721
MagicBeanDev:github/pr/2722
MagicBeanDev:github/pr/2715
MagicBeanDev:github/pr/2714
MagicBeanDev:github/pr/2707
MagicBeanDev:github/pr/2703
MagicBeanDev:github/pr/2705
MagicBeanDev:github/pr/2697
MagicBeanDev:github/pr/2679
MagicBeanDev:github/pr/2676
MagicBeanDev:github/pr/2669
MagicBeanDev:docs/what-hookshot-connects-to
MagicBeanDev:fix/hdr-encode-test
MagicBeanDev:fix/main-tests
MagicBeanDev:fix/play-cache-files
MagicBeanDev:perf/game-barrier-order
MagicBeanDev:feat/heap-lazy
MagicBeanDev:feat/texture-detail
MagicBeanDev:perf/astc-opaque-bc1
MagicBeanDev:fix/fs-dir-lock
MagicBeanDev:fix/framegen-native-kept
MagicBeanDev:fix/update-no-downgrade
MagicBeanDev:port/k10-gcn-f16-mirrors
MagicBeanDev:fix/framegen-mult-cap
MagicBeanDev:chore/release-as-2.1.1
MagicBeanDev:perf/pcache-load-switch
MagicBeanDev:fix/prepare-progress-log
MagicBeanDev:fix/update-closes-stale-game
MagicBeanDev:fix/hdr-scaled-sampled
MagicBeanDev:perf/first-start-preset
MagicBeanDev:perf/zero-upload
MagicBeanDev:fix/vcore-escape-2s
MagicBeanDev:test/fake-gh-atomic
MagicBeanDev:perf/batch-idle-flush
MagicBeanDev:test/finish-stub-a
MagicBeanDev:chore/record-2.1.0
MagicBeanDev:fix/main-red-tests
MagicBeanDev:fix/test-display-startup-driver-cache
MagicBeanDev:feat/mesh-shader-mode
MagicBeanDev:fix/menu-probes-at-startup
MagicBeanDev:fix/fps-counter-classic
MagicBeanDev:fix/test-log-batch-flake
MagicBeanDev:fix/narrow-screen-hud
MagicBeanDev:ci/prereleases
MagicBeanDev:release/v2.0.15
MagicBeanDev:hotfix/v2.0.15
MagicBeanDev:fix/present-ahead-replay
MagicBeanDev:release/v2.0.13
MagicBeanDev:fix/bracket-paths-2013
MagicBeanDev:fix/hdr-overlays-2013
MagicBeanDev:fix/fps-counter-scale-2013
MagicBeanDev:fix/stick-seen-at-rest
MagicBeanDev:hotfix/menu-fits-screen
MagicBeanDev:fix/f1-always-opens-menu
MagicBeanDev:fix/ini-keep-lines
MagicBeanDev:claude/cranky-pike-7cf247
MagicBeanDev:perf/texture-answer-flat
MagicBeanDev:fix/stamp-test-modules
MagicBeanDev:fix/appimage-fonts
MagicBeanDev:fix/display-awake
MagicBeanDev:release/v2.0.12
MagicBeanDev:fix/discord-windows
MagicBeanDev:rc/v2.0.12-tests
MagicBeanDev:fix/dlss-keep-feature
MagicBeanDev:rc/v2.0.12
MagicBeanDev:button-icons
No reviewers
Labels
Clear labels
accessibility
Barrier affecting people with disabilities
autorelease: pending
autorelease: tagged
bug
Something isn't working
dependencies
Pull requests that update a dependency file
documentation
Improvements or additions to documentation
duplicate
This issue or pull request already exists
enhancement
New feature or request
github_actions
Pull requests that update GitHub Actions code
good first issue
Good for newcomers
help wanted
Extra attention is needed
invalid
This doesn't seem right
question
Further information is requested
testing wanted
wontfix
This will not be worked on
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
Milestone
Clear milestone
No items
No milestone
Assignees
Clear assignees
No assignees
2 participants
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2292
Loading…
Reference in a new issue
No description provided.
Delete branch "mooki000/play-harness"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What this is for: a script steering Link needs his position, camera direction and current game state before its next input. This moves the proposed state-file stream into the control bridge already merged in #926, so tools use one protocol rather than a second output channel. Work is tracked here; #2335 was closed to keep it in this pull request. What is measured: on Arch Linux/RTX 4090, gcc and clang pass the socket sampler and bridge checks; each rejects 38 compiled behavioral regressions. A copied-save comparison found nine standing-state fields identical over file and socket, and a watched Bool transition also appeared in
changed. Not measured: Windows, AMD/Intel GPUs, rendering parity, frame-rate cost, long play sessions or real controllers.Problem
host/control.calready offersstatusand subscriptions, anddocs/control.mddescribes it as the connection for tools.What changed
host/oot_state_tap.chost/control.h,host/control.cHosControlGamecallbacks copy the latest snapshot, replace/cancel a watch and consume a pending sample.status.gamecarries the core fields; the explicitly requestedootevent carries samples with optional flags/changes. Disconnect, replacement and unsubscribe clear the old watch.host/hos_main.cscripts/hookshot_ctl.pywatch --events oot --every N --flags NAME,0xHASH --changesexposes the stream.docs/control.md,docs/development.mddocs/tested-configurations.mdThe new core fields are
t, sampleframe,map,pos,facing,camera,life,max_life,rupees,promptandmusic, alongside the bridge'sevent,loadingandage. Unknown/unreadable values arenull. Position is in metres, yaw in degrees, life in quarter hearts. The top-levelmapremains the last known map;game.mapis the sampled value.flagsaccepts up to 64 names or explicit hashes. Enum values retain the#<hash>representation.changes:truescans Bool/Int/Enum/UInt values: the first sample establishes a baseline, at most 32 changes are returned, andmorecounts the remainder. Both lookup and scanning reject tables above 2^22 slots; d847c0cf's cap is retained. A pending sample is not overwritten before the bridge takes it.Protocol 1 is retained for these additive fields and the opt-in event. Existing draw-count
stateevents and the defaultpresent,statesubscription are unchanged. Sampling is off withHOS_CONTROLunset: one early return, no guest reads or allocation. No arbitrary-memory opt-in is needed for these named, read-only values.How to try it
Start the game with
HOS_CONTROLas described indocs/control.md, then:Equivalent subscription:
Events have
event:"oot", the subscription'ssub, and a nestedgamesnapshot. Usegame.frame, not the envelope's delivery frame, to date it. Samples stop wherever Link is not updated, including the title and pauses;statusretains the last snapshot. Without a fresh sample, do not assume Link is still in control. The default cadence stays 60 presents; a steering client explicitly requests 6 here.How it was tested
Environment: 2026-11-11 UTC; Arch Linux, kernel 7.2.15-1-omarchy; Core i9-13900K; RTX 4090, NVIDIA 615.78.08; gcc 16.2.1 and clang 23.1.3. Source base
a5b955fb. File binaryd847c0cf; socket binarya57f35fe. Later commitsd06e4956and045fc13dchange only the test fixture and evidence documentation, not runtime code. Both binaries used the same matching lift and cached hot objects through the build gate.Game-free
test_oot_state_tap.pywith gcc and clang, warnings as errors, through the no-core wrapper:Coverage retains the old values, typed name/hash lookup and collision, MurmurHash3 reference and main constants, both table limits, escaped strings, nulls and hook-lock wiring. It adds independent subscriptions, 6/12/default-60 cadence, pending samples, replacement/unsubscribe/disconnect, moved/resized tables, 32 changes plus overflow, and the largest escaped watch payload. The resize case changes a key that stays in its slot, so rehashing alone cannot make a broken reset pass.
38 compiled behavioral mutations fail with each compiler: missing fields, cadence, independent baselines, null/range and finite-value guards, both caps, hashing/collisions, change accounting, lifecycle, retained samples, buffer size and argument validation. Two additional source negatives per compiler check missing hook wiring and the old checkout: 80 expected negative failures total, not 80 compiled cases.
test_control.py: 29 PASS groups with gcc and 29 with clang, including the existing transport/security/input checks.test_hookshot_ctl.py: 14 tests, OK. The same tests against the old client fail in the new status/watch checks (2 failures, 1 error).Whole suite through
hs-idle.sh, using the dependency Python:The failure is
tests/test_bake_mimo.py:223:AssertionError: the unreadable .zs was not reported. That test andnx2/bake_mimo.pyare byte-identical toa5b955fb; this PR does not change either. The base was not rerun, so this is an unchanged-source failure, not a separately reproduced baseline result. The sampler, bridge and client tests pass in the suite. The no-core check reported no coredumps. Rerun on its own afterwards,test_bake_mimo.pypasses: twice on this head (theg++and theclang++run of the four checks) and once on the base's tree, so it failed once in the 32-at-once suite run under load and not since.In the game
Each game loaded a fresh copy of the same harness save. No directional input was sent. The completed pair used one ordinary 197.8 s GPU hold, headless, 1920x1080 output with 1280x720 captures and
RX_VK_PRECOMPILE=0. The machine was shared; the wall times below are run lengths, not performance measurements.d847c0cf[hos host], so it stopped before starting the socket game. Kept, not counted as a comparison.d847c0cfa57f35fechanged.The comparison script asserted exact equality of map, position, facing, camera, life, maximum life, rupees, prompt and music, before the flag-changing debug action in the throwaway run. It then checked the watched name and its independently computed hash in the socket event stream:
All three games exited 0. Each had 0 fatal, crash, device-lost, kernel-driver-fault and NVRM lines.
Visual limit: I inspected the captures. The file run shows Link at the saved position. The socket run's last capture shows the hearts and controls on black, not Link; its log still reports shader jobs queued and draws left out as not ready. Shader caches were not matched or warmed for an image comparison. The run proves the state values and flag reporting, not rendering parity or the cause of the black picture.
Not tested
a5b955fb.mainhas moved on since (ef81aee7when this was pushed); the branch merges with it without a conflict, and nothing was rerun on the merge.Risks
changes:truescans four tables at the requested cadence. The 32-entry output cap does not make that scan free; it stays opt-in. Intermediate values between samples are not a history of every write.status.gamethrough a pause/load is not proof of current control. Clients must use fresh sample frames and known-false event/loading values before steering.Overlaps
The overlap scan found 43 open PRs touching these files; the actual conflicts were read:
docs/tested-configurations.md. Keep both its historical compiler-worker row and these run rows; no runtime hunk conflicts.docs/control.mdanddocs/development.md. Its replacement control documentation isdocs/dev/automation.md. Move the new protocol text, development reference and their source-reading test checks with that reorganization; do not drop their coverage. The host/client changes merge automatically.The local pre-push checks pass (commit identity, trailers, no game file, a test changed, the title). The range-diff against
d847c0cfkeeps its two existing commits and adds the bridge migration, strengthened fixture and run evidence.Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra [email protected]
Rebased onto main
b29967bfso the workflows run here: they had waited for an approval on the old head, and main's game-free tests are green again since #2380. The patch is unchanged (git range-diff: the commit is equal), and its test passes on the new base.Nice, self-contained addition. Some specifics I checked:
host/oot_state_tap.c).nx2/build.pycompiles everyhost/*.c, so nothing else needs wiring.hos_oot_lift_playerruns under the existingcheats_lock.HOS_OOT_STATE_FILEis unset, the cost is onegetenvon the first call and then an early return.hos_guest_range, and nothing in the game is written.0x84ca6bc0→ manager +32/+40 → camera +120) matchesoot_gameplay_viewinhost/hos_main.c.docs/development.md.fopenon a UTF-8 path is fine on Windows because of the exe's UTF-8 manifest;tests/test_utf8_paths.pypasses.tests/test_oot_state_tap.pypasses here (gcc,-Werror). The red Windows check istests/test_native_astc_texture_count.py, which main fixed in #2397. Theappimagefailure was Launchpad timing out. Neither comes from this PR.Minor points, none of them blocking:
host/oot_state_tap.cstate_entry: the probe loop runs up tosizeiterations, wheresizecomes from guest memory.state_put_changescaps it at1 << 22, but this function doesn't. A torn or garbage read while a save loads could make one line walk billions of slots on a guest worker. Using the same cap here (if (size > (1u << 22)) return 0;) would keep a bad read from stalling the game.status(now on main) and #2048's!cheat 998already report map, event, loading and age. You offered to move the fields intostatus. A maintainer should decide whether to keep two channels. I'm fine with this file as it is, because a file stream suitsHOS_INPUT_LIVEscripts.InterlockedCompareExchange64, whichhost/posixprovides.Hi, can you check if this is integrated with the control bridge?
The code is clean (opt-in, read-only, every guest read checked, and the test passes).
To answer the earlier question: it isn't integrated with the control bridge.
host/control.calready hasstatus(event, loading, age) andsubscribe(map, state), anddocs/control.mddescribes the bridge as the one connection for tools. Since you offered, moving these fields intostatus/subscribeseems better than a second channel.One fix either way:
state_entry(host/oot_state_tap.c:107) walks up tosizeslots read from guest memory with no upper limit. The sibling function caps it at1u<<22, and this one should too.I read the code and the discussion. I agree with Joey-Magnus's review: the tap is opt-in, read-only, every guest read is range-checked, and the test is self-contained and declared (
RUN_ALL_ARGS). I have nothing to add on correctness.Two things are still open:
host/control.calready hasstatusandsubscribe, anddocs/control.mdcalls the bridge the one connection for tools. @mooki000 offered in the description to move the fields there, but hasn't answered since. Whether to keep a second channel (a file stream, which does suitHOS_INPUT_LIVEscripts) or fold the fields intostatus/subscribeis a call for @NotRexed. I'm not approving over that question.state_entryprobe cap. The loop inhost/oot_state_tap.cstill runs up to asizeread from guest memory with no upper bound.state_put_changescaps it at1 << 22; this one should too (Joey-Magnus's point, still unfixed at this head). Small change, no new test needed beyond the existing one.CI: the Windows source-check failure is
test_native_astc_texture_count.py, which main fixed in #2397 (this branch is based just before it), andappimageis a Launchpad timeout. Neither touches this PR. Once the cap is in and the channel question is settled, this is fine to merge.@hckrman101 @Joey-Magnus thanks both.
d847c0cf):state_entryreturns no entry for a table that reports more than1 << 22slots, the same limit asstate_put_changes. The test's fixture sets such a size on a table whose first slot matches the flag asked for and expects no entry; it fails without the line, with gcc and clang. Rebased ontoa5b955fb, so the Windows job has #2397's fix.status/subscribeand drop the file switches, so the bridge stays the one connection for tools (unless @NotRexed wants the file kept). I'll push that here once a run in the game shows the same values over the socket as the file gives.Re-review at
d847c0cf.Fixed: the
state_entryprobe cap (Joey-Magnus's point). It now returns no entry for a table that reports more than1 << 22slots, the same limit asstate_put_changes. I read the new check and the added test: the fixture accepts exactly1 << 22and rejects(1 << 22) + 1even when the first slot matches, so it covers the case. Nothing else changed in the code; the rebase onto current main also picks up #2397's Windows fix.Still open: the one-channel question. mooki000 confirmed the tap is not integrated with the control bridge and offered to move the fields into
status/subscribeand drop the file switches, unless @NotRexed wants the file stream kept (it does suitHOS_INPUT_LIVEscripts). That is @NotRexed's call, so I'm not approving over it. If the fields move, this will need another look, since the file, its docs and the test would change a lot.CI: at the time of writing most jobs are still running on the new head (builder package, appimage and actionlint are green). I found no problems in the code itself.
@hckrman101 @Joey-Magnus the one-channel question, answered in code:
I moved the fields into the bridge in
a57f35fe:status.gameis the snapshot;subscribewithevents:["oot"],every,flagsandchangesstreams it. The four file switches and file writing are gone. Reads stay in Link's locked update; the socket thread receives copies. The lookup cap fromd847c0cfremains, andd06e4956strengthens the resize-baseline check.On Linux/RTX 4090, the same copied save gave identical map, position, facing, camera, life/max life, rupees, prompt and music over file versus socket. A watched Bool changed 0→1 and the same hash appeared in
changed. Both games exited cleanly; no driver faults. gcc/clang checks pass, with 38 behavioral regressions rejected by each.Windows and AMD are not tested. Nor is rendering parity: the socket capture showed HUD on black with shaders still queued. The full suite was 437 passed, 1 failed, 20 skipped; the failure was
test_bake_mimo.py's unreadable-.zsassertion, a test and a script this change does not touch, and rerun on its own it passes here and on the base's tree. Details and all three runs are in the updated description.@NotRexed if you would rather keep the file stream for
HOS_INPUT_LIVEscripts, say so: the first two commits are still the file version as reviewed (d847c0cf), and the move is the three commits above them, so it can come out again or the file can stay beside the bridge.Rebased the actual pushed head
045fc13donto current main87991bc8as723758ff. The only conflict was additive test-evidence rows; both contributors' records remain verbatim, with no runtime change, dropped commit or altered author/message/trailer. All 27 selected checks passed with GCC and Clang on the first rebase; the sampler, bridge and client controls failed against main's missing feature. Its suite was 470 passed / 1 inherited background-input fixture warning / 20 skipped. The final rebase adds only upstream #2671 and changes no PR file blob; the three feature checks, final-main negatives, #2671's offline check and Windows-target syntax all ran again. The final PR gate passes. The suite was not repeated, and no new gameplay, native Windows or performance evidence is claimed. Sources: the #2292 evidence and final-handoff ledger above.Both open points from the earlier round are settled. The
state_entrycap is in, and the fields now go throughstatus.gameand anootsubscription instead of a second file channel, as hckrman101 and I asked. Guest reads still happen only in Link's update undercheats_lock. Only copied JSON crossesstate_lock. Watch names are restricted to[A-Za-z0-9_.]or0xplus 8 hex digits before they go into JSON. Every path that drops a connection clears its watch. I merged it into current main:test_oot_state_tap,test_control,test_hookshot_ctl,test_utf8_pathsandtest_env_onpass.Minor points:
host/oot_state_tap.c:351-371: a subscription withchanges:truescans up to four GameData tables while holdingstate_lockon the game's worker, so astatuscall waits for that scan. Fine for a debug channel.status.game.event,loadingandagecan now benull. Before, they were always booleans or a string.docs/control.mdsays so, but scripts that test== falsewill notice.G.game->oot_state(&event, &loading, &child), which this PR replaces. Whichever lands second needs to adapt. #2561 also movesdocs/control.md.Rebased
45310b24onto86a9cda2; new headf2970916.The merged age command keeps main’s typed
oot_state; our JSON copy is nowoot_sample. No wire-protocol change.test_control.py,test_hookshot_ctl.pyandtest_oot_state_tap.pypass with GCC and Clang (6 invocations).Whole source check: 497 passed, 3 failed, 20 skipped. Each failure also occurs on untouched pinned main; this is not an all-green result.
Failures: the one-variant cache fixture’s missing ZSTD declarations, the missing Skip opening README row, and this isolated environment’s .NET reference pack. The first two are fixed on main since; this head merges cleanly with it.
Built this head from source; one windowless Linux/RTX 4090 run used a copied save.
statusshowed child then adult;age adult --arrive 60succeeded. The same watch delivered 2 checked samples before and 2 after the reload (49 events received overall).The run exited normally with no fatal, crash, device-loss or kernel-fault line. No performance claim.
A post-switch screenshot was captured and inspected; it is not a rendering-parity comparison.
The earlier approval predates this rebase. A full run of the project's checks in my fork, Windows included, ran on this head: the control and client tests pass on both systems, and the only failing tests were the ones failing on main at that time (since fixed there by #2861 and #2883). It also found one real Windows problem, fixed in the last commit: the client now waits for a busy pipe that
open()reports as EINVAL.Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra [email protected]