Worker & Protocol

worker/ (protocol + worker entrypoint) and runtime/ (main-thread lifecycle, transport, frame retention). Everything between the host application and the engine.

Protocol
worker/messages.ts — discriminated unions over postMessage
Transport
runtime/transport.ts — wraps a Worker factory; queues messages until the worker boots
Host handle
createSimulation()Simulation (stores: status, frames; events; run/pause/reset/ack/getFrame/dispose)
Flow control
Ack-based backpressure — the consumer’s pace bounds worker memory/compute

Message protocol

Host → Worker (ToWorkerMessage)
Type Payload Effect in the worker
init sdcpn, extensions?, initialMarking, parameterValues, seed, dt, maxTime (number | null), maxFramesAhead?, batchSize? Runs buildSimulation() (compiles user code, packs frame 0), replies frame(0) then ready. Resets lastAckedFrame = -1.
start Starts the async compute loop (no-op if running; refuses from complete/error).
pause Stops the loop, keeps all state, replies paused.
stop Discards the SimulationInstance entirely.
setBackpressure maxFramesAhead?, batchSize? Live-updates loop tuning.
ack frameNumber lastAckedFrame = max(lastAckedFrame, n) — unblocks computation.
Worker → Host (ToMainMessage)
Type Payload Effect on the main thread
ready initialFrameCount Resolves the createSimulation promise; status → Ready.
frame / frames SimulationFramePayload = { time, frame: ArrayBuffer } (single / batch) Appended to the frame store; frames store publishes {count, latest: reader}.
paused frameNumber Status → Paused.
complete reason: "deadlock" | "maxTime", frameNumber Status → Complete; emitted on the event stream.
error message, itemId (offending SDCPN item if known) Status → Error; rejects init if still initializing.

Sequence — one quick simulation

Host (playback driver) createSimulation (main) simulation.worker createSimulation(config) init {sdcpn, initialMarking, seed, dt…} buildSimulation() compile + frame 0 frame {t:0, ArrayBuffer²} ready {initialFrameCount:1} Promise resolves · status "Ready" simulation.run() start loop blocked: lastAckedFrame = -1 → nothing computes until first ack loop — while frames remain to compute ack(frameNumber) — playback mode decides when computeNextFrame() × batchSize while n - acked < ahead frames [{time, ArrayBuffer²}, …] frames store → {count, latest reader} complete {reason: deadlock | maxTime} status "Complete" + event emitted
² = the frame ArrayBuffer is structured-clone copied across the boundary (no transfer list); the worker keeps its own copy in SimulationInstance.frames[].

Backpressure — the ack contract

// worker compute loop (simplified from simulation.worker.ts)
while (isRunning) {
  if (lastAckedFrame < 0 || currentFrame - lastAckedFrame >= maxFramesAhead) {
    await delay(10); continue;          // wait for the consumer
  }
  batch = compute up to batchSize frames (stop on complete/error)
  post "frame" | "frames"
  await delay(0);                        // let pause/stop/ack messages in
}
Play mode (core playback module) maxFramesAhead batchSize Ack behaviour
viewOnly n/a No core backpressure profile — the React playback provider pauses the worker and never acks, so nothing new is computed.
computeBuffer 40 10 Acks when playback is within ~0.5 s of the last computed frame — keeps a small rolling buffer ahead of the playhead.
computeMax 10000 500 Acks every arrival → compute as fast as possible.
Worker defaults 1000 1000 Used when init omits the settings.

Lifecycle

Initializing Ready Running Paused Complete Error ready msg run() pause() run() deadlock / maxTime error msg reset(): stop + clear store → Ready (from any state)
Main-thread SimulationState. The worker mirrors a smaller internal status (ready / running / complete / error); complete and error are terminal for the worker — reset() sends stop and requires a fresh init to run again.

Main-thread runtime responsibilities (createSimulation)

Refactoring seams. (1) Frames could be posted with a transfer list to halve copies — but not as-is: computeNextFrame() re-reads the latest stored frame to compute the next one, so transferring (detaching) the posted buffer first requires stepping from a retained working copy (e.g. latest-only retention or a double buffer). (2) reset() keeps the main-thread status Ready but the worker has discarded its simulation — a subsequent run() sends start to a worker with nothing to run; a full re-init is what actually happens in the UI flow. (3) The 10 ms poll while waiting for acks is a busy-wait; a promise-per-ack would be exact.