feat(bridge): protocol 2 — the read path, and the first CI this repo has had #2

Merged
whitlocktech merged 2 commits from feat/phase-3-read-path into main 2026-09-16 16:36:59 +00:00
Member

Fifteen hooks: presence, deaths, chat, gathering, raided structures, bans, reports, login attempts and the wipe. Every frame carries an envelope — type, serverId, wipeId — built in one place, so nothing can emit a frame without one.

Proven on live Oxide and Carbon 2.0.259.0 from the byte-identical file.

Three rules the code enforces structurally

  • A read-path hook never vetoes. Four of these are documented as "returning a non-null value overrides default behavior", so every hook is declared void and cannot answer. A return null today is one edit from return true tomorrow, and the edit looks harmless in a diff; a signature change does not. CanUserLogin is in the wave for what it observes.
  • A hook that can fire more than once a second per player is a counter. OnDispenserGather fires on every swing at a tree. It accumulates into a per-player tally flushed once a minute as one player.tally frame — as a delta, so a dropped frame costs one interval instead of corrupting the series.
  • wipeId is derived here, from the save's creation time, because this is the only component that can read it.

Two defects a live server found (second commit)

  • The wipe id was null for every real session. Init runs before the save is loaded. Invisible for two phases because a hot-reloaded plugin reads an already-loaded world and gets the right answer every time; it took a server that booted with the plugin — which is every real one — to show wipeId=none beside a save on disk.
  • Unload blocked the game's main thread for 2002 ms, reported by Carbon as hook 'Unload' took longer than 100ms next to link thread did not stop cleanly. The link thread sits in a blocking TcpClient.Connect, which has no timeout and cannot be woken. A host that refuses answers instantly; a host that drops does not answer at all — every loopback test is the first kind. Now bounded, with a stop handle of its own (it cannot share Wake, which also means "the queue has something in it").

After the fix: no slow-hook warning, no stranded thread.

The CI this repo did not have

No .gitea/workflows/ existed at all. scripts/checkPlugin.js asks the three questions a compiler here cannot — this plugin is deployed as source and built by the framework against game assemblies that exist only on a Rust server:

  1. every hook is in ExpectedHooks, so rg.hooks can report it;
  2. every hook is void, unless answering is written down in ANSWERS_DELIBERATELY (currently empty);
  3. ProtocolVersion agrees with overlay.toml.

The void rule is inverted on purpose. A list of vetoable hooks would be maintained here against a catalogue in another repository, and the first one somebody forgot to add is the one that would pass.

Its own suite breaks it seven ways — including the failure that would make the other six meaningless: a method parser that silently matches nothing.

