Skip to content

Commit fa37ec4

Browse files
authored
feat: move driver controls to channels (#668)
1 parent e69c9ae commit fa37ec4

25 files changed

Lines changed: 954 additions & 63 deletions

docs/IMPLEMENTATION_PLAN.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
| **Phase 2a remaining items** | R1+R2 LANDED, R3 PENDING | `feat/phase-2a-integration` for R1+R2 | R1 (reference-lap fetch dedup) + R2 (post-debounce write log) landed 2026-05-19. R3 (Empty Dashboard substrate baseline test) is a test run, not code work — pending. |
3131
| **Phase 2b — Architectural cleanup (remaining)** | NOT STARTED || A1, A4, A5, A6, A7 completion, A9. Lower urgency now Standings memory issue is resolved |
3232
| **Phase 3 — Channel-based bridge** | LANDED; MEMORY GATE OPEN | PRs #646, #649#652, #656, #658 | Typed rate-aware channels, per-window subscriptions, deterministic replay validation, Fuel processor/renderer migration, conditional legacy telemetry, and performance instrumentation are on `main`. The Fuel-only A/B removed legacy deliveries and reduced app-wide renderer wake-ups by 42.4%; both baseline and candidate still failed the memory-slope gate. |
33-
| **Phase 4 — Main-process processors** | IN PROGRESS | PRs #659#667 | Fuel, lap times, car speeds, reference laps, relative gaps, sector timing, Standings core state, live positions, and radio are on `main`. The complete Session Bar migration is in PR #667; legacy telemetry removal or development-only restriction remains. |
33+
| **Phase 4 — Main-process processors** | IN PROGRESS | PRs #659#667 | The planned derived processors and complete Session Bar migration are on `main`. Four final slices migrate direct telemetry consumers, then remove the legacy renderer firehose; the first slice is Input and Tachometer via `driver-controls.snapshot`. |
3434
| **Phase 5 — Worker-thread SDK loop** | NOT STARTED || |
3535
| **Phase 6 — Native optimisations** | DEFERRED || Only if Phase 4 profiling demands |
3636

@@ -274,10 +274,14 @@ Today every renderer wakes 25 times/sec regardless of what's mounted. A weather
274274
- [x] StandingsProcessor — PR #664
275275
- [x] Standings live-position projection — PR #665
276276
- [x] Radio transmit state — `radio.snapshot`, event-driven and demand-activated; PR #666
277-
- [ ] Session-bar telemetry migration
278-
- [ ] Shared race/session timing projection — `session-timing.snapshot`, demand-driven at 5 Hz; PR #667 in review
279-
- [ ] Auxiliary items (weather, fuel/units, brake bias, incidents, lap results, player position, and top speed) — `session-bar.snapshot`; PR #667 in review
280-
- [ ] Legacy `'telemetry'` channel removed or dev-only
277+
- [x] Session-bar telemetry migration — PR #667
278+
- [x] Shared race/session timing projection — `session-timing.snapshot`, demand-driven at 5 Hz
279+
- [x] Auxiliary items (weather, fuel/units, brake bias, incidents, lap results, player position, and top speed) — `session-bar.snapshot`
280+
- [ ] Direct telemetry migration and legacy removal
281+
1. [ ] Input and Tachometer — full-precision `driver-controls.snapshot`; PR #668 in review
282+
2. [ ] Positional, pit-state, and warning consumers — Pitlane Helper, maps, Battle/Relative helpers, Blind Spot, Rejoin, Faster/Slow Car
283+
3. [ ] Remaining low-frequency consumers plus an explicit development-only path for Telemetry Inspector
284+
4. [ ] Delete the legacy `'telemetry'` IPC/store/provider infrastructure, run the complete replay suite, and re-profile
281285

282286
### Phase 5 — Worker-thread SDK loop
283287

@@ -468,7 +472,8 @@ LLM agents: read this file at the start of any session that touches the architec
468472

469473
## 6. Activity log
470474

471-
- **2026-08-09** — PR #666 merged. Opened PR #667 for the complete Session Bar migration: shared race/session timing plus auxiliary weather, fuel, incident, lap-result, position, and top-speed data now use demand-driven snapshots wired for live/tape and mock sources. Legacy telemetry restriction remains the next Phase 4 step — `feat/session-bar-channel` — in review
475+
- **2026-08-09** — PR #667 merged. Opened PR #668 for the first of four final Phase 4 slices: Input and Tachometer move to a full-precision, demand-driven `driver-controls.snapshot`; positional/warning consumers, low-frequency/debug consumers, and final legacy deletion follow as separate reviewable PRs — `feat/driver-controls-channel` — in review
476+
- **2026-08-09** — PR #666 merged. Opened PR #667 for the complete Session Bar migration: shared race/session timing plus auxiliary weather, fuel, incident, lap-result, position, and top-speed data now use demand-driven snapshots wired for live/tape and mock sources — `feat/session-bar-channel` — merged as PR #667
472477

473478
- **2026-08-09** — PR #665 merged. Opened PR #666 for the next explicit Phase 4 slice: move bursty `RadioTransmitCarIdx` state to a demand-activated, event-driven `radio.snapshot` channel while retaining renderer-configured icon persistence. Session-bar migration follows; legacy telemetry restriction/removal remains the Phase 4 exit step — `feat/radio-channel` — in review
474479

src/app/bridge/iracingSdk/iracingSdkBridge.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { StandingsRuntime } from '../../processors/standingsRuntime';
1919
import { RadioRuntime } from '../../processors/radioRuntime';
2020
import { SessionTimingRuntime } from '../../processors/sessionTimingRuntime';
2121
import { SessionBarRuntime } from '../../processors/sessionBarRuntime';
22+
import { DriverControlsRuntime } from '../../processors/driverControlsRuntime';
2223

2324
// Keys consumed by the renderer. Anything outside this set is dropped before
2425
// the telemetry object crosses the IPC boundary — reducing structured-clone
@@ -223,6 +224,9 @@ export async function publishIRacingSDKEvents(
223224
const sessionBarRuntime = channelBus
224225
? new SessionBarRuntime(channelBus, lifecycle, perfMetrics, isTapeReplay)
225226
: undefined;
227+
const driverControlsRuntime = channelBus
228+
? new DriverControlsRuntime(channelBus, lifecycle, perfMetrics)
229+
: undefined;
226230

227231
let shouldStop = false;
228232
let lastRunningState: boolean | undefined = undefined;
@@ -329,6 +333,7 @@ export async function publishIRacingSDKEvents(
329333
radioRuntime?.onFrame(telemetry);
330334
sessionTimingRuntime?.onFrame(telemetry);
331335
sessionBarRuntime?.onFrame(telemetry);
336+
driverControlsRuntime?.onFrame(telemetry);
332337
if (
333338
perfTelemetryDeliveryEnabled &&
334339
overlayManager.hasLegacyStreamSubscribers('telemetry')
@@ -365,6 +370,7 @@ export async function publishIRacingSDKEvents(
365370
standingsRuntime?.onSession(session);
366371
sessionTimingRuntime?.onSession(session);
367372
sessionBarRuntime?.onSession(session);
373+
driverControlsRuntime?.onSession(session);
368374
overlayManager.publishMessage('sessionData', session);
369375
sessionCallbacks.forEach((callback) => callback(session));
370376
perfMetrics.markEnd('sessionPublish');
@@ -430,6 +436,7 @@ export async function publishIRacingSDKEvents(
430436
radioRuntime?.dispose();
431437
sessionTimingRuntime?.dispose();
432438
sessionBarRuntime?.dispose();
439+
driverControlsRuntime?.dispose();
433440
referenceLapRuntime?.dispose();
434441
perfMetrics.stopReporting();
435442
},

src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { RadioRuntime } from '../../../processors/radioRuntime';
1212
import { SessionTimingRuntime } from '../../../processors/sessionTimingRuntime';
1313
import { LapTimesRuntime } from '../../../processors/lapTimesRuntime';
1414
import { SessionBarRuntime } from '../../../processors/sessionBarRuntime';
15+
import { DriverControlsRuntime } from '../../../processors/driverControlsRuntime';
1516

1617
export async function publishIRacingSDKEvents(
1718
overlayManager: OverlayManager,
@@ -64,6 +65,9 @@ export async function publishIRacingSDKEvents(
6465
const sessionBarRuntime = channelBus
6566
? new SessionBarRuntime(channelBus, lifecycle, perfMetrics)
6667
: undefined;
68+
const driverControlsRuntime = channelBus
69+
? new DriverControlsRuntime(channelBus, lifecycle, perfMetrics)
70+
: undefined;
6771

6872
bridge.onSessionData((session) => {
6973
carSpeedsRuntime?.onSession(session);
@@ -73,6 +77,7 @@ export async function publishIRacingSDKEvents(
7377
standingsRuntime?.onSession(session);
7478
sessionTimingRuntime?.onSession(session);
7579
sessionBarRuntime?.onSession(session);
80+
driverControlsRuntime?.onSession(session);
7681
overlayManager.publishMessage('sessionData', session);
7782
});
7883

@@ -87,6 +92,7 @@ export async function publishIRacingSDKEvents(
8792
radioRuntime?.onFrame(telemetry);
8893
sessionTimingRuntime?.onFrame(telemetry);
8994
sessionBarRuntime?.onFrame(telemetry);
95+
driverControlsRuntime?.onFrame(telemetry);
9096
perfMetrics.markStart('broadcast');
9197
overlayManager.publishMessage('telemetry', telemetry);
9298
perfMetrics.markEnd('broadcast');
@@ -110,6 +116,7 @@ export async function publishIRacingSDKEvents(
110116
radioRuntime?.dispose();
111117
sessionTimingRuntime?.dispose();
112118
sessionBarRuntime?.dispose();
119+
driverControlsRuntime?.dispose();
113120
referenceLapRuntime?.dispose();
114121
perfMetrics.stopReporting();
115122
originalStop();
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Session, Telemetry } from '@irdashies/types';
3+
import { DriverControlsProcessor } from './DriverControlsProcessor';
4+
5+
const frame = (values: Record<string, number | boolean>): Telemetry =>
6+
Object.fromEntries(
7+
Object.entries(values).map(([key, current]) => [key, { value: [current] }])
8+
) as unknown as Telemetry;
9+
10+
describe('DriverControlsProcessor', () => {
11+
it('projects full-precision input and engine values', () => {
12+
const processor = new DriverControlsProcessor();
13+
processor.init({} as Session);
14+
processor.onFrame(
15+
frame({
16+
Brake: 0.123456,
17+
BrakeRaw: 0.223456,
18+
Throttle: 0.765432,
19+
Clutch: 0.25,
20+
Gear: 3,
21+
Speed: 52.4,
22+
SteeringWheelAngle: -0.4321,
23+
BrakeABSactive: true,
24+
RPM: 6342,
25+
})
26+
);
27+
28+
expect(processor.snapshot()).toMatchObject({
29+
brake: 0.123456,
30+
brakeRaw: 0.223456,
31+
throttle: 0.765432,
32+
clutch: 0.25,
33+
gear: 3,
34+
speed: 52.4,
35+
steeringWheelAngle: -0.4321,
36+
brakeAbsActive: true,
37+
rpm: 6342,
38+
version: 1,
39+
});
40+
});
41+
42+
it('publishes only changes and resets at lifecycle boundaries', () => {
43+
const processor = new DriverControlsProcessor();
44+
const telemetry = frame({ Gear: 2, RPM: 5000 });
45+
processor.onFrame(telemetry);
46+
expect(processor.snapshot().version).toBe(1);
47+
processor.onFrame(telemetry);
48+
expect(processor.snapshot().version).toBe(1);
49+
50+
processor.onLifecycle({ type: 'sessionNumChange' });
51+
expect(processor.snapshot()).toMatchObject({
52+
gear: undefined,
53+
rpm: undefined,
54+
version: 2,
55+
});
56+
});
57+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type {
2+
DriverControlsSnapshot,
3+
Session,
4+
SessionLifecycleEvent,
5+
Telemetry,
6+
} from '@irdashies/types';
7+
import type { TelemetryProcessor } from './TelemetryProcessor';
8+
9+
const rawValue = (frame: Telemetry, key: string): unknown =>
10+
(frame as unknown as Record<string, { value?: unknown[] } | undefined>)[key]
11+
?.value?.[0];
12+
13+
const numberValue = (frame: Telemetry, key: string): number | undefined => {
14+
const current = rawValue(frame, key);
15+
return typeof current === 'number' ? current : undefined;
16+
};
17+
18+
const booleanValue = (frame: Telemetry, key: string): boolean | undefined => {
19+
const current = rawValue(frame, key);
20+
return typeof current === 'boolean' ? current : undefined;
21+
};
22+
23+
export class DriverControlsProcessor implements TelemetryProcessor<DriverControlsSnapshot> {
24+
readonly channel = 'driver-controls.snapshot';
25+
readonly tickRateHz = 60;
26+
27+
private readonly latest: DriverControlsSnapshot = { version: 0 };
28+
29+
init(session: Session): void {
30+
const shiftRpm = session.DriverInfo?.DriverCarSLShiftRPM;
31+
const blinkRpm = session.DriverInfo?.DriverCarSLBlinkRPM;
32+
const shiftChanged = this.set('shiftRpm', shiftRpm);
33+
const blinkChanged = this.set('blinkRpm', blinkRpm);
34+
if (shiftChanged || blinkChanged) this.latest.version += 1;
35+
}
36+
37+
onFrame(frame: Telemetry): void {
38+
let changed = false;
39+
changed = this.set('brake', numberValue(frame, 'Brake')) || changed;
40+
changed = this.set('brakeRaw', numberValue(frame, 'BrakeRaw')) || changed;
41+
changed = this.set('throttle', numberValue(frame, 'Throttle')) || changed;
42+
changed =
43+
this.set('throttleRaw', numberValue(frame, 'ThrottleRaw')) || changed;
44+
changed = this.set('clutch', numberValue(frame, 'Clutch')) || changed;
45+
changed = this.set('clutchRaw', numberValue(frame, 'ClutchRaw')) || changed;
46+
changed = this.set('gear', numberValue(frame, 'Gear')) || changed;
47+
changed = this.set('speed', numberValue(frame, 'Speed')) || changed;
48+
changed =
49+
this.set('displayUnits', numberValue(frame, 'DisplayUnits')) || changed;
50+
changed =
51+
this.set(
52+
'steeringWheelAngle',
53+
numberValue(frame, 'SteeringWheelAngle')
54+
) || changed;
55+
changed =
56+
this.set('brakeAbsActive', booleanValue(frame, 'BrakeABSactive')) ||
57+
changed;
58+
changed = this.set('rpm', numberValue(frame, 'RPM')) || changed;
59+
changed =
60+
this.set('shiftGrindRpm', numberValue(frame, 'ShiftGrindRPM')) || changed;
61+
changed = this.set('oilTemp', numberValue(frame, 'OilTemp')) || changed;
62+
changed = this.set('waterTemp', numberValue(frame, 'WaterTemp')) || changed;
63+
changed =
64+
this.set('engineWarnings', numberValue(frame, 'EngineWarnings')) ||
65+
changed;
66+
if (changed) this.latest.version += 1;
67+
}
68+
69+
onLifecycle(event: SessionLifecycleEvent): void {
70+
if (event.type === 'enter') return;
71+
this.latest.brake = undefined;
72+
this.latest.brakeRaw = undefined;
73+
this.latest.throttle = undefined;
74+
this.latest.throttleRaw = undefined;
75+
this.latest.clutch = undefined;
76+
this.latest.clutchRaw = undefined;
77+
this.latest.gear = undefined;
78+
this.latest.speed = undefined;
79+
this.latest.displayUnits = undefined;
80+
this.latest.steeringWheelAngle = undefined;
81+
this.latest.brakeAbsActive = undefined;
82+
this.latest.rpm = undefined;
83+
this.latest.shiftGrindRpm = undefined;
84+
this.latest.oilTemp = undefined;
85+
this.latest.waterTemp = undefined;
86+
this.latest.engineWarnings = undefined;
87+
this.latest.shiftRpm = undefined;
88+
this.latest.blinkRpm = undefined;
89+
this.latest.version += 1;
90+
}
91+
92+
snapshot(): DriverControlsSnapshot {
93+
return this.latest;
94+
}
95+
96+
private set<K extends Exclude<keyof DriverControlsSnapshot, 'version'>>(
97+
key: K,
98+
value: DriverControlsSnapshot[K]
99+
): boolean {
100+
if (this.latest[key] === value) return false;
101+
this.latest[key] = value;
102+
return true;
103+
}
104+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import type { Telemetry } from '@irdashies/types';
3+
import { ChannelBus } from '../bridge/channelBridge';
4+
import { DriverControlsRuntime } from './driverControlsRuntime';
5+
6+
const frame = (gear: number, rpm: number) =>
7+
({ Gear: { value: [gear] }, RPM: { value: [rpm] } }) as Telemetry;
8+
const target = {
9+
id: 1,
10+
isDestroyed: () => false,
11+
isVisible: () => true,
12+
send: vi.fn(),
13+
};
14+
const metrics = () => ({ markStart: vi.fn(), markEnd: vi.fn() });
15+
16+
describe('DriverControlsRuntime', () => {
17+
it('publishes changed controls only while demanded', () => {
18+
const bus = new ChannelBus();
19+
const publish = vi.spyOn(bus, 'publish');
20+
const runtime = new DriverControlsRuntime(bus, undefined, metrics());
21+
runtime.onFrame(frame(2, 5000));
22+
expect(publish).not.toHaveBeenCalled();
23+
24+
bus.subscribe(target, 'driver-controls.snapshot');
25+
runtime.onFrame(frame(2, 5000));
26+
runtime.onFrame(frame(2, 5000));
27+
expect(publish).toHaveBeenCalledOnce();
28+
expect(publish).toHaveBeenCalledWith(
29+
'driver-controls.snapshot',
30+
expect.objectContaining({ gear: 2, rpm: 5000 })
31+
);
32+
33+
bus.unsubscribe(target.id, 'driver-controls.snapshot');
34+
runtime.onFrame(frame(3, 6000));
35+
expect(publish).toHaveBeenCalledOnce();
36+
});
37+
38+
it('activates for subscribers that predate the runtime', () => {
39+
const bus = new ChannelBus();
40+
const publish = vi.spyOn(bus, 'publish');
41+
bus.subscribe(target, 'driver-controls.snapshot');
42+
const runtime = new DriverControlsRuntime(bus, undefined, metrics());
43+
runtime.onFrame(frame(4, 6500));
44+
expect(publish).toHaveBeenCalledOnce();
45+
});
46+
47+
it('publishes a reset and clears cached state when disposed', () => {
48+
const bus = new ChannelBus();
49+
const publish = vi.spyOn(bus, 'publish');
50+
const clearSnapshot = vi.spyOn(bus, 'clearSnapshot');
51+
bus.subscribe(target, 'driver-controls.snapshot');
52+
const runtime = new DriverControlsRuntime(bus, undefined, metrics());
53+
runtime.onFrame(frame(4, 6500));
54+
runtime.dispose();
55+
expect(publish).toHaveBeenLastCalledWith(
56+
'driver-controls.snapshot',
57+
expect.objectContaining({ gear: undefined, rpm: undefined })
58+
);
59+
expect(clearSnapshot).toHaveBeenLastCalledWith('driver-controls.snapshot');
60+
});
61+
});

0 commit comments

Comments
 (0)