Find where the game reports powerup hits for every racer (runtime spike) #17

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

Goal

Confirm, at runtime on BLUR_ENGINE=recomp, where the game reports powerup hits for every racer and what the payload means, so the feed can emit hit events (follow-up ticket). Research spike: a throwaway hook + log, findings written up, no feed changes.

What static analysis found (shipped Blur.exe, sha256 dba0264b...)

  • A game event listener in the audio code: class vtable 0xef64f0, slot 4 (+0x10) = handler 0xc8ee40 (void __thiscall(this, int event_id, ?, const Payload*), retn 0xc). It switches on event_id 0..14 (byte map 0xc90014, jump table 0xc8fff4): ids 0, 2, 4, 10, 11, 12, 14 are handled.
  • Event 4 = pickup (powerup) hit on a car (case at 0xc8f63a): it plays PickupHitCar, PickupHitShield, PickupSplashHitCar, PickupDoubleHitCar, PickupBackwardsHitCar, PickupModHitCar, PickupMiniHitCar... (strings 0xef665c..0xef677c) and sets FocusCarFired, IsFocusCar, PickupLevelHit.
    Payload fields used: +0x8 u8, +0x9 u8 racer index (looked up with 0xb915d0 -> racer object, whose +0xbc > 0 selects the ...HitShield sound, so probably the car that was hit), +0xa u8 other racer index or 0xff (probably the firer), +0xc int powerup kind (forced to 1 when +0x20 == 4; values 2 and 6 take other branches), +0x10 s8 level, +0x14 int, +0x20 int hit kind.
  • Event 2 (case 0xc8ef14, PickupBackwardsEnded/PickupDoubleEnded/PickupEnded...: payload +0x18 0..9) and event 12 (case 0xc8ee77, PickupHitWorld/PickupHitProjectile) are the other pickup events.
  • It gets events for non-local racers too: case 4 loops over the local viewports comparing them with +0x9 to work out IsFocusCar (0xc8f6dc..0xc8f716).
  • 0xc8ee40 is a function in the current recomp build (work/recomp/m3/gen/recomp_table.cpp) and runs during races (work/recomp/m2/tb_blur_race.txt), so it can be hooked: add it to HOOKS in tools/recomp/emit.py, regenerate, and register in recomp::hooks() like src/host/skip_intro.cpp.
  • Separately: the HUD gets a UIEnums.GameMessage.Wrecked message and shows MPL_WRECKED_BY (dumped Screens/Ingame/Hud.lua MessageUpdate), and Lua has GetWreckerModLoadout(player_index), but these are for local players only. The AI dialogue triggers (DriverHitByPerk, PlayerHitByPerk, DriverIsFatallyDamaged, name table 0xbcfb20) are SP AI-only. Neither covers every racer.
  • Not found yet: who dispatches the events (listener base vtable 0xe851d0, subscriptions keyed by +0x14, unsubscribe 0x523cd0), racer health, wreck flag, damage amount.

Steps

  1. Temporary hook on 0xc8ee40 that logs event_id and the first 0x30 payload bytes (plus race_time) and then calls the original.
  2. Collect logs: SP race with AI (fire each powerup at AI, get hit by AI), 2-player split-screen, 2-instance LAN (tools/modes/plans, work/ws-capture). Note each hit (who fired, who was hit, powerup, shielded, wrecked).
  3. Map the payload: victim, attacker, powerup kind -> name (Shunt, Bolt, Mine, Shock, Barge, Nitro, Repair, Shield), level, splash/shield/backwards, and whether a wreck shows up as its own event or flag.
  4. LAN: compare the two instances' logs. Does every instance get event 4 for hits between other cars? Same values? Is the hit simulated locally or received (count and timing differences)?
  5. Look for racer health and wreck state (racer +0xebc is set to -1.0 at init 0x450cd7, a candidate; check "DriverIsLowHealth" thresholds) and whether 5 Hz polling in race_scan.cpp would catch wrecks. Report damage per hit if found.
  6. Look for a better hook point than the audio listener (the dispatcher or the code that builds the event), so hits still arrive with audio disabled or missing.