Spec: docs/rust-link/PROTOCOL.md §8 (RunicGateway/docs#255).


  • AI-assisted: written with Claude Code (Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

Fifteen hooks: presence, deaths, chat, gathering, raided structures, bans, reports, login attempts and the wipe. Every frame carries an envelope — `type`, `serverId`, `wipeId` — built in one place, so nothing can emit a frame without one. **Proven on live Oxide *and* Carbon 2.0.259.0 from the byte-identical file.** ## Three rules the code enforces structurally - **A read-path hook never vetoes.** Four of these are documented as *"returning a non-null value overrides default behavior"*, so every hook is declared **`void`** and cannot answer. A `return null` today is one edit from `return true` tomorrow, and the edit looks harmless in a diff; a signature change does not. `CanUserLogin` is in the wave for what it *observes*. - **A hook that can fire more than once a second per player is a counter.** `OnDispenserGather` fires on every swing at a tree. It accumulates into a per-player tally flushed once a minute as one `player.tally` frame — as a **delta**, so a dropped frame costs one interval instead of corrupting the series. - **`wipeId` is derived here**, from the save's creation time, because this is the only component that can read it. ## Two defects a live server found (second commit) - **The wipe id was null for every real session.** `Init` runs *before* the save is loaded. Invisible for two phases because a hot-reloaded plugin reads an already-loaded world and gets the right answer every time; it took a server that **booted** with the plugin — which is every real one — to show `wipeId=none` beside a save on disk. - **`Unload` blocked the game's main thread for 2002 ms**, reported by Carbon as `hook 'Unload' took longer than 100ms` next to `link thread did not stop cleanly`. The link thread sits in a blocking `TcpClient.Connect`, which has no timeout and cannot be woken. **A host that refuses answers instantly; a host that drops does not answer at all** — every loopback test is the first kind. Now bounded, with a stop handle of its own (it cannot share `Wake`, which also means "the queue has something in it"). After the fix: no slow-hook warning, no stranded thread. ## The CI this repo did not have No `.gitea/workflows/` existed at all. `scripts/checkPlugin.js` asks the three questions a compiler here cannot — this plugin is deployed as *source* and built by the framework against game assemblies that exist only on a Rust server: 1. every hook is in `ExpectedHooks`, so **`rg.hooks`** can report it; 2. every hook is `void`, unless answering is written down in `ANSWERS_DELIBERATELY` (currently empty); 3. `ProtocolVersion` agrees with `overlay.toml`. **The void rule is inverted on purpose.** A list of *vetoable* hooks would be maintained here against a catalogue in another repository, and the first one somebody forgot to add is the one that would pass. Its own suite breaks it seven ways — including the failure that would make the other six meaningless: a method parser that silently matches nothing. Spec: `docs/rust-link/PROTOCOL.md` §8 (RunicGateway/docs#255). --- - [x] AI-assisted: written with Claude Code (Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 2 commits 2026-09-16 13:40:31 +00:00
Fifteen hooks: presence, deaths, chat, gathering, raided structures, bans,
reports, login attempts and the wipe. Every frame now carries an envelope —
`type`, `serverId` and `wipeId` — built in one place so nothing can emit a frame
without one.

Three rules the code enforces structurally rather than by intention:

  • A read-path hook never vetoes. Four of these are documented as "returning a
    non-null value overrides default behavior", so every hook is declared `void`
    and cannot answer. `CanUserLogin` is in the wave for what it observes.

  • A hook that can fire more than once a second per player is a counter.
    `OnDispenserGather` fires on every swing at a tree; it accumulates into a
    per-player tally flushed once a minute as one `player.tally` frame, as a
    delta rather than a running total.

  • `wipeId` is derived here, from the save's creation time, because this is the
    only component that can read it. PROTOCOL.md §8.2 reverses protocol 1 on
    that point deliberately.

`server.hello` becomes a board rather than a greeting, and `players.online`
joins it; both are re-sent on connect and every 60 seconds, which is what makes
a restarted sidecar repopulate itself without asking.

`rg.hooks` reports which hooks have actually fired. Hooks bind by name and arity
through reflection on both frameworks, so a rename by Facepunch and a name
Carbon's catalogue omits present identically — as silence. This is the standing
answer to both, and it outranks either catalogue because it is a measurement.

The repository had no `.gitea/workflows/` at all. `scripts/checkPlugin.js` asks
the three questions a compiler here cannot: every hook is in `ExpectedHooks`, so
`rg.hooks` can see it; every hook is `void`, unless answering is a decision
written down in `ANSWERS_DELIBERATELY`; and `ProtocolVersion` agrees with
`overlay.toml`, which is what stops a bundle that will not compose. The void
rule is inverted on purpose — a list of *vetoable* hooks would have to be
maintained against a catalogue in another repository, and the first one somebody
forgot to add is the one that would pass. Its own suite breaks it seven ways,
including the failure that would make the other six meaningless: a method parser
that silently matches nothing.

Proven on a live Oxide server: compiled, loaded, the envelope correct, both ban
hooks firing, and the boards repopulating a sidecar whose database had been
deleted 0.3 seconds earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
fix(bridge): resolve the wipe at OnServerInitialized, and bound the connect
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
551359c08e
Two defects the Carbon rig found, neither visible on a development loopback.

**The wipe id was null for every real session.** `Init` runs before the save is
loaded, so `SaveRestore.SaveCreatedTime` is not yet meaningful there and the id
resolved at load time stayed null for the life of the process — every frame
shipped without the field R12 splits history on. It was invisible because a
hot-reloaded plugin reads an already-loaded world and gets the right answer every
time; it took a server that BOOTED with the plugin installed, which is every real
one, to show `wipeId=none` beside a save sitting on disk. Resolved again at
`OnServerInitialized`, and lazily while still unknown.

**Unload blocked the main thread for two seconds against an unreachable sidecar.**
Observed as `hook 'Unload' took longer than 100ms [2002ms]` next to `link thread
did not stop cleanly` — phase 1's stall arriving by a different road. The link
thread sits in a blocking `TcpClient.Connect`, which has no timeout of its own
and cannot be woken; a host that REFUSES answers instantly, which is every
loopback test, and a host that DROPS does not answer at all. The connect is now
bounded, and waits on a stop handle of its own so a reload abandons it at once.
It cannot share `Wake`, which also means 'the queue has something in it' and is
signalled by every hook.

After: the same reload logs no slow-hook warning and no stranded thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
whitlocktech merged commit 47bd166d91 into main 2026-09-16 16:36:59 +00:00
whitlocktech deleted branch feat/phase-3-read-path 2026-09-16 16:37:01 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Rust-Plugins#2
No description provided.