Skip to content

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.

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.

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.

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.requested with 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.

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_&lt;SubSessionID&gt;.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 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;

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.

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.)

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-core imports no sim package — a lint rule refuses one — and deck-iracing implements the interface over the iRacing SDK. Adding a new sim’s deck side is “write a sibling deck-<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’s getCommands() 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 #1351 deck-core imports no iracing-sdk and sees a sim only through SimConnection — 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’s SimIRacingAction for its typed sdkController to read telemetry and session info, and Race Admin’s command helper takes the controller as a parameter — and 15 action files call getCommands(); the package also imports iracing-sdk and sim-events-iracing itself. On the Race Engineer side, 15 audio-scenarios catalog files import iracing-sdk or sim-events-iracing, and 8 of them read raw iRacing telemetry through getLatestTelemetry(). So “everything right of SEAM 1 is sim-agnostic” is an aspiration both consumers fall short of; SimIRacingAction at 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 through IDeckPlatformAdapter — 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 immediate dialDown + dialUp pair and the actions see an ordinary press (#1013). The dial display is not: since #1013 it is on the seam through IDeckActionContext.dialCanvas() / setDialCanvas(), where each adapter reports a DialCanvasProfile for 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 via deck-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.
  • onHostReady sits 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 — IDeckPlatformAdapter inherits it, with the global-settings read and write, from settings’ SettingsHost (#1365) — and optional because Elgato, one of the three adapters, has nothing to say.
  • Some pieces are Windows-native. iracing-native (keyboard/window) and audio-native (mixer) are C++ addons that fall back to mocks on other platforms — see cross-platform development.