@downdraft/engine 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/core/src/index.ts +2 -2
  2. package/core/src/platform/rpc.ts +4 -1
  3. package/core/src/render/game-renderer.ts +127 -64
  4. package/core/src/sab/grid-buffer.ts +55 -2
  5. package/core/src/sab/record-buffer.ts +48 -5
  6. package/core/src/sab/sim-channel.ts +181 -17
  7. package/core/src/sab/slot-buffer.ts +55 -1
  8. package/core/src/sab/types.ts +11 -0
  9. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  10. package/core/src/util/index.ts +1 -0
  11. package/core/src/util/session-timer.ts +23 -0
  12. package/core/src/worker/base-worker-host.ts +2 -1
  13. package/core/src/worker/buffer-sync.ts +20 -13
  14. package/core/src/worker/rpc.ts +54 -5
  15. package/core/src/worker/sim-worker-base.ts +5 -2
  16. package/core/src/worker/task-pool.ts +4 -0
  17. package/dist/.dd-manifest.json +861 -851
  18. package/dist/core/src/index.js +2 -2
  19. package/dist/core/src/platform/rpc.js +2 -1
  20. package/dist/core/src/render/game-renderer.js +125 -59
  21. package/dist/core/src/sab/grid-buffer.js +53 -1
  22. package/dist/core/src/sab/record-buffer.js +49 -5
  23. package/dist/core/src/sab/sim-channel.js +173 -17
  24. package/dist/core/src/sab/slot-buffer.js +54 -1
  25. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  26. package/dist/core/src/util/index.js +1 -0
  27. package/dist/core/src/util/session-timer.js +22 -0
  28. package/dist/core/src/worker/base-worker-host.js +2 -1
  29. package/dist/core/src/worker/buffer-sync.js +16 -9
  30. package/dist/core/src/worker/rpc.js +46 -8
  31. package/dist/core/src/worker/sim-worker-base.js +5 -2
  32. package/dist/core/src/worker/task-pool.js +5 -0
  33. package/dist/libraries/devtools/src/backend.js +35 -9
  34. package/dist/libraries/devtools/src/collectors.js +73 -22
  35. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  36. package/dist/modules/devtools/src/blitz/host.js +124 -30
  37. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  38. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  39. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  40. package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
  41. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  42. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  43. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  44. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  45. package/dist/modules/html-ui/src/host.js +6 -3
  46. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  47. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  48. package/libraries/devtools/src/backend.ts +38 -12
  49. package/libraries/devtools/src/collectors.ts +75 -22
  50. package/modules/devtools/src/blitz/fonts.ts +78 -0
  51. package/modules/devtools/src/blitz/host.ts +107 -31
  52. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  53. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  54. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  55. package/modules/devtools/src/blitz/panels/performance.ts +24 -12
  56. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  57. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  58. package/modules/devtools/src/blitz/theme.ts +26 -4
  59. package/modules/html-ui/src/doc-backend.ts +67 -4
  60. package/modules/html-ui/src/host.ts +11 -3
  61. package/modules/html-ui/src/protocol.ts +1 -1
  62. package/package.json +2 -2
package/core/src/index.ts CHANGED
@@ -56,7 +56,7 @@ export type { MultiInputChannelInstance, MultiInputChannelOptions } from "./sab/
56
56
  export { INP, INP_FLAG, INP_HDR, INPUT_MAGIC, INPUT_VERSION, InputBufferReader, InputBufferWriter, InputChannel, KEY, MAX_INPUT_PLAYERS } from "./sab/game-input";
57
57
 
58
58
  // Sim SAB Channel
59
- export { allocateInputBuffer, allocateSimBuffer, ENT, MAX_ENTITIES, MAX_PLAYERS, PLR, PLR_FLAG, SIM_ENTITY_SLOT_SIZE, SIM_HDR, SIM_MAGIC, SIM_PLAYER_SLOT_SIZE, SIM_VERSION, SimBufferReader, SimBufferWriter, SimChannel } from "./sab/sim-channel";
59
+ export { allocateInputBuffer, allocateSimBuffer, ENT, MAX_ENTITIES, MAX_PLAYERS, PLR, PLR_FLAG, PREV, SIM_ENTITY_SLOT_SIZE, SIM_HDR, SIM_MAGIC, SIM_PLAYER_SLOT_SIZE, SIM_VERSION, SimBufferReader, SimBufferWriter, SimChannel } from "./sab/sim-channel";
60
60
  export type { PhysicsTimingData } from "./sab/sim-channel";
