worker/ (protocol + worker entrypoint) and
runtime/
(main-thread lifecycle, transport, frame retention). Everything between
the host application and the engine.
worker/messages.ts — discriminated unions over
postMessage
runtime/transport.ts — wraps a Worker factory;
queues messages until the worker boots
createSimulation() → Simulation (stores:
status, frames; events;
run/pause/reset/ack/getFrame/dispose)
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.
|
ArrayBuffer is
structured-clone copied
across the boundary (no transfer list); the worker keeps its own copy in
SimulationInstance.frames[].
// 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. |
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.
createSimulation)
createWorkerTransport(createWorker) queues outbound
messages until the worker resolves; test code can inject a pre-built
SimulationTransport instead.
createInMemorySimulationFrameStore(sdcpn)
compiles the reader factory once and appends every payload — it keeps
all frames so playback can scrub. Alternative stores
(latest-only, sliding window, persisted) can be swapped in without
changing consumers.
status and
frames readable stores + events stream; hosts
subscribe with store.subscribe(listener).
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.