Per-racer lap times and a sub-second race clock in the feed #15

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

What to build

Per-racer lap timing and a sub-second race clock in the game-state feed, for every racer (local, AI, remote network humans), in single-player, split-screen and network races. Today the feed only has whole-second race_time for the local racer; the overlay guesses lap times from lap-counter changes (~1 s accuracy).

Where the data is (shipped Blur.exe, sha256 dba0264b...)

The racer object (vtable 0xe72ac8, found by src/host/race_scan.cpp) has a lap block at +0xf44; getter 0x4361d0 returns racer+0xf44:

  • +0xf44 u8 lap (already read)
  • +0xf48 float, current-lap timer in seconds, restarts each lap (already read as Racer::lap_time, but only used for the local racer's race_time)
  • +0xf4c float, best lap (strong static evidence, verify at runtime): initialised to -1.0 with +0xf48 at 0x450ce5 (constant 0xe646a4 = 0xbf800000); only read when lap > 1 (0xc24607); FUN_00c0d850 inserts +0xf48 into a sorted lap-record table only when it is lower than +0xf4c; 0xc25015 compares the two and tests +0xf4c == -1.

Every racer has this object and the scan already reads all of them, so no new discovery is needed for AI/remote racers.

Proposed shape

Add to each standings entry (only when known; omit, do not emit -1):

  • lap_ms: current-lap elapsed, integer milliseconds (+0xf48)
  • last_ms: last completed lap, ms. Computed host-side per racer when its lap byte goes up: prev_sample_timer + wall_dt_between_samples - new_timer (the timer restarts at the line, so this recovers the crossing to ~frame accuracy rather than the 200 ms sample period). Ignore lap decreases (see the lap-decrease ticket).
  • best_ms: +0xf4c if runtime check confirms it is the best lap; otherwise min of last_ms host-side.
  • race_ms: that racer's elapsed race time (sum of completed laps + lap_ms).

Add a top-level key race_ms (local racer's elapsed race time, integer ms), published alongside race_time (keep race_time in whole seconds for compatibility). Put timing in standings (≈5 Hz, same cadence as speeds) rather than a new key, so one message carries consistent position + timing.

Extend race::Racer with best (float, +0xf4c); keep per-racer timing state (prev lap, prev timer, cumulative, last/best) in race::Feed, keyed by racer index, reset with the race.

Steps

  1. Runtime check first: during an SP race and a 2-instance LAN race (see tools/modes/plans, work/ws-capture), log +0xf48/+0xf4c for all racers around lap crossings. Confirm +0xf4c = best lap (and whether it is maintained for remote racers). If it is something else (e.g. last lap), use it as that and compute the other host-side; note the result in ADR 0013.
  2. Implement in src/host/race_scan.cpp / race_feed.{h,cpp}.
  3. Update docs/state-websocket.md (standings fields, race_ms, units, "absent until known") and ADR 0013's field list.

Acceptance criteria

  • standings entries carry lap_ms, race_ms, and last_ms/best_ms once a lap is complete, for all racers
  • Top-level race_ms (integer ms) published; race_time unchanged
  • Unit tests in tests/race_feed_test.cpp: last lap derived across a lap change, best is the minimum, values absent before the first completed lap, state reset between races
  • On recomp, an SP race's last_ms/best_ms match the game's results/HUD lap times within 50 ms
  • Works for remote racers in a LAN race (checked in a 2-instance capture)
  • Docs + ADR 0013 updated
  • Verified on BLUR_ENGINE=recomp

Blocked by

## What to build Per-racer lap timing and a sub-second race clock in the game-state feed, for every racer (local, AI, remote network humans), in single-player, split-screen and network races. Today the feed only has whole-second `race_time` for the local racer; the overlay guesses lap times from lap-counter changes (~1 s accuracy). ## Where the data is (shipped `Blur.exe`, sha256 `dba0264b...`) The racer object (vtable `0xe72ac8`, found by `src/host/race_scan.cpp`) has a lap block at `+0xf44`; getter `0x4361d0` returns `racer+0xf44`: - `+0xf44` u8 lap (already read) - `+0xf48` float, current-lap timer in seconds, restarts each lap (already read as `Racer::lap_time`, but only used for the local racer's `race_time`) - `+0xf4c` float, **best lap** (strong static evidence, verify at runtime): initialised to -1.0 with `+0xf48` at `0x450ce5` (constant `0xe646a4` = `0xbf800000`); only read when lap > 1 (`0xc24607`); `FUN_00c0d850` inserts `+0xf48` into a sorted lap-record table only when it is lower than `+0xf4c`; `0xc25015` compares the two and tests `+0xf4c == -1`. Every racer has this object and the scan already reads all of them, so no new discovery is needed for AI/remote racers. ## Proposed shape Add to each `standings` entry (only when known; omit, do not emit -1): - `lap_ms`: current-lap elapsed, integer milliseconds (`+0xf48`) - `last_ms`: last completed lap, ms. Computed host-side per racer when its lap byte goes up: `prev_sample_timer + wall_dt_between_samples - new_timer` (the timer restarts at the line, so this recovers the crossing to ~frame accuracy rather than the 200 ms sample period). Ignore lap decreases (see the lap-decrease ticket). - `best_ms`: `+0xf4c` if runtime check confirms it is the best lap; otherwise min of `last_ms` host-side. - `race_ms`: that racer's elapsed race time (sum of completed laps + `lap_ms`). Add a top-level key `race_ms` (local racer's elapsed race time, integer ms), published alongside `race_time` (keep `race_time` in whole seconds for compatibility). Put timing in `standings` (≈5 Hz, same cadence as `speeds`) rather than a new key, so one message carries consistent position + timing. Extend `race::Racer` with `best` (float, `+0xf4c`); keep per-racer timing state (prev lap, prev timer, cumulative, last/best) in `race::Feed`, keyed by racer index, reset with the race. ## Steps 1. Runtime check first: during an SP race and a 2-instance LAN race (see `tools/modes/plans`, `work/ws-capture`), log `+0xf48`/`+0xf4c` for all racers around lap crossings. Confirm `+0xf4c` = best lap (and whether it is maintained for remote racers). If it is something else (e.g. last lap), use it as that and compute the other host-side; note the result in ADR 0013. 2. Implement in `src/host/race_scan.cpp` / `race_feed.{h,cpp}`. 3. Update `docs/state-websocket.md` (standings fields, `race_ms`, units, "absent until known") and ADR 0013's field list. ## Acceptance criteria - [ ] `standings` entries carry `lap_ms`, `race_ms`, and `last_ms`/`best_ms` once a lap is complete, for all racers - [ ] Top-level `race_ms` (integer ms) published; `race_time` unchanged - [ ] Unit tests in `tests/race_feed_test.cpp`: last lap derived across a lap change, best is the minimum, values absent before the first completed lap, state reset between races - [ ] On recomp, an SP race's `last_ms`/`best_ms` match the game's results/HUD lap times within 50 ms - [ ] Works for remote racers in a LAN race (checked in a 2-instance capture) - [ ] Docs + ADR 0013 updated - [ ] Verified on `BLUR_ENGINE=recomp` ## Blocked by - #14
Author
Owner

Implemented in f47ed58: standings entries get lap_ms, race_ms, last_ms/best_ms (absent until a lap completes) for all racers; top-level race_ms added, race_time unchanged. Lap times derived host-side (prev_timer + wall_dt - new_timer), best = host-side min, +0xf4c NOT read (unverified). Unit tests in tests/race_feed_test.cpp pass (standalone build). Docs + ADR 0013 updated.

Not done (needs the game running, not possible unattended): runtime check of +0xf4c, recomp SP check vs HUD within 50 ms, 2-instance LAN check for remote racers, BLUR_ENGINE=recomp verification.

Implemented in f47ed58: `standings` entries get `lap_ms`, `race_ms`, `last_ms`/`best_ms` (absent until a lap completes) for all racers; top-level `race_ms` added, `race_time` unchanged. Lap times derived host-side (`prev_timer + wall_dt - new_timer`), best = host-side min, `+0xf4c` NOT read (unverified). Unit tests in `tests/race_feed_test.cpp` pass (standalone build). Docs + ADR 0013 updated. **Not done (needs the game running, not possible unattended):** runtime check of `+0xf4c`, recomp SP check vs HUD within 50 ms, 2-instance LAN check for remote racers, `BLUR_ENGINE=recomp` verification.
Author
Owner

Runtime verification done on BLUR_ENGINE=recomp (worktree build, temp per-racer logging of +0xf48/+0xf4c, removed before commit).

  • +0xf4c = best lap, confirmed. SP career race (20 racers, ~400 s, 3 completed laps each): every time a racer completed a lap, +0xf4c became min(best, that lap time) in the same 200 ms sample as the lap counter; -1 until lap 1 completes. Now read into Racer::best and used for best_ms; last_ms equals it exactly on a new-best lap, otherwise it is the host-side estimate (clamped >= best).
  • SP vs game lap times: 30 new-best laps compared against the feed: last_ms mean error 18 ms, max 67 ms for the pure host-side estimate (before using the game best and before stamping the sample time at read time); with the game best the new-best laps are exact.
  • LAN (2 instances, alpha/bravo, 1 human + 1 remote human): +0xf4c is maintained for the remote racer. 4 new-best laps per instance, last_ms error 0 ms; both instances agree on each racer best within ~20 ms (alpha 87.284 on both; bravo 87.335 vs 87.318).
  • Unit tests (tests/race_feed_test.cpp) pass incl. the game-best path. Docs + ADR 0013 updated with the confirmed layout.

Caveat: non-best laps last_ms is an estimate (no ground truth available from memory); the HUD screenshot comparison was not done, game-internal best values were used as truth. Commit: see branch head.

Runtime verification done on `BLUR_ENGINE=recomp` (worktree build, temp per-racer logging of +0xf48/+0xf4c, removed before commit). - **+0xf4c = best lap, confirmed.** SP career race (20 racers, ~400 s, 3 completed laps each): every time a racer completed a lap, +0xf4c became min(best, that lap time) in the same 200 ms sample as the lap counter; -1 until lap 1 completes. Now read into `Racer::best` and used for `best_ms`; `last_ms` equals it exactly on a new-best lap, otherwise it is the host-side estimate (clamped >= best). - **SP vs game lap times:** 30 new-best laps compared against the feed: `last_ms` mean error 18 ms, max 67 ms for the pure host-side estimate (before using the game best and before stamping the sample time at read time); with the game best the new-best laps are exact. - **LAN (2 instances, alpha/bravo, 1 human + 1 remote human):** `+0xf4c` is maintained for the remote racer. 4 new-best laps per instance, `last_ms` error 0 ms; both instances agree on each racer best within ~20 ms (alpha 87.284 on both; bravo 87.335 vs 87.318). - Unit tests (`tests/race_feed_test.cpp`) pass incl. the game-best path. Docs + ADR 0013 updated with the confirmed layout. Caveat: non-best laps `last_ms` is an estimate (no ground truth available from memory); the HUD screenshot comparison was not done, game-internal best values were used as truth. Commit: see branch head.
Author
Owner

Completed and merged by Harmonic (task 15).

Completed and merged by Harmonic (task 15).
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#15
No description provided.