61
61
 
62
62
  // SAB Framework
@@ -90,7 +90,7 @@ export { EntitySimWorkerHost } from "./worker/entity-sim-worker-host";
90
90
  export type { EntitySimApi, EntitySimHostOptions } from "./worker/entity-sim-worker-host";
91
91
  export { exposeProfilingApi, InstrumentedWorkerHost } from "./worker/instrumented-worker-host";
92
92
  export type { InstrumentedWorkerHostOptions } from "./worker/instrumented-worker-host";
93
- export { expose, exposeEvents, getWorkerHost, wrap } from "./worker/rpc";
93
+ export { expose, exposeBatchedEvents, exposeEvents, getWorkerHost, wrap } from "./worker/rpc";
94
94
  export type { ExposeOptions, HostMessageHandler, WorkerApi, WorkerEventEmitter, WorkerHost, WorkerProxy } from "./worker/rpc";
95
95
  export { createSimWorker } from "./worker/sim-worker-base";
96
96
  export type { CreateSimWorkerOptions, SimAfterTicksContext, SimTickContext, SimWorkerControl, SimWorkerStats } from "./worker/sim-worker-base";
@@ -1,3 +1,6 @@
1
+
2
+ import { untrackedTimeout } from "../util/session-timer";
3
+
1
4
  export type RPCMessageType = "request" | "response" | "event";
2
5
 