Deliverable

A comment on this issue with the payload layout, powerup kind table, availability matrix (SP / split-screen / LAN x local / AI / remote) and the recommended hook point, plus an update to ADR 0013 (or a new ADR) if hooking becomes the recorded approach. Do not commit the hook unless it is the follow-up's code. Recomp only.

Acceptance criteria

  • Payload fields for event 4 confirmed from logs (victim, attacker, powerup kind, shield, wreck)
  • Availability matrix filled in from real SP, split-screen and LAN runs
  • Recommended hook point, and whether wreck/damage need polling as well
  • Findings commented on this issue; follow-up ticket updated with the confirmed field mapping
## Goal Confirm, at runtime on `BLUR_ENGINE=recomp`, where the game reports powerup hits for **every** racer and what the payload means, so the feed can emit hit events (follow-up ticket). Research spike: a throwaway hook + log, findings written up, no feed changes. ## What static analysis found (shipped `Blur.exe`, sha256 `dba0264b...`) - A game event listener in the audio code: class vtable `0xef64f0`, slot 4 (`+0x10`) = handler **`0xc8ee40`** (`void __thiscall(this, int event_id, ?, const Payload*)`, `retn 0xc`). It switches on `event_id` 0..14 (byte map `0xc90014`, jump table `0xc8fff4`): ids 0, 2, 4, 10, 11, 12, 14 are handled. - **Event 4 = pickup (powerup) hit on a car** (case at `0xc8f63a`): it plays `PickupHitCar`, `PickupHitShield`, `PickupSplashHitCar`, `PickupDoubleHitCar`, `PickupBackwardsHitCar`, `PickupModHitCar`, `PickupMiniHitCar`... (strings `0xef665c..0xef677c`) and sets `FocusCarFired`, `IsFocusCar`, `PickupLevelHit`. Payload fields used: `+0x8` u8, `+0x9` u8 racer index (looked up with `0xb915d0` -> racer object, whose `+0xbc` > 0 selects the `...HitShield` sound, so probably the car that was hit), `+0xa` u8 other racer index or `0xff` (probably the firer), `+0xc` int powerup kind (forced to 1 when `+0x20 == 4`; values 2 and 6 take other branches), `+0x10` s8 level, `+0x14` int, `+0x20` int hit kind. - Event 2 (case `0xc8ef14`, `PickupBackwardsEnded`/`PickupDoubleEnded`/`PickupEnded`...: payload `+0x18` 0..9) and event 12 (case `0xc8ee77`, `PickupHitWorld`/`PickupHitProjectile`) are the other pickup events. - It gets events for non-local racers too: case 4 loops over the local viewports comparing them with `+0x9` to work out `IsFocusCar` (`0xc8f6dc..0xc8f716`). - `0xc8ee40` is a function in the current recomp build (`work/recomp/m3/gen/recomp_table.cpp`) and runs during races (`work/recomp/m2/tb_blur_race.txt`), so it can be hooked: add it to `HOOKS` in `tools/recomp/emit.py`, regenerate, and register in `recomp::hooks()` like `src/host/skip_intro.cpp`. - Separately: the HUD gets a `UIEnums.GameMessage.Wrecked` message and shows `MPL_WRECKED_BY` (dumped `Screens/Ingame/Hud.lua` `MessageUpdate`), and Lua has `GetWreckerModLoadout(player_index)`, but these are for local players only. The AI dialogue triggers (`DriverHitByPerk`, `PlayerHitByPerk`, `DriverIsFatallyDamaged`, name table `0xbcfb20`) are SP AI-only. Neither covers every racer. - Not found yet: who dispatches the events (listener base vtable `0xe851d0`, subscriptions keyed by `+0x14`, unsubscribe `0x523cd0`), racer health, wreck flag, damage amount. ## Steps 1. Temporary hook on `0xc8ee40` that logs `event_id` and the first 0x30 payload bytes (plus `race_time`) and then calls the original. 2. Collect logs: SP race with AI (fire each powerup at AI, get hit by AI), 2-player split-screen, 2-instance LAN (`tools/modes/plans`, `work/ws-capture`). Note each hit (who fired, who was hit, powerup, shielded, wrecked). 3. Map the payload: victim, attacker, powerup kind -> name (Shunt, Bolt, Mine, Shock, Barge, Nitro, Repair, Shield), level, splash/shield/backwards, and whether a wreck shows up as its own event or flag. 4. LAN: compare the two instances' logs. Does every instance get event 4 for hits between other cars? Same values? Is the hit simulated locally or received (count and timing differences)? 5. Look for racer health and wreck state (racer `+0xebc` is set to -1.0 at init `0x450cd7`, a candidate; check "DriverIsLowHealth" thresholds) and whether 5 Hz polling in `race_scan.cpp` would catch wrecks. Report damage per hit if found. 6. Look for a better hook point than the audio listener (the dispatcher or the code that builds the event), so hits still arrive with audio disabled or missing. ## Deliverable A comment on this issue with the payload layout, powerup kind table, availability matrix (SP / split-screen / LAN x local / AI / remote) and the recommended hook point, plus an update to ADR 0013 (or a new ADR) if hooking becomes the recorded approach. Do not commit the hook unless it is the follow-up's code. Recomp only. ## Acceptance criteria - [ ] Payload fields for event 4 confirmed from logs (victim, attacker, powerup kind, shield, wreck) - [ ] Availability matrix filled in from real SP, split-screen and LAN runs - [ ] Recommended hook point, and whether wreck/damage need polling as well - [ ] Findings commented on this issue; follow-up ticket updated with the confirmed field mapping
mintopia removed their assignment 2026-10-19 13:36:04 +00:00
Author
Owner

