feat(coop): co-op rooms: a relay, progress sync, and a Co-op tab to make, join and accept #2274

Open
mooki000 wants to merge 6 commits from mooki000/coop-relay-wire into main
mooki000 commented 2026-11-11 13:08:53 +00:00 (Migrated from github.com)

What this is for: co-op (#2269): each friend plays their own game/save, sharing accepted progress through the game's setters. This draft adds bounded events and optional maker-PC hosting, not other players' Links, enemies, combat or warp. Measured: 2be28f893fa378a28de2a110caf491970be7b32e on de866017e1584372fc75a1caab3784b3ba1d9f98: game-free focused 60 pass / 2 fail, suite 467 pass / 15 fail / 20 skip, Windows-target syntax below. Every failure is reproduced on untouched base. Not measured: no game build/play, current Windows execution/link, LAN/cross-OS/controller or performance proof on this head. All game pictures below are historical.

Draft: 18 commits. Rebase recorded 2026-11-11T22:57:08-06:00; evidence assembled 2026-11-11T23:52:40-06:00. The socket decision remains open.

Problem

  • The game's own nn::socket stays offline; there was no host path for players' games to find a room or exchange progress. host/coop_net.c and host/coop.c keep that networking outside the guest game.
  • Games behind different routers need a reachable relay. scripts/coop_relay.py forwards sealed messages; host/coop_proto.c derives separate locator and message keys so the relay need not know the room secret or open progress.
  • Sharing raw save files would bypass player consent and the game's rules. host/coop_state.c merges accepted, signed progress; host/oot_coop.c checks the live flag table and calls the game's setters only after acceptance and a save copy.
  • Players need to do this without environment switches or hand-editing settings. Text fields and the Co-op tab in gpu/vk/vk_menu.cpp expose the same session and progress commands while the game runs.
    Additional defects: Windows name/printf/CRT conflicts; unstable fixture task counts; area resets mistaken for save loads; cancelled event fragments blocking later work or combining ACK epochs; localized UI helpers translating typed data.

What changed

Additions above the original six

Part / commit Adds
7 / 1c60356f Kuba's bind → bind_tables rename removes the Winsock declaration collision; IN → COOP_FLAG_IN also avoids the Windows annotation macro. Existing fixture callers/extraction markers migrate with their checks. Co-authored with Kuba.
8 / 68088064 Wait for a joined fixture helper's specific Linux TID to disappear before taking the exact task-count baseline. Five-second bound; no runtime behavior or thread/socket assertion removed.
9 / 184cceb0 Three bounded runtime packet-size traces and one codec diagnostic use %u plus unsigned, preserving format attributes and warning errors; no global ANSI-stdio override.
10 / 1cd02fc4 Declare secure-CRT guards before generated fixture headers, including the screensaver fixture; preserve runtime code and behavioral checks.
11 / 1f1d0a72 Use saved total play time rather than the per-area clock for progress save identity. Ordinary area-changing doors/loading zones keep acceptance and the existing backup; real save loads still require both anew.
12 / d2fc0f7f Raise configurable Python-relay member ceilings to 480 forwarded copies/s and 256 KiB/s, with double bursts; charge every broadcast copy to both budgets.
13 / 35931fa8 Add bounded session events, fragmentation, freshly sealed retries, whole callbacks and per-sender/recipient FIFO, separate from replaceable state datagrams.
14 / b8d0cbed Publish session-bound progress consent through coop_member_accepted(id); a roster entry or recycled member number is not acceptance.
15 / c517e746 Document message ownership, state/event calls, consent, payload and shared traffic budgets, receiver-side gameplay checks, and the layer integration example.
16 / 97f56483 Optional maker-PC relay (coop_host / HOS_COOP_HOST): its own socket/thread, local maker authorization, cookie proof, sealed forwarding, bounded state and joined shutdown; no router/firewall automation.
17 / dcf84430 Cache only the latest refused join answer. A changed reason is charged again, including when it changes back; preserve Python/C relay parity.
18 / 2be28f89 Retire cancelled incomplete events by recipient delivery floor; ACK assembly epochs prevent combining partial receipts and reject stale ACKs. Record revoke/reaccept transitions between socket steps. ACK body v2 requires peer updates.

Layer interface: coop_send_event(member, kind, bytes, n), coop_on_event(kind, callback, user), coop_member_accepted(id); contract/example in docs/coop.md#adding-a-layer. EVENT/ACK stay kinds16/17, but ACK body v2/27 bytes requires all peers to update. Eight queued events, 16 KiB each, 1024-byte fragments; one fragment/32 ms, retry250 ms, ACK50 ms. Whole FIFO callbacks are not gameplay authorization or durable delivery. Handle queue-full; validate signed rules, current session, authority/context/age/bounds and recheck bounded work on the game thread. Consent alone is not the completed-backup gate.

Rebase resolutions

Part5 met #1930's API bump: retain ocarina bindings, then text API20 and Co-op API21 (base API19). Part6 met #1864: retain game_lang exclusion and row_key(...), saved_value(...). #2435 keyboard prompts, #2574 DLSS failure hook, #2541 aspect migration, #2116 POSIX identity and Bug report/Restart/Quit remain. Part5 additionally keeps typed values/caret widths literal and migrates 13 custom-size translations; labels/placeholders stay localized. Replaying CRT guards conflicted with that new fixture: both retained, no old assertion removed. Other commits had no textual rebase conflicts.

Original six parts

Historical tested hashes versus current counterparts; per-prefix tests retain their tested identities:

Original part Published / historical tested commit Rebased counterpart
1 e27f1a92 ae4c5a3b
2 52c471bb 5ed79bfd
3 2f34da9a 67662ccb
4 841492c4 b8a6fabc
5 b4ec89b7 6b9ab189
6 71d4ab0f eb904f9c
# Commit What it adds Its tests (all run at that commit)
1 e27f1a92 Standard-library UDP relay and C codec (keyed BLAKE2b, XChaCha20-Poly1305, EdDSA), Monocypher4.0.2 and docs; no game access. test_coop_proto.py (gcc and clang), test_coop_relay.py, test_appimage_source_headers.py
2 52c471bb Game-free flag registers, per-player key/rupee/ammunition pools, acceptance by key and maker-signed rules. test_coop_sync.py (gcc and clang)
3 2f34da9a Opt-in UDP thread/socket: join/rejoin, roster, sealed messages, leave before counter reuse, graceful goodbye; no game-memory access. test_coop_session.py (gcc and clang), test_coop_env_file.py, test_coop_options_text.py, test_output_resolution.py, test_env_file.py, test_control.py (gcc and clang), test_display_startup_failure.py, test_env_on.py
4 841492c4 Runtime classification from game groups, no extracted flag list; SetBool/SetInt only raise values after acceptance/backup. Includes the Lens item-count fix. test_oot_coop.py (gcc and clang), test_save_copy.py (gcc and clang), test_env_file.py, test_present_trace.py, test_coop_env_file.py, test_env_on.py
5 b4ec89b7 Typed rows, caret/paste and window text queue; take_text/API20, first used by Custom output size. test_menu_text_field.py, test_menu_restart_slots.py, test_screensaver.py, test_menu_env_overrides.py, test_preset_exempt.py, test_menu_keys.py, test_menu_sections.py; vk_menu.cpp with -Wall -Wextra: 2 warnings, both main's
6 71d4ab0f — feat(menu): a Co-op tab to make, join, accept and leave a co-op room the Insert menu's last tab: Your name and Relay (text fields), Make a room, Join a room, the room's secret (masked), the members with their key fingerprints, Accept the room, Leave the room, the maker's Remove a player and Reset the pools, a Room rules page saved under the options file's coop_* keys (no numeric option ids), MKWVK_API_VERSION 21. 12 files, +1321 -42 test_menu_coop_tab.py, test_menu_text_field.py, test_env_on.py, test_menu_env_overrides.py, test_preset_exempt.py, test_control.py, test_menu_keys.py, test_menu_sections.py; vk_menu.cpp as at 5

How to try it

You need this branch built (docs/development.md), from your own copy of the game as always.

On two PCs:

  1. One of you runs the relay where both games can reach it by UDP (a forwarded port, or a small server): python -I scripts/coop_relay.py --port 24680 (--help lists its limits).
  2. Each of you starts the game and opens the Insert menu (Insert, or the menu shortcut you chose); the Co-op tab is the last one.
  3. Your name: Enter, type a name, Enter. Relay: Enter, type the relay's address (host or host:port; the port is 24680 if you leave it out), Enter. Both are saved in hookshot_options.ini.
  4. The first player chooses Make a room. Room secret: Enter shows it, Enter hides it. Give it to your friend privately: whoever has it can join.
  5. The second player chooses Join a room, types or pastes (Ctrl+V) the secret, Enter.
  6. Load your save. When the tab shows Accept the room (it shows once a save is in play), read the key fingerprints to each other, then press it twice. Your whole save folder is copied to coop-backup/ first; docs/coop.md says how to go back to it.
  7. Play. What one of you gets reaches the other's game through the game's own flags, so it comes without the game's "you got" message and pose. Shown so far between two games: items (given with the debug tools, and the Kokiri Sword from a real chest on an earlier head of this stack), a chest that one player opened showing open in the other's game, and a song marker.
  8. Leave the room (press twice), or just close the game: it says goodbye to the room.

On one PC, two folders: copy the built game folder twice, start the relay with --host 127.0.0.1, give each game its own user folder (./hookshot --user <folder> on Linux), use 127.0.0.1:24680 as the Relay in both, and follow steps 2 to 8.

Optional maker hosting: set coop_host=1 in hookshot_options.ini or real-environment HOS_COOP_HOST=1 before startup, then Make. Off by default; Join never hosts. Empty Relay uses UDP24680; 127.0.0.1:<port> chooses another; foreign hosts are refused. coop_port still means the client socket. Friends use the maker's reachable address and receive the secret privately. Internet needs UDP forwarding/firewall permission and may fail behind CGNAT. No UPnP/firewall changes or hosting UI row are added. Leaving/switching off/closing the maker ends its hosted room.

How it was tested

Current head — game-free

Arch/Linux 7.2.15-1-omarchy, i9-13900K; GCC 16.2.1, Clang 23.1.3, the project's pinned Python. Focused 2026-11-11T22:59:51-06:00 → 2026-11-11T23:08:53-06:00. 31 files in both compiler environments: 60 pass/2 fail; pure Python checks are not two native compilations. Loads are one-minute endpoints, not timing/performance claims.

Focused invocations: exit/expected and load
Check (tests/) GCC: exit; load Clang: exit; load
test_bug_report.py 0; 10.77 → 12.15 0; 40.08 → 40.16
test_coop_env_file.py 0; 12.15 → 12.15 0; 40.16 → 40.16
test_coop_events.py 0; 12.15 → 14.27 0; 40.16 → 40.89
test_coop_host_relay.py 0; 14.27 → 21.65 0; 40.89 → 16.82
test_coop_options_text.py 0; 21.65 → 21.65 0; 16.82 → 16.82
test_coop_proto.py 0; 21.65 → 21.84 0; 16.82 → 16.82
test_coop_relay.py 0; 21.84 → 21.84 0; 16.82 → 16.19
test_coop_session.py 0; 21.84 → 33.59 0; 16.19 → 9.18
test_coop_sync.py 0; 33.59 → 41.34 0; 9.18 → 8.55
test_env_file.py 0; 41.34 → 41.48 0; 8.55 → 8.55
test_menu_coop_tab.py 0; 41.48 → 41.48 0; 8.55 → 8.55
test_menu_env_on.py 0; 41.48 → 41.20 0; 8.55 → 8.55
test_menu_env_overrides.py 0; 41.20 → 41.20 0; 8.55 → 8.55
test_menu_hit.py 0; 41.20 → 41.20 0; 8.55 → 8.55
test_menu_hooks.py 0; 41.20 → 41.20 0; 8.55 → 8.55
test_menu_i18n.py 1; 41.20 → 41.20 1; 8.55 → 8.55
test_menu_keys.py 0; 41.20 → 41.20 0; 8.55 → 8.55
test_menu_mouse_scroll.py 0; 41.20 → 41.20 0; 8.55 → 8.35
test_menu_restart.py 0; 41.20 → 41.20 0; 8.35 → 8.35
test_menu_restart_slots.py 0; 41.20 → 41.20 0; 8.35 → 8.35
test_menu_sections.py 0; 41.20 → 41.20 0; 8.35 → 8.35
test_menu_setting_walk.py 0; 41.20 → 41.20 0; 8.35 → 8.35
test_menu_size.py 0; 41.20 → 41.20 0; 8.35 → 8.35
test_menu_text_field.py 0; 41.20 → 41.20 0; 8.35 → 8.35
test_menu_tune.py 0; 41.20 → 41.20 0; 8.35 → 8.35
test_menu_utf8.py 0; 41.20 → 40.46 0; 8.35 → 8.35
test_oot_coop.py 0; 40.46 → 39.55 0; 8.35 → 8.24
test_present_trace.py 0; 39.55 → 39.55 0; 8.24 → 8.24
test_save_copy.py 0; 39.55 → 40.08 0; 8.24 → 7.43
test_screensaver.py 0; 40.08 → 40.08 0; 7.43 → 7.43
test_vcore_fs_open.py 0; 40.08 → 40.08 0; 7.43 → 7.43

One whole suite: 467 passed, 15 failed, 20 skipped in 143 s (linux, 32 at once); exit1; load 11.65 → 39.26; 2026-11-11T23:11:01-06:00 → 2026-11-11T23:13:23-06:00. CC=gcc CXX=clang++ python -I -B tests/run_all.py. Only failing configurations were run once on untouched de866017e1584372fc75a1caab3784b3ba1d9f98; all failed there too. No zero-test baseline or whole-main-suite pass is claimed. The suite remains red.

Configuration Inherited check Stack/main exit Main 1-min load Observed on both
focused/gcc test_menu_i18n.py 1 / 1 10.28 → 10.28 A translated Gamepad prompt key no longer exists in vk_menu.cpp.
focused/clang test_menu_i18n.py 1 / 1 10.28 → 10.28 A translated Gamepad prompt key no longer exists in vk_menu.cpp.
suite/suite test_audio_aux.py 1 / 1 39.26 → 6.36 Extracted audio fixture lacks the env_on declaration used by reverb_frame.
suite/suite test_audio_biquad.py 1 / 1 6.36 → 6.36 Extracted audio fixture lacks the env_on declaration used by reverb_frame.
suite/suite test_display_startup_failure.py 1 / 1 6.36 → 18.10 Extracted display startup fixture lacks window_title.
suite/suite test_dlss_checks.py 1 / 1 18.10 → 6.33 The under-a-third-wide policy case fails at fixture line40 on both trees.
suite/suite test_env_on.py 1 / 1 6.33 → 6.33 RX_VK_CAPTURE_KEEP_OLD and RX_VK_CAP_BLOCKS still use set-means-on reads in vk_memory.cpp.
suite/suite test_framegen_preset.py 1 / 1 6.33 → 6.33 Menu extraction lacks the temporal_reset_failure stand-in.
suite/suite test_hdr_encode.py 1 / 1 6.33 → 6.33 The DLSS engine/chain HDR-colour wiring assertion fails.
suite/suite test_linux_docker_packages.py 1 / 1 6.33 → 6.33 The PPA sets disagree: ubuntu-toolchain-r/test versus the parsed --no-update token.
suite/suite test_menu_i18n.py 1 / 1 6.33 → 6.33 A translated Gamepad prompt key no longer exists in vk_menu.cpp.
suite/suite test_mods_size_table.py 1 / 1 6.33 → 6.33 The routing fixture fails to link hos_xinput_pads from prompts.c.
suite/suite test_mouse_native.py 1 / 1 6.33 → 6.33 The camera fixture expects yaw -1 but observes yaw4 (pitch10).
suite/suite test_readback_part.py 1 / 1 6.33 → 8.47 The extracted readback fixture has an extra closing brace and out-of-scope timeline/completed.
suite/suite test_rumble_menu.py 1 / 1 8.47 → 8.47 Menu extraction lacks the temporal_reset_failure stand-in.
suite/suite test_texel_buffer_ownership.py 1 / 1 8.47 → 8.47 The extracted texel fixture lacks dead_import_bytes, also breaking std::min deduction.
suite/suite test_wayland_libdecor_hint.py 1 / 1 8.47 → 8.47 Extracted display-init fixture lacks window_title.

Behavioral before/after proofs

Every ordinary area-changing loading zone was affected by the old per-area clock, not just warp: Players s2 B logged 2 false loads going to the field/back, hidden by HOS_COOP_ACCEPT=always. The current real-engine fixture preserves accepted offers/copies across area changes, but requires both anew for another slot, same-slot reload and new game. No corrected-head game proof yet.

All 14 event cases pass with ASan/UBSan, including loss/reordering and cancel, cancel-accept, reset-ack, delayed-ack, reaccept-step; the concurrent case also passes TSan. Those 5 new cases reject the previous helper. The Portuguese-table compiled value-cell fixture rejects renderer50bd1255 and passes literal Player/Off, clipping/caret and translated-placeholder checks; no screenshot proof is implied.

Family Old clock Old event helper Old renderer Current Before loads: clock; events; renderer
gcc exit 1; 9 checks failed 5 assertions rejected exit1: literal-value assertion all pass 7.43 → 7.82; 7.82 → 7.82; 7.82 → 7.82
clang exit 1; 9 checks failed 5 assertions rejected exit1: literal-value assertion all pass 7.82 → 8.48; 8.48 → 9.88; 9.88 → 9.88

Windows

17 Windows units + 1 native Linux-only unit; zero errors/name clashes. Clang --target=x86_64-w64-mingw32, llvm-mingw UCRT headers; generated headers from 71d4ab0f on fce53453 (existing actual generated headers, not a new game build). host/posix/display_sdl.c is Linux-only. Warnings retained: -Wdeprecated-enum-enum-conversion: 1, -Wmissing-field-initializers: 7, -Wunused-const-variable: 1, -Wunused-function: 3.

Syntax units, warnings and loads
Target / translation unit Exit; errors; name clashes Warnings by kind 1-min load
windows / host/control.c 0; 0; 0 none 10.77 → 10.77
windows / host/coop.c 0; 0; 0 none 10.77 → 12.15
windows / host/coop_menu.c 0; 0; 0 none 12.15 → 12.15
windows / host/coop_net.c 0; 0; 0 none 12.15 → 12.15
windows / host/coop_proto.c 0; 0; 0 none 12.15 → 12.15
windows / host/coop_relay.c 0; 0; 0 none 12.15 → 12.15
windows / host/coop_state.c 0; 0; 0 none 12.15 → 12.15
windows / host/display.c 0; 0; 0 none 12.15 → 12.15
windows / host/hos_main.c 0; 0; 0 -Wmissing-field-initializers: 1, -Wunused-const-variable: 1, -Wunused-function: 1 12.15 → 12.15
windows / host/hos_relaunch.c 0; 0; 0 none 12.15 → 12.15
windows / host/hos_sdl.c 0; 0; 0 none 12.15 → 12.15
windows / host/nn_applet.c 0; 0; 0 none 12.15 → 12.15
windows / host/nn_fs.c 0; 0; 0 none 12.15 → 12.15
windows / host/nvn_native.c 0; 0; 0 -Wunused-function: 1 12.15 → 12.15
windows / host/oot_coop.c 0; 0; 0 none 12.15 → 12.15
linux-only / host/posix/display_sdl.c 0; 0; 0 none 12.15 → 12.15
windows / host/rx_backend.c 0; 0; 0 -Wmissing-field-initializers: 6 12.15 → 13.42
windows / gpu/vk/vk_menu.cpp 0; 0; 0 -Wdeprecated-enum-enum-conversion: 1, -Wunused-function: 1 13.42 → 13.42

Windows CI compiles portable co-op fixtures but lacks generated headers for this full host/menu sweep. Syntax is not a Windows link/run. Kuba's Windows11 report, on 71d4ab0f plus his bind rename, established build/link/run, loopback Make/Join/Leave and physical typing. His fix is co-authored here. Accept/progress, LAN/cross-OS/controllers/suite were not tested there; he also reported a shader-start fault seen on releases, so it was not fault-free.

Historical fixture repair and stress proof — not a final-head stress rerun

On fe3d1eda (on published 71d4ab0f), 2026-11-11T18:22:54-06:00 to 2026-11-11T19:58:10-06:00: 40 complete session invocations, 320 modes passed, no session failure. Each compiler ran the complete test 20 times, not a reduced menu-only selection. Every start overlapped an active gated whole suite; endpoint one-minute loads were 5.93–181.58. These are correctness checks under load, not timing measurements.

The separate TSan lifetime probe observed a joined helper TID still in /proc/self/task after 624/1000 joins. The repair waits for that specific joined TID to disappear, bounded at five seconds, before the fixture counts its baseline. It changes no production lifetime rule and removes no exact thread/socket check. The deliberate still-live production-worker mutation was caught: exit 1, load 19.98 → 51.99, with FAIL coop_session: and the co-op thread has ended (threads 3, before co-op was on 2).

The 18 load-generating whole suites were not all green. All retained test_env_on.py; additional compiler failures are named in the table. The logs record compiler SIGKILLs, a killed cc1plus, and a shaderbatch compile failure without a diagnostic; their causes were not established. They were not rerun to erase failures. The later fixture fix does not turn the older published suite into a pass.

Every load suite, including all failures
Load suite Pass/fail/skip Exit 1-min load Every failure
1 459/1/20 1 36.56 → 34.04 tests/test_env_on.py
2 458/2/20 1 34.04 → 33.18 nx2/test_depth_sample_types.py, tests/test_env_on.py
3 459/1/20 1 33.18 → 34.26 tests/test_env_on.py
4 459/1/20 1 34.26 → 31.73 tests/test_env_on.py
5 459/1/20 1 31.73 → 26.91 tests/test_env_on.py
6 459/1/20 1 8.04 → 98.67 tests/test_env_on.py
7 459/1/20 1 98.67 → 36.21 tests/test_env_on.py
8 459/1/20 1 8.64 → 29.26 tests/test_env_on.py
9 459/1/20 1 29.26 → 26.97 tests/test_env_on.py
10 458/2/20 1 5.93 → 38.94 tests/test_bake_mimo.py, tests/test_env_on.py
11 458/2/20 1 38.94 → 49.81 tests/test_env_on.py, tests/test_lower_immediate_not_register.py
12 458/2/20 1 9.43 → 54.87 nx2/test_unsigned_image_types.py, tests/test_env_on.py
13 459/1/20 1 54.87 → 53.18 tests/test_env_on.py
14 459/1/20 1 53.18 → 30.87 tests/test_env_on.py
15 459/1/20 1 7.10 → 34.41 tests/test_env_on.py
16 458/2/20 1 34.41 → 30.34 nx2/test_unsigned_image_types.py, tests/test_env_on.py
17 459/1/20 1 30.34 → 25.31 tests/test_env_on.py
18 459/1/20 1 8.74 → 21.72 tests/test_env_on.py

Historical Windows format/CRT proof

The real build uses UCRT, ANSI stdio0 and vsnprintf, not GNU's wrapper. Explicit Microsoft-format cross-GCC rejects the old traces and passes %u/unsigned; unchanged coop_net passes. Clang emits both COFF objects; Linux GCC/Clang sessions pass8/8 each and codecs79 vectors/400000 fuzz inputs each. All19 steps match expected exits; attributes/-Werror unchanged. Secure-CRT guards precede fixture headers. Actual Windows fixture execution remains pending.

Every recorded format-proof command outcome and load
Step Exit / expected 1-min load start → end
gcc-version 0 / 0 not retained in cached preflight
gcc-target 0 / 0 not retained in cached preflight
clang-version 0 / 0 not retained in cached preflight
real-build-stdio-macros 0 / 0 not retained in cached preflight
before-gcc-ms-coop 1 / 1 not retained in cached preflight
before-gcc-ms-coop_net 0 / 0 not retained in cached preflight
before-gcc-ms-protocol-fixture-with-headers 1 / 1 9.43 → 9.43
after-gcc-ms-coop 0 / 0 9.43 → 9.43
after-gcc-ms-coop_net 0 / 0 9.43 → 36.94
after-gcc-ms-protocol-fixture-with-headers 0 / 0 36.94 → 36.94
after-clang-windows-coop 0 / 0 36.94 → 85.48
object-kind-coop 0 / 0 85.48 → 85.48
after-clang-windows-coop_net 0 / 0 85.48 → 107.14
object-kind-coop_net 0 / 0 107.14 → 107.14
real-build-stdio-symbols 0 / 0 107.14 → 107.14
linux-gcc-test_coop_session 0 / 0 107.14 → 126.28
linux-gcc-test_coop_proto 0 / 0 126.28 → 118.25
linux-clang-test_coop_session 0 / 0 118.25 → 95.67
linux-clang-test_coop_proto 0 / 0 95.67 → 97.17

Previous integration 94cb598f on55b976c0: 60 focused passes; 465 passed, 1 failed, 20 skipped in 221 s (linux, 32 at once), only env_on failed. Those results are not relabelled as this head.

Historical build and game evidence

The retained account below belongs only to its named heads. “Current/head/not pushed/queued” inside dated additions is historical, not this rebase. Other-player figures in follow-on experiments are outside this pull request. Pictures and both dated additions/chest are unchanged.

Linux (Arch, kernel 7.2.15-1-omarchy, i9-13900K), RTX 4090 with NVIDIA 615.78.08, clang 23.1.3, gcc 16.2.1, Python 3.12.16. Head 71d4ab0f on main fce53453, 2026-11-11 (US Central). The final session was headless, output 2048x1152, screenshots 1280x720.

Full make_game.py build: exit 0 and its completion line; the two game copies have matching executable / renderer SHA-256 prefixes 92986f4c15a12e84 / 65e42fecc682aed9, both stamped 71d4ab0f on fce53453. One-minute load at build request / completion: 127.77 / 16.94. No build-speed or frame-time claim.

Each published commit on its own (historical)

Each of the six commits checked out alone, and the tests it adds or touches run there, each with the compiler tests/run_all.py gives it (and the C tests that default to gcc once more with clang): 39 runs, 5 failed. The failures: test_env_on.py at commits 3, 4, 6 fails on main fce53453 itself, on HOS_FPS_LOG (host/oot_high_fps.c:15) (main's #2470 reads that switch as set-means-on; nothing of this stack); test_coop_session.py at commit 3 (--cc gcc and --cc clang) failed its menu fixture's process-thread counts at load 74.46 and 102.99; the complete standalone test then passed all 8 runs with each compiler after starting at load 39.72 (GCC ended at 29.20, Clang at 14.26). The repeated code and fixture were unchanged at the then-final 71d4ab0f head. This is evidence of a load-sensitive fixture, not an all-green suite or proof that the fixture is repaired. Besides what they compile and run, these tests check in the source where each part is wired in (the build compiles Monocypher; the game starts co-op beside the Discord status and says goodbye on both ways out; the present ends with progress sync's call and the save mount's writes take its lock; the interface's new members sit in their places in the host's table; the tab, its page and its keys in the options file).

Part / tested commit Test invocations passed / failed Renderer syntax
1 / e27f1a92 4 / 0 not a renderer part
2 / 52c471bb 2 / 0 not a renderer part
3 / 2f34da9a 7 / 3 (test_coop_session, test_env_on) not a renderer part
4 / 841492c4 7 / 1 (test_env_on) not a renderer part
5 / b4ec89b7 7 / 0 exit 0; 2 warnings
6 / 71d4ab0f 7 / 1 (test_env_on) exit 0; 2 warnings

Per-commit one-minute loads: 69.79 to 145.67. The two renderer syntax passes each retain the same enum-arithmetic and unused-function warnings recorded on main.

The whole suite on 71d4ab0f (historical)

python -I -B tests/run_all.py on 71d4ab0f: 458 passed, 2 failed, 20 skipped in 227 s (linux, 32 at once) (one-minute load 125.19 → 84.33). Failures: tests/test_env_on.py: the isolated test fails on main fce53453 itself at HOS_FPS_LOG (host/oot_high_fps.c:15), introduced by #2470; tests/test_coop_session.py: the ThreadSanitizer menu fixture's thread-count assertions failed under load (including one thread more). Main has no co-op session test, so the main-tree selection ran zero tests and is not a passing baseline. The complete standalone GCC and Clang repeats described above pass on the unchanged session code. The suite was not rerun and remains failed.

Two games on 71d4ab0f, from the Co-op tab alone (historical)

Two headless games on the RTX 4090, separate user folders and copies of the same child Kokiri save; Python relay on loopback. RX_VK_MENU_SCRIPT typed names/relay/secret and drove Make/Join/Accept; no automatic co-op enable/join/accept. Declared aids: HOS_COOP_ROOM fixes A's test secret, HOS_COOP_TRACE logs, HOS_COOP_TEST_SET sets B's song through the game routine, debug tools supply A's sword, and HOS_CONTROL quits gracefully. This is scripted menu input, not physical keyboard/controller proof.

Session s1 Session s2 Session s3 (71d4ab0f)
Head / actual base 67d2de81 / b744b1ec f7a9640d / 940eb9ef 71d4ab0f / fce53453
Games' version line v2.1.0-rc.3-76-g67d2de81 v2.1.0-rc.3-138-gf7a9640d v2.1.0-rc.3-191-g71d4ab0f
One-minute load A start → end; B start → end 6.79 → 10.98; 6.63 → 10.98 1.35 → 10.45; 4.69 → 10.45 91.55 → 106.27; 45.17 → 106.27
The one hold on the card (UTC) 2026-11-11 17:30:03 to 2026-11-11 17:34:52 (289 s) 2026-11-11 22:13:03 to 2026-11-11 22:17:52 (289 s) 2026-11-11 23:55:45 to 2026-11-12 00:00:37 (292 s)
B started when (A's seconds; the card's free MiB; memory available) 76 s; 17369 MiB; 68 GB 75 s; 17445 MiB; 69 GB 75 s; 20589 MiB; 56 GB
Memory pressure before asking (full avg10) 0.47 0.00 0.00
Checks: stack / session / tab (PASS, FAIL, N/A) 25, 0, 0 / 29, 0, 3 / 17, 0, 0 25, 0, 0 / 29, 0, 3 / 17, 0, 0 25, 0, 0 / 29, 0, 3 / 17, 0, 0
Both left the room (relay's log, US Central) Bob 11:34:48, Alice2 11:34:51 Bob 16:17:48, Alice2 16:17:51 Bob 18:00:32, Alice2 18:00:36
Presents A / B; exit A / B 15600 / 10800; 0 / 0 15360 / 10560; 0 / 0 9960 / 7200; 0 / 0
Screenshots A / B 260 / 180 257 / 176 166 / 121
Fatal, crash, device lost, core dumps (both) 0 0 0
Kernel driver fault lines (Xid, kmemsysDoCacheOp) / all NVRM lines over the hold 0 / 0 0 / 0 0 / 0

The legacy session checker's N/A rows are the other-player figures (outside this stack), menu pictures and renaming; the tab checker covers the latter two. Its old descriptions mentioning automatic join/accept are not the test setup: the quoted log lines and the separate environment checks prove that this session used the tab.

The checks establish tab-only setup/acceptance; each backup equals its starting save (14 files); A's sword reaches B and B's song reaches A. Readback: A 1 applied/1 getter agreement; B 12 applied/11 getter agreements plus 1 Int readback; none missing. Six stats lines report no discarded datagram. Typed settings persist; both games send goodbye on the bridge's quit. The table retains launch guards, loads, counters and kernel results.

Historical 71d4ab0f session panel: A makes the room, B types the join secret, both accept, and B receives the item (game-log evidence).

  1. A's tab after Make a room: “In a room, 1 player”, Alice marked as the maker, and the room secret masked by the menu.
  2. B entering the room secret in Join a room. This input field was redacted for publication; the successful join is established by B's log and the later two-player roster, not by the “Co-op is off” status still visible in this input frame.
  3. A after accepting Bob: Bob's fingerprint is gold and the maker's Remove a player / Reset the pools rows are present.
  4. B after accepting Alice: Alice's fingerprint is gold and Accept the room has disappeared. These are each game's acceptance frames, not a claim that the pictures were simultaneous.
  5. B's game just after the item values arrived: Link remains in normal play. This image alone does not show the sword in the inventory, and no pickup animation is claimed.
  6. A separate, explicitly labelled excerpt from B's game log: the item values were applied and read back, and the sword's count was already 1 before the debug inventory read. The 71d4ab0f session did not open B's Equipment page; that visual proof belongs to the earlier chest session below.

Historical 71d4ab0f session panel: A makes the room, B types the join secret, both accept, and B receives the item (game-log evidence).

The room's secret is masked in every panel, and it is in neither game's log (searched).

Earlier sessions

The two-game sessions made on this stack's predecessor branches (the first Co-op tab, the chest and Lens sessions, the sessions with later parts on top that are not in this pull request) are in this description's edit history (its versions of 2026-11-11) and in the comments below. They are evidence for those commits, not for this head, and are left out here to keep this description to what this head carries.

Not tested

No current-head game build/play, GPU/two-game/manual-consent/loading-zone/hosted-game proof; no current Windows execution/link. No two-PC/LAN/internet/NAT/cross-OS, more than two real games, AMD/Deck/controller or Linux physical typing proof. No corrected Lens crossing, new chest/rupee chest/boss/door behavior, remote-area chest, live Remove/Reset/rule change or manual backup restoration. No performance/bandwidth proof or gameplay layer built on events.

Risks

Progress affects real saves: acceptance, whole-folder backup, classification/getter checks and game setters remain mandatory. Wrong classification can import wrong progress. Co-op is off by default with no socket/thread/table scan; hosting adds its own joined socket/thread. Relay loss, shared bandwidth ceilings, maker departure and NAT can prevent delivery.

Secret holders can forge ordinary events/ACKs; receipt is not gameplay approval. Durable progress/rules keep individual signatures; protocol/nonce/signature use still needs review. ACKv2 is incompatible with v1; callbacks borrow bytes, queue-full must be handled, session/consent changes cancel work. ABI callback order must move together. Literal-field/translation tests are not all-language playtests. A restarted maker can retain its key but bring no rules: question6 remains open. This is a substantial draft, not an all-green claim.

Overlaps

Saved git merge-tree trial at 2026-11-11T23:51:26-06:00, against main 87991bc8b21c271f2dee3daf7f06fd006faa865c: exit 0, clean at that moment, tree c23d9a4f44ee7c06ec654e273b40d24b08b1277c. This is not a continuing mergeability claim.
Standalone snapshot 2026-11-11T23:05:49-06:00: 88 PRs touch 68 files; 21 conflict entries, all hunks read. #2453 needs journal tree/commit synchronization reconciled with backup barriers; #2578's TCP gameplay must be ported deliberately into the UDP/session/consent/backup contract, not concatenated. #2600 must preserve packed-file dispatch plus save-writer accounting. Other host-ABI additions need matching declarations/positional tables/version; fixture-only conflicts retain both tests. #882's options-present flag precedes co-op's continue; #2371 retains retake persistence/cancel prompts; #2566 retains dynamic sizes plus custom editing/translations. Independent option/context tail additions are not evidence of gameplay incompatibility.

Conflicting PRs and exact paths
PR / recorded head Conflicting files Text blocks
#882 / 990febcb gpu/vk/vk_menu.cpp 1
#1521 / 4f6ccdb9 gpu/vk/vk_menu.h 1
#1543 / 2ef23f2f tests/test_present_trace.py 1
#1560 / 1fc9986f docs/development.md, gpu/vk/vk_menu.h 2
#1590 / 6fb092bb tests/test_menu_env_overrides.py, tests/test_menu_keys.py 3
#1872 / 5cf62486 host/hos_sdl.c, host/nn_fs.c, host/posix/display_sdl.c 3
#1882 / 5f85fe70 gpu/vk/vk_ui.h 1
#2151 / b5af3839 tests/test_vcore_fs_open.py 3
#2261 / 919ba0ee host/rx_backend.c 1
#2317 / 26793b60 gpu/vk/vk_api.h, host/rx_backend.c, tests/test_env_file.py 4
#2371 / a2bed6b2 gpu/vk/menu_i18n.h, gpu/vk/vk_menu.cpp 15
#2430 / d827111d tests/test_env_file.py 3
#2453 / 92ea6643 host/nn_fs.c, readme.md 10
#2459 / 9bd89259 gpu/vk/vk_menu.h, host/rx_backend.c 2
#2478 / f654ef20 host/rx_backend.c 2
#2479 / 44e48391 host/rx_backend.c 2
#2530 / 4c276b88 gpu/vk/vk_menu.h 1
#2566 / e0cedba7 gpu/vk/menu_i18n.h, gpu/vk/vk_api.h, gpu/vk/vk_menu.cpp, host/rx_backend.c, readme.md 19
#2578 / c5a0c1d3 docs/coop.md, host/oot_coop.c, readme.md, tests/test_oot_coop.py 8
#2600 / 041c9f2b gpu/vk/vk_api.h, host/nn_fs.c, host/rx_backend.c, tests/test_vcore_fs_open.py 8
#2604 / 6ecc6a79 gpu/vk/vk_api.h 1

All 9 unchanged co-op lane heads conflict in wholesale trial merges; rewritten-base add/add blocks inflate counts. Rebase topic ranges preserving ABI, Windows binder, clock, epochs, accepted-session binding, hosting and localization. No helper/lane branch moved.

Questions for you

  1. Merge this as one pull request, or split it back into one per part? The original six parts and the later repair/helper/hosting commits are separated above; the per-prefix tests remain historical, not reruns of every rebased commit.
  2. The socket itself (question 1 of #2269): is a UDP socket in the host acceptable at all?
  3. Where should the relay live: in this repository, as here (it and the game must speak the same version), or in one of its own? (Question 3 of #2269.)
  4. Is Monocypher under third_party/ acceptable, or would you rather have another library behind the same few functions?
  5. Is the Co-op tab, the strip's last, where you want it, and are coop_name, coop_server, coop the names you want in hookshot_options.ini?
  6. A room's maker whose game started again: should it sign the rules again with its kept key when no rules are in force, or require a returning player who accepted them? In CoopGame4's two-game run on f7a9640d, the restarted maker kept its key but neither game obtained the rules or shared progress.

What comes after it

Other players' Links (coop-players) and the tab's additions (coop-tab-more, coop-tab-where) are separate follow-on lanes.
Warp (coop-warp), hits and duels (coop-hits), and the shared world and area state (coop-world, coop-area) build above them; none of these lanes is in this pull request.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 [email protected]
Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra [email protected]

**What this is for:** co-op (#2269): each friend plays their own game/save, sharing accepted progress through the game's setters. This draft adds bounded events and optional maker-PC hosting, not other players' Links, enemies, combat or warp. **Measured:** `2be28f893fa378a28de2a110caf491970be7b32e` on `de866017e1584372fc75a1caab3784b3ba1d9f98`: game-free focused **60 pass / 2 fail**, suite **467 pass / 15 fail / 20 skip**, Windows-target syntax below. Every failure is reproduced on untouched base. **Not measured:** no game build/play, current Windows execution/link, LAN/cross-OS/controller or performance proof on this head. All game pictures below are historical. Draft: **18 commits**. Rebase recorded 2026-11-11T22:57:08-06:00; evidence assembled 2026-11-11T23:52:40-06:00. The socket decision remains open. ## Problem - The game's own `nn::socket` stays offline; there was no host path for players' games to find a room or exchange progress. `host/coop_net.c` and `host/coop.c` keep that networking outside the guest game. - Games behind different routers need a reachable relay. `scripts/coop_relay.py` forwards sealed messages; `host/coop_proto.c` derives separate locator and message keys so the relay need not know the room secret or open progress. - Sharing raw save files would bypass player consent and the game's rules. `host/coop_state.c` merges accepted, signed progress; `host/oot_coop.c` checks the live flag table and calls the game's setters only after acceptance and a save copy. - Players need to do this without environment switches or hand-editing settings. Text fields and the Co-op tab in `gpu/vk/vk_menu.cpp` expose the same session and progress commands while the game runs. Additional defects: Windows name/printf/CRT conflicts; unstable fixture task counts; area resets mistaken for save loads; cancelled event fragments blocking later work or combining ACK epochs; localized UI helpers translating typed data. ## What changed ### Additions above the original six | Part / commit | Adds | |---|---| | 7 / `1c60356f` | Kuba's `bind` → `bind_tables` rename removes the Winsock declaration collision; `IN` → `COOP_FLAG_IN` also avoids the Windows annotation macro. Existing fixture callers/extraction markers migrate with their checks. Co-authored with Kuba. | | 8 / `68088064` | Wait for a joined fixture helper's specific Linux TID to disappear before taking the exact task-count baseline. Five-second bound; no runtime behavior or thread/socket assertion removed. | | 9 / `184cceb0` | Three bounded runtime packet-size traces and one codec diagnostic use `%u` plus `unsigned`, preserving format attributes and warning errors; no global ANSI-stdio override. | | 10 / `1cd02fc4` | Declare secure-CRT guards before generated fixture headers, including the screensaver fixture; preserve runtime code and behavioral checks. | | 11 / `1f1d0a72` | Use saved total play time rather than the per-area clock for progress save identity. Ordinary area-changing doors/loading zones keep acceptance and the existing backup; real save loads still require both anew. | | 12 / `d2fc0f7f` | Raise configurable Python-relay member ceilings to 480 forwarded copies/s and 256 KiB/s, with double bursts; charge every broadcast copy to both budgets. | | 13 / `35931fa8` | Add bounded session events, fragmentation, freshly sealed retries, whole callbacks and per-sender/recipient FIFO, separate from replaceable state datagrams. | | 14 / `b8d0cbed` | Publish session-bound progress consent through `coop_member_accepted(id)`; a roster entry or recycled member number is not acceptance. | | 15 / `c517e746` | Document message ownership, state/event calls, consent, payload and shared traffic budgets, receiver-side gameplay checks, and the layer integration example. | | 16 / `97f56483` | Optional maker-PC relay (`coop_host` / `HOS_COOP_HOST`): its own socket/thread, local maker authorization, cookie proof, sealed forwarding, bounded state and joined shutdown; no router/firewall automation. | | 17 / `dcf84430` | Cache only the latest refused join answer. A changed reason is charged again, including when it changes back; preserve Python/C relay parity. | | 18 / `2be28f89` | Retire cancelled incomplete events by recipient delivery floor; ACK assembly epochs prevent combining partial receipts and reject stale ACKs. Record revoke/reaccept transitions between socket steps. ACK body v2 requires peer updates. | Layer interface: `coop_send_event(member, kind, bytes, n)`, `coop_on_event(kind, callback, user)`, `coop_member_accepted(id)`; contract/example in `docs/coop.md#adding-a-layer`. EVENT/ACK stay kinds16/17, but ACK body **v2/27 bytes requires all peers to update**. Eight queued events, 16 KiB each, 1024-byte fragments; one fragment/32 ms, retry250 ms, ACK50 ms. Whole FIFO callbacks are not gameplay authorization or durable delivery. Handle queue-full; validate signed rules, current session, authority/context/age/bounds and recheck bounded work on the game thread. Consent alone is not the completed-backup gate. ### Rebase resolutions Part5 met #1930's API bump: retain ocarina bindings, then text API20 and Co-op API21 (base API19). Part6 met #1864: retain `game_lang` exclusion and `row_key(...), saved_value(...)`. #2435 keyboard prompts, #2574 DLSS failure hook, #2541 aspect migration, #2116 POSIX identity and Bug report/Restart/Quit remain. Part5 additionally keeps typed values/caret widths literal and migrates 13 custom-size translations; labels/placeholders stay localized. Replaying CRT guards conflicted with that new fixture: both retained, no old assertion removed. Other commits had no textual rebase conflicts. ### Original six parts Historical tested hashes versus current counterparts; per-prefix tests retain their tested identities: | Original part | Published / historical tested commit | Rebased counterpart | |---|---|---| | 1 | `e27f1a92` | `ae4c5a3b` | | 2 | `52c471bb` | `5ed79bfd` | | 3 | `2f34da9a` | `67662ccb` | | 4 | `841492c4` | `b8a6fabc` | | 5 | `b4ec89b7` | `6b9ab189` | | 6 | `71d4ab0f` | `eb904f9c` | | # | Commit | What it adds | Its tests (all run at that commit) | |---|---|---|---| | 1 | `e27f1a92` | Standard-library UDP relay and C codec (keyed BLAKE2b, XChaCha20-Poly1305, EdDSA), Monocypher4.0.2 and docs; no game access. | `test_coop_proto.py` (gcc and clang), `test_coop_relay.py`, `test_appimage_source_headers.py` | | 2 | `52c471bb` | Game-free flag registers, per-player key/rupee/ammunition pools, acceptance by key and maker-signed rules. | `test_coop_sync.py` (gcc and clang) | | 3 | `2f34da9a` | Opt-in UDP thread/socket: join/rejoin, roster, sealed messages, leave before counter reuse, graceful goodbye; no game-memory access. | `test_coop_session.py` (gcc and clang), `test_coop_env_file.py`, `test_coop_options_text.py`, `test_output_resolution.py`, `test_env_file.py`, `test_control.py` (gcc and clang), `test_display_startup_failure.py`, `test_env_on.py` | | 4 | `841492c4` | Runtime classification from game groups, no extracted flag list; SetBool/SetInt only raise values after acceptance/backup. Includes the Lens item-count fix. | `test_oot_coop.py` (gcc and clang), `test_save_copy.py` (gcc and clang), `test_env_file.py`, `test_present_trace.py`, `test_coop_env_file.py`, `test_env_on.py` | | 5 | `b4ec89b7` | Typed rows, caret/paste and window text queue; take_text/API20, first used by Custom output size. | `test_menu_text_field.py`, `test_menu_restart_slots.py`, `test_screensaver.py`, `test_menu_env_overrides.py`, `test_preset_exempt.py`, `test_menu_keys.py`, `test_menu_sections.py`; `vk_menu.cpp` with `-Wall -Wextra`: 2 warnings, both main's | | 6 | `71d4ab0f` — `feat(menu): a Co-op tab to make, join, accept and leave a co-op room` | the Insert menu's last tab: Your name and Relay (text fields), Make a room, Join a room, the room's secret (masked), the members with their key fingerprints, Accept the room, Leave the room, the maker's Remove a player and Reset the pools, a Room rules page saved under the options file's `coop_*` keys (no numeric option ids), `MKWVK_API_VERSION` 21. 12 files, +1321 -42 | `test_menu_coop_tab.py`, `test_menu_text_field.py`, `test_env_on.py`, `test_menu_env_overrides.py`, `test_preset_exempt.py`, `test_control.py`, `test_menu_keys.py`, `test_menu_sections.py`; `vk_menu.cpp` as at 5 | ## How to try it You need this branch built (docs/development.md), from your own copy of the game as always. **On two PCs:** 1. One of you runs the relay where both games can reach it by UDP (a forwarded port, or a small server): `python -I scripts/coop_relay.py --port 24680` (`--help` lists its limits). 2. Each of you starts the game and opens the Insert menu (Insert, or the menu shortcut you chose); the Co-op tab is the last one. 3. **Your name**: Enter, type a name, Enter. **Relay**: Enter, type the relay's address (`host` or `host:port`; the port is 24680 if you leave it out), Enter. Both are saved in `hookshot_options.ini`. 4. The first player chooses **Make a room**. **Room secret**: Enter shows it, Enter hides it. Give it to your friend privately: whoever has it can join. 5. The second player chooses **Join a room**, types or pastes (Ctrl+V) the secret, Enter. 6. Load your save. When the tab shows **Accept the room** (it shows once a save is in play), read the key fingerprints to each other, then press it twice. Your whole save folder is copied to `coop-backup/` first; docs/coop.md says how to go back to it. 7. Play. What one of you gets reaches the other's game through the game's own flags, so it comes without the game's "you got" message and pose. Shown so far between two games: items (given with the debug tools, and the Kokiri Sword from a real chest on an earlier head of this stack), a chest that one player opened showing open in the other's game, and a song marker. 8. **Leave the room** (press twice), or just close the game: it says goodbye to the room. **On one PC, two folders:** copy the built game folder twice, start the relay with `--host 127.0.0.1`, give each game its own user folder (`./hookshot --user <folder>` on Linux), use `127.0.0.1:24680` as the Relay in both, and follow steps 2 to 8. Optional maker hosting: set `coop_host=1` in `hookshot_options.ini` or real-environment `HOS_COOP_HOST=1` before startup, then **Make**. Off by default; Join never hosts. Empty Relay uses UDP24680; `127.0.0.1:<port>` chooses another; foreign hosts are refused. `coop_port` still means the client socket. Friends use the maker's reachable address and receive the secret privately. Internet needs UDP forwarding/firewall permission and may fail behind CGNAT. No UPnP/firewall changes or hosting UI row are added. Leaving/switching off/closing the maker ends its hosted room. ## How it was tested ### Current head — game-free Arch/Linux 7.2.15-1-omarchy, i9-13900K; GCC 16.2.1, Clang 23.1.3, the project's pinned Python. Focused 2026-11-11T22:59:51-06:00 → 2026-11-11T23:08:53-06:00. **31 files in both compiler environments: 60 pass/2 fail**; pure Python checks are not two native compilations. Loads are one-minute endpoints, not timing/performance claims. <details> <summary>Focused invocations: exit/expected and load</summary> | Check (tests/) | GCC: exit; load | Clang: exit; load | |---|---|---| | test_bug_report.py | 0; 10.77 → 12.15 | 0; 40.08 → 40.16 | | test_coop_env_file.py | 0; 12.15 → 12.15 | 0; 40.16 → 40.16 | | test_coop_events.py | 0; 12.15 → 14.27 | 0; 40.16 → 40.89 | | test_coop_host_relay.py | 0; 14.27 → 21.65 | 0; 40.89 → 16.82 | | test_coop_options_text.py | 0; 21.65 → 21.65 | 0; 16.82 → 16.82 | | test_coop_proto.py | 0; 21.65 → 21.84 | 0; 16.82 → 16.82 | | test_coop_relay.py | 0; 21.84 → 21.84 | 0; 16.82 → 16.19 | | test_coop_session.py | 0; 21.84 → 33.59 | 0; 16.19 → 9.18 | | test_coop_sync.py | 0; 33.59 → 41.34 | 0; 9.18 → 8.55 | | test_env_file.py | 0; 41.34 → 41.48 | 0; 8.55 → 8.55 | | test_menu_coop_tab.py | 0; 41.48 → 41.48 | 0; 8.55 → 8.55 | | test_menu_env_on.py | 0; 41.48 → 41.20 | 0; 8.55 → 8.55 | | test_menu_env_overrides.py | 0; 41.20 → 41.20 | 0; 8.55 → 8.55 | | test_menu_hit.py | 0; 41.20 → 41.20 | 0; 8.55 → 8.55 | | test_menu_hooks.py | 0; 41.20 → 41.20 | 0; 8.55 → 8.55 | | test_menu_i18n.py | 1; 41.20 → 41.20 | 1; 8.55 → 8.55 | | test_menu_keys.py | 0; 41.20 → 41.20 | 0; 8.55 → 8.55 | | test_menu_mouse_scroll.py | 0; 41.20 → 41.20 | 0; 8.55 → 8.35 | | test_menu_restart.py | 0; 41.20 → 41.20 | 0; 8.35 → 8.35 | | test_menu_restart_slots.py | 0; 41.20 → 41.20 | 0; 8.35 → 8.35 | | test_menu_sections.py | 0; 41.20 → 41.20 | 0; 8.35 → 8.35 | | test_menu_setting_walk.py | 0; 41.20 → 41.20 | 0; 8.35 → 8.35 | | test_menu_size.py | 0; 41.20 → 41.20 | 0; 8.35 → 8.35 | | test_menu_text_field.py | 0; 41.20 → 41.20 | 0; 8.35 → 8.35 | | test_menu_tune.py | 0; 41.20 → 41.20 | 0; 8.35 → 8.35 | | test_menu_utf8.py | 0; 41.20 → 40.46 | 0; 8.35 → 8.35 | | test_oot_coop.py | 0; 40.46 → 39.55 | 0; 8.35 → 8.24 | | test_present_trace.py | 0; 39.55 → 39.55 | 0; 8.24 → 8.24 | | test_save_copy.py | 0; 39.55 → 40.08 | 0; 8.24 → 7.43 | | test_screensaver.py | 0; 40.08 → 40.08 | 0; 7.43 → 7.43 | | test_vcore_fs_open.py | 0; 40.08 → 40.08 | 0; 7.43 → 7.43 | </details> **One whole suite:** `467 passed, 15 failed, 20 skipped in 143 s (linux, 32 at once)`; exit1; load 11.65 → 39.26; 2026-11-11T23:11:01-06:00 → 2026-11-11T23:13:23-06:00. `CC=gcc CXX=clang++ python -I -B tests/run_all.py`. Only failing configurations were run once on untouched `de866017e1584372fc75a1caab3784b3ba1d9f98`; all failed there too. No zero-test baseline or whole-main-suite pass is claimed. The suite remains red. | Configuration | Inherited check | Stack/main exit | Main 1-min load | Observed on both | |---|---|---|---|---| | focused/gcc | `test_menu_i18n.py` | 1 / 1 | 10.28 → 10.28 | A translated Gamepad prompt key no longer exists in vk_menu.cpp. | | focused/clang | `test_menu_i18n.py` | 1 / 1 | 10.28 → 10.28 | A translated Gamepad prompt key no longer exists in vk_menu.cpp. | | suite/suite | `test_audio_aux.py` | 1 / 1 | 39.26 → 6.36 | Extracted audio fixture lacks the env_on declaration used by reverb_frame. | | suite/suite | `test_audio_biquad.py` | 1 / 1 | 6.36 → 6.36 | Extracted audio fixture lacks the env_on declaration used by reverb_frame. | | suite/suite | `test_display_startup_failure.py` | 1 / 1 | 6.36 → 18.10 | Extracted display startup fixture lacks window_title. | | suite/suite | `test_dlss_checks.py` | 1 / 1 | 18.10 → 6.33 | The under-a-third-wide policy case fails at fixture line40 on both trees. | | suite/suite | `test_env_on.py` | 1 / 1 | 6.33 → 6.33 | RX_VK_CAPTURE_KEEP_OLD and RX_VK_CAP_BLOCKS still use set-means-on reads in vk_memory.cpp. | | suite/suite | `test_framegen_preset.py` | 1 / 1 | 6.33 → 6.33 | Menu extraction lacks the temporal_reset_failure stand-in. | | suite/suite | `test_hdr_encode.py` | 1 / 1 | 6.33 → 6.33 | The DLSS engine/chain HDR-colour wiring assertion fails. | | suite/suite | `test_linux_docker_packages.py` | 1 / 1 | 6.33 → 6.33 | The PPA sets disagree: ubuntu-toolchain-r/test versus the parsed --no-update token. | | suite/suite | `test_menu_i18n.py` | 1 / 1 | 6.33 → 6.33 | A translated Gamepad prompt key no longer exists in vk_menu.cpp. | | suite/suite | `test_mods_size_table.py` | 1 / 1 | 6.33 → 6.33 | The routing fixture fails to link hos_xinput_pads from prompts.c. | | suite/suite | `test_mouse_native.py` | 1 / 1 | 6.33 → 6.33 | The camera fixture expects yaw -1 but observes yaw4 (pitch10). | | suite/suite | `test_readback_part.py` | 1 / 1 | 6.33 → 8.47 | The extracted readback fixture has an extra closing brace and out-of-scope timeline/completed. | | suite/suite | `test_rumble_menu.py` | 1 / 1 | 8.47 → 8.47 | Menu extraction lacks the temporal_reset_failure stand-in. | | suite/suite | `test_texel_buffer_ownership.py` | 1 / 1 | 8.47 → 8.47 | The extracted texel fixture lacks dead_import_bytes, also breaking std::min deduction. | | suite/suite | `test_wayland_libdecor_hint.py` | 1 / 1 | 8.47 → 8.47 | Extracted display-init fixture lacks window_title. | ### Behavioral before/after proofs Every ordinary area-changing loading zone was affected by the old per-area clock, not just warp: Players s2 B logged 2 false loads going to the field/back, hidden by `HOS_COOP_ACCEPT=always`. The current real-engine fixture preserves accepted offers/copies across area changes, but requires both anew for another slot, same-slot reload and new game. No corrected-head game proof yet. All 14 event cases pass with ASan/UBSan, including loss/reordering and cancel, cancel-accept, reset-ack, delayed-ack, reaccept-step; the concurrent case also passes TSan. Those 5 new cases reject the previous helper. The Portuguese-table compiled value-cell fixture rejects renderer50bd1255 and passes literal Player/Off, clipping/caret and translated-placeholder checks; no screenshot proof is implied. | Family | Old clock | Old event helper | Old renderer | Current | Before loads: clock; events; renderer | |---|---|---|---|---|---| | gcc | exit 1; 9 checks failed | 5 assertions rejected | exit1: literal-value assertion | all pass | 7.43 → 7.82; 7.82 → 7.82; 7.82 → 7.82 | | clang | exit 1; 9 checks failed | 5 assertions rejected | exit1: literal-value assertion | all pass | 7.82 → 8.48; 8.48 → 9.88; 9.88 → 9.88 | ### Windows **17 Windows units + 1 native Linux-only unit; zero errors/name clashes.** Clang `--target=x86_64-w64-mingw32`, llvm-mingw UCRT headers; generated headers from 71d4ab0f on fce53453 (existing actual generated headers, not a new game build). `host/posix/display_sdl.c` is Linux-only. Warnings retained: -Wdeprecated-enum-enum-conversion: 1, -Wmissing-field-initializers: 7, -Wunused-const-variable: 1, -Wunused-function: 3. <details> <summary>Syntax units, warnings and loads</summary> | Target / translation unit | Exit; errors; name clashes | Warnings by kind | 1-min load | |---|---|---|---| | windows / `host/control.c` | 0; 0; 0 | none | 10.77 → 10.77 | | windows / `host/coop.c` | 0; 0; 0 | none | 10.77 → 12.15 | | windows / `host/coop_menu.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/coop_net.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/coop_proto.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/coop_relay.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/coop_state.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/display.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/hos_main.c` | 0; 0; 0 | -Wmissing-field-initializers: 1, -Wunused-const-variable: 1, -Wunused-function: 1 | 12.15 → 12.15 | | windows / `host/hos_relaunch.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/hos_sdl.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/nn_applet.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/nn_fs.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/nvn_native.c` | 0; 0; 0 | -Wunused-function: 1 | 12.15 → 12.15 | | windows / `host/oot_coop.c` | 0; 0; 0 | none | 12.15 → 12.15 | | linux-only / `host/posix/display_sdl.c` | 0; 0; 0 | none | 12.15 → 12.15 | | windows / `host/rx_backend.c` | 0; 0; 0 | -Wmissing-field-initializers: 6 | 12.15 → 13.42 | | windows / `gpu/vk/vk_menu.cpp` | 0; 0; 0 | -Wdeprecated-enum-enum-conversion: 1, -Wunused-function: 1 | 13.42 → 13.42 | </details> Windows CI compiles portable co-op fixtures but lacks generated headers for this full host/menu sweep. Syntax is not a Windows link/run. [Kuba's Windows11 report](https://git.magicbean.dev/MagicBeanDev/Hookshot/pulls/2274), on **71d4ab0f plus his bind rename**, established build/link/run, loopback Make/Join/Leave and physical typing. His fix is co-authored here. Accept/progress, LAN/cross-OS/controllers/suite were not tested there; he also reported a shader-start fault seen on releases, so it was not fault-free. ### Historical fixture repair and stress proof — not a final-head stress rerun On `fe3d1eda` (on published `71d4ab0f`), 2026-11-11T18:22:54-06:00 to 2026-11-11T19:58:10-06:00: **40 complete session invocations, 320 modes passed, no session failure**. Each compiler ran the complete test 20 times, not a reduced menu-only selection. Every start overlapped an active gated whole suite; endpoint one-minute loads were 5.93–181.58. These are correctness checks under load, not timing measurements. The separate TSan lifetime probe observed a joined helper TID still in `/proc/self/task` after **624/1000** joins. The repair waits for that specific joined TID to disappear, bounded at five seconds, before the fixture counts its baseline. It changes no production lifetime rule and removes no exact thread/socket check. The deliberate still-live production-worker mutation was caught: exit **1**, load 19.98 → 51.99, with `FAIL coop_session: and the co-op thread has ended (threads 3, before co-op was on 2)`. The **18** load-generating whole suites were not all green. All retained `test_env_on.py`; additional compiler failures are named in the table. The logs record compiler SIGKILLs, a killed `cc1plus`, and a shaderbatch compile failure without a diagnostic; their causes were not established. They were not rerun to erase failures. The later fixture fix does not turn the older published suite into a pass. <details> <summary>Every load suite, including all failures</summary> | Load suite | Pass/fail/skip | Exit | 1-min load | Every failure | |---|---|---|---|---| | 1 | 459/1/20 | 1 | 36.56 → 34.04 | `tests/test_env_on.py` | | 2 | 458/2/20 | 1 | 34.04 → 33.18 | `nx2/test_depth_sample_types.py`, `tests/test_env_on.py` | | 3 | 459/1/20 | 1 | 33.18 → 34.26 | `tests/test_env_on.py` | | 4 | 459/1/20 | 1 | 34.26 → 31.73 | `tests/test_env_on.py` | | 5 | 459/1/20 | 1 | 31.73 → 26.91 | `tests/test_env_on.py` | | 6 | 459/1/20 | 1 | 8.04 → 98.67 | `tests/test_env_on.py` | | 7 | 459/1/20 | 1 | 98.67 → 36.21 | `tests/test_env_on.py` | | 8 | 459/1/20 | 1 | 8.64 → 29.26 | `tests/test_env_on.py` | | 9 | 459/1/20 | 1 | 29.26 → 26.97 | `tests/test_env_on.py` | | 10 | 458/2/20 | 1 | 5.93 → 38.94 | `tests/test_bake_mimo.py`, `tests/test_env_on.py` | | 11 | 458/2/20 | 1 | 38.94 → 49.81 | `tests/test_env_on.py`, `tests/test_lower_immediate_not_register.py` | | 12 | 458/2/20 | 1 | 9.43 → 54.87 | `nx2/test_unsigned_image_types.py`, `tests/test_env_on.py` | | 13 | 459/1/20 | 1 | 54.87 → 53.18 | `tests/test_env_on.py` | | 14 | 459/1/20 | 1 | 53.18 → 30.87 | `tests/test_env_on.py` | | 15 | 459/1/20 | 1 | 7.10 → 34.41 | `tests/test_env_on.py` | | 16 | 458/2/20 | 1 | 34.41 → 30.34 | `nx2/test_unsigned_image_types.py`, `tests/test_env_on.py` | | 17 | 459/1/20 | 1 | 30.34 → 25.31 | `tests/test_env_on.py` | | 18 | 459/1/20 | 1 | 8.74 → 21.72 | `tests/test_env_on.py` | </details> ### Historical Windows format/CRT proof The real build uses UCRT, ANSI stdio0 and `vsnprintf`, not GNU's wrapper. Explicit Microsoft-format cross-GCC rejects the old traces and passes `%u`/unsigned; unchanged coop_net passes. Clang emits both COFF objects; Linux GCC/Clang sessions pass8/8 each and codecs79 vectors/400000 fuzz inputs each. All19 steps match expected exits; attributes/-Werror unchanged. Secure-CRT guards precede fixture headers. Actual Windows fixture execution remains pending. <details> <summary>Every recorded format-proof command outcome and load</summary> | Step | Exit / expected | 1-min load start → end | |---|---|---| | gcc-version | 0 / 0 | not retained in cached preflight | | gcc-target | 0 / 0 | not retained in cached preflight | | clang-version | 0 / 0 | not retained in cached preflight | | real-build-stdio-macros | 0 / 0 | not retained in cached preflight | | before-gcc-ms-coop | 1 / 1 | not retained in cached preflight | | before-gcc-ms-coop_net | 0 / 0 | not retained in cached preflight | | before-gcc-ms-protocol-fixture-with-headers | 1 / 1 | 9.43 → 9.43 | | after-gcc-ms-coop | 0 / 0 | 9.43 → 9.43 | | after-gcc-ms-coop_net | 0 / 0 | 9.43 → 36.94 | | after-gcc-ms-protocol-fixture-with-headers | 0 / 0 | 36.94 → 36.94 | | after-clang-windows-coop | 0 / 0 | 36.94 → 85.48 | | object-kind-coop | 0 / 0 | 85.48 → 85.48 | | after-clang-windows-coop_net | 0 / 0 | 85.48 → 107.14 | | object-kind-coop_net | 0 / 0 | 107.14 → 107.14 | | real-build-stdio-symbols | 0 / 0 | 107.14 → 107.14 | | linux-gcc-test_coop_session | 0 / 0 | 107.14 → 126.28 | | linux-gcc-test_coop_proto | 0 / 0 | 126.28 → 118.25 | | linux-clang-test_coop_session | 0 / 0 | 118.25 → 95.67 | | linux-clang-test_coop_proto | 0 / 0 | 95.67 → 97.17 | </details> Previous integration **94cb598f on55b976c0**: 60 focused passes; `465 passed, 1 failed, 20 skipped in 221 s (linux, 32 at once)`, only env_on failed. Those results are not relabelled as this head. ## Historical build and game evidence The retained account below belongs only to its named heads. “Current/head/not pushed/queued” inside dated additions is historical, not this rebase. Other-player figures in follow-on experiments are outside this pull request. Pictures and both dated additions/chest are unchanged. Linux (Arch, kernel 7.2.15-1-omarchy, i9-13900K), RTX 4090 with NVIDIA 615.78.08, clang 23.1.3, gcc 16.2.1, Python 3.12.16. Head 71d4ab0f on main fce53453, 2026-11-11 (US Central). The final session was headless, output 2048x1152, screenshots 1280x720. Full `make_game.py` build: exit 0 and its completion line; the two game copies have matching executable / renderer SHA-256 prefixes `92986f4c15a12e84` / `65e42fecc682aed9`, both stamped 71d4ab0f on fce53453. One-minute load at build request / completion: 127.77 / 16.94. No build-speed or frame-time claim. ### Each published commit on its own (historical) Each of the six commits checked out alone, and the tests it adds or touches run there, each with the compiler `tests/run_all.py` gives it (and the C tests that default to gcc once more with clang): **39 runs, 5 failed**. The failures: `test_env_on.py` at commits 3, 4, 6 fails on main fce53453 itself, on `HOS_FPS_LOG (host/oot_high_fps.c:15)` (main's #2470 reads that switch as set-means-on; nothing of this stack); `test_coop_session.py` at commit 3 (--cc gcc and --cc clang) failed its menu fixture's process-thread counts at load 74.46 and 102.99; the complete standalone test then passed all 8 runs with each compiler after starting at load 39.72 (GCC ended at 29.20, Clang at 14.26). The repeated code and fixture were unchanged at the then-final 71d4ab0f head. This is evidence of a load-sensitive fixture, not an all-green suite or proof that the fixture is repaired. Besides what they compile and run, these tests check in the source where each part is wired in (the build compiles Monocypher; the game starts co-op beside the Discord status and says goodbye on both ways out; the present ends with progress sync's call and the save mount's writes take its lock; the interface's new members sit in their places in the host's table; the tab, its page and its keys in the options file). | Part / tested commit | Test invocations passed / failed | Renderer syntax | |---|---|---| | 1 / `e27f1a92` | 4 / 0 | not a renderer part | | 2 / `52c471bb` | 2 / 0 | not a renderer part | | 3 / `2f34da9a` | 7 / 3 (test_coop_session, test_env_on) | not a renderer part | | 4 / `841492c4` | 7 / 1 (test_env_on) | not a renderer part | | 5 / `b4ec89b7` | 7 / 0 | exit 0; 2 warnings | | 6 / `71d4ab0f` | 7 / 1 (test_env_on) | exit 0; 2 warnings | Per-commit one-minute loads: 69.79 to 145.67. The two renderer syntax passes each retain the same enum-arithmetic and unused-function warnings recorded on main. ### The whole suite on 71d4ab0f (historical) `python -I -B tests/run_all.py` on 71d4ab0f: **`458 passed, 2 failed, 20 skipped in 227 s (linux, 32 at once)`** (one-minute load 125.19 → 84.33). Failures: `tests/test_env_on.py`: the isolated test fails on main fce53453 itself at `HOS_FPS_LOG (host/oot_high_fps.c:15)`, introduced by #2470; `tests/test_coop_session.py`: the ThreadSanitizer menu fixture's thread-count assertions failed under load (including `one thread more`). Main has no co-op session test, so the main-tree selection ran zero tests and is **not** a passing baseline. The complete standalone GCC and Clang repeats described above pass on the unchanged session code. The suite was not rerun and remains failed. ### Two games on 71d4ab0f, from the Co-op tab alone (historical) Two headless games on the RTX 4090, separate user folders and copies of the same child Kokiri save; Python relay on loopback. RX_VK_MENU_SCRIPT typed names/relay/secret and drove Make/Join/Accept; no automatic co-op enable/join/accept. Declared aids: HOS_COOP_ROOM fixes A's test secret, HOS_COOP_TRACE logs, HOS_COOP_TEST_SET sets B's song through the game routine, debug tools supply A's sword, and HOS_CONTROL quits gracefully. This is scripted menu input, not physical keyboard/controller proof. | | Session s1 | Session s2 | Session s3 (71d4ab0f) | |---|---|---|---| | Head / actual base | `67d2de81` / `b744b1ec` | `f7a9640d` / `940eb9ef` | `71d4ab0f` / `fce53453` | | Games' version line | `v2.1.0-rc.3-76-g67d2de81` | `v2.1.0-rc.3-138-gf7a9640d` | `v2.1.0-rc.3-191-g71d4ab0f` | | One-minute load A start → end; B start → end | 6.79 → 10.98; 6.63 → 10.98 | 1.35 → 10.45; 4.69 → 10.45 | 91.55 → 106.27; 45.17 → 106.27 | | The one hold on the card (UTC) | 2026-11-11 17:30:03 to 2026-11-11 17:34:52 (289 s) | 2026-11-11 22:13:03 to 2026-11-11 22:17:52 (289 s) | 2026-11-11 23:55:45 to 2026-11-12 00:00:37 (292 s) | | B started when (A's seconds; the card's free MiB; memory available) | 76 s; 17369 MiB; 68 GB | 75 s; 17445 MiB; 69 GB | 75 s; 20589 MiB; 56 GB | | Memory pressure before asking (full avg10) | 0.47 | 0.00 | 0.00 | | Checks: stack / session / tab (PASS, FAIL, N/A) | 25, 0, 0 / 29, 0, 3 / 17, 0, 0 | 25, 0, 0 / 29, 0, 3 / 17, 0, 0 | 25, 0, 0 / 29, 0, 3 / 17, 0, 0 | | Both left the room (relay's log, US Central) | Bob 11:34:48, Alice2 11:34:51 | Bob 16:17:48, Alice2 16:17:51 | Bob 18:00:32, Alice2 18:00:36 | | Presents A / B; exit A / B | 15600 / 10800; 0 / 0 | 15360 / 10560; 0 / 0 | 9960 / 7200; 0 / 0 | | Screenshots A / B | 260 / 180 | 257 / 176 | 166 / 121 | | Fatal, crash, device lost, core dumps (both) | 0 | 0 | 0 | | Kernel driver fault lines (Xid, kmemsysDoCacheOp) / all NVRM lines over the hold | 0 / 0 | 0 / 0 | 0 / 0 | The legacy session checker's N/A rows are the other-player figures (outside this stack), menu pictures and renaming; the tab checker covers the latter two. Its old descriptions mentioning automatic join/accept are not the test setup: the quoted log lines and the separate environment checks prove that this session used the tab. The checks establish tab-only setup/acceptance; each backup equals its starting save (14 files); A's sword reaches B and B's song reaches A. Readback: A 1 applied/1 getter agreement; B 12 applied/11 getter agreements plus 1 Int readback; none missing. Six stats lines report no discarded datagram. Typed settings persist; both games send goodbye on the bridge's quit. The table retains launch guards, loads, counters and kernel results. Historical 71d4ab0f session panel: A makes the room, B types the join secret, both accept, and B receives the item (game-log evidence). 1. A's tab after Make a room: “In a room, 1 player”, Alice marked as the maker, and the room secret masked by the menu. 2. B entering the room secret in Join a room. This input field was redacted for publication; the successful join is established by B's log and the later two-player roster, not by the “Co-op is off” status still visible in this input frame. 3. A after accepting Bob: Bob's fingerprint is gold and the maker's Remove a player / Reset the pools rows are present. 4. B after accepting Alice: Alice's fingerprint is gold and Accept the room has disappeared. These are each game's acceptance frames, not a claim that the pictures were simultaneous. 5. B's game just after the item values arrived: Link remains in normal play. This image alone does not show the sword in the inventory, and no pickup animation is claimed. 6. A separate, explicitly labelled excerpt from B's game log: the item values were applied and read back, and the sword's count was already 1 before the debug inventory read. The 71d4ab0f session did not open B's Equipment page; that visual proof belongs to the earlier chest session below. ![Historical 71d4ab0f session panel: A makes the room, B types the join secret, both accept, and B receives the item (game-log evidence).](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/fb512e66080b3177770533c07fe482ea1e903c44/hookshot-coop-stack-session.jpg) The room's secret is masked in every panel, and it is in neither game's log (searched). ### Earlier sessions The two-game sessions made on this stack's predecessor branches (the first Co-op tab, the chest and Lens sessions, the sessions with later parts on top that are not in this pull request) are in this description's edit history (its versions of 2026-11-11) and in the comments below. They are evidence for those commits, not for this head, and are left out here to keep this description to what this head carries. ## Not tested No current-head game build/play, GPU/two-game/manual-consent/loading-zone/hosted-game proof; no current Windows execution/link. No two-PC/LAN/internet/NAT/cross-OS, more than two real games, AMD/Deck/controller or Linux physical typing proof. No corrected Lens crossing, new chest/rupee chest/boss/door behavior, remote-area chest, live Remove/Reset/rule change or manual backup restoration. No performance/bandwidth proof or gameplay layer built on events. ## Risks Progress affects real saves: acceptance, whole-folder backup, classification/getter checks and game setters remain mandatory. Wrong classification can import wrong progress. Co-op is off by default with no socket/thread/table scan; hosting adds its own joined socket/thread. Relay loss, shared bandwidth ceilings, maker departure and NAT can prevent delivery. Secret holders can forge ordinary events/ACKs; receipt is not gameplay approval. Durable progress/rules keep individual signatures; protocol/nonce/signature use still needs review. ACKv2 is incompatible with v1; callbacks borrow bytes, queue-full must be handled, session/consent changes cancel work. ABI callback order must move together. Literal-field/translation tests are not all-language playtests. A restarted maker can retain its key but bring no rules: question6 remains open. This is a substantial draft, not an all-green claim. ## Overlaps Saved `git merge-tree` trial at **2026-11-11T23:51:26-06:00**, against main `87991bc8b21c271f2dee3daf7f06fd006faa865c`: **exit 0, clean at that moment**, tree `c23d9a4f44ee7c06ec654e273b40d24b08b1277c`. This is not a continuing mergeability claim. Standalone snapshot 2026-11-11T23:05:49-06:00: **88 PRs touch 68 files; 21 conflict entries**, all hunks read. #2453 needs journal tree/commit synchronization reconciled with backup barriers; #2578's TCP gameplay must be ported deliberately into the UDP/session/consent/backup contract, not concatenated. #2600 must preserve packed-file dispatch plus save-writer accounting. Other host-ABI additions need matching declarations/positional tables/version; fixture-only conflicts retain both tests. #882's options-present flag precedes co-op's continue; #2371 retains retake persistence/cancel prompts; #2566 retains dynamic sizes plus custom editing/translations. Independent option/context tail additions are not evidence of gameplay incompatibility. <details> <summary>Conflicting PRs and exact paths</summary> | PR / recorded head | Conflicting files | Text blocks | |---|---|---| | #882 / `990febcb` | `gpu/vk/vk_menu.cpp` | 1 | | #1521 / `4f6ccdb9` | `gpu/vk/vk_menu.h` | 1 | | #1543 / `2ef23f2f` | `tests/test_present_trace.py` | 1 | | #1560 / `1fc9986f` | `docs/development.md`, `gpu/vk/vk_menu.h` | 2 | | #1590 / `6fb092bb` | `tests/test_menu_env_overrides.py`, `tests/test_menu_keys.py` | 3 | | #1872 / `5cf62486` | `host/hos_sdl.c`, `host/nn_fs.c`, `host/posix/display_sdl.c` | 3 | | #1882 / `5f85fe70` | `gpu/vk/vk_ui.h` | 1 | | #2151 / `b5af3839` | `tests/test_vcore_fs_open.py` | 3 | | #2261 / `919ba0ee` | `host/rx_backend.c` | 1 | | #2317 / `26793b60` | `gpu/vk/vk_api.h`, `host/rx_backend.c`, `tests/test_env_file.py` | 4 | | #2371 / `a2bed6b2` | `gpu/vk/menu_i18n.h`, `gpu/vk/vk_menu.cpp` | 15 | | #2430 / `d827111d` | `tests/test_env_file.py` | 3 | | #2453 / `92ea6643` | `host/nn_fs.c`, `readme.md` | 10 | | #2459 / `9bd89259` | `gpu/vk/vk_menu.h`, `host/rx_backend.c` | 2 | | #2478 / `f654ef20` | `host/rx_backend.c` | 2 | | #2479 / `44e48391` | `host/rx_backend.c` | 2 | | #2530 / `4c276b88` | `gpu/vk/vk_menu.h` | 1 | | #2566 / `e0cedba7` | `gpu/vk/menu_i18n.h`, `gpu/vk/vk_api.h`, `gpu/vk/vk_menu.cpp`, `host/rx_backend.c`, `readme.md` | 19 | | #2578 / `c5a0c1d3` | `docs/coop.md`, `host/oot_coop.c`, `readme.md`, `tests/test_oot_coop.py` | 8 | | #2600 / `041c9f2b` | `gpu/vk/vk_api.h`, `host/nn_fs.c`, `host/rx_backend.c`, `tests/test_vcore_fs_open.py` | 8 | | #2604 / `6ecc6a79` | `gpu/vk/vk_api.h` | 1 | </details> All 9 unchanged co-op lane heads conflict in wholesale trial merges; rewritten-base add/add blocks inflate counts. Rebase topic ranges preserving ABI, Windows binder, clock, epochs, accepted-session binding, hosting and localization. No helper/lane branch moved. ## Questions for you 1. Merge this as one pull request, or split it back into one per part? The original six parts and the later repair/helper/hosting commits are separated above; the per-prefix tests remain historical, not reruns of every rebased commit. 2. The socket itself (question 1 of #2269): is a UDP socket in the host acceptable at all? 3. Where should the relay live: in this repository, as here (it and the game must speak the same version), or in one of its own? (Question 3 of #2269.) 4. Is Monocypher under `third_party/` acceptable, or would you rather have another library behind the same few functions? 5. Is the Co-op tab, the strip's last, where you want it, and are `coop_name`, `coop_server`, `coop` the names you want in `hookshot_options.ini`? 6. A room's maker whose game started again: should it sign the rules again with its kept key when no rules are in force, or require a returning player who accepted them? In CoopGame4's two-game run on f7a9640d, the restarted maker kept its key but neither game obtained the rules or shared progress. ## What comes after it Other players' Links (`coop-players`) and the tab's additions (`coop-tab-more`, `coop-tab-where`) are separate follow-on lanes. Warp (`coop-warp`), hits and duels (`coop-hits`), and the shared world and area state (`coop-world`, `coop-area`) build above them; none of these lanes is in this pull request. Assisted-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 <[email protected]> Assisted-by: GPT-6 Astra Co-Authored-By: GPT-6 Astra <[email protected]>
mooki000 commented 2026-11-12 00:13:15 +00:00 (Migrated from github.com)

This draft now carries the whole stack as six commits (it was the first part only): the relay and wire format, the merge engine, the runtime session, progress sync, text fields in the Insert menu, and the Co-op tab. Head 71d4ab0f on main fce53453; it merges with ef81aee7 without a conflict.

The description is rewritten for it: what each commit adds and its tests, how to try it on two PCs or on one, the two-game session on this head, what failed in the test runs and why, what is not tested (Windows first of all: I have no Windows PC and would be glad of a tester), and six questions at the end. It stays a draft until the first of them, a socket in the host at all, is answered.

This draft now carries the whole stack as six commits (it was the first part only): the relay and wire format, the merge engine, the runtime session, progress sync, text fields in the Insert menu, and the Co-op tab. Head `71d4ab0f` on main `fce53453`; it merges with `ef81aee7` without a conflict. The description is rewritten for it: what each commit adds and its tests, how to try it on two PCs or on one, the two-game session on this head, what failed in the test runs and why, what is not tested (Windows first of all: I have no Windows PC and would be glad of a tester), and six questions at the end. It stays a draft until the first of them, a socket in the host at all, is answered.
mooki000 commented 2026-11-12 01:02:27 +00:00 (Migrated from github.com)

@NotRexed @hckrman101 an update that bears on the questions in this draft's description, and one new question.

Two co-op drafts are becoming one. itzdaface opened #2578 the same evening as this stack: Windows only, a direct link over a VPN, and well ahead of mine on what players see (the other players as animated Links, shared enemies, bosses, PvP, played with real friends). We agreed there to put them together under this pull request: this stack as the link, the session, consent and progress sync, and his bodies, enemies, bosses and PvP on top as his own commits, ported to this stack's sealed messages. He is building this branch on Windows first, which nobody has done yet. Both drafts stay drafts until you have answered question 2 here: whether a socket in the host is acceptable at all.

Stages (question 1 here, now larger). Together this is a lot to review. Would you take it in this order, each stage off by default and reviewable alone?

  1. the link and the session (commits 1 to 3 here: no contact with the game)
  2. progress sync and the Co-op tab (commits 4 to 6; the text fields are also alone as #2594)
  3. the other players' bodies
  4. shared enemies and bosses
  5. PvP

The relay (question 3 here, now more pressing). His players asked why co-op needs a VPN, and a relay that someone has to run is the same hurdle moved. I am adding "host on this PC": the relay inside the game for a room's maker, so that on one home network there is nothing else to start, and across the internet the maker forwards one UDP port. Beyond that: would the project consider hosting a public relay? It cannot read or change what it forwards (the room's secret never reaches it), and it has limits per member, per network and in all.

@NotRexed @hckrman101 an update that bears on the questions in this draft's description, and one new question. **Two co-op drafts are becoming one.** itzdaface opened #2578 the same evening as this stack: Windows only, a direct link over a VPN, and well ahead of mine on what players see (the other players as animated Links, shared enemies, bosses, PvP, played with real friends). We agreed there to put them together under this pull request: this stack as the link, the session, consent and progress sync, and his bodies, enemies, bosses and PvP on top as his own commits, ported to this stack's sealed messages. He is building this branch on Windows first, which nobody has done yet. Both drafts stay drafts until you have answered question 2 here: whether a socket in the host is acceptable at all. **Stages** (question 1 here, now larger). Together this is a lot to review. Would you take it in this order, each stage off by default and reviewable alone? 1. the link and the session (commits 1 to 3 here: no contact with the game) 2. progress sync and the Co-op tab (commits 4 to 6; the text fields are also alone as #2594) 3. the other players' bodies 4. shared enemies and bosses 5. PvP **The relay** (question 3 here, now more pressing). His players asked why co-op needs a VPN, and a relay that someone has to run is the same hurdle moved. I am adding "host on this PC": the relay inside the game for a room's maker, so that on one home network there is nothing else to start, and across the internet the maker forwards one UDP port. Beyond that: would the project consider hosting a public relay? It cannot read or change what it forwards (the room's secret never reaches it), and it has limits per member, per network and in all.
itzdaface commented 2026-11-12 02:21:55 +00:00 (Migrated from github.com)

@mooki000 Windows results for 71d4ab0f, as promised. Short version: one compile error, a two-line fix, and after that everything I tried works.

Setup: Windows 11, Ryzen 7 7700X, RTX 3060 12 GB, 32 GB RAM. Built with nx2/make_game.py --update into its own game folder, using the Python and llvm-mingw (clang 23.1.5) that ship with the v2.1.0 Hookshot Builder. The relay ran under Python 3.14.5 on the same PC.

1. Does it build? Not as it is, by one name

host/oot_coop.c has a static function called bind. On Windows coop_net.h pulls in winsock2.h, which declares bind(SOCKET, ...):

host/oot_coop.c:1061:13: error: conflicting types for 'bind'
host/oot_coop.c:1373:30: error: too few arguments to function call, expected 3, have 2

It was the only file in the whole build that failed. The fix is a rename:

-static void bind(Ctx *c, uint64_t mgr) {
+static void bind_tables(Ctx *c, uint64_t mgr) {
-    if (!G.bound) bind(c, mgr);
+    if (!G.bound) bind_tables(c, mgr);

With that, everything compiles and links, Monocypher and -lws2_32 -lbcrypt included. I did not run the test suite, so I have nothing on the two tests you said don't build on the Windows job.

2. Does it run? Yes

First start prepared 38,809 shader programs and the pipelines and ended cleanly. My NVIDIA driver faulted on one pipeline during that, which the game skipped. That also happens on this PC with release builds, so I don't think it is yours.

3. Make a room and Join through a relay on 127.0.0.1? Yes, both ways

Two games on one PC, two folders, from the environment (HOS_COOP_AUTO=create and join, a fixed HOS_COOP_ROOM, both headless):

  • maker: the relay answered in 3 ms, room 234… created (member 1), then Second (member 2) joined the room
  • joiner: the relay answered in 2 ms, Kuba (member 1) joined the room, joined room 234… as member 2
  • each game listed the other with its key fingerprint, and both got to "a room to accept"
  • stats on both: thrown away 0 (tag 0, sender 0, not co-op 0, replay 0), outbox full 0, round trip 1 ms
  • after I killed the games the relay dropped each one as silent 15 s, as the doc says

By hand, from the Co-op tab, in a window, with a second headless game waiting in a room:

  • typing in Your name and Relay works (so WM_CHAR is fine)
  • Make a room works: room XHM… created (member 1) in the game, created and joined as member 1 in the relay
  • Leave the room works: the relay logged Kuba (member 1) left and closed the room at once
  • Join a room with a typed secret works: joined room 234… as member 2, and the tab listed the other player

4. What does the firewall ask? Nothing

No Windows Firewall prompt appeared, and there is no firewall rule for either test exe afterwards. That is with the relay on loopback only, so it says nothing yet about a relay on another machine.

Not tested yet

  • Accept the room and real progress sync between the two games. That is next.
  • A relay on another PC or a server, and Windows with Linux in one room.
  • Controllers in the tab, and the test suite.

Two small notes

  • A headless game (HOS_HEADLESS) does not close when Windows asks it to (taskkill without /f; there is no window to take the request), so I had to force it. That is why the headless runs show silent 15 s and not a goodbye. From the window, Leave says goodbye properly.
  • For "copy the built game folder twice" on Windows: the romfs is 24 GB, and I was short on disk. Hard-linking the romfs files into the second folder (os.link) costs no space and worked fine, since the game only reads them. Might be worth a line in the doc.

The fix

It is one commit on my branch (coop-win, on top of your head). If you add me (itzdaface) as a collaborator on your fork I'll push it straight onto coop-relay-wire, and later the body, enemy and PvP commits the same way. If you'd rather review each one, I'll open pull requests against your branch. Or just take the two lines above; it's faster than either.

@mooki000 Windows results for `71d4ab0f`, as promised. Short version: one compile error, a two-line fix, and after that everything I tried works. **Setup:** Windows 11, Ryzen 7 7700X, RTX 3060 12 GB, 32 GB RAM. Built with `nx2/make_game.py --update` into its own game folder, using the Python and llvm-mingw (clang 23.1.5) that ship with the v2.1.0 Hookshot Builder. The relay ran under Python 3.14.5 on the same PC. ### 1. Does it build? Not as it is, by one name `host/oot_coop.c` has a static function called `bind`. On Windows `coop_net.h` pulls in `winsock2.h`, which declares `bind(SOCKET, ...)`: ``` host/oot_coop.c:1061:13: error: conflicting types for 'bind' host/oot_coop.c:1373:30: error: too few arguments to function call, expected 3, have 2 ``` It was the only file in the whole build that failed. The fix is a rename: ```diff -static void bind(Ctx *c, uint64_t mgr) { +static void bind_tables(Ctx *c, uint64_t mgr) { - if (!G.bound) bind(c, mgr); + if (!G.bound) bind_tables(c, mgr); ``` With that, everything compiles and links, Monocypher and `-lws2_32 -lbcrypt` included. I did not run the test suite, so I have nothing on the two tests you said don't build on the Windows job. ### 2. Does it run? Yes First start prepared 38,809 shader programs and the pipelines and ended cleanly. My NVIDIA driver faulted on one pipeline during that, which the game skipped. That also happens on this PC with release builds, so I don't think it is yours. ### 3. Make a room and Join through a relay on 127.0.0.1? Yes, both ways **Two games on one PC, two folders, from the environment** (`HOS_COOP_AUTO=create` and `join`, a fixed `HOS_COOP_ROOM`, both headless): - maker: `the relay answered in 3 ms`, `room 234… created (member 1)`, then `Second (member 2) joined the room` - joiner: `the relay answered in 2 ms`, `Kuba (member 1) joined the room`, `joined room 234… as member 2` - each game listed the other with its key fingerprint, and both got to "a room to accept" - stats on both: thrown away 0 (tag 0, sender 0, not co-op 0, replay 0), outbox full 0, round trip 1 ms - after I killed the games the relay dropped each one as `silent 15 s`, as the doc says **By hand, from the Co-op tab,** in a window, with a second headless game waiting in a room: - typing in **Your name** and **Relay** works (so `WM_CHAR` is fine) - **Make a room** works: `room XHM… created (member 1)` in the game, `created` and `joined as member 1` in the relay - **Leave the room** works: the relay logged `Kuba (member 1) left` and closed the room at once - **Join a room** with a typed secret works: `joined room 234… as member 2`, and the tab listed the other player ### 4. What does the firewall ask? Nothing No Windows Firewall prompt appeared, and there is no firewall rule for either test exe afterwards. That is with the relay on loopback only, so it says nothing yet about a relay on another machine. ### Not tested yet - **Accept the room** and real progress sync between the two games. That is next. - A relay on another PC or a server, and Windows with Linux in one room. - Controllers in the tab, and the test suite. ### Two small notes - A headless game (`HOS_HEADLESS`) does not close when Windows asks it to (`taskkill` without `/f`; there is no window to take the request), so I had to force it. That is why the headless runs show `silent 15 s` and not a goodbye. From the window, Leave says goodbye properly. - For "copy the built game folder twice" on Windows: the romfs is 24 GB, and I was short on disk. Hard-linking the romfs files into the second folder (`os.link`) costs no space and worked fine, since the game only reads them. Might be worth a line in the doc. ### The fix It is one commit on my branch (`coop-win`, on top of your head). If you add me (`itzdaface`) as a collaborator on your fork I'll push it straight onto `coop-relay-wire`, and later the body, enemy and PvP commits the same way. If you'd rather review each one, I'll open pull requests against your branch. Or just take the two lines above; it's faster than either.
mooki000 commented 2026-11-12 02:27:13 +00:00 (Migrated from github.com)

@itzdaface thank you, this is exactly the report this draft was missing.

The fix goes in as you wrote it (bind to bind_tables), as a commit with you as co-author, in the next push. I owe you an admission: a Windows-target syntax pass on my side showed this very error earlier today and it was misread as a fault of the check. That pass now runs over every co-op file before a push, and its errors count.

The head is about to move, so please wait for it before the next test. The next push replaces 71d4ab0f with the same six commits on current main (the Bug report row that merged today changes the same menu code as the Co-op tab), and adds:

  • your fix, and the two test fixes for the Windows job;
  • a fix that matters for exactly what you test next: on 71d4ab0f the room is offered again, and the save copied again, at every loading zone (the clock I watched to notice "another save was loaded" restarts with every area). Accept and sync work, but you would see the offer come back each time you change area;
  • the relay's limits raised and made options (480 copies and 256 KB a second per member);
  • the call that says whether this player has accepted a member, and a page for layer authors: how a body, enemy or PvP message is sent, received and checked;
  • "Host on this PC": the relay inside the game, for the room's maker.

The helper for messages that must arrive comes a little later: review found two defects in it (a cancelled half-sent event blocked later ones; acknowledgements of two attempts could add up), and it goes in when its tests pass.

Your commits: pull requests against coop-relay-wire on my fork, please. I merge them without squashing, so they stay yours, and I look at each the same day. I rebase that branch on main now and then and will say so here before I do.

Or the other way round, if you prefer: what matters to me is that this base ends up under your players, enemies and PvP, not which pull request carries it. If you would rather keep #2578 as the one, say so, and I open the base as a pull request against your feat/coop instead; this draft then stays the place where the base alone can be read and tested.

Your two notes: the hard-link tip goes into the doc. The windowless game that ignores a polite close on Windows is not co-op's alone; I will look at a console handler for it separately. And thank you for the firewall check: with the relay inside the game, the maker's game listens on a UDP port, so Windows will ask then; I will write down what it asks once one of us has seen it.

Edited: the change merged today is a Bug report row in the Gameplay tab, not a tab of its own; I had the wrong word.

@itzdaface thank you, this is exactly the report this draft was missing. **The fix** goes in as you wrote it (`bind` to `bind_tables`), as a commit with you as co-author, in the next push. I owe you an admission: a Windows-target syntax pass on my side showed this very error earlier today and it was misread as a fault of the check. That pass now runs over every co-op file before a push, and its errors count. **The head is about to move**, so please wait for it before the next test. The next push replaces `71d4ab0f` with the same six commits on current main (the Bug report row that merged today changes the same menu code as the Co-op tab), and adds: - your fix, and the two test fixes for the Windows job; - a fix that matters for exactly what you test next: on `71d4ab0f` the room is offered again, and the save copied again, at every loading zone (the clock I watched to notice "another save was loaded" restarts with every area). Accept and sync work, but you would see the offer come back each time you change area; - the relay's limits raised and made options (480 copies and 256 KB a second per member); - the call that says whether this player has accepted a member, and a page for layer authors: how a body, enemy or PvP message is sent, received and checked; - "Host on this PC": the relay inside the game, for the room's maker. The helper for messages that must arrive comes a little later: review found two defects in it (a cancelled half-sent event blocked later ones; acknowledgements of two attempts could add up), and it goes in when its tests pass. **Your commits:** pull requests against `coop-relay-wire` on my fork, please. I merge them without squashing, so they stay yours, and I look at each the same day. I rebase that branch on main now and then and will say so here before I do. **Or the other way round, if you prefer:** what matters to me is that this base ends up under your players, enemies and PvP, not which pull request carries it. If you would rather keep #2578 as the one, say so, and I open the base as a pull request against your `feat/coop` instead; this draft then stays the place where the base alone can be read and tested. **Your two notes:** the hard-link tip goes into the doc. The windowless game that ignores a polite close on Windows is not co-op's alone; I will look at a console handler for it separately. And thank you for the firewall check: with the relay inside the game, the maker's game listens on a UDP port, so Windows will ask then; I will write down what it asks once one of us has seen it. *Edited: the change merged today is a Bug report row in the Gameplay tab, not a tab of its own; I had the wrong word.*
itzdaface commented 2026-11-12 02:32:41 +00:00 (Migrated from github.com)

@mooki000 sounds good on all of it. Pull requests against coop-relay-wire works for me, and I'm happy with #2274 staying the one. I'll wait for the new head, rebuild, and test Accept, sync and "Host on this PC" on Windows, including what the firewall asks.

@mooki000 sounds good on all of it. Pull requests against `coop-relay-wire` works for me, and I'm happy with #2274 staying the one. I'll wait for the new head, rebuild, and test Accept, sync and "Host on this PC" on Windows, including what the firewall asks.
github-sync changed title from WIP: feat(coop): co-op rooms: a relay, progress sync, and a Co-op tab to make, join and accept to feat(coop): co-op rooms: a relay, progress sync, and a Co-op tab to make, join and accept 2026-11-12 06:22:57 +00:00
Owner

@mooki000 commented on GitHub · 2026-11-12 06:03 UTC · original

@itzdaface the new head is up: 2be28f89, 18 commits on main de866017 (it merges cleanly with main as of now). The branch was force-pushed once, as announced; rebase your coop-win on it and send your commits as pull requests against coop-relay-wire.

What is above the six commits you built:

  • 1c60356f your bind rename, with you as co-author; the macro IN in that file is renamed too (it shadowed a Windows header's).
  • 184cceb0, 1cd02fc4, 68088064: the two tests that did not build on the Windows job, and a fixture's thread count.
  • 1f1d0a72 the loading-zone fix: the room is no longer offered again and the save no longer copied again at every area change (it watched a clock that restarts in each area; it now watches the save's total play time). A real load of another save still asks again.
  • d2fc0f7f the relay's limits: 480 forwarded copies and 256 KiB a second per member (twice that as burst), as options of scripts/coop_relay.py.
  • 35931fa8, 2be28f89 messages that must arrive: coop_send_event(member, kind, bytes, n) and coop_on_event(kind, callback, user). Up to eight queued events of at most 16 KiB each, sent in 1024-byte parts, sealed anew at every resend until acknowledged, delivered whole, once and in order per sender; leaving, a new session or withdrawn consent cancels. An acknowledgement means "delivered to the callback", not "the game accepted it".
  • b8d0cbed coop_member_accepted(id): whether this player has accepted that member in this session. Being on the roster is not consent.
  • c517e746 docs/coop.md, "Adding a layer": kinds, the calls above, pacing, and what a receiver checks.
  • 97f56483, dcf84430 Host on this PC: with coop_host=1 in hookshot_options.ini (or HOS_COOP_HOST=1), Make a room starts the relay inside the maker's game (UDP 24680 unless the Relay field names another port). Off by default.

Tested here: game-free only, with GCC 16.2.1 and Clang 23.1.3: the protocol, session, relay, event, hosted-relay and text-field checks pass with both (I ran those six myself on this head); the loading-zone and the event fixes each fail on the old code. A Windows-target syntax pass over the 17 host files that are built on Windows shows no error and no clash with a Windows header name. Not done on this head: no game has been built or run from it yet, on Linux or Windows; a two-game session here is queued for tonight and I will post what it shows.

What would help most from Windows, in this order: does it build; Make, Join and Leave through python -I scripts/coop_relay.py --host 127.0.0.1 --port 24680 as before; then Accept and progress sync across a loading zone (the offer should come once, not at every door); then the same with the script stopped and coop_host=1 on the maker, and what the firewall asks.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 06:03 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6816046193) @itzdaface the new head is up: `2be28f89`, 18 commits on main `de866017` (it merges cleanly with main as of now). The branch was force-pushed once, as announced; rebase your `coop-win` on it and send your commits as pull requests against `coop-relay-wire`. **What is above the six commits you built:** - `1c60356f` your `bind` rename, with you as co-author; the macro `IN` in that file is renamed too (it shadowed a Windows header's). - `184cceb0`, `1cd02fc4`, `68088064`: the two tests that did not build on the Windows job, and a fixture's thread count. - `1f1d0a72` **the loading-zone fix**: the room is no longer offered again and the save no longer copied again at every area change (it watched a clock that restarts in each area; it now watches the save's total play time). A real load of another save still asks again. - `d2fc0f7f` the relay's limits: 480 forwarded copies and 256 KiB a second per member (twice that as burst), as options of `scripts/coop_relay.py`. - `35931fa8`, `2be28f89` **messages that must arrive**: `coop_send_event(member, kind, bytes, n)` and `coop_on_event(kind, callback, user)`. Up to eight queued events of at most 16 KiB each, sent in 1024-byte parts, sealed anew at every resend until acknowledged, delivered whole, once and in order per sender; leaving, a new session or withdrawn consent cancels. An acknowledgement means "delivered to the callback", not "the game accepted it". - `b8d0cbed` `coop_member_accepted(id)`: whether this player has accepted that member in this session. Being on the roster is not consent. - `c517e746` `docs/coop.md`, "Adding a layer": kinds, the calls above, pacing, and what a receiver checks. - `97f56483`, `dcf84430` **Host on this PC**: with `coop_host=1` in `hookshot_options.ini` (or `HOS_COOP_HOST=1`), Make a room starts the relay inside the maker's game (UDP 24680 unless the Relay field names another port). Off by default. **Tested here:** game-free only, with GCC 16.2.1 and Clang 23.1.3: the protocol, session, relay, event, hosted-relay and text-field checks pass with both (I ran those six myself on this head); the loading-zone and the event fixes each fail on the old code. A Windows-target syntax pass over the 17 host files that are built on Windows shows no error and no clash with a Windows header name. **Not done on this head: no game has been built or run from it yet**, on Linux or Windows; a two-game session here is queued for tonight and I will post what it shows. **What would help most from Windows, in this order:** does it build; Make, Join and Leave through `python -I scripts/coop_relay.py --host 127.0.0.1 --port 24680` as before; then Accept and progress sync across a loading zone (the offer should come once, not at every door); then the same with the script stopped and `coop_host=1` on the maker, and what the firewall asks. <!-- github-sync:c:6816046193 -->
Owner

@mooki000 commented on GitHub · 2026-11-12 08:14 UTC · original

The two-game session on the new line, as promised. Built from 2be28f89; the head is now 2f412422, which adds two test-only commits for the Windows checks (a fixture helper named accept beside winsock's, and an empty HOS_COOP_HOST, which a Windows process cannot hold), so the game code is the same.

Setup: Arch Linux, RTX 4090 (NVIDIA 615.78.08), one full build (11 min 24 s) and two copies of it, two games on this PC with their own saves, the relay script on loopback, no window. Everything co-op was set in the Co-op tab by scripted input: no HOS_COOP_AUTO, no HOS_COOP_ACCEPT.

What it showed, in order (2026-11-12 02:04:26 to 02:09:14 local, 288 s; both games exited 0):

  1. Alice makes a room from the tab; Bob joins with the secret.
  2. Each accepts the other by hand (the second press confirms).
  3. Before anything is applied, each game copies its save: both copies are byte-equal to their 14-file starting saves.
  4. A sword granted to Alice arrives and is applied on Bob; a song marker set on Bob arrives and is applied on Alice.
  5. Alice renames herself Alice2: Bob gets a new offer and accepts again.
  6. Both quit through the control bridge; the relay logs a Leave for each, no timeout.

No fatal line, crash or device loss in either log, and no kernel or driver message during the run. The run's three checklists (the stack's, the tab's and the older session's) pass with no failure.

1: Alice made the room from the Co-op tab. 2: Bob confirms acceptance. 3: accepted, the offer row gone. 4: Alice changes her name to Alice2. 5: Bob sees Alice2 as a new offer. 6: Bob accepts Alice2. The room secret is masked by the game in all six

Not shown by this run: the relay inside the maker's game (a link-only run of it is next on this PC tonight), a loading zone after acceptance (the fix is shown by its game-free test only), two PCs, Windows, a controller, more than two players.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 08:14 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6818394613) The two-game session on the new line, as promised. Built from `2be28f89`; the head is now `2f412422`, which adds two test-only commits for the Windows checks (a fixture helper named `accept` beside winsock's, and an empty `HOS_COOP_HOST`, which a Windows process cannot hold), so the game code is the same. **Setup:** Arch Linux, RTX 4090 (NVIDIA 615.78.08), one full build (11 min 24 s) and two copies of it, two games on this PC with their own saves, the relay script on loopback, no window. Everything co-op was set in the Co-op tab by scripted input: no `HOS_COOP_AUTO`, no `HOS_COOP_ACCEPT`. **What it showed, in order** (2026-11-12 02:04:26 to 02:09:14 local, 288 s; both games exited 0): 1. Alice makes a room from the tab; Bob joins with the secret. 2. Each accepts the other by hand (the second press confirms). 3. Before anything is applied, each game copies its save: both copies are byte-equal to their 14-file starting saves. 4. A sword granted to Alice arrives and is applied on Bob; a song marker set on Bob arrives and is applied on Alice. 5. Alice renames herself Alice2: Bob gets a new offer and accepts again. 6. Both quit through the control bridge; the relay logs a Leave for each, no timeout. No fatal line, crash or device loss in either log, and no kernel or driver message during the run. The run's three checklists (the stack's, the tab's and the older session's) pass with no failure. ![1: Alice made the room from the Co-op tab. 2: Bob confirms acceptance. 3: accepted, the offer row gone. 4: Alice changes her name to Alice2. 5: Bob sees Alice2 as a new offer. 6: Bob accepts Alice2. The room secret is masked by the game in all six](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/21c6a230767de5e645ae95970cb065616f3679d8/hookshot-coop-next-2be28f89-tab.jpg) **Not shown by this run:** the relay inside the maker's game (a link-only run of it is next on this PC tonight), a loading zone after acceptance (the fix is shown by its game-free test only), two PCs, Windows, a controller, more than two players. <!-- github-sync:c:6818394613 -->
Owner

@mooki000 commented on GitHub · 2026-11-12 08:21 UTC · original

The in-game relay, two games on this PC, same build (2be28f89), 02:14:39 to 02:16:41 local:

  • Shown: with coop_host=1 on the maker only and no relay script running, Alice's game logs hosting on this PC, makes the room, and its own relay admits Bob; each game sees the other in the room.
  • Not shown, and the run's own last check failed: the script then stops the maker and expects Bob to notice that the room ended. The stop did not reach the game process (it ended the wrappers around it; both games were still running and were ended afterwards from outside), so the check "B did not notice that its maker and relay ended" failed for a reason that says nothing about the game either way. No kernel or driver message during the run.
  • Still unshown over the in-game relay: acceptance and progress, a Leave from the tab, and what the joiner sees when the maker closes.

I am repairing the stop step and running that part once more tonight; the result goes here.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 08:21 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6818523676) The in-game relay, two games on this PC, same build (`2be28f89`), 02:14:39 to 02:16:41 local: - **Shown:** with `coop_host=1` on the maker only and no relay script running, Alice's game logs `hosting on this PC`, makes the room, and its own relay admits Bob; each game sees the other in the room. - **Not shown, and the run's own last check failed:** the script then stops the maker and expects Bob to notice that the room ended. The stop did not reach the game process (it ended the wrappers around it; both games were still running and were ended afterwards from outside), so the check "B did not notice that its maker and relay ended" failed for a reason that says nothing about the game either way. No kernel or driver message during the run. - **Still unshown over the in-game relay:** acceptance and progress, a Leave from the tab, and what the joiner sees when the maker closes. I am repairing the stop step and running that part once more tonight; the result goes here. <!-- github-sync:c:6818523676 -->
Owner

@mooki000 commented on GitHub · 2026-11-12 08:38 UTC · original

The repaired rerun of the in-game relay's last step (same build 2be28f89, 02:33:34 to 02:34:58 local): passes.

  • The earlier stop had signalled the wrapper's process group; the game sat in another one. The repaired stop signals the game's own process: the maker's game ended 0.25 s after SIGTERM.
  • Only after that exit, Bob's game logs coop: relay lost; rejoining, then coop: connecting to the relay and repeated connection attempts. So the joiner notices that the maker and its relay are gone.
  • Both of the run's link checks pass (make and join through the maker's own relay; the loss noticed). No kernel or driver message.

Not claimed: that Bob can do anything useful after the loss (he keeps trying to reconnect to a relay that is gone; what the tab should say then is a design question for this pull request), acceptance or progress over the in-game relay, a Leave from the tab.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 08:38 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6818855545) The repaired rerun of the in-game relay's last step (same build `2be28f89`, 02:33:34 to 02:34:58 local): **passes.** - The earlier stop had signalled the wrapper's process group; the game sat in another one. The repaired stop signals the game's own process: the maker's game ended 0.25 s after SIGTERM. - Only after that exit, Bob's game logs `coop: relay lost; rejoining`, then `coop: connecting to the relay` and repeated connection attempts. So the joiner notices that the maker and its relay are gone. - Both of the run's link checks pass (make and join through the maker's own relay; the loss noticed). No kernel or driver message. Not claimed: that Bob can do anything useful after the loss (he keeps trying to reconnect to a relay that is gone; what the tab should say then is a design question for this pull request), acceptance or progress over the in-game relay, a Leave from the tab. <!-- github-sync:c:6818855545 -->
Owner

@mooki000 commented on GitHub · 2026-11-12 12:46 UTC · original

First, a correction to my reply on #2578: one “damage taken so far, highest wins” total loses simultaneous hits from different players. Keep a cumulative component per member/session and enemy incarnation, merge each component by maximum, then sum and clamp to the agreed life cap. Repeated state adds nothing; received damage must not be counted again as a local hit. The pushed “Adding a layer” docs carried the same wrong sentence; it is corrected in a4c080e0.

@itzdaface, the base is now a4c080e0. The game code is still that of 2be28f89: the intervening commits are two test fixes and this documentation correction. The two-game tab result therefore covers the same game code: acceptance, save backups and progress exchange through the separate relay. The in-game relay has shown make/join and noticing the maker's exit, not yet acceptance/progress or tab Leave over that relay.

For your port, I'd keep the split we agreed:

  • Bodies: their own message beside the small POS message, keeping your animation and smoothing.
  • Enemies/bosses: aggregate state instead of one packet per enemy, keep your nearest-player hand-over, and carry the component damage history across leader changes. A missing record is not a death.
  • One-offs: use the base's coop_send_event / coop_on_event for PvP requests and boss hand-over, rather than another retry loop. Only COOP_EVENT_QUEUED means it queued; handle a full queue. An ACK means callback delivery, not accepted damage.

Callbacks borrow their bytes on the co-op thread: copy bounded work for the game update, then recheck accepted member/session, signed feature permission, area/incarnation and freshness there. Keep mutual PvP opt-in and your cutscene/recovery guard; let the receiver adjudicate a hit rather than trusting a requested heart subtraction. Retries have no time limit while the same accepted sessions remain, so stale hits must still be rejected.

Your planned Windows Accept/sync/hosting checks remain useful, especially a loading zone after acceptance. The base does not yet contain the body, enemy or PvP implementation; those remain your gameplay commits, through pull requests against coop-relay-wire as agreed.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 12:46 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6823991481) First, a correction to my reply on #2578: **one “damage taken so far, highest wins” total loses simultaneous hits from different players.** Keep a cumulative component per member/session and enemy incarnation, merge each component by maximum, then sum and clamp to the agreed life cap. Repeated state adds nothing; received damage must not be counted again as a local hit. The pushed “Adding a layer” docs carried the same wrong sentence; it is corrected in `a4c080e0`. @itzdaface, the base is now `a4c080e0`. The game code is still that of `2be28f89`: the intervening commits are two test fixes and this documentation correction. The [two-game tab result](https://git.magicbean.dev/MagicBeanDev/Hookshot/pulls/2274) therefore covers the same game code: acceptance, save backups and progress exchange through the separate relay. The in-game relay has shown make/join and [noticing the maker's exit](https://git.magicbean.dev/MagicBeanDev/Hookshot/pulls/2274), not yet acceptance/progress or tab Leave over that relay. For your port, I'd keep the split we agreed: - **Bodies:** their own message beside the small POS message, keeping your animation and smoothing. - **Enemies/bosses:** aggregate state instead of one packet per enemy, keep your nearest-player hand-over, and carry the component damage history across leader changes. A missing record is not a death. - **One-offs:** use the base's `coop_send_event` / `coop_on_event` for PvP requests and boss hand-over, rather than another retry loop. Only `COOP_EVENT_QUEUED` means it queued; handle a full queue. An ACK means callback delivery, not accepted damage. Callbacks borrow their bytes on the co-op thread: copy bounded work for the game update, then recheck accepted member/session, signed feature permission, area/incarnation and freshness there. Keep mutual PvP opt-in and your cutscene/recovery guard; let the receiver adjudicate a hit rather than trusting a requested heart subtraction. Retries have no time limit while the same accepted sessions remain, so stale hits must still be rejected. Your planned Windows Accept/sync/hosting checks remain useful, especially a loading zone after acceptance. The base does not yet contain the body, enemy or PvP implementation; those remain your gameplay commits, through pull requests against `coop-relay-wire` as agreed. <!-- github-sync:c:6823991481 -->
Owner

@mooki000 commented on GitHub · 2026-11-12 17:32 UTC · original

Three proofs from two games running together, as promised for this draft.

Floor-switch flag SHOWN; Lens of Truth SHOWN; clear-room sharing NOT SHOWN by this method.
Two windowless games on one Linux PC (RTX 4090), separate saves, automatic room acceptance and a local relay; not a Windows or two-PC test.

Floor-switch flag — binaries built from 2be28f89, with identical game code to this PR's a4c080e0.
A walked onto the real switch; B stood still. With progress sharing on, both players' flag changed from 0 to 1.
Extracted sender log: [hos host] coop: sync: sent progress 6fb4f455 = 1 to all.
Extracted receiver log: [hos Presentation Thread] coop: sync: the game shows progress 6fb4f455 = 1 (its getter agrees).
With progress sharing off, the same steps set A's flag to 1 while B stayed at 0; the shared-flag assertion correctly failed.
Nothing visible changes on B's screen for this one, so the evidence is the flag in both games (the log lines above and each game's getter), not a picture. The permanent flag transfers; the live switch and its platform timer are not shared.

Lens of Truth — binaries built from 2be28f89, with identical game code to this PR's a4c080e0.
A received the Lens through a debug aid, not from a chest. B received no local grant before the inventory pictures.
Extracted receiver log: [hos Presentation Thread] coop: sync: applied items 49d1c374 = 1.
Receiver's later getter: [hos BaseProcExecutor3] cheats: inventory Item_GlassTruth +1 (had 1, now 1, event status 2).
Both Equipment pages start with an empty Lens slot and then show the goggles, including B's unselected icon.
With item sharing off, A still gets the goggles but B has neither the count nor the Equipment icon; the same transfer assertion fails.
Negative receiver getter: [hos BaseProcExecutor4] cheats: inventory Item_GlassTruth +1 (had 0, now 0, event status 2).
Getter probes happen after the pictures; their had value is the prior count, and the negative probe itself then grants the item locally.
The Equipment page after the grant, player A left and player B right; sharing on above, off below:

The Equipment page of both players after A got the Lens: with item sharing on B has the goggles too, with it off B's slot is empty

Clear room: NOT SHOWN by this method. The scripted sender never clears its room with sharing on or off, so the response to a real clear was not tested. This is a gap in the test, not a finding about the feature; it needs a working clear route or a player to perform the clear.
All games in these four shown-proof runs exited normally, without logged runtime or driver faults. No performance claim or actual Lens-use claim is made.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 17:32 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6831046415) Three proofs from two games running together, as promised for this draft. **Floor-switch flag SHOWN; Lens of Truth SHOWN; clear-room sharing NOT SHOWN by this method.** Two windowless games on one Linux PC (RTX 4090), separate saves, automatic room acceptance and a local relay; not a Windows or two-PC test. **Floor-switch flag — binaries built from `2be28f89`, with identical game code to this PR's `a4c080e0`.** A walked onto the real switch; B stood still. With progress sharing on, both players' flag changed from 0 to 1. Extracted sender log: `[hos host] coop: sync: sent progress 6fb4f455 = 1 to all`. Extracted receiver log: `[hos Presentation Thread] coop: sync: the game shows progress 6fb4f455 = 1 (its getter agrees)`. With progress sharing off, the same steps set A's flag to 1 while B stayed at 0; the shared-flag assertion correctly failed. Nothing visible changes on B's screen for this one, so the evidence is the flag in both games (the log lines above and each game's getter), not a picture. The permanent flag transfers; the live switch and its platform timer are not shared. **Lens of Truth — binaries built from `2be28f89`, with identical game code to this PR's `a4c080e0`.** A received the Lens through a debug aid, not from a chest. B received no local grant before the inventory pictures. Extracted receiver log: `[hos Presentation Thread] coop: sync: applied items 49d1c374 = 1`. Receiver's later getter: `[hos BaseProcExecutor3] cheats: inventory Item_GlassTruth +1 (had 1, now 1, event status 2)`. Both Equipment pages start with an empty Lens slot and then show the goggles, including B's unselected icon. With item sharing off, A still gets the goggles but B has neither the count nor the Equipment icon; the same transfer assertion fails. Negative receiver getter: `[hos BaseProcExecutor4] cheats: inventory Item_GlassTruth +1 (had 0, now 0, event status 2)`. Getter probes happen after the pictures; their `had` value is the prior count, and the negative probe itself then grants the item locally. The Equipment page after the grant, player A left and player B right; sharing on above, off below: ![The Equipment page of both players after A got the Lens: with item sharing on B has the goggles too, with it off B's slot is empty](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/b8edfef39a99007125aeebd9fbdc09b7c3221955/hookshot-coop-proof-lens-equipment.jpg) **Clear room: NOT SHOWN by this method.** The scripted sender never clears its room with sharing on or off, so the response to a real clear was not tested. This is a gap in the test, not a finding about the feature; it needs a working clear route or a player to perform the clear. All games in these four shown-proof runs exited normally, without logged runtime or driver faults. No performance claim or actual Lens-use claim is made. <!-- github-sync:c:6831046415 -->
Owner

@mooki000 commented on GitHub · 2026-11-12 18:33 UTC · original

A note on direction, so nobody is surprised: in #2892 I have proposed that co-op become the first module on a small mod interface (the same one a randomizer or a restoration mod would use), instead of landing in the core as this draft has it. That delays co-op, and I think it is the better shape for the project. Whether native modules are acceptable at all is the maintainers' call, and it is one of the questions there.

Until that is answered this draft stays the working branch: it is being rebased over today's main, with a fix for the Windows check that fails here (two test modes shared one UDP port; on Windows a wildcard socket and a loopback one can both bind it, so the fix takes the port exclusively).

@itzdaface this touches what we agreed about combining our work: the link, the session and your layers on top would stay as planned, only where they live would change. Your view is welcome in #2892.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 18:33 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6832470016) A note on direction, so nobody is surprised: in #2892 I have proposed that co-op become the first module on a small mod interface (the same one a randomizer or a restoration mod would use), instead of landing in the core as this draft has it. That delays co-op, and I think it is the better shape for the project. Whether native modules are acceptable at all is the maintainers' call, and it is one of the questions there. Until that is answered this draft stays the working branch: it is being rebased over today's main, with a fix for the Windows check that fails here (two test modes shared one UDP port; on Windows a wildcard socket and a loopback one can both bind it, so the fix takes the port exclusively). @itzdaface this touches what we agreed about combining our work: the link, the session and your layers on top would stay as planned, only where they live would change. Your view is welcome in #2892. <!-- github-sync:c:6832470016 -->
Commenting is not possible because the repository is archived.
No description provided.