Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
| **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. |
| **Phase 2b — Architectural cleanup (remaining)** | NOT STARTED | — | A1, A4, A5, A6, A7 completion, A9. Lower urgency now Standings memory issue is resolved |
| **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. |
| **Phase 4 — Main-process processors** | IN PROGRESS | PRs #659–#666 | Fuel, lap times, car speeds, reference laps, relative gaps, sector timing, Standings core state, and live positions are on `main`. Radio migration is in PR #666; session-bar migration and legacy telemetry removal or development-only restriction remain. |
| **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`. Session timing is in PR #667; auxiliary Session Bar telemetry and legacy telemetry removal or development-only restriction remain. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
| **Phase 5 — Worker-thread SDK loop** | NOT STARTED | — | |
| **Phase 6 — Native optimisations** | DEFERRED | — | Only if Phase 4 profiling demands |

Expand Down Expand Up @@ -273,8 +273,10 @@ Today every renderer wakes 25 times/sec regardless of what's mounted. A weather
- [x] SectorTimingProcessor — PR #663
- [x] StandingsProcessor — PR #664
- [x] Standings live-position projection — PR #665
- [ ] Radio transmit state — `radio.snapshot`, event-driven and demand-activated; PR #666 in review
- [x] Radio transmit state — `radio.snapshot`, event-driven and demand-activated; PR #666
- [ ] Session-bar telemetry migration
- [x] Shared race/session timing projection — `session-timing.snapshot`, demand-driven at 5 Hz; PR #667 in review
- [x] Auxiliary items (weather, fuel/units, brake bias, incidents, lap results, player position, and top speed) — `session-bar.snapshot`; PR #667
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- [ ] Legacy `'telemetry'` channel removed or dev-only

### Phase 5 — Worker-thread SDK loop
Expand Down Expand Up @@ -466,6 +468,8 @@ LLM agents: read this file at the start of any session that touches the architec

## 6. Activity log

- **2026-08-09** — PR #666 merged. Opened PR #667 for the next Phase 4 slice: move shared Session Bar race/session timing derivation from React into a demand-driven 5 Hz `session-timing.snapshot`, shared with the existing lap-times runtime and wired for live/tape plus mock sources. Auxiliary Session Bar telemetry remains a follow-up before the legacy stream can be restricted — `feat/session-bar-channel` — in review

- **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

- **2026-08-08** — Standings PR #664 merged. Opened PR #665 to move live in-class position calculation from renderer telemetry hooks into the demand-driven Standings processor with reusable projection buffers and conditional renderer subscriptions. Radio and session-bar telemetry remain after this slice — `feat/standings-live-positions` — in review
Expand Down
21 changes: 21 additions & 0 deletions src/app/bridge/iracingSdk/iracingSdkBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { RelativeGapRuntime } from '../../processors/relativeGapRuntime';
import { SectorTimingRuntime } from '../../processors/sectorTimingRuntime';
import { StandingsRuntime } from '../../processors/standingsRuntime';
import { RadioRuntime } from '../../processors/radioRuntime';
import { SessionTimingRuntime } from '../../processors/sessionTimingRuntime';
import { SessionBarRuntime } from '../../processors/sessionBarRuntime';

// Keys consumed by the renderer. Anything outside this set is dropped before
// the telemetry object crosses the IPC boundary — reducing structured-clone
Expand Down Expand Up @@ -208,6 +210,19 @@ export async function publishIRacingSDKEvents(
lifecycle && channelBus
? new RadioRuntime(channelBus, lifecycle, perfMetrics, isTapeReplay)
: undefined;
const sessionTimingRuntime =
lifecycle && channelBus && lapTimesRuntime
? new SessionTimingRuntime(
channelBus,
lifecycle,
perfMetrics,
lapTimesRuntime,
isTapeReplay
)
: undefined;
const sessionBarRuntime = channelBus
? new SessionBarRuntime(channelBus, lifecycle, perfMetrics, isTapeReplay)
: undefined;

let shouldStop = false;
let lastRunningState: boolean | undefined = undefined;
Expand Down Expand Up @@ -312,6 +327,8 @@ export async function publishIRacingSDKEvents(
sectorTimingRuntime?.onFrame(telemetry);
standingsRuntime?.onFrame(telemetry);
radioRuntime?.onFrame(telemetry);
sessionTimingRuntime?.onFrame(telemetry);
sessionBarRuntime?.onFrame(telemetry);
if (
perfTelemetryDeliveryEnabled &&
overlayManager.hasLegacyStreamSubscribers('telemetry')
Expand Down Expand Up @@ -346,6 +363,8 @@ export async function publishIRacingSDKEvents(
relativeGapRuntime?.onSession(session);
sectorTimingRuntime?.onSession(session);
standingsRuntime?.onSession(session);
sessionTimingRuntime?.onSession(session);
sessionBarRuntime?.onSession(session);
overlayManager.publishMessage('sessionData', session);
sessionCallbacks.forEach((callback) => callback(session));
perfMetrics.markEnd('sessionPublish');
Expand Down Expand Up @@ -409,6 +428,8 @@ export async function publishIRacingSDKEvents(
sectorTimingRuntime?.dispose();
standingsRuntime?.dispose();
radioRuntime?.dispose();
sessionTimingRuntime?.dispose();
sessionBarRuntime?.dispose();
referenceLapRuntime?.dispose();
perfMetrics.stopReporting();
},
Expand Down
47 changes: 47 additions & 0 deletions src/app/bridge/iracingSdk/mock-data/mockSdkBridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,53 @@ describe('mockSdkBridge processor channels', () => {
);
});

it('publishes session timing without a lifecycle in demo mode', async () => {
const bus = new ChannelBus();
const publish = vi.spyOn(bus, 'publish');
bus.subscribe(
{
id: 3,
isDestroyed: () => false,
isVisible: () => true,
send: vi.fn(),
},
'session-timing.snapshot'
);
const bridge = await publishIRacingSDKEvents(
{ publishMessage: vi.fn() } as never,
undefined,
bus
);
try {
callbacks.session?.({
DriverInfo: { DriverCarIdx: 0, Drivers: [{ CarIdx: 0 }] },
SessionInfo: {
Sessions: [{ SessionNum: 1, SessionType: 'Race', SessionLaps: 20 }],
},
} as unknown as Session);
callbacks.telemetry?.({
SessionTime: { value: [120] },
SessionNum: { value: [1] },
SessionState: { value: [4] },
SessionTimeTotal: { value: [2400] },
SessionTimeRemain: { value: [2280] },
CamCarIdx: { value: [0] },
CarIdxLap: { value: [2] },
CarIdxPosition: { value: [1] },
CarIdxLapDistPct: { value: [0.25] },
CarIdxBestLapTime: { value: [60] },
CarIdxLastLapTime: { value: [60] },
} as unknown as Telemetry);

expect(publish).toHaveBeenCalledWith(
'session-timing.snapshot',
expect.objectContaining({ currentLap: 2, sessionType: 'Race' })
);
} finally {
bridge.stop();
}
});

it('feeds mock data through the relative-gap runtime', async () => {
const bus = new ChannelBus();
const publish = vi.spyOn(bus, 'publish');
Expand Down
26 changes: 26 additions & 0 deletions src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { RelativeGapRuntime } from '../../../processors/relativeGapRuntime';
import { SectorTimingRuntime } from '../../../processors/sectorTimingRuntime';
import { StandingsRuntime } from '../../../processors/standingsRuntime';
import { RadioRuntime } from '../../../processors/radioRuntime';
import { SessionTimingRuntime } from '../../../processors/sessionTimingRuntime';
import { LapTimesRuntime } from '../../../processors/lapTimesRuntime';
import { SessionBarRuntime } from '../../../processors/sessionBarRuntime';

export async function publishIRacingSDKEvents(
overlayManager: OverlayManager,
Expand All @@ -19,6 +22,9 @@ export async function publishIRacingSDKEvents(
perfMetrics.startReporting();

const bridge = generateMockData();
const lapTimesRuntime = channelBus
? new LapTimesRuntime(channelBus, lifecycle, perfMetrics)
: undefined;
const carSpeedsRuntime = channelBus
? new CarSpeedsRuntime(channelBus, lifecycle, perfMetrics)
: undefined;
Expand Down Expand Up @@ -46,24 +52,41 @@ export async function publishIRacingSDKEvents(
const radioRuntime = channelBus
? new RadioRuntime(channelBus, lifecycle, perfMetrics)
: undefined;
const sessionTimingRuntime =
channelBus && lapTimesRuntime
? new SessionTimingRuntime(
channelBus,
lifecycle,
perfMetrics,
lapTimesRuntime
)
: undefined;
const sessionBarRuntime = channelBus
? new SessionBarRuntime(channelBus, lifecycle, perfMetrics)
: undefined;

bridge.onSessionData((session) => {
carSpeedsRuntime?.onSession(session);
referenceLapRuntime?.onSession(session);
relativeGapRuntime?.onSession(session);
sectorTimingRuntime?.onSession(session);
standingsRuntime?.onSession(session);
sessionTimingRuntime?.onSession(session);
sessionBarRuntime?.onSession(session);
overlayManager.publishMessage('sessionData', session);
});

bridge.onTelemetry((telemetry) => {
perfMetrics.markStart('processTelemetry');
lapTimesRuntime?.onFrame(telemetry);
carSpeedsRuntime?.onFrame(telemetry);
referenceLapRuntime?.onFrame(telemetry);
relativeGapRuntime?.onFrame(telemetry);
sectorTimingRuntime?.onFrame(telemetry);
standingsRuntime?.onFrame(telemetry);
radioRuntime?.onFrame(telemetry);
sessionTimingRuntime?.onFrame(telemetry);
sessionBarRuntime?.onFrame(telemetry);
perfMetrics.markStart('broadcast');
overlayManager.publishMessage('telemetry', telemetry);
perfMetrics.markEnd('broadcast');
Expand All @@ -80,10 +103,13 @@ export async function publishIRacingSDKEvents(
...bridge,
stop: () => {
carSpeedsRuntime?.dispose();
lapTimesRuntime?.dispose();
relativeGapRuntime?.dispose();
sectorTimingRuntime?.dispose();
standingsRuntime?.dispose();
radioRuntime?.dispose();
sessionTimingRuntime?.dispose();
sessionBarRuntime?.dispose();
referenceLapRuntime?.dispose();
perfMetrics.stopReporting();
originalStop();
Expand Down
64 changes: 64 additions & 0 deletions src/app/processors/SessionBarProcessor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import type { Session, Telemetry } from '@irdashies/types';
import { SessionBarProcessor } from './SessionBarProcessor';

describe('SessionBarProcessor', () => {
it('projects the auxiliary session bar state and resets with lifecycle', () => {
const processor = new SessionBarProcessor();
processor.init({
WeekendInfo: {
TrackDisplayName: 'Okayama',
WeekendOptions: { IncidentLimit: 17 },
},
DriverInfo: {
DriverCarIdx: 0,
Drivers: [{ CarIdx: 0, CarID: 67, CarClassID: 1 }],
},
SessionInfo: {
Sessions: [{ SessionNum: 1, SessionName: 'Race', SessionType: 'Race' }],
},
} as unknown as Session);
processor.onFrame({
SessionTime: { value: [1] },
SessionNum: { value: [1] },
DisplayUnits: { value: [1] },
FuelLevel: { value: [30] },
PlayerCarTeamIncidentCount: { value: [2] },
CarIdxPosition: { value: [1] },
CarIdxClassPosition: { value: [1] },
CarIdxBestLapTime: { value: [90] },
Lap: { value: [2] },
Speed: { value: [50] },
} as unknown as Telemetry);
expect(processor.snapshot()).toMatchObject({
sessionName: 'Race',
trackDisplayName: 'Okayama',
fuelLevel: 30,
incidents: 2,
playerClassPosition: 1,
playerClassSize: 1,
sessionBestLap: 90,
});
processor.onFrame({
SessionTime: { value: [1.05] },
SessionNum: { value: [1] },
Lap: { value: [2] },
Speed: { value: [70] },
} as unknown as Telemetry);
processor.onFrame({
SessionTime: { value: [1.2] },
SessionNum: { value: [1] },
Lap: { value: [3] },
Speed: { value: [40] },
} as unknown as Telemetry);
expect(processor.snapshot()).toMatchObject({
lastLapTopSpeed: 70,
sessionBestTopSpeed: 70,
});
processor.onLifecycle({ type: 'disconnect' });
expect(processor.snapshot()).toMatchObject({
sessionNum: null,
incidents: 0,
});
});
});
Loading