Spike results (recomp, runtime)

I ran a throwaway hook in a private build (not committed). It logged every event reaching 0xc8ee40 and every send through 0x535770, plus 5 Hz snapshots of each racer's object. Runs:

  • SP career race, T1E1, 20 cars, 2 runs of 4-5 min
  • SP destruction, T1E3, with BLUR_NO_AUDIO=1
  • 2-player split-screen, 10 cars
  • 2-instance LAN race

Firing used virtual pad A; keyboard fire didn't work in the driver. About 690 hit events in total. ADR 0016 (docs/decisions/0016-powerup-hits-from-game-event-send.md) records the approach.

Event 4 payload (0x28 bytes)

off type meaning
+0x0 u32 message tag 0xaac4c005 (pickup messages are 0xaac4c0NN)
+0x4 u32 size (0x28)
+0x8 u8 firer racer index (the static guess had the firer at +0xa)
+0x9 u8 victim racer index
+0xa u8 = victim for self-applied pickups (Nitro, Repair), otherwise 0xff
+0xc i32 powerup kind (table below)
+0x10 s8 always 0 in every run, so no "level" seen
+0x14 u32 hit serial, unique per hit, identical on both LAN instances
+0x18 i32 Bolt burst number (shared by one volley's bolts)
+0x20 i32 0, sometimes 2 on Bolt/Shunt; meaning unknown (backwards/mod?)
+0x24 u8 1 or 2; 2 on most Shock hits and 1 of 3 Barge hits, so probably splash vs direct (not confirmed)

Racer index = racer +0x314, the same numbering as standings/racer. Shield and wreck aren't in the payload. Read them from the victim racer when the event fires, because damage is already applied by then:

  • health = racer i32 +0x3dc: full is 599/649/699 depending on the car, stops at 0
  • shielded = racer +0xbc > 0; damage 0 in all 11 shielded hits
  • wrecked = health 0 after the hit (racer bytes +0x38c/+0x3ac are also 1 while wrecked)

Powerup kind (+0xc; game enum at 0xf6ea80) and damage measured

kind name target damage
0 Nitro self (+0xa = self) 0
1 Shock others; once self (own dome) -50
2 Shunt others -100
3 Bolt others, one event per bolt (3 per volley) -50 per bolt
4 Barge others (split-screen only; not in T1E1's pickup set) -100
5 Mine others; twice self (own mine) -100
6 Repair self + (back to full)
7 Shield not an event 4. Activation is event 14 (+0x8 racer, +0xc = 7) -

Other events on the same channel: 2 = pickup ended/projectile expired (+0x8 racer, +0xc kind, +0x10 serial), 12 = hit world/projectile.

Availability matrix (event 4 seen?)

local fired local hit AI→AI AI↔local remote→local local→remote remote↔remote
SP yes yes yes yes - - -
split-screen yes (P2 = racer 1) yes yes yes - - -
LAN (2 inst.) yes yes no AI in LAN no AI in LAN yes yes not tested (needs 3 instances)

LAN: both instances logged the same 5 hits with the same serial +0x14 and the same wall-clock time (within about 10 ms). Each instance uses its own racer numbering: on each one, local = 0. Health +0x3dc matched on both. The damage-taken (+0x598) and hits-taken (+0x5d8) counters only update on the victim's own instance. In this driver the unfocused instance ignores pad input, so only the focused one fired.

0x535770, the game's event send (this = hub 0x111d1e8, args channel, event_id, a2, payload). Filter on channel == 9 && event_id == 4.

  • All 4 producers send hits through it: 0x475dbd, 0x47691d, 0x476aab, 0x476e44.
  • It null-checks the sink (*this)[9] (= the audio listener 0xc8ee40) before calling it, so it doesn't depend on audio. That comes from reading the code; a missing sink was never seen at run time.
  • BLUR_NO_AUDIO=1 only drops our dsound backend. The sink still exists and still gets the events.
  • It's hot (about 250 sends/s), so check the filter first and call the original.

Wreck/damage polling

Needed for wrecks.

  • Collisions do damage in 50-point steps with no event, and can wreck a car: 2 of the 3 split-screen wrecks had no hit.
  • A wreck holds health +0x3dc = 0 (and +0x38c = 1) for 2.6-4 s until respawn, so the existing 5 Hz race_scan.cpp poll always catches it.
  • Hit damage itself doesn't need polling: read the victim's health in the hook.
  • The earlier candidate +0xebc is a timer, not health.

#18 is updated with this mapping.

## Spike results (recomp, runtime) I ran a throwaway hook in a private build (not committed). It logged every event reaching `0xc8ee40` and every send through `0x535770`, plus 5 Hz snapshots of each racer's object. Runs: - SP career race, T1E1, 20 cars, 2 runs of 4-5 min - SP destruction, T1E3, with `BLUR_NO_AUDIO=1` - 2-player split-screen, 10 cars - 2-instance LAN race Firing used virtual pad A; keyboard fire didn't work in the driver. About 690 hit events in total. ADR 0016 (`docs/decisions/0016-powerup-hits-from-game-event-send.md`) records the approach. ### Event 4 payload (0x28 bytes) | off | type | meaning | |---|---|---| | +0x0 | u32 | message tag `0xaac4c005` (pickup messages are `0xaac4c0NN`) | | +0x4 | u32 | size (0x28) | | **+0x8** | u8 | **firer** racer index (the static guess had the firer at +0xa) | | **+0x9** | u8 | **victim** racer index | | +0xa | u8 | = victim for self-applied pickups (Nitro, Repair), otherwise `0xff` | | **+0xc** | i32 | **powerup kind** (table below) | | +0x10 | s8 | always 0 in every run, so no "level" seen | | +0x14 | u32 | hit serial, unique per hit, **identical on both LAN instances** | | +0x18 | i32 | Bolt burst number (shared by one volley's bolts) | | +0x20 | i32 | 0, sometimes 2 on Bolt/Shunt; meaning unknown (backwards/mod?) | | +0x24 | u8 | 1 or 2; 2 on most Shock hits and 1 of 3 Barge hits, so probably splash vs direct (not confirmed) | Racer index = racer `+0x314`, the same numbering as `standings`/`racer`. **Shield and wreck aren't in the payload.** Read them from the victim racer when the event fires, because damage is already applied by then: - **health** = racer i32 `+0x3dc`: full is 599/649/699 depending on the car, stops at 0 - **shielded** = racer `+0xbc > 0`; damage 0 in all 11 shielded hits - **wrecked** = health 0 after the hit (racer bytes `+0x38c`/`+0x3ac` are also 1 while wrecked) ### Powerup kind (`+0xc`; game enum at `0xf6ea80`) and damage measured | kind | name | target | damage | |---|---|---|---| | 0 | Nitro | self (`+0xa` = self) | 0 | | 1 | Shock | others; once self (own dome) | -50 | | 2 | Shunt | others | -100 | | 3 | Bolt | others, one event per bolt (3 per volley) | -50 per bolt | | 4 | Barge | others (split-screen only; not in T1E1's pickup set) | -100 | | 5 | Mine | others; twice self (own mine) | -100 | | 6 | Repair | self | + (back to full) | | 7 | Shield | not an event 4. Activation is event 14 (`+0x8` racer, `+0xc` = 7) | - | Other events on the same channel: 2 = pickup ended/projectile expired (`+0x8` racer, `+0xc` kind, `+0x10` serial), 12 = hit world/projectile. ### Availability matrix (event 4 seen?) | | local fired | local hit | AI→AI | AI↔local | remote→local | local→remote | remote↔remote | |---|---|---|---|---|---|---|---| | SP | yes | yes | yes | yes | - | - | - | | split-screen | yes (P2 = racer 1) | yes | yes | yes | - | - | - | | LAN (2 inst.) | yes | yes | no AI in LAN | no AI in LAN | yes | yes | not tested (needs 3 instances) | LAN: both instances logged the same 5 hits with the same serial `+0x14` and the same wall-clock time (within about 10 ms). Each instance uses its own racer numbering: on each one, local = 0. Health `+0x3dc` matched on both. The damage-taken (`+0x598`) and hits-taken (`+0x5d8`) counters only update on the victim's own instance. In this driver the unfocused instance ignores pad input, so only the focused one fired. ### Recommended hook point **`0x535770`**, the game's event send (`this` = hub `0x111d1e8`, args `channel, event_id, a2, payload`). Filter on `channel == 9 && event_id == 4`. - All 4 producers send hits through it: `0x475dbd`, `0x47691d`, `0x476aab`, `0x476e44`. - It null-checks the sink `(*this)[9]` (= the audio listener `0xc8ee40`) before calling it, so it doesn't depend on audio. That comes from reading the code; a missing sink was never seen at run time. - `BLUR_NO_AUDIO=1` only drops our dsound backend. The sink still exists and still gets the events. - It's hot (about 250 sends/s), so check the filter first and call the original. ### Wreck/damage polling **Needed for wrecks.** - Collisions do damage in 50-point steps with no event, and can wreck a car: 2 of the 3 split-screen wrecks had no hit. - A wreck holds health `+0x3dc` = 0 (and `+0x38c` = 1) for 2.6-4 s until respawn, so the existing 5 Hz `race_scan.cpp` poll always catches it. - Hit damage itself doesn't need polling: read the victim's health in the hook. - The earlier candidate `+0xebc` is a timer, not health. #18 is updated with this mapping.
Author
Owner

Completed and merged by Harmonic (task 18).

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