Powerup hit events on the game-state websocket #18

Closed
opened 2026-10-19 13:19:28 +00:00 by mintopia · 3 comments
Owner

What to build

A websocket message every time a racer is hit by a powerup, for every racer (local, AI, remote), in SP, split-screen and LAN races.

Blocked by the runtime spike #17. The field mapping below comes from static analysis and must be replaced by what the spike confirms.

Message shape

A new message type, not a key in the state feed:

{"type":"event","event":"hit","victim":3,"attacker":1,"powerup":"bolt","level":1,"shielded":false,"wrecked":false,"race_ms":63210}

attacker is absent when unknown (mines/world). powerup is lower-case: shunt, bolt, mine, shock, barge, plus whatever else the spike shows. race_ms (from #15) only if that ticket has landed; otherwise leave it out. If the spike finds damage or wreck events, add damage and a separate {"event":"wreck",...}.

Why a new type rather than change: the state feed (src/host/game_state.cpp) is last-value-wins. state::set only notifies when the value differs, so two identical hits in a row would be dropped, and the connect snapshot would replay a stale hit to new clients. Hits are one-shot events, so they need their own path that never goes into the snapshot. Unknown type values are ignorable by existing clients.

Where the data comes from

Confirmed by spike #17 (see its results comment and ADR 0016):

  • Hook 0x535770, the game event send (thiscall, this = hub 0x111d1e8; stack args channel, event_id, a2, payload). Handle only channel == 9 && event_id == 4; for everything else call the original straight away, since it runs about 250 times a second. Add it to HOOKS in tools/recomp/emit.py. This replaces the audio listener 0xc8ee40: the send null-checks that sink, so the hook doesn't depend on audio.
  • Payload: +0x8 u8 attacker (firer), +0x9 u8 victim, +0xa = victim for self-applied pickups, else 0xff. +0xc i32 powerup: 0 nitro, 1 shock, 2 shunt, 3 bolt, 4 barge, 5 mine, 6 repair. +0x14 u32 hit serial (the same on every LAN instance). Indices are racer +0x314, the same numbering as standings.
  • Self-applied kinds (0 Nitro, 6 Repair, attacker == victim) are pickups being used, not hits. Probably skip them, or tag them.
  • Every hit has a firer, mines included. A racer can hit itself with its own mine or Shock dome.
  • No level in the payload: +0x10 was always 0. Drop level from the message.
  • When the event fires, damage has already been applied. Read the victim racer:
    • health i32 +0x3dc
    • shielded = +0xbc > 0 (damage 0)
    • wrecked = health 0
    • damage = previous health (from the scan) minus current health. Or use the fixed values: Shock/Bolt 50 (one event per bolt), Shunt/Mine/Barge 100.
  • Collision damage and collision wrecks have no event. A separate wreck event needs the 5 Hz race_scan.cpp poll: health +0x3dc reaches 0 (+0x38c = 1) for 2.6-4 s.
  • Availability: SP (local + AI), split-screen (both players + AI), LAN (local and remote, on each instance). Remote-to-remote LAN hits weren't tested (needs 3 instances).

Implementation notes

  • New feed entry point, e.g. state::event(const std::string& json) in game_state.{h,cpp} with its own subscribers. src/host/state_ws.mm sends it to connected clients and nothing else (no snapshot, no BLUR_STATE_LOG key churn).
  • The hook runs on the guest thread: only copy the payload and queue it. JSON and sending happen off-thread, like the existing listeners. No cost when BLUR_STATE_WS is unset (the hook returns straight to the original).
  • Map racer indices the same way as standings (racer), so clients can join with names.

Acceptance criteria

  • tests/state_ws_test.mm: an emitted event reaches every connected client as {"type":"event",...}, is not in the snapshot sent to a client connecting later, and two identical events both arrive
  • Unit test for payload -> JSON mapping (powerup names, missing attacker)
  • docs/state-websocket.md documents the event message type and the hit fields
  • On recomp: hits on AI in SP, between split-screen players, and between remote players in a 2-instance LAN race all produce events (per the spike's availability matrix)
  • No measurable frame-time cost with no client connected
  • Verified on BLUR_ENGINE=recomp

Blocked by

## What to build A websocket message every time a racer is hit by a powerup, for every racer (local, AI, remote), in SP, split-screen and LAN races. **Blocked by the runtime spike #17.** The field mapping below comes from static analysis and must be replaced by what the spike confirms. ## Message shape A new message type, not a key in the state feed: ```json {"type":"event","event":"hit","victim":3,"attacker":1,"powerup":"bolt","level":1,"shielded":false,"wrecked":false,"race_ms":63210} ``` `attacker` is absent when unknown (mines/world). `powerup` is lower-case: `shunt`, `bolt`, `mine`, `shock`, `barge`, plus whatever else the spike shows. `race_ms` (from #15) only if that ticket has landed; otherwise leave it out. If the spike finds damage or wreck events, add `damage` and a separate `{"event":"wreck",...}`. Why a new type rather than `change`: the state feed (`src/host/game_state.cpp`) is last-value-wins. `state::set` only notifies when the value differs, so two identical hits in a row would be dropped, and the connect `snapshot` would replay a stale hit to new clients. Hits are one-shot events, so they need their own path that never goes into the snapshot. Unknown `type` values are ignorable by existing clients. ## Where the data comes from Confirmed by spike #17 (see its results comment and ADR 0016): - **Hook `0x535770`**, the game event send (thiscall, `this` = hub `0x111d1e8`; stack args `channel, event_id, a2, payload`). Handle only `channel == 9 && event_id == 4`; for everything else call the original straight away, since it runs about 250 times a second. Add it to `HOOKS` in `tools/recomp/emit.py`. This replaces the audio listener `0xc8ee40`: the send null-checks that sink, so the hook doesn't depend on audio. - Payload: `+0x8` u8 **attacker** (firer), `+0x9` u8 **victim**, `+0xa` = victim for self-applied pickups, else `0xff`. `+0xc` i32 powerup: 0 `nitro`, 1 `shock`, 2 `shunt`, 3 `bolt`, 4 `barge`, 5 `mine`, 6 `repair`. `+0x14` u32 hit serial (the same on every LAN instance). Indices are racer `+0x314`, the same numbering as `standings`. - Self-applied kinds (0 Nitro, 6 Repair, attacker == victim) are pickups being used, not hits. Probably skip them, or tag them. - Every hit has a firer, mines included. A racer can hit itself with its own mine or Shock dome. - No `level` in the payload: `+0x10` was always 0. Drop `level` from the message. - When the event fires, damage has already been applied. Read the victim racer: - **health** i32 `+0x3dc` - **shielded** = `+0xbc > 0` (damage 0) - **wrecked** = health 0 - `damage` = previous health (from the scan) minus current health. Or use the fixed values: Shock/Bolt 50 (one event per bolt), Shunt/Mine/Barge 100. - Collision damage and collision wrecks have no event. A separate `wreck` event needs the 5 Hz `race_scan.cpp` poll: health `+0x3dc` reaches 0 (`+0x38c` = 1) for 2.6-4 s. - Availability: SP (local + AI), split-screen (both players + AI), LAN (local and remote, on each instance). Remote-to-remote LAN hits weren't tested (needs 3 instances). ## Implementation notes - New feed entry point, e.g. `state::event(const std::string& json)` in `game_state.{h,cpp}` with its own subscribers. `src/host/state_ws.mm` sends it to connected clients and nothing else (no snapshot, no `BLUR_STATE_LOG` key churn). - The hook runs on the guest thread: only copy the payload and queue it. JSON and sending happen off-thread, like the existing listeners. No cost when `BLUR_STATE_WS` is unset (the hook returns straight to the original). - Map racer indices the same way as `standings` (`racer`), so clients can join with names. ## Acceptance criteria - [ ] `tests/state_ws_test.mm`: an emitted event reaches every connected client as `{"type":"event",...}`, is not in the snapshot sent to a client connecting later, and two identical events both arrive - [ ] Unit test for payload -> JSON mapping (powerup names, missing attacker) - [ ] `docs/state-websocket.md` documents the `event` message type and the `hit` fields - [ ] On recomp: hits on AI in SP, between split-screen players, and between remote players in a 2-instance LAN race all produce events (per the spike's availability matrix) - [ ] No measurable frame-time cost with no client connected - [ ] Verified on `BLUR_ENGINE=recomp` ## Blocked by - #17
Author
Owner

Implemented {"type":"event","event":"hit",...} per ADR 0016: hook 0x535770 (ch 9/ev 4, added to emit.py HOOKS) -> queue -> worker thread -> state::event (new, never stored, no snapshot, identical events all delivered) -> state_ws broadcast. Fields: victim, attacker (absent if 0xff), powerup, damage (fixed; 0 if shielded), shielded, wrecked (victim health/shield via 5 Hz scan racer registry), race_ms (state race_ms, #15 landed). Nitro/Repair skipped; level dropped. Hook only installed with BLUR_STATE_WS and bails to orig when no client connected. Tests: hit_event_test (mapping), state_ws_test (event delivery, not in later snapshot, duplicates). docs/state-websocket.md updated.

NOT done: runtime verification on BLUR_ENGINE=recomp (SP/split-screen/LAN) and frame-time check — no game install/inputs in this unattended worktree; needs emit.py regen + manual race. Also no separate wreck event (collision wrecks need the 5 Hz poll; follow-up).

Implemented `{"type":"event","event":"hit",...}` per ADR 0016: hook 0x535770 (ch 9/ev 4, added to emit.py HOOKS) -> queue -> worker thread -> `state::event` (new, never stored, no snapshot, identical events all delivered) -> state_ws broadcast. Fields: victim, attacker (absent if 0xff), powerup, damage (fixed; 0 if shielded), shielded, wrecked (victim health/shield via 5 Hz scan racer registry), race_ms (state `race_ms`, #15 landed). Nitro/Repair skipped; `level` dropped. Hook only installed with BLUR_STATE_WS and bails to orig when no client connected. Tests: hit_event_test (mapping), state_ws_test (event delivery, not in later snapshot, duplicates). docs/state-websocket.md updated. NOT done: runtime verification on BLUR_ENGINE=recomp (SP/split-screen/LAN) and frame-time check — no game install/inputs in this unattended worktree; needs emit.py regen + manual race. Also no separate `wreck` event (collision wrecks need the 5 Hz poll; follow-up).
Author
Owner

Runtime verification on BLUR_ENGINE=recomp (build from this branch, emit.py regenerated: only the shard holding 0x535770 changed):

  • SP career race (20 cars): 42 hit events in ~110 s, bolt/mine/shock/shunt, AI->AI and local fired (attacker 0); race_ms present once published; victim health read after the hit looked right (e.g. 599->549) and a shield read non-zero.
  • Split-screen 2P: 36 hit events, including player 2 (racer 1) as victim.
  • 2-instance LAN (host + client on one Mac): one bolt, host sees victim 1/attacker 0, client sees victim 0/attacker 1, same time (1 ms apart), each instance with its own racer numbering. Remote-to-remote needs 3 instances (not testable on one Mac).
  • Frame time: SP race, 1280x800, vsync off, no client connected: median 83 fps without BLUR_STATE_WS, 84 with it (hook returns to the original when no client is connected; not installed when BLUR_STATE_WS unset).
  • ctest: 17/17 pass. Noted in ADR 0016.
    Not done: separate wreck event (collision wrecks have no game event; optional per ticket). wrecked/shielded true was not observed in the runs (shield read non-zero once in a debug log).
Runtime verification on BLUR_ENGINE=recomp (build from this branch, emit.py regenerated: only the shard holding 0x535770 changed): - SP career race (20 cars): 42 hit events in ~110 s, bolt/mine/shock/shunt, AI->AI and local fired (attacker 0); race_ms present once published; victim health read after the hit looked right (e.g. 599->549) and a shield read non-zero. - Split-screen 2P: 36 hit events, including player 2 (racer 1) as victim. - 2-instance LAN (host + client on one Mac): one bolt, host sees victim 1/attacker 0, client sees victim 0/attacker 1, same time (1 ms apart), each instance with its own racer numbering. Remote-to-remote needs 3 instances (not testable on one Mac). - Frame time: SP race, 1280x800, vsync off, no client connected: median 83 fps without BLUR_STATE_WS, 84 with it (hook returns to the original when no client is connected; not installed when BLUR_STATE_WS unset). - ctest: 17/17 pass. Noted in ADR 0016. Not done: separate `wreck` event (collision wrecks have no game event; optional per ticket). `wrecked`/`shielded` true was not observed in the runs (shield read non-zero once in a debug log).
Author
Owner

Completed and merged by Harmonic (task 17).

Completed and merged by Harmonic (task 17).
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
mintopia/blur#18
No description provided.