Architecture
iRaceDeck is shaped like an hourglass. Many possible sims funnel in through one narrow seam, pass through a single shared “brain,” then fan out through a second narrow seam to many devices. Those two seams — the event bus (inbound) and the IDeckPlatformAdapter (outbound) — are the whole architecture in a sentence: add a sim by writing one new translator, add a device by writing one new adapter, and nothing else changes. A third, smaller seam sits beside the bus: the sim connection, the little the deck layer itself needs from a sim (is it connected, which flags are out, how to resolve a title template), so a second sim also brings its own small deck-layer package. How far the hourglass holds today is the subject of the last section.
This page is the visual companion to the Tech Stack page (which lists what each package is). Here we show how they fit and flow.
The big picture
Section titled “The big picture”flowchart TB ir["iRacing sim"]:::ext future["future sims<br/>(AC, rF2, ...)"]:::ghost futureTrans["future translator<br/>(sim-events-...)"]:::ghost futureDeck["future deck layer<br/>(deck-...)"]:::ghost sdk["iracing-sdk"]:::sim trans["sim-events-iracing<br/>(translator)"]:::sim bus(["event-bus<br/>SEAM 1 — semantic events"]):::seam deckir["deck-iracing<br/>(iRacing side of the deck layer)"]:::sim simconn(["SimConnection<br/>SEAM 3 — sim connection"]):::seam actions["iracing-actions<br/>(icons + buttons)"]:::core re["audio-scenarios<br/>(Race Engineer)"]:::audio adapter(["IDeckPlatformAdapter<br/>SEAM 2 — device boundary"]):::seam elg["Elgato adapter"]:::adp mir["Mirabox adapter"]:::adp ula["Ulanzi adapter"]:::adp deck["Stream Deck / Mirabox / Ulanzi hardware"]:::ext spk["audio output"]:::ext ir --> sdk sdk --> trans trans --> bus sdk --> deckir deckir --> simconn simconn -->|"connected · flags · title templates"| actions deckir <-.->|"telemetry reads · commands (leak)"| actions future -.-> futureTrans futureTrans -.-> bus future -.-> futureDeck futureDeck -.-> simconn actions -->|"key-press readout"| bus bus --> re actions --> adapter adapter --> elg adapter --> mir adapter --> ula elg --> deck mir --> deck ula --> deck re --> spk classDef ext fill:#33404d,color:#fff,stroke:#1d262e; classDef ghost fill:#33404d,color:#c5ccd3,stroke:#5a6573,stroke-dasharray:4 3; classDef sim fill:#d9822b,color:#fff,stroke:#9c5e1f; classDef seam fill:#8e44ad,color:#fff,stroke:#5e2d73,stroke-width:3px; classDef core fill:#2d7dd2,color:#fff,stroke:#1f5793; classDef audio fill:#2e9e5b,color:#fff,stroke:#1f6e40; classDef adp fill:#16a085,color:#fff,stroke:#0e6f5c;
Arrows show runtime flow. The purple stadium nodes are the abstraction seams; they’re styled the same way in every diagram below so you can anchor on them. The dashed nodes are hypothetical — they show where a second sim would plug in: a translator onto the bus, and a deck layer behind the sim connection. The dashed arrow between deck-iracing and the actions is not a seam but a leak, drawn so it can’t be missed (see Seams & where the abstraction leaks).
Three things to notice. First, only iracing-actions flows down to the device seam — the Race Engineer (audio-scenarios) is a sibling consumer whose output goes to your speakers, never through the deck. Second, the sim reaches the two consumers by different roads: the Race Engineer hears semantic events through the bus (SEAM 1), while the keys’ shared behaviour — greying out while the sim is disconnected, the flag overlay, live title templates — comes through the sim connection (SEAM 3), the only thing deck-core knows about a simulator (#1351). Third, no action subscribes to the bus today, and many still read iRacing telemetry and send iRacing commands directly through deck-iracing: everything above the two sim seams is sim-specific, and everything below them is sim-agnostic only in part (see the Seams & where the abstraction leaks section below).
The arrow from iracing-actions back into the bus is the one event the deck layer publishes: a Session Info key press that asks the Race Engineer to read the value it shows out. Its payload is already in the driver’s display unit, so it is as sim-agnostic as the translator’s events.
Inbound: telemetry → semantic events
Section titled “Inbound: telemetry → semantic events”The translator, sim-events-iracing, is where iRacing telemetry becomes events. It subscribes to the SDK controller’s ticks, diffs each snapshot against the previous one, and publishes semantic events onto the bus — events named for what they mean in racing terms (flag.yellow.raised, lap.completed) rather than for the raw telemetry they were derived from, each fired once on the meaningful change. The bus envelope carries telemetry as a generic field — it imports no SDK — which is what makes it reusable for a future sim-events-<sim>.
flowchart TB ir["iRacing sim"]:::ext mem["shared memory"]:::ext sdk["iracing-sdk<br/>sdkController"]:::sim trans["sim-events-iracing<br/>translator"]:::sim bus(["event-bus<br/>SEAM 1"]):::seam p["plugin-runtime<br/>replay record"]:::core re["audio-scenarios"]:::audio ir --> mem mem -->|"polls telemetry"| sdk sdk -->|"tick: snapshot"| trans trans -->|"diff vs previous → semantic event<br/>(e.g. flag.yellow.raised)"| bus bus -->|"subscribe"| p bus -->|"subscribe"| re classDef ext fill:#33404d,color:#fff,stroke:#1d262e; classDef sim fill:#d9822b,color:#fff,stroke:#9c5e1f; classDef seam fill:#8e44ad,color:#fff,stroke:#5e2d73,stroke-width:3px; classDef core fill:#2d7dd2,color:#fff,stroke:#1f5793; classDef audio fill:#2e9e5b,color:#fff,stroke:#1f6e40;
A reaction driven by the bus never reads telemetry for its trigger: it subscribes to an event the translator derived, the way the Race Engineer hears flag.yellow.raised. The translator is not the only reader of iRacing telemetry, though. No action subscribes to the bus today: a key’s flag overlay asks deck-core’s sim connection for the active flags on each tick, and many actions read telemetry and session info directly through deck-iracing’s SimIRacingAction. Part of the Race Engineer catalog also reads the raw snapshot through getLatestTelemetry(). Both direct reads are listed under Seams & where the abstraction leaks below.
What the event bus provides
Section titled “What the event bus provides”event-bus is the contract every consumer codes against. It gives them three things:
- A typed, sim-agnostic event catalog — the vocabulary of things that can happen (
flag.yellow.raised, an overtake, a laps-of-fuel-left crossing, a pit-lane transition). It’s a plain pub/sub package that imports no simulator SDK, so the vocabulary stays the same no matter which sim feeds it. - Decoupled fan-out — publishers and subscribers never reference each other. The translator publishes nearly everything; since #466 one deck action does too — pressing a Session Info key publishes
telemetryReadout.requestedwith the value the key shows, for the Race Engineer to speak. The Race Engineer and — for the replay lap record — the plugin itself each subscribe independently; no action subscribes today. You can add a consumer without touching the producer, and — in principle — swap the producer without touching the consumers. - A generic telemetry snapshot on the envelope — alongside the semantic payload, each event carries the latest raw telemetry in a generic field. This is the sim-specific escape hatch: the Race Engineer’s radar and spotter engines read it (via
getLatestTelemetry) because their job needs the full per-car picture, not a single event. It is also the part of this seam that is not sim-agnostic yet — see the leaks below.
The catalog spans around 60 events grouped into families — pit lane and stops, flags, start lights, rolling start, pit service, tires, car control, pit limiter, incidents and off-tracks, overtakes and position changes, laps, fuel, proximity radar, track wetness, damage, session lifecycle, and the replay record (lap starts, lap times, greens, session starts and incidents). Payloads range from empty (pure transitions like pitLane.entered) to rich records:
flag.yellow.raised → { scope: "local" | "full" }fuel.lapsLeft.crossed → { count: number, lapsLeft: number }overtake.completed → { position, previousPosition, gapBehindMeters?, isLeader, … }The canonical, always-current list — every event name and its exact payload — is the SimEventMap type in event-bus/src/event-catalog.ts. This page deliberately doesn’t reproduce it: the types are the source of truth, and they change too often to mirror by hand.
Outbound: reactions to devices, commands to iRacing
Section titled “Outbound: reactions to devices, commands to iRacing”There are two outbound paths, and they’re easy to conflate. The render path turns an event or state change into pixels on a key. The command path turns a button press into an action inside iRacing. Both run through iracing-actions, but they exit through different doors.
flowchart TB evt["event / state change<br/>(from SEAM 1)"]:::seam press["button / dial press"]:::ext action["iracing-actions<br/>action handler"]:::core icon["assembleIcon()<br/>icon-composer"]:::core core(["deck-core<br/>IDeckPlatformAdapter — SEAM 2"]):::seam dkir["deck-iracing<br/>getCommands()"]:::sim elg["Elgato"]:::adp mir["Mirabox"]:::adp ula["Ulanzi"]:::adp dev["device pixels"]:::ext ir["iRacing"]:::ext evt -->|"render"| action action --> icon icon --> core core --> elg core --> mir core --> ula elg --> dev mir --> dev ula --> dev press -->|"command"| action action -->|"getCommands() — SDK broadcast"| dkir dkir --> ir action -->|"getKeyboard() — native inject"| ir action -->|"chat #macro"| ir classDef ext fill:#33404d,color:#fff,stroke:#1d262e; classDef sim fill:#d9822b,color:#fff,stroke:#9c5e1f; classDef seam fill:#8e44ad,color:#fff,stroke:#5e2d73,stroke-width:3px; classDef core fill:#2d7dd2,color:#fff,stroke:#1f5793; classDef adp fill:#16a085,color:#fff,stroke:#0e6f5c;
The render path is fully abstracted: iracing-actions hands a finished icon — an SVG data URI — to deck-core, and whichever adapter is loaded paints it on the real hardware. The icon crosses SEAM 2 unchanged, still as SVG; inside each adapter’s context implementation (setImage, Elgato’s setFeedback, and setDialCanvas for a dial’s own screen), deck-core’s rasterizer service converts it to PNG in-plugin (@iracedeck/rasterizer, wrapping @resvg/resvg-js, via the render function the plugin injected at startup) and sends the device pixels rather than an SVG string, so every key and dial looks identical regardless of which host’s own SVG engine it’s running on. The command path has three mechanisms — the SDK broadcast (getCommands(), from deck-iracing, since commands are iRacing’s vocabulary and not part of deck-core) is preferred, native keyboard injection (getKeyboard()) covers what the SDK can’t, and chat macros cover the rest.
The settings path (the settings window and the Property Inspectors)
Section titled “The settings path (the settings window and the Property Inspectors)”Since #992 there is a third outbound path that has nothing to do with iRacing: the plugin serves a web page and the user’s browser is a client of the plugin. And since #993 the plugin also owns the settings themselves: plugin-global settings live in one JSON file per ecosystem (SettingsStore → %LOCALAPPDATA%\iRaceDeck\Settings\<Stream Deck | Mirabox | Ulanzi>\global-settings.json), loaded at startup and saved — debounced and atomically — on every change. Both editing surfaces reach it the same way: the settings window and every Property Inspector speak the PI protocol to the plugin’s own loopback server. The deck host’s store is read once and written once: read only when there is no file yet, to migrate an existing installation across on the first start after the upgrade; written exactly once per start with a full mirror of the cache plus _settingsChannel (the loopback server’s port and token), which is the bootstrap a Property Inspector needs to find the plugin. The mirror is a whole object because a deck host’s setGlobalSettings replaces rather than merges, and it is skipped entirely when the migration read went unanswered — mirroring schema defaults would destroy a copy the plugin never got to see. That copy doubles as the downgrade safety net.
flowchart LR pi["Property Inspectors<br/>(sdpi-components<br/>+ pi-settings-bridge / ulanzi-pi-bridge)"]:::ext win["Settings window<br/>(browser --app=, same sdpi-components<br/>+ settings-window-bridge)"]:::ext srv["settings-window<br/>loopback server<br/>loopback · token · Origin/cookie<br/>(runs for the plugin's lifetime)"]:::core upd["app-updates<br/>update-check service"]:::core gs["settings<br/>global-settings<br/>(single writer)"]:::core boot["plugin-runtime initSettings<br/>store-ready startup block"]:::core store["SettingsStore<br/>global-settings.json<br/>(per ecosystem, under LOCALAPPDATA)"]:::core core(["IDeckPlatformAdapter — SEAM 2"]):::seam host["deck host store"]:::ext site["iracedeck.com<br/>changelog.json · voice-catalog.json"]:::ext win -->|"PI protocol over ws://127.0.0.1"| srv pi -->|"global settings over ws://127.0.0.1, token on the upgrade"| srv srv -->|"changed keys only"| gs gs -->|"save (debounced, atomic)"| store store -->|"load at startup"| gs gs -->|"push on any change"| srv srv --> win srv --> pi win -->|"GET /updates/status — is there a newer version?"| srv srv -->|"ask for the verdict"| upd upd -->|"fetch changelog.json — 1 h cache, opt-out"| site host -->|"read ONCE — migration"| core core --> gs gs -->|"store ready"| boot boot -->|"mirror once per start — full object + _settingsChannel"| core core --> host host -.->|"one bootstrap read — _settingsChannel"| pi pi -->|"per-action settings, sendToPlugin, the bootstrap read"| host classDef ext fill:#33404d,color:#fff,stroke:#1d262e; classDef seam fill:#8e44ad,color:#fff,stroke:#5e2d73,stroke-width:3px; classDef core fill:#2d7dd2,color:#fff,stroke:#1f5793;
The window is the PI framework re-hosted: the same sdpi-components.js, pi-components.js, and global-*.ejs partials, talking to a fake host inside the plugin that speaks the global-settings subset of the Elgato PI protocol. Anything the plugin does on the window’s behalf — persist its bounds, switch a deck’s profile, play an audio preview, reveal the settings file in Explorer — arrives as a sendToPlugin command and is validated by settings-window‘s command handler, which hands each one to a callback the bootstrap injected; SimHub reachability, which a direct fetch from the window’s origin could not read (it is cross-origin), is a GET /simhub/roles route on the same server. The server, its request guard, the launcher and that handler are the settings-window package, moved out of deck-core in #1367. The same cross-origin constraint gives the window’s What’s New tab its one runtime (#1016): the plugin — app-updates’ update-check service, which the server asks — fetches the changelog artifact the website publishes, keeps only the dated releases newer than the running build, sanitizes their bullets to a four-tag allow-list, and serves the verdict at GET /updates/status — cached for an hour, gated on a setting, and silent about every failure, so the compiled-in release notes never depend on it. That is the only request the plugin makes to anything but iRacing and SimHub. The page is opened as a chromeless app window in a Chromium browser with a dedicated profile, and closes itself when its socket to the plugin dies. The server is no longer started on demand: it comes up from the plugin’s store-ready startup block and stays up, so its address is known before any UI asks for it — and that same block, in plugin-runtime’s settings phase rather than the settings package’s global-settings module, is what mirrors the store, plus the server’s _settingsChannel, to the deck host for the PIs to bootstrap from — the channel itself is never persisted in the plugin’s own file. If the settings file can’t be read, the block never runs: no server, no channel.
Property Inspectors reach the same server through a bridge script the build injects ahead of sdpi-components.js (pi-settings-bridge.js on Elgato and Mirabox, the Ulanzi PI bridge on Ulanzi). Both run one shared state machine: when the PI’s host socket opens it makes a single plain getGlobalSettings read, takes _settingsChannel out of the answer, opens the loopback socket, and from then on global-settings frames go to the plugin and the plugin’s pushes go to sdpi — the host’s are dropped, because the file is truth. Everything else a PI does (per-action settings, sendToPlugin, openUrl) still goes to the deck host untouched. If there is no channel, the socket is refused, or a phase doesn’t settle within three seconds, the PI falls back to the host path with a console warning — it keeps displaying and per-action settings keep working, but global-settings edits made there stay in the deck host’s copy, which the plugin no longer reads; a later push carrying a channel it hasn’t tried switches it over. So the loopback server is the one writer’s front door for every surface, and the guard that protects it accepts a valid token whatever the request’s Origin — Property Inspectors are file:// or host-served pages — while a token-less request must still match the loopback origin before its SameSite=Strict cookie counts. Details, security model, and rules: .claude/rules/settings-window.md and .claude/rules/global-settings.md.
The replay record
Section titled “The replay record”Since #1162 and #1203 the plugin also keeps a small record of its own for each session it sees: the replay session store, the replay-store package (in deck-core until #1367), one JSON file per SubSessionID under %LOCALAPPDATA%\iRaceDeck\Replay\<Stream Deck | Mirabox | Ulanzi>\. The file is a set of sections, one per feature and each with its own version: the user’s replay markers, the lap record — the replay frame at which every car started every lap, and each lap’s time — and, since #1354, the greens — the frame at which each race went green and the frame of every caution restart — and, since #1426, the session starts — the frame at which each session began — and the incidents — the frame at which a car’s incident count rose, recorded and not yet jumped to. It is what lets a saved replay opened days later find them again, because the .rpy reports the same SubSessionID as the live session, and a build that does not know a section carries it through every write untouched. It is fed from two directions. A subscriber from deck-iracing that the shared bootstrap wires onto the SDK controller opens the store’s record whenever the session identity first appears or changes, live or in a replay, and closes it when the SDK disconnects; it knows nothing about what happens inside a session. What happens inside — a car crossing the line, a lap time being posted, the race going green — is the translator’s to detect like any other sim fact, so sim-events-iracing has one recorder diff per section: replay-laps publishes replay.lapStarted and replay.lapTimed, replay-greens publishes replay.greenFlag, replay-sessions publishes replay.sessionStarted and replay-incidents publishes replay.incident. The recorders run ahead of the translator’s replay guard and share one module that says which ticks are live enough to record from, so no two sections can disagree about it: a tick in the car for every recorder, and for the session starts, which need only the session’s identity, its clock and a frame, also a tick out of the car at the live edge of a live session. The bootstrap’s replay phase subscribes to each event and hands it to its section of the store, which drops an event for any session but the open one. The actions and the replay engine (below) then read the store synchronously: Replay Markers adds, deletes and walks its markers; Replay Control’s Jump to Fastest Lap and Jump to Race Start, which run in the engine, look up the recorded frame, and its Next / Previous Lap and Session step through the recorded lap and session starts, each before it falls back to searching the replay; and after a Next / Previous Incident search the engine compares where the search landed with the recorded incidents, sending nothing. Both read the current replay position through the replay engine’s command gate (readReplayPosition, over resolveReplayPosition in replay-engine’s replay-position-state.ts), the one place that knows how to turn telemetry into a frame and that also counts a jump still under way as the position. Writes are debounced and atomic like the settings store’s, with the same synchronous flush on exit; an offline session, which has no SubSessionID, is kept in memory only, a corrupt file is moved aside before a fresh one is written, and a file that cannot be read at all is never written over. The folder is per ecosystem because each plugin process holds a whole copy of the session’s record and writes it back whole, so two deck hosts running at once on one file would overwrite each other’s changes; a merge-on-write was rejected because it would bring back markers the other host had deleted.
flowchart LR sdk["iracing-sdk<br/>sdkController"]:::sim trans["sim-events-iracing<br/>replay recorder diffs<br/>laps · greens · sessions · incidents"]:::sim bus(["event-bus<br/>SEAM 1"]):::seam plugin["plugin-runtime<br/>session subscriber + record subscriptions"]:::core store["replay-store<br/>replay session store<br/>markers · laps · greens · sessions · incidents"]:::core file["session_<SubSessionID>.json<br/>(per ecosystem, under LOCALAPPDATA)"]:::ext actions["iracing-actions<br/>Replay Markers · Replay Control"]:::core engine["replay-engine<br/>lap presses · record step · incident comparison"]:::sim sdk -->|"tick: session identity"| plugin sdk -->|"tick: snapshot"| trans trans -->|"replay.lapStarted · replay.lapTimed · replay.greenFlag · replay.sessionStarted · replay.incident"| bus bus -->|"subscribe"| plugin plugin -->|"open / close session · record laps, greens, sessions and incidents"| store store -->|"save (debounced, atomic)"| file file -->|"load on session open"| store actions -->|"markers"| store actions -->|"presses"| engine engine -->|"recorded laps, greens, sessions, incidents · a lap start the walk found"| store classDef ext fill:#33404d,color:#fff,stroke:#1d262e; classDef sim fill:#d9822b,color:#fff,stroke:#9c5e1f; classDef seam fill:#8e44ad,color:#fff,stroke:#5e2d73,stroke-width:3px; classDef core fill:#2d7dd2,color:#fff,stroke:#1f5793;
Driving the replay is the job of a second package, replay-engine (#1467), which Replay Control’s keys and dial, Replay Markers and the hidden legacy replay actions share. iRacing has one replay, and a replay command sent while a jump is under way ends the jump somewhere else, so the engine keeps each piece of replay state once: the replay command gate, the one module that sends a replay command and decides when each may go out; the replay cursor, which one long-running driver at a time claims; where the replay is headed; jump playback and the paced search; the one record of the replay speed, which every key and the dial show; the one camera-switch waiter, which holds a key’s or the dial’s camera switch until a jump has landed; and the Jump to Fastest Lap and Jump to Race Start presses with their lap walks. Leaving the replay is one event inside the engine that each of these honours, so a lap walk stops rather than running until it gives up. The engine is iRacing’s and imports no deck package and no settings: the bootstrap’s replay phase hands it the SDK controller, the replay and camera commands, the clock, a logger and the two settings it reads, once, before the actions are registered.
The Race Engineer branch
Section titled “The Race Engineer branch”The Race Engineer is a self-contained subsystem hanging off SEAM 1. It subscribes to the same bus, but instead of drawing icons it picks voice lines and plays them through a native mixer.
Its clips come from two places (#1034). The compiled-in manifest is the built-in half, and since #1034 stage 3 that is the walkie-talkie sfx alone: no plugin ships a voice, so every voice is an installed pack under %LOCALAPPDATA%\iRaceDeck\Race Engineer\Voices — dropped in by hand, or downloaded from the published catalog by voice-packs’ installer (#1100), which verifies the archive against the hash the catalog states, extracts it under its own entry validation, and swaps it into place so a failure at any step leaves the pack already installed untouched. default, the one pack iRaceDeck installs unasked (its other catalog packs, such as iracedeck-terse, wait for the user’s Install), gets there without anyone pressing anything: voice-packs’ voice-pack launch step runs at every plugin start and, once the settings load has settled (so the catalog’s development base-URL override has been read), installs default if it is missing and brings it — and every other pack the catalog installed — up to the catalog’s digest, retrying on a schedule that tightens while the Race Engineer is switched on. Nothing about it opens a window, and that pack is the one the settings window lists without a Remove button. The pack’s install record also decides how its voices are named: every voice of a pack iRaceDeck installed itself (catalog, bundled seed, or the development root) is published to the dropdown as iRaceDeck: <pack label> (iRaceDeck: <pack label>: <voice label> when that pack ships several voices) and sorted ahead of every sideloaded voice, in voice-packs’ voice-labels.ts, so the Property Inspector renders the list in the order it receives it. Either way voice-packs scans that directory, audio-service resolves a clip against a list of roots (the plugin’s own assets first, then one root per pack), and the scenario engine consumes the union of their manifests. Outside its pack folder a voice is <pack id>::<voice id> (#1144), so two packs may each ship a voice with the same bare id: the voice-pack service rewrites each pack’s voice/<voice id>/… clips to voice/<pack id>::<voice id>/… before the engine sees them, and each pack’s root is bound to its own voices — audio-service resolves a voice/<pack>::<voice>/… path only inside that pack’s root, as the pack’s own voice/<voice>/… file, and never reaches a bound root through the ordered walk it keeps for everything else (the sfx). Because every voice still arrives in the one voice/<id>/… shape, nothing downstream — pool derivation, {voice} substitution, validation — knows a second root exists, or that the id carries a pack.
Since #1064 the words are the pack’s too, not only the recordings. A scenario is split into two artifacts that pair by id. The code keeps the contract — the trigger (when / where), the scheduling knobs (weight, family, interrupt, cooldown, …), the channel, and the default radio frame — and registers the vocabulary a script may name: every spoken value (defineVar), yes/no condition (defineCond) and multi-way case (defineCase, with its key set declared) is a resolver in code, each with a description the generated pack-author reference publishes (/docs/voice-packs/reference/). The pack owns the script: voice/<id>/callouts.json, one per voice, a JSON document in the @iracedeck/callout-script grammar that says which clips, pools and vocabulary to assemble for each contract, in what order, and which callouts to skip. The grammar’s only operator is !; a pack can reference the vocabulary but never define any of it, so it cannot change when a callout fires or what it may interrupt. The same scanner in voice-packs that admits a pack’s clips reads its script beside them, and the voice-pack service hands the engine one map of <pack id>::<voice id> → parsed script (setScripts) after every scan, straight after the merged manifest, so a script is never live before its clips are. The engine compiles every voice’s script eagerly against the contracts and vocabulary it holds: an entry naming something unknown is skipped with one warning, a callout the script leaves out is skipped silently, and a voice with no script is a valid clips-only voice whose callouts all skip (the plugins raise the voice-script-missing banner when that voice is the active one). At fire time a contract looks up the active voice’s compiled sequence and wraps it in the frame the pack defines — the radio beeps and the ambience bed, once inline in 35 sequences as @pit-crew.radio-open / -close — dropping the beep steps or the ambient steps according to the Radio beeps / Pit ambience settings, read live, and only when there is speech to wrap. The grammar is its own leaf package (zod its only dependency) because three consumers that must not depend on each other validate the same contract: the engine (compile), the scanner (admit), and the asset generator that extracts the committed artifact from configs/<id>.voice.json (pnpm generate:callout-scripts), which the plugin build copies into assets/audio and the packer stages into every archive. The flags family took this shape in #1064 and #1065 moved every other family across, one conditional step at a time (a closed-set table becomes a case, a predicate a named condition, a value a var; a script may also define fragments it includes within itself, inlined at compile time), so the whole catalog is contracts in code plus entries in the pack — the Scenario type with an inline sequence survives only as an engine primitive for tests — and every first-party voice’s script (default, and the iracedeck-terse pack’s shawn) is held complete by a test — making every callout the reference voice default makes, and skipping exactly the ones it skips — while each voice’s script is held to its own clips by another.
flowchart TB bus(["event-bus<br/>SEAM 1"]):::seam scen["audio-scenarios<br/>scenario engine<br/>(contracts + vocabulary in code)"]:::audio assets["audio-assets<br/>(bundled sfx clips)"]:::audio packs["installed voice packs<br/>(%LOCALAPPDATA%)<br/>clips + callouts.json per voice"]:::ext scan["voice-packs<br/>voice-pack scanner + service"]:::audio gs["settings<br/>global settings<br/>Radio beeps · Pit ambience"]:::core svc["audio-service<br/>(audio roots, each pack's bound to its voices)"]:::audio nat["audio-native<br/>(miniaudio mixer)"]:::audio spk["speakers"]:::ext bus -->|"subscribe (when: event)"| scen assets -->|"built-in manifest"| scen packs -->|"scan: clips + scripts"| scan scan -->|"merged manifest (setManifest)"| scen scan -->|"pack::voice id → script (setScripts)"| scen gs -.->|"frame options, read at fire time"| scen cat["iracedeck.com<br/>voice-catalog.json"]:::ext inst["voice-packs<br/>voice-pack installer<br/>verify · validate · atomic swap"]:::audio cat -->|"catalog"| inst launch["voice-packs<br/>voice-pack launch step<br/>ensure default · update packs"]:::audio launch -->|"at every start, and on Rescan"| inst inst -->|"install / update / remove"| packs scen -->|"play voice sequence<br/>(frame + script, compiled per voice)"| svc assets -.->|"root 1"| svc packs -.->|"root 2..n"| svc svc --> nat nat --> spk classDef core fill:#2c3e50,color:#fff,stroke:#1a252f; classDef ext fill:#33404d,color:#fff,stroke:#1d262e; classDef seam fill:#8e44ad,color:#fff,stroke:#5e2d73,stroke-width:3px; classDef audio fill:#2e9e5b,color:#fff,stroke:#1f6e40;
Startup: one composition root
Section titled “Startup: one composition root”Every plugin starts the same way (#1349). Its plugin.ts is a shell: it builds its host’s adapter — and on Stream Deck a PluginExtension for what only that host has (bundled profiles and the automatic switch between them by session kind, the connected-deck list, the device type) — and calls plugin-runtime’s startPlugin, which runs eleven synchronous phases in a fixed order. Each phase takes what it needs from the earlier ones as arguments, so it cannot run before that exists; an order test pins the rest.
flowchart TB shell["plugin.ts — the shell<br/>adapter + optional PluginExtension"]:::plugin p1["1 · initCore<br/>config · log level · watchdog · SDK · event bus"]:::core p2["2 · initSim<br/>the sim translator"]:::sim p3["3 · initInput<br/>keyboard · clipboard · rasterizer"]:::core p4["4 · initAudio"]:::audio p5["5 · initVoicePacks"]:::audio p6["6 · initRaceEngineer<br/>scenario engine + race-engineer-wiring"]:::audio p7["7 · initSettings<br/>settings store · settings window · store-ready block"]:::core p8["8 · initReplay<br/>replay engine · replay store"]:::core p9["9 · registerActions<br/>shared list + the extension's extras"]:::core p10["10 · startServices<br/>global settings · launch step · SimHub · app monitor"]:::core p11(["11 · adapter.connect()<br/>IDeckPlatformAdapter — SEAM 2"]):::seam bus(["event-bus<br/>SEAM 1"]):::seam shell -->|"startPlugin(host)"| p1 p1 --> p2 --> p3 --> p4 --> p5 --> p6 --> p7 --> p8 --> p9 --> p10 --> p11 p2 -->|"publishes"| bus p2 -.->|"SimRuntime"| p6 classDef sim fill:#d9822b,color:#fff,stroke:#9c5e1f; classDef seam fill:#8e44ad,color:#fff,stroke:#5e2d73,stroke-width:3px; classDef core fill:#2d7dd2,color:#fff,stroke:#1f5793; classDef audio fill:#2e9e5b,color:#fff,stroke:#1f6e40; classDef plugin fill:#596775,color:#fff,stroke:#3c4651;
Solid arrows are call order; the dashed one is the one handoff worth naming. initSim is the only phase that knows which sim is running: it starts sim-events-iracing on the bus and returns a SimRuntime — the translator’s query side as one object (live positions and gaps, the caution phase and lineup, the overtake gate, …) — which the Race Engineer phase hands to race-engineer-wiring. That package builds the bus caches the engine’s conditions read and every dependency registerPitCrew takes, in one place the scenario harness shares: it calls wireRaceEngineer directly, replacing only the three snapshots its UI composes. A second sim would replace what initSim starts and what its SimRuntime answers; today that runtime is still iRacing-shaped (#1351). Host differences arrive only through the adapter (setLogLevel, logLocation, openUrl, onOpenSettingsRequest) or the extension, never by testing for a host name. The phases and the orderings inside them are listed in .claude/rules/plugin-structure.md.
Package dependency map
Section titled “Package dependency map”The diagrams above show runtime flow. This one shows something different: build-time imports — which package depends on which. Read the arrows as “imports.” They point downward, toward the foundation.
flowchart TB
subgraph plugins["Plugins — shells over the shared bootstrap"]
psd["iracing-plugin-stream-deck"]:::plugin
pmb["iracing-plugin-mirabox"]:::plugin
pul["iracing-plugin-ulanzi"]:::plugin
end
subgraph boot["Shared bootstrap"]
prt["plugin-runtime"]:::core
rew["race-engineer-wiring"]:::audio
pbld["plugin-build"]:::core
end
subgraph adapters["Device adapters"]
aelg["deck-adapter-elgato"]:::adp
amb["deck-adapter-mirabox"]:::adp
aul["deck-adapter-ulanzi"]:::adp
end
subgraph shared["Shared action layer"]
acts["iracing-actions"]:::core
pic["pi-components"]:::core
end
subgraph brain["Core / translate / scenarios"]
dc["deck-core"]:::core
dkir["deck-iracing"]:::sim
sei["sim-events-iracing"]:::sim
asc["audio-scenarios"]:::audio
end
subgraph platform["Platform + services"]
sdk["iracing-sdk"]:::sim
eb(["event-bus"]):::seam
asv["audio-service"]:::audio
ic["icon-composer"]:::core
sett["settings"]:::core
vpk["voice-packs"]:::audio
rstore["replay-store"]:::core
reng["replay-engine"]:::sim
diag["diagnostics"]:::core
aupd["app-updates"]:::core
swin["settings-window"]:::core
end
subgraph foundation["Foundation — no internal deps"]
icons["icons"]:::core
rast["rasterizer"]:::core
pinat["iracing-native"]:::sim
anat["audio-native"]:::audio
aasset["audio-assets"]:::audio
tdata["track-data"]:::sim
cscript["callout-script"]:::audio
csettings["callout-settings"]:::audio
appc["app-constants"]:::core
futils["fetch-utils"]:::core
end
psd --> aelg
pmb --> amb
pul --> aul
psd --> prt
pmb --> prt
pul --> prt
psd --> pbld
pmb --> pbld
pul --> pbld
psd --> dc
psd --> sett
psd --> dkir
psd --> sei
psd --> acts
pbld --> pic
pbld --> aasset
pbld --> appc
prt --> acts
prt --> rew
prt --> dc
prt --> dkir
prt --> rast
prt --> appc
prt --> sett
prt --> vpk
prt --> rstore
prt --> reng
prt --> diag
prt --> aupd
prt --> swin
rew --> sett
rew --> asc
rew --> sei
rew --> eb
aelg --> dc
amb --> dc
aul --> dc
acts --> dc
acts --> dkir
acts --> appc
acts --> sett
acts --> rstore
acts --> reng
acts --> diag
pic -.-> appc
pic -.-> futils
acts --> eb
acts --> icons
acts --> sei
acts --> asc
dc --> ic
dkir --> dc
dkir --> sdk
dkir --> sett
dkir --> appc
dkir --> rstore
sei --> eb
sei --> sdk
sei --> tdata
asc --> asv
asc --> aasset
asc --> eb
asc --> sei
asc --> cscript
aasset -.-> cscript
dc --> sett
sett --> csettings
sett --> cscript
sett --> appc
asc --> csettings
pic -.-> csettings
dc --> appc
vpk --> appc
vpk --> futils
vpk --> cscript
rstore --> sett
reng --> sdk
reng --> rstore
reng --> appc
diag --> appc
aupd --> appc
aupd --> futils
swin --> sett
swin --> appc
swin --> cscript
swin --> aupd
sdk --> pinat
asv --> anat
classDef seam fill:#8e44ad,color:#fff,stroke:#5e2d73,stroke-width:3px;
classDef sim fill:#d9822b,color:#fff,stroke:#9c5e1f;
classDef core fill:#2d7dd2,color:#fff,stroke:#1f5793;
classDef audio fill:#2e9e5b,color:#fff,stroke:#1f6e40;
classDef adp fill:#16a085,color:#fff,stroke:#0e6f5c;
classDef plugin fill:#596775,color:#fff,stroke:#3c4651;
To keep this readable, @iracedeck/logger (imported by nearly every package) and a few cross-cutting edges are omitted — plugin-runtime also pulls in the audio stack, event-bus, and sim-events-iracing directly; the Mirabox and Ulanzi shells import only their adapter and plugin-runtime, and at build time plugin-build, whose shared Rollup config (createPluginRollupConfig) is what imports pi-components for the Property Inspectors and audio-assets for the sounds. The Stream Deck plugin imports the same, plus what its elgato-extension.ts needs: deck-core for the profile switcher and settings for the _deckDevices write and the auto-switch settings, and, for the automatic profile switch by session kind (#1111), sim-events-iracing for the current session kind, deck-iracing for the SDK ticks and iRacing’s exit, and iracing-actions for the bundled-profile list — those five edges drawn, its app-constants edge left out — and @elgato/streamdeck for the SDK object’s type, which its shell also imports to build the adapter. The shape that matters: deck-core is the hub the device adapters share, and the foundation packages at the bottom depend on nothing internal. Since #1351 deck-core imports no sim at all — there is no deck-core → iracing-sdk edge — and sees the simulator only through its SimConnection interface; deck-iracing sits on top of it, implements that interface over iracing-sdk, and is what iracing-actions and plugin-runtime import for the iRacing side of the deck layer. A second sim would add a sibling deck-<sim> rather than touch deck-core. rasterizer is a foundation package too (it wraps @resvg/resvg-js and has no internal iRaceDeck dependencies), but note the arrow direction: plugin-runtime imports it and injects a render function into deck-core‘s rasterizer service at startup (initializeRasterizer(...), gated by the pngRasterization feature flag) — deck-core itself never imports rasterizer, so there’s deliberately no deck-core → rasterizer edge here. callout-script (#1064) is the other foundation package with more than one importer above it: the voice-pack format — the script grammar and its parser, and since #1134 the voice-pack.json schema and the rules a pack is admitted by — with zod as its only dependency, imported by audio-scenarios (to compile a script against the contracts, and for lint:pack to judge a pack by the scanner’s own rules), by voice-packs (to admit a pack and its scripts while scanning), and — the dashed edge, a devDependency — by audio-assets, whose generator extracts the committed voice/<id>/callouts.json from the authored voice config and validates it with the same parser. It is a separate package precisely so those three never have to import each other: the voice-pack stack must not depend on the Race Engineer to validate a file. (settings-window and settings import it too: the settings window’s commands check a page-supplied pack id with its packId, and settings qualifies stored voice ids.) callout-settings (#1350) is a leaf for the same reason: it declares every Race Engineer callout opt-in once — each calloutEnabled* key, its checkbox label and its default, grouped by callout family — so settings builds the settings schema and the isCalloutEnabled lookup from it, audio-scenarios derives its callout ids from it, and pi-components renders the settings window’s checkbox rows from it at build time (the dashed edge, a devDependency), without any of them importing another. plugin-runtime reads it too, for the opponent-flag gate it hands the translator — one of its omitted edges. app-constants and fetch-utils (#1364) are two more leaves, with no dependencies at all. app-constants holds what every layer must agree on — the setting-key names, the voice-pack status payload, the changelog policies, the Focus iRacing Window modes, the Mouse to Sim pointer target, the key map and default-binding parser, the settings window’s page name and warning ids, the _warnings banner record (PiWarning, #1366), and LogLocation, the host log location the adapter contract reports and the watchdog writes to (#1367) — and is imported by settings, deck-core, deck-iracing, voice-packs, diagnostics, app-updates, settings-window, iracing-actions, plugin-runtime, the three device adapters (for LogLocation, edges left out), and plugin-build, which reads the page name at build time. fetch-utils holds the request deadline and the byte-capped JSON read that app-updates’ changelog client and voice-packs’ catalog client use. Neither touches Node, so the Property Inspector’s browser bundle takes both: the two dashed pi-components edges are devDependencies it bundles, and they are the only workspace packages its build lets through — an import of any other @iracedeck/ package fails it. That is how the binding field and the plugin’s startup binding seed share one parser, and the components read the plugin’s own setting keys rather than copies of them, without the bundle ever reaching the deck-core barrel and its Node code. deck-core imports app-constants and re-exports none of it, so every name has one import path. settings (#1365) sits between those leaves and deck-core: the global-settings schema and cache, the migrations, the plugin-owned settings store, run-scoped keys, the PI warning banners, first run and the startup gates, built on app-constants, callout-settings and callout-script (to qualify voice ids). deck-core depends on it and never the reverse, so it cannot name IDeckPlatformAdapter; it declares the host surface it needs as SettingsHost instead, which the adapter interface extends. deck-core re-exports none of its names, so deck-iracing, iracing-actions, plugin-runtime, race-engineer-wiring and the Stream Deck plugin import it directly. voice-packs (#1366) is the Race Engineer’s voice-pack stack — the scanner and the voice-pack service, the catalog and its client, download, archive extraction, storage, the installer, the launch step, the voice labels and the missing-callout-script banner — moved out of deck-core. It is built on app-constants, fetch-utils and callout-script and on nothing above them: what it needs from settings (the warning writers, the settled signal the launch step waits on, the change listener) plugin-runtime injects, and the banner record it builds is app-constants’ PiWarning. plugin-runtime is its one importer drawn, and deck-core re-exports none of its names. Four more packages left deck-core in #1367, which leaves it holding only the deck abstraction: replay-store (the per-session replay store, built on settings’ path helpers and imported by deck-iracing’s replay-session subscriber, the replay actions, the replay engine and plugin-runtime), diagnostics (the main-thread watchdog, the resource monitor, the CPU profile capture and, since #1387, the registry of plugin-state sections a Telemetry Snapshot records, on app-constants alone, imported by plugin-runtime and by iracing-actions for Telemetry Control’s capture and snapshot), app-updates (the version check and the What’s New update check, on app-constants and fetch-utils) and settings-window (the loopback server and its guard, the launcher, the command handler and the channel publisher, on settings, app-constants, callout-script and app-updates, whose UpdateStatus type its server serves). With them went deck-core’s edges to callout-script and fetch-utils. replay-engine (#1467) is the replay engine the replay actions share — the replay command gate, the cursor, jump playback, the paced search, the speed state, the camera-switch waiter and the lap walks — moved out of iracing-actions. It is iRacing’s, built on iracing-sdk, replay-store and app-constants, and imports nothing from the deck layer or the settings: plugin-runtime’s replay phase injects the SDK controller, the replay and camera commands, the clock, a logger and the two settings it reads, and a lint rule refuses an import of deck-core, deck-iracing, settings, icons or iracing-actions there. iracing-actions and plugin-runtime import it. The snapshot’s state registry adds no edge: settings, sim-events-iracing and audio-scenarios each export a reader of their own state and import nothing from diagnostics, and plugin-runtime, which already imports all of them, registers the readers at the end of initCore for Telemetry Control to collect when a snapshot is taken. (pi-components, the Elgato adapter and voice-packs each list settings as a devDependency for one test, the Mirabox and Ulanzi adapters list diagnostics as one for their log-file test, and audio-assets lists voice-packs as one for its packer test; those edges are left out, as is the scenario harness, which is not drawn.)
Seams & where the abstraction leaks
Section titled “Seams & where the abstraction leaks”The system has three intended seams, and naming what each one buys you is the fastest way to understand the codebase:
- SEAM 1 —
event-bus. Everything upstream is sim-specific; everything that subscribes is meant to be sim-agnostic. Adding a new sim is, in principle, “write a new translator that publishes the same event catalog.” - SEAM 2 —
IDeckPlatformAdapter. One shared action set is defined once and runs on every device. Adding a new device is “write a new adapter that implements the interface” — which is exactly how Mirabox and Ulanzi were added after Elgato. - SEAM 3 —
SimConnection(#1351). The deck layer’s whole view of a simulator: connected or not, a per-tick signal, the active flags for the key overlay, and title-template resolution.deck-coreimports no sim package — a lint rule refuses one — anddeck-iracingimplements the interface over the iRacing SDK. Adding a new sim’s deck side is “write a siblingdeck-<sim>that implements it”; the commands and direct telemetry reads that actions still make are not part of it (see the leaks below).
Honest architecture documents its leaks, too. These are the places where the clean story above doesn’t fully hold today:
- Inbound is abstracted; outbound is not. Telemetry is funneled through the sim-agnostic bus, but the command path is still iRacing-shaped:
deck-iracing’sgetCommands()returns iRacing SDK commands. A second sim would need its own command vocabulary, which has no seam yet (#714). The deck layer itself is no longer part of this leak: since #1351deck-coreimports noiracing-sdkand sees a sim only throughSimConnection— connected or not, a per-tick signal, the active flags and title templates — and a lint rule on its sources refuses any import of an iRacing package. - The shared layer reads iRacing directly. Measured for #1351: 27 action files read the SDK controller directly — 26 action classes extend
deck-iracing’sSimIRacingActionfor its typedsdkControllerto read telemetry and session info, and Race Admin’s command helper takes the controller as a parameter — and 15 action files callgetCommands(); the package also importsiracing-sdkandsim-events-iracingitself. On the Race Engineer side, 15audio-scenarioscatalog files importiracing-sdkorsim-events-iracing, and 8 of them read raw iRacing telemetry throughgetLatestTelemetry(). So “everything right of SEAM 1 is sim-agnostic” is an aspiration both consumers fall short of;SimIRacingActionat least names the action half, so it is one search away. Porting these reads is #714’s read model. - Device differences leak around the adapter. Only a Stream Deck+ dial reports touch, long press and push + turn — a Mirabox knob has turn and press alone — so that extended gesture set is handled at build time via a platform feature flag (
dialExtendedGestures), not expressed throughIDeckPlatformAdapter— a real device difference living outside the device seam. The knob press is normalised inside the Mirabox adapter instead: the host never reports a knob’s release, so the adapter delivers every press as an immediatedialDown+dialUppair and the actions see an ordinary press (#1013). The dial display is not: since #1013 it is on the seam throughIDeckActionContext.dialCanvas()/setDialCanvas(), where each adapter reports aDialCanvasProfilefor its dial screen (the 200×100 Stream Deck+ strip slot, the 176×112 screen above a Mirabox knob, or none on Ulanzi) and the actions pick a drawing by the profile, never by the platform. (A previous device difference here — divergent SVG rendering capability between hosts’ own engines — was eliminated rather than papered over: since issue #642, icons still cross SEAM 2 as SVG, but each adapter’s context implementation converts them to PNG in-plugin viadeck-core’s rasterizer service before the host send, so no host-specific SVG engine is in the picture anymore.) - Ulanzi reuses Elgato’s plugin and action UUIDs verbatim. A pragmatic coupling: UlanziStudio doesn’t validate the UUID prefix, so reusing the canonical IDs avoided a parallel identity scheme.
onHostReadysits ON the interface, but optional. Only the two WebSocket adapters can report the moment their socket opens; Elgato’s SDK queues a send until the connection exists, so it declares nothing at all. Optional rather than required so that absence is a statement — “there is nothing to wait for” — instead of a stub that satisfies the type while never honouring the contract. It is on the seam because the settings layer must consume it —IDeckPlatformAdapterinherits it, with the global-settings read and write, fromsettings’SettingsHost(#1365) — and optional because Elgato, one of the three adapters, has nothing to say.- Some pieces are Windows-native.
iracing-native(keyboard/window) andaudio-native(mixer) are C++ addons that fall back to mocks on other platforms — see cross-platform development.