3
6
  export interface RPCMessage {
@@ -34,7 +37,7 @@ export class RPC {
34
37
  }
35
38
  const id = this.messageIdCounter = (this.messageIdCounter + 1) % 0xFFFFFFFF;
36
39
  return new Promise((resolve, reject) => {
37
- const timer = setTimeout(() => {
40
+ const timer = untrackedTimeout(() => {
38
41
  if (this.pending.has(id)) {
39
42
  this.pending.delete(id);
40
43
  reject(new Error(`[RPC] Call to "${channel}" timed out after ${timeoutMs}ms`));
@@ -233,6 +233,9 @@ export class GameRenderer implements CanvasResizeHandler {
233
233
  uiInputRouter: UIInputRouter | null = null;
234
234
  /** Screen-space compositors (html-ui panels etc.) drawn in the UI pass. */
235
235
  private uiCompositors = new Set<import("../module/renderer-module").ScreenUiCompositor>();
236
+ /** uiCompositors as a pre-sorted array — rebuilt on add/remove so the
237
+ * frame loop doesn't spread+sort the set every frame. */
238
+ private uiCompositorOrder: import("../module/renderer-module").ScreenUiCompositor[] = [];
236
239
 
237
240
  // Render loop state
238
241
  private running = false;
@@ -272,13 +275,25 @@ export class GameRenderer implements CanvasResizeHandler {
272
275
 
273
276
  // Depth texture cache (used when the graph does not own depth)
274
277
  // LRU: Map insertion order = access order (delete + re-set on access).
275
- private depthTextures = new Map<string, GPUTexture>();
278
+ // Numeric key packs (w,h) — a `${w}x${h}` string key allocated a fresh
279
+ // string per lookup per viewport per frame.
280
+ private depthTextures = new Map<number, GPUTexture>();
276
281
  private depthTextureCacheSize: number;
277
282
 
278
283
  // Frame stats
279
284
  private frameDrawCalls = 0;
280
285
  private frameTriangles = 0;
281
286
 
287
+ // Per-frame submit interception: while non-null, queue.submit() calls
288
+ // (game callbacks, render-target providers, helpers) append their buffers
289
+ // here in call order instead of crossing into wgpu — the whole frame then
290
+ // lands in ONE queue.submit at end-of-frame. Games legitimately submit
291
+ // inside afterFrame callbacks (2D renderers, overlays), so folding the UI
292
+ // composite into the main batch requires capturing those submits rather
293
+ // than submitting early or reordering.
294
+ private frameSubmitSink: GPUCommandBuffer[] | null = null;
295
+ private submitInterceptorDevice: GPUDevice | null = null;
296
+
282
297
  // Callbacks
283
298
  private callbacks: FrameCallbacks = {};
284
299
 
@@ -354,6 +369,7 @@ export class GameRenderer implements CanvasResizeHandler {
354
369
  return false;
355
370
  }
356
371
  this.device = borrowedDevice ?? await this.requestDeviceFromAdapter(adapter!);
372
+ this.installQueueSubmitInterceptor(this.device);
357
373
  this.adapter = adapter;
358
374
  // The host configured the surface with its own usage set at creation —
359
375
  // reconfiguring here would tear down the swapchain and race the host's
@@ -426,8 +442,16 @@ export class GameRenderer implements CanvasResizeHandler {
426
442
  setViewportCount: (count) => this.setViewportCount(count),
427
443
  getUIInputRouter: () => this.uiInputRouter,
428
444
  registerUiCompositor: (c) => {
445
+ const isNew = !this.uiCompositors.has(c);
429
446
  this.uiCompositors.add(c);
430
- return () => this.uiCompositors.delete(c);
447
+ if (isNew) {
448
+ this.uiCompositorOrder = [...this.uiCompositors].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
449
+ }
450
+ return () => {
451
+ if (this.uiCompositors.delete(c)) {
452
+ this.uiCompositorOrder = [...this.uiCompositors].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
453
+ }
454
+ };
431
455
  },
432
456
  });
433
457
 
@@ -543,6 +567,7 @@ export class GameRenderer implements CanvasResizeHandler {
543
567
  const device = await this.requestDeviceFromAdapter(adapter);
544
568
  device.lost.then((info) => this.handleDeviceLost(info));
545
569
  this.device = device;
570
+ this.installQueueSubmitInterceptor(device);
546
571
  this.adapter = adapter;
547
572
 
548
573
  installShaderValidationGuard(device);
@@ -798,15 +823,39 @@ export class GameRenderer implements CanvasResizeHandler {
798
823
  this.renderFrame();
799
824
  } catch (err) {
800
825
  log.error("game-renderer", `Render loop error: ${(err as Error).message}\n${(err as Error).stack}`);
801
- if (this.device?.lost) {
802
- this.device.lost.then((info: GPUDeviceLostInfo) => {
803
- this.handleDeviceLost(info);
804
- });
805
- }
826
+ // No per-error device.lost.then here: the device already has exactly
827
+ // one lost subscription (init + reinit paths), and on native each
828
+ // .lost access mints a fresh promise — per-error attaches accumulated
829
+ // duplicate watchers during an error storm.
806
830
  }
807
831
  this.scheduleRender();
808
832
  };
809
833
 
834
+ /**
835
+ * Redirect queue.submit() into frameSubmitSink while a frame is in
836
+ * flight. Submission order is preserved — buffers land in the sink in
837
+ * the order submit() was called — so mid-frame submits (uploads in
838
+ * beforeFrame, a game's afterFrame pass, endFrame hooks) still execute
839
+ * ahead of work recorded later, just inside the single end-of-frame
840
+ * queue.submit rather than as extra queue-lock + driver calls.
841
+ */
842
+ private installQueueSubmitInterceptor(device: GPUDevice): void {
843
+ if (this.submitInterceptorDevice === device) return;
844
+ this.submitInterceptorDevice = device;
845
+ const queue = device.queue as any;
846
+ if (queue.__ddSubmitIntercepted) return;
847
+ queue.__ddSubmitIntercepted = true;
848
+ const origSubmit: GPUQueue["submit"] = queue.submit.bind(queue);
849
+ queue.submit = (commandBuffers: Iterable<GPUCommandBuffer>) => {
850
+ const sink = this.frameSubmitSink;
851
+ if (!sink) {
852
+ origSubmit(commandBuffers);
853
+ return;
854
+ }
855
+ for (const cb of commandBuffers) sink.push(cb);
856
+ };
857
+ }
858
+
810
859
  private renderFrame(): void {
811
860
  const now = performance.now();
812
861
 
@@ -856,6 +905,15 @@ export class GameRenderer implements CanvasResizeHandler {
856
905
  // recreates the invalid resources.
857
906
  const gpuError = this.gpuProfiler?.hasUncapturedError() ?? false;
858
907
 
908
+ // Collect command buffers from all phases and submit once at the end
909
+ // of the frame — one queue.submit per frame instead of 2-4. The sink
910
+ // is armed BEFORE beginFrame so submit() calls from hooks and game
911
+ // callbacks (beforeFrame uploads, afterFrame render passes, the XR
912
+ // endFrame encoder) are captured in call order into the same batch
913
+ // rather than crossing into wgpu individually.
914
+ const frameCommandBuffers: GPUCommandBuffer[] = [];
915
+ this.frameSubmitSink = frameCommandBuffers;
916
+
859
917
  // XR render target provider beginFrame hook
860
918
  if (this.renderTargetProvider && !gpuError) {
861
919
  this.renderTargetProvider.beginFrame();
@@ -869,9 +927,6 @@ export class GameRenderer implements CanvasResizeHandler {
869
927
  offscreen.ensureTargets(this.canvas.width, this.canvas.height);
870
928
  }
871
929
 
872
- // Collect command buffers from all phases and submit once at the end
873
- // (reduces CPU→GPU sync points from 3+ per frame to 1).
874
- const frameCommandBuffers: GPUCommandBuffer[] = [];
875
930
  if (!gpuError) {
876
931
  // 2D mode with viewportCount=0: clear the canvas if a clearColor is
877
932
  // configured, then let the afterFrame callback do custom rendering.
@@ -922,21 +977,6 @@ export class GameRenderer implements CanvasResizeHandler {
922
977
  frameCommandBuffers.push(...passMailbox.drain("preSubmit"));
923
978
  }
924
979
 
925
- // Submit all command buffers for this frame in a single queue.submit() call
926
- if (frameCommandBuffers.length > 0) {
927
- this.device!.queue.submit(frameCommandBuffers);
928
- }
929
-
930
- // Read GPU timer results asynchronously (1-frame latency).
931
- // Must be called AFTER queue.submit() — readGpuTimers() calls mapAsync on
932
- // the read buffer, and a mapped/mapping-pending buffer cannot be used in a
933
- // submitted command buffer (the copy-to-readBuffer was encoded above).
934
- if (this.gpuProfiler) {
935
- this.gpuProfiler.readGpuTimers().then(() => {
936
- // Results available for next frame
937
- }).catch(() => {});
938
- }
939
-
940
980
  // Record telemetry
941
981
  if (this.telemetryCollector) {
942
982
  this.telemetryCollector.recordFrame(dt * 1000);
@@ -975,43 +1015,68 @@ export class GameRenderer implements CanvasResizeHandler {
975
1015
  this.frameTriangles = 0;
976
1016
  }
977
1017
 
978
- // XR render target provider endFrame hook (skip on GPU error)
979
- if (!gpuError && this.renderTargetProvider && this.device) {
980
- this.renderTargetProvider.endFrame(this.device.createCommandEncoder());
981
- }
1018
+ try {
1019
+ // XR render target provider endFrame hook (skip on GPU error). Its
1020
+ // submits are captured by frameSubmitSink and join the batch.
1021
+ if (!gpuError && this.renderTargetProvider && this.device) {
1022
+ this.renderTargetProvider.endFrame(this.device.createCommandEncoder());
1023
+ }
982
1024
 
983
- // After frame callback
984
- this.callbacks.afterFrame?.(dt, this.elapsedTime);
985
- this.rendererModuleHost?.dispatchFrame("afterFrame", dt, this.elapsedTime);
986
-
987
- // Composite registered screen-space UI (html-ui panels) on top of the
988
- // final image (skip on GPU error to avoid cascade). This must run AFTER
989
- // afterFrame — 2D games (viewportCount=0) do their custom rendering in
990
- // the afterFrame callback, so drawing UI earlier would put it underneath
991
- // their clear pass.
992
- if (!gpuError && this.device && this.context) {
993
- const compositors = [...this.uiCompositors]
994
- .filter((c) => c.hasContent())
995
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
996
- const uiCanvasView = compositors.length > 0
997
- ? this.getSurfaceTexture()?.createView()
998
- : undefined;
999
- if (uiCanvasView) {
1000
- const uiEncoder = this.device.createCommandEncoder();
1001
- const uiPass = uiEncoder.beginRenderPass({
1002
- colorAttachments: [{
1003
- view: uiCanvasView,
1004
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
1005
- loadOp: "load" as GPULoadOp,
1006
- storeOp: "store" as GPUStoreOp,
1007
- }],
1008
- });
1009
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
1010
- uiPass.end();
1011
- // "preUi" slot — worker passes ordered ahead of the UI-composite
1012
- // submit so their output is visible to the compositor this frame.
1013
- this.device.queue.submit([...(passMailbox?.drain("preUi") ?? []), uiEncoder.finish()]);
1025
+ // After frame callback — game submits here append to the batch in
1026
+ // order, still ahead of the UI composite below.
1027
+ this.callbacks.afterFrame?.(dt, this.elapsedTime);
1028
+ this.rendererModuleHost?.dispatchFrame("afterFrame", dt, this.elapsedTime);
1029
+
1030
+ // Composite registered screen-space UI (html-ui panels) on top of the
1031
+ // final image (skip on GPU error to avoid cascade). This must run AFTER
1032
+ // afterFrame — 2D games (viewportCount=0) do their custom rendering in
1033
+ // the afterFrame callback, so drawing UI earlier would put it underneath
1034
+ // their clear pass.
1035
+ if (!gpuError && this.device && this.context) {
1036
+ const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
1037
+ const uiCanvasView = compositors.length > 0
1038
+ ? this.getSurfaceTexture()?.createView()
1039
+ : undefined;
1040
+ if (uiCanvasView) {
1041
+ const uiEncoder = this.device.createCommandEncoder();
1042
+ const uiPass = uiEncoder.beginRenderPass({
1043
+ colorAttachments: [{
1044
+ view: uiCanvasView,
1045
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1046
+ loadOp: "load" as GPULoadOp,
1047
+ storeOp: "store" as GPUStoreOp,
1048
+ }],
1049
+ });
1050
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
1051
+ uiPass.end();
1052
+ // "preUi" slot — worker passes ordered ahead of the UI-composite
1053
+ // buffer inside the end-of-frame submit.
1054
+ frameCommandBuffers.push(...(passMailbox?.drain("preUi") ?? []), uiEncoder.finish());
1055
+ }
1014
1056
  }
1057
+ } finally {
1058
+ // End of frame: disarm the submit sink and flush everything recorded
1059
+ // this frame — engine passes, captured callback submits, mailbox
1060
+ // passes, the UI composite — in a single queue.submit(). Runs even on
1061
+ // a callback throw so already-recorded work still lands.
1062
+ this.frameSubmitSink = null;
1063
+ if (frameCommandBuffers.length > 0 && this.device) {
1064
+ try {
1065
+ this.device.queue.submit(frameCommandBuffers);
1066
+ } catch (err) {
1067
+ log.error("game-renderer", `queue.submit failed: ${(err as Error).message ?? err}`);
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ // Read GPU timer results asynchronously (1-frame latency).
1073
+ // Must be called AFTER queue.submit() — readGpuTimers() calls mapAsync on
1074
+ // the read buffer, and a mapped/mapping-pending buffer cannot be used in a
1075
+ // submitted command buffer (the copy-to-readBuffer was encoded above).
1076
+ if (this.gpuProfiler) {
1077
+ this.gpuProfiler.readGpuTimers().then(() => {
1078
+ // Results available for next frame
1079
+ }).catch(() => {});
1015
1080
  }
1016
1081
 
1017
1082
  // Present the surface (native wgpu requires explicit presentation;
@@ -1220,7 +1285,7 @@ export class GameRenderer implements CanvasResizeHandler {
1220
1285
 
1221
1286
  private createDepthTexture(w: number, h: number): GPUTextureView {
1222
1287
  if (!this.device) throw new Error("No device");
1223
- const key = `${w}x${h}`;
1288
+ const key = w * 65536 + h;
1224
1289
  let tex = this.depthTextures.get(key);
1225
1290
  if (tex) {
1226
1291
  // LRU: move to most-recently-used by re-inserting.
@@ -1314,9 +1379,7 @@ export class GameRenderer implements CanvasResizeHandler {
1314
1379
  * this helper only runs the compositors.
1315
1380
  */
1316
1381
  protected renderScreenUiCompositors(): void {
1317
- const compositors = [...this.uiCompositors]
1318
- .filter((c) => c.hasContent())
1319
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1382
+ const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
1320
1383
  if (compositors.length === 0 || !this.device || !this.context) return;
1321
1384
  const canvasView = this.getSurfaceTexture()?.createView();
1322
1385
  if (!canvasView) return;
@@ -1,5 +1,5 @@
1
- import type { ChannelLayout, GridLayerLayout, HeaderViews } from "./types";
2
1
  import { describeHeaderValidation } from "./errors";
2
+ import type { ChannelLayout, GridLayerLayout, HeaderViews } from "./types";
3
3
 
4
4
  function createHeaderViews(sab: SharedArrayBuffer, headerSize: number): HeaderViews {
5
5
  const u32Length = headerSize / 4;
@@ -66,6 +66,32 @@ export class GridReader {
66
66
  return this.getSequence() !== lastSeen;
67
67
  }
68
68
 
69
+ /**
70
+ * True while the writer holds an open write window (sequence is odd).
71
+ * Readers can use this to cheaply defer sync work past a publish.
72
+ */
73
+ isWriteInProgress(): boolean {
74
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
75
+ }
76
+
77
+ /**
78
+ * Run `fn` against a consistent snapshot of the channel. The sequence
79
+ * word is a seqlock: odd while the writer is publishing, even when the
80
+ * data is stable. The read is retried while the word is odd or changes
81
+ * mid-read; after maxRetries it returns whatever the last attempt read
82
+ * rather than starving the reader (writers publish every tick).
83
+ */
84
+ readConsistent<T>(fn: () => T, maxRetries = 64): T {
85
+ for (let i = 0; i < maxRetries; i++) {
86
+ const s1 = Atomics.load(this.seqArr, 0);
87
+ if ((s1 & 1) === 0) {
88
+ const out = fn();
89
+ if (Atomics.load(this.seqArr, 0) === s1) return out;
90
+ }
91
+ }
92
+ return fn();
93
+ }
94
+
69
95
  snapshot(): Record<string, unknown> {
70
96
  const data: Record<string, unknown> = {};
71
97
  data["sequence"] = this.getSequence();
@@ -88,6 +114,8 @@ export class GridWriter {
88
114
  layers: Record<string, Float32Array | Int32Array | Uint32Array | Float64Array>;
89
115
  private seqArr: Int32Array;
90
116
  private layout: ChannelLayout;
117
+ /** True while a beginWrite() window is open (sequence is odd). */
118
+ private writeOpen = false;
91
119
 
92
120
  constructor(sab: SharedArrayBuffer, layout: ChannelLayout) {
93
121
  this.layout = layout;
@@ -107,10 +135,35 @@ export class GridWriter {
107
135
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
108
136
  }
109
137
 
110
- bumpSequence(): void {
138
+ /**
139
+ * Open a write window — marks the sequence word odd so readers know a
140
+ * publish is in progress. Idempotent so mutating paths can call it
141
+ * unconditionally.
142
+ */
143
+ beginWrite(): void {
144
+ if (this.writeOpen) return;
145
+ this.writeOpen = true;
146
+ Atomics.add(this.seqArr, 0, 1);
147
+ }
148
+
149
+ /** Close the write window opened by beginWrite() — sequence back to even. */
150
+ endWrite(): void {
151
+ if (!this.writeOpen) return;
152
+ this.writeOpen = false;
111
153
  Atomics.add(this.seqArr, 0, 1);
112
154
  }
113
155
 
156
+ /**
157
+ * Publish a commit. Without an open write window it steps +2 (even→even)
158
+ * so the sequence always lands on a stable value while still advancing
159
+ * once per publish; inside a window it closes it (+1, odd→even).
160
+ */
161
+ bumpSequence(): void {
162
+ const step = this.writeOpen ? 1 : 2;
163
+ this.writeOpen = false;
164
+ Atomics.add(this.seqArr, 0, step);
165
+ }
166
+
114
167
  getSequence(): number {
115
168
  return Atomics.load(this.seqArr, 0);
116
169
  }
@@ -67,6 +67,32 @@ export class RecordReader {
67
67
  return this.getSequence() !== lastSeen;
68
68
  }
69
69
 
70
+ /**
71
+ * True while the writer holds an open write window (sequence is odd).
72
+ * Readers can use this to cheaply defer sync work past a publish.
73
+ */
74
+ isWriteInProgress(): boolean {
75
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
76
+ }
77
+
78
+ /**
79
+ * Run `fn` against a consistent snapshot of the channel. The sequence
80
+ * word is a seqlock: odd while the writer is publishing, even when the
81
+ * data is stable. The read is retried while the word is odd or changes
82
+ * mid-read; after maxRetries it returns whatever the last attempt read
83
+ * rather than starving the reader (writers publish every tick).
84
+ */
85
+ readConsistent<T>(fn: () => T, maxRetries = 64): T {
86
+ for (let i = 0; i < maxRetries; i++) {
87
+ const s1 = Atomics.load(this.seqArr, 0);
88
+ if ((s1 & 1) === 0) {
89
+ const out = fn();
90
+ if (Atomics.load(this.seqArr, 0) === s1) return out;
91
+ }
92
+ }
93
+ return fn();
94
+ }
95
+
70
96
  snapshot(): Record<string, unknown> {
71
97
  const data: Record<string, unknown> = {};
72
98
  if (this.layout.fields) {
@@ -93,6 +119,8 @@ export class RecordWriter {
93
119
  fields: Record<string, Float32Array | Int32Array | Uint32Array | Float64Array>;
94
120
  private seqArr: Int32Array;
95
121
  private layout: ChannelLayout;
122
+ /** True while a beginWrite() window is open (sequence is odd). */
123
+ private writeOpen = false;
96
124
 
97
125
  constructor(sab: SharedArrayBuffer, layout: ChannelLayout) {
98
126
  this.layout = layout;
@@ -112,18 +140,33 @@ export class RecordWriter {
112
140
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
113
141
  }
114
142
 
143
+ /**
144
+ * Open a write window — marks the sequence word odd so readers know a
145
+ * publish is in progress. Idempotent so mutating paths can call it
146
+ * unconditionally.
147
+ */
115
148
  beginWrite(): void {
116
- const seq = Atomics.load(this.seqArr, 0);
117
- Atomics.store(this.seqArr, 0, seq + 1);
149
+ if (this.writeOpen) return;
150
+ this.writeOpen = true;
151
+ Atomics.add(this.seqArr, 0, 1);
118
152
  }
119
153
 
154
+ /** Close the write window opened by beginWrite() — sequence back to even. */
120
155
  endWrite(): void {
121
- // Sequence is incremented in beginWrite() — endWrite() is a no-op
122
- // to avoid double incrementing.
156
+ if (!this.writeOpen) return;
157
+ this.writeOpen = false;
158
+ Atomics.add(this.seqArr, 0, 1);
123
159
  }
124
160
 
161
+ /**
162
+ * Publish a commit. Without an open write window it steps +2 (even→even)
163
+ * so the sequence always lands on a stable value while still advancing
164
+ * once per publish; inside a window it closes it (+1, odd→even).
165
+ */
125
166
  bumpSequence(): void {
126
- Atomics.add(this.seqArr, 0, 1);
167
+ const step = this.writeOpen ? 1 : 2;
168
+ this.writeOpen = false;
169
+ Atomics.add(this.seqArr, 0, step);
127
170
  }
128
171
 
129
172
  getSequence(): number {