@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
@@ -45,7 +45,7 @@ export { createMultiInputChannel, DEFAULT_KEY_BITFIELD_COUNT, DEFAULT_MAX_PLAYER
45
45
  // Game Input SAB Channel
46
46
  export { INP, INP_FLAG, INP_HDR, INPUT_MAGIC, INPUT_VERSION, InputBufferReader, InputBufferWriter, InputChannel, KEY, MAX_INPUT_PLAYERS } from "./sab/game-input.js";
47
47
  // Sim SAB Channel
48
- 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.js";
48
+ 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.js";
49
49
  // SAB Framework
50
50
  export { defineChannel, defineManifest } from "./sab/define.js";
51
51
  export { isDebug, resetWarnings, ValidationError as SABValidationError, setDebug, warnOnce } from "./sab/errors.js";
@@ -57,7 +57,7 @@ export { BufferSyncHost, BufferSyncWorker, isBufferSyncMessage } from "./worker/
57
57
  export { CrashRecoveryManager, DEFAULT_RECOVERY_CONFIG } from "./worker/crash-recovery.js";
58
58
  export { EntitySimWorkerHost } from "./worker/entity-sim-worker-host.js";
59
59
  export { exposeProfilingApi, InstrumentedWorkerHost } from "./worker/instrumented-worker-host.js";
60
- export { expose, exposeEvents, getWorkerHost, wrap } from "./worker/rpc.js";
60
+ export { expose, exposeBatchedEvents, exposeEvents, getWorkerHost, wrap } from "./worker/rpc.js";
61
61
  export { createSimWorker } from "./worker/sim-worker-base.js";
62
62
  export { RawInputRegionWriter, SimWorkerHost } from "./worker/sim-worker-host.js";
63
63
  export { createTaskWorker, PortChannel, TaskPool } from "./worker/task-pool.js";
@@ -1,3 +1,4 @@
1
+ import { untrackedTimeout } from "../util/session-timer.js";
1
2
  export class RPC {
2
3
  handlers = new Map();
3
4
  pending = new Map();
@@ -20,7 +21,7 @@ export class RPC {
20
21
  }
21
22
  const id = this.messageIdCounter = (this.messageIdCounter + 1) % 0xFFFFFFFF;
22
23
  return new Promise((resolve, reject) => {
23
- const timer = setTimeout(() => {
24
+ const timer = untrackedTimeout(() => {
24
25
  if (this.pending.has(id)) {
25
26
  this.pending.delete(id);
26
27
  reject(new Error(`[RPC] Call to "${channel}" timed out after ${timeoutMs}ms`));
@@ -42,6 +42,9 @@ export class GameRenderer {
42
42
  uiInputRouter = null;
43
43
  /** Screen-space compositors (html-ui panels etc.) drawn in the UI pass. */
44
44
  uiCompositors = new Set();
45
+ /** uiCompositors as a pre-sorted array — rebuilt on add/remove so the
46
+ * frame loop doesn't spread+sort the set every frame. */
47
+ uiCompositorOrder = [];
45
48
  // Render loop state
46
49
  running = false;
47
50
  deviceLost = false;
@@ -75,11 +78,22 @@ export class GameRenderer {
75
78
  frameCtx = null;
76
79
  // Depth texture cache (used when the graph does not own depth)
77
80
  // LRU: Map insertion order = access order (delete + re-set on access).
81
+ // Numeric key packs (w,h) — a `${w}x${h}` string key allocated a fresh
82
+ // string per lookup per viewport per frame.
78
83
  depthTextures = new Map();
79
84
  depthTextureCacheSize;
80
85
  // Frame stats
81
86
  frameDrawCalls = 0;
82
87
  frameTriangles = 0;
88
+ // Per-frame submit interception: while non-null, queue.submit() calls
89
+ // (game callbacks, render-target providers, helpers) append their buffers
90
+ // here in call order instead of crossing into wgpu — the whole frame then
91
+ // lands in ONE queue.submit at end-of-frame. Games legitimately submit
92
+ // inside afterFrame callbacks (2D renderers, overlays), so folding the UI
93
+ // composite into the main batch requires capturing those submits rather
94
+ // than submitting early or reordering.
95
+ frameSubmitSink = null;
96
+ submitInterceptorDevice = null;
83
97
  // Callbacks
84
98
  callbacks = {};
85
99
  // Offscreen mode (set by game for postprocessing)
@@ -150,6 +164,7 @@ export class GameRenderer {
150
164
  return false;
151
165
  }
152
166
  this.device = borrowedDevice ?? await this.requestDeviceFromAdapter(adapter);
167
+ this.installQueueSubmitInterceptor(this.device);
153
168
  this.adapter = adapter;
154
169
  // The host configured the surface with its own usage set at creation —
155
170
  // reconfiguring here would tear down the swapchain and race the host's
@@ -214,8 +229,16 @@ export class GameRenderer {
214
229
  setViewportCount: (count) => this.setViewportCount(count),
215
230
  getUIInputRouter: () => this.uiInputRouter,
216
231
  registerUiCompositor: (c) => {
232
+ const isNew = !this.uiCompositors.has(c);
217
233
  this.uiCompositors.add(c);
218
- return () => this.uiCompositors.delete(c);
234
+ if (isNew) {
235
+ this.uiCompositorOrder = [...this.uiCompositors].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
236
+ }
237
+ return () => {
238
+ if (this.uiCompositors.delete(c)) {
239
+ this.uiCompositorOrder = [...this.uiCompositors].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
240
+ }
241
+ };
219
242
  },
220
243
  });
221
244
  // Initial viewport layout
@@ -328,6 +351,7 @@ export class GameRenderer {
328
351
  const device = await this.requestDeviceFromAdapter(adapter);
329
352
  device.lost.then((info) => this.handleDeviceLost(info));
330
353
  this.device = device;
354
+ this.installQueueSubmitInterceptor(device);
331
355
  this.adapter = adapter;
332
356
  installShaderValidationGuard(device);
333
357
  this.gpuResourceTracker = new GPUResourceTracker();
@@ -563,14 +587,40 @@ export class GameRenderer {
563
587
  }
564
588
  catch (err) {
565
589
  log.error("game-renderer", `Render loop error: ${err.message}\n${err.stack}`);
566
- if (this.device?.lost) {
567
- this.device.lost.then((info) => {
568
- this.handleDeviceLost(info);
569
- });
570
- }
590
+ // No per-error device.lost.then here: the device already has exactly
591
+ // one lost subscription (init + reinit paths), and on native each
592
+ // .lost access mints a fresh promise — per-error attaches accumulated
593
+ // duplicate watchers during an error storm.
571
594
  }
572
595
  this.scheduleRender();
573
596
  };
597
+ /**
598
+ * Redirect queue.submit() into frameSubmitSink while a frame is in
599
+ * flight. Submission order is preserved — buffers land in the sink in
600
+ * the order submit() was called — so mid-frame submits (uploads in
601
+ * beforeFrame, a game's afterFrame pass, endFrame hooks) still execute
602
+ * ahead of work recorded later, just inside the single end-of-frame
603
+ * queue.submit rather than as extra queue-lock + driver calls.
604
+ */
605
+ installQueueSubmitInterceptor(device) {
606
+ if (this.submitInterceptorDevice === device)
607
+ return;
608
+ this.submitInterceptorDevice = device;
609
+ const queue = device.queue;
610
+ if (queue.__ddSubmitIntercepted)
611
+ return;
612
+ queue.__ddSubmitIntercepted = true;
613
+ const origSubmit = queue.submit.bind(queue);
614
+ queue.submit = (commandBuffers) => {
615
+ const sink = this.frameSubmitSink;
616
+ if (!sink) {
617
+ origSubmit(commandBuffers);
618
+ return;
619
+ }
620
+ for (const cb of commandBuffers)
621
+ sink.push(cb);
622
+ };
623
+ }
574
624
  renderFrame() {
575
625
  const now = performance.now();
576
626
  // Frame rate limiter: phase accumulator (skip during XR — XR drives its own cadence)
@@ -615,6 +665,14 @@ export class GameRenderer {
615
665
  // simulation stays responsive. The flag is cleared by recovery code that
616
666
  // recreates the invalid resources.
617
667
  const gpuError = this.gpuProfiler?.hasUncapturedError() ?? false;
668
+ // Collect command buffers from all phases and submit once at the end
669
+ // of the frame — one queue.submit per frame instead of 2-4. The sink
670
+ // is armed BEFORE beginFrame so submit() calls from hooks and game
671
+ // callbacks (beforeFrame uploads, afterFrame render passes, the XR
672
+ // endFrame encoder) are captured in call order into the same batch
673
+ // rather than crossing into wgpu individually.
674
+ const frameCommandBuffers = [];
675
+ this.frameSubmitSink = frameCommandBuffers;
618
676
  // XR render target provider beginFrame hook
619
677
  if (this.renderTargetProvider && !gpuError) {
620
678
  this.renderTargetProvider.beginFrame();
@@ -625,9 +683,6 @@ export class GameRenderer {
625
683
  if (offscreen && useOffscreen && offscreen.ensureTargets && !gpuError) {
626
684
  offscreen.ensureTargets(this.canvas.width, this.canvas.height);
627
685
  }
628
- // Collect command buffers from all phases and submit once at the end
629
- // (reduces CPU→GPU sync points from 3+ per frame to 1).
630
- const frameCommandBuffers = [];
631
686
  if (!gpuError) {
632
687
  // 2D mode with viewportCount=0: clear the canvas if a clearColor is
633
688
  // configured, then let the afterFrame callback do custom rendering.
@@ -674,19 +729,6 @@ export class GameRenderer {
674
729
  if (passMailbox) {
675
730
  frameCommandBuffers.push(...passMailbox.drain("preSubmit"));
676
731
  }
677
- // Submit all command buffers for this frame in a single queue.submit() call
678
- if (frameCommandBuffers.length > 0) {
679
- this.device.queue.submit(frameCommandBuffers);
680
- }
681
- // Read GPU timer results asynchronously (1-frame latency).
682
- // Must be called AFTER queue.submit() — readGpuTimers() calls mapAsync on
683
- // the read buffer, and a mapped/mapping-pending buffer cannot be used in a
684
- // submitted command buffer (the copy-to-readBuffer was encoded above).
685
- if (this.gpuProfiler) {
686
- this.gpuProfiler.readGpuTimers().then(() => {
687
- // Results available for next frame
688
- }).catch(() => { });
689
- }
690
732
  // Record telemetry
691
733
  if (this.telemetryCollector) {
692
734
  this.telemetryCollector.recordFrame(dt * 1000);
@@ -721,42 +763,68 @@ export class GameRenderer {
721
763
  this.frameDrawCalls = 0;
722
764
  this.frameTriangles = 0;
723
765
  }
724
- // XR render target provider endFrame hook (skip on GPU error)
725
- if (!gpuError && this.renderTargetProvider && this.device) {
726
- this.renderTargetProvider.endFrame(this.device.createCommandEncoder());
766
+ try {
767
+ // XR render target provider endFrame hook (skip on GPU error). Its
768
+ // submits are captured by frameSubmitSink and join the batch.
769
+ if (!gpuError && this.renderTargetProvider && this.device) {
770
+ this.renderTargetProvider.endFrame(this.device.createCommandEncoder());
771
+ }
772
+ // After frame callback — game submits here append to the batch in
773
+ // order, still ahead of the UI composite below.
774
+ this.callbacks.afterFrame?.(dt, this.elapsedTime);
775
+ this.rendererModuleHost?.dispatchFrame("afterFrame", dt, this.elapsedTime);
776
+ // Composite registered screen-space UI (html-ui panels) on top of the
777
+ // final image (skip on GPU error to avoid cascade). This must run AFTER
778
+ // afterFrame — 2D games (viewportCount=0) do their custom rendering in
779
+ // the afterFrame callback, so drawing UI earlier would put it underneath
780
+ // their clear pass.
781
+ if (!gpuError && this.device && this.context) {
782
+ const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
783
+ const uiCanvasView = compositors.length > 0
784
+ ? this.getSurfaceTexture()?.createView()
785
+ : undefined;
786
+ if (uiCanvasView) {
787
+ const uiEncoder = this.device.createCommandEncoder();
788
+ const uiPass = uiEncoder.beginRenderPass({
789
+ colorAttachments: [{
790
+ view: uiCanvasView,
791
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
792
+ loadOp: "load",
793
+ storeOp: "store",
794
+ }],
795
+ });
796
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
797
+ uiPass.end();
798
+ // "preUi" slot — worker passes ordered ahead of the UI-composite
799
+ // buffer inside the end-of-frame submit.
800
+ frameCommandBuffers.push(...(passMailbox?.drain("preUi") ?? []), uiEncoder.finish());
801
+ }
802
+ }
727
803
  }
728
- // After frame callback
729
- this.callbacks.afterFrame?.(dt, this.elapsedTime);
730
- this.rendererModuleHost?.dispatchFrame("afterFrame", dt, this.elapsedTime);
731
- // Composite registered screen-space UI (html-ui panels) on top of the
732
- // final image (skip on GPU error to avoid cascade). This must run AFTER
733
- // afterFrame — 2D games (viewportCount=0) do their custom rendering in
734
- // the afterFrame callback, so drawing UI earlier would put it underneath
735
- // their clear pass.
736
- if (!gpuError && this.device && this.context) {
737
- const compositors = [...this.uiCompositors]
738
- .filter((c) => c.hasContent())
739
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
740
- const uiCanvasView = compositors.length > 0
741
- ? this.getSurfaceTexture()?.createView()
742
- : undefined;
743
- if (uiCanvasView) {
744
- const uiEncoder = this.device.createCommandEncoder();
745
- const uiPass = uiEncoder.beginRenderPass({
746
- colorAttachments: [{
747
- view: uiCanvasView,
748
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
749
- loadOp: "load",
750
- storeOp: "store",
751
- }],
752
- });
753
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
754
- uiPass.end();
755
- // "preUi" slot — worker passes ordered ahead of the UI-composite
756
- // submit so their output is visible to the compositor this frame.
757
- this.device.queue.submit([...(passMailbox?.drain("preUi") ?? []), uiEncoder.finish()]);
804
+ finally {
805
+ // End of frame: disarm the submit sink and flush everything recorded
806
+ // this frame — engine passes, captured callback submits, mailbox
807
+ // passes, the UI composite — in a single queue.submit(). Runs even on
808
+ // a callback throw so already-recorded work still lands.
809
+ this.frameSubmitSink = null;
810
+ if (frameCommandBuffers.length > 0 && this.device) {
811
+ try {
812
+ this.device.queue.submit(frameCommandBuffers);
813
+ }
814
+ catch (err) {
815
+ log.error("game-renderer", `queue.submit failed: ${err.message ?? err}`);
816
+ }
758
817
  }
759
818
  }
819
+ // Read GPU timer results asynchronously (1-frame latency).
820
+ // Must be called AFTER queue.submit() — readGpuTimers() calls mapAsync on
821
+ // the read buffer, and a mapped/mapping-pending buffer cannot be used in a
822
+ // submitted command buffer (the copy-to-readBuffer was encoded above).
823
+ if (this.gpuProfiler) {
824
+ this.gpuProfiler.readGpuTimers().then(() => {
825
+ // Results available for next frame
826
+ }).catch(() => { });
827
+ }
760
828
  // Present the surface (native wgpu requires explicit presentation;
761
829
  // in browsers this is automatic at the end of the frame). Must be last —
762
830
  // all submissions targeting the surface texture happen above.
@@ -951,7 +1019,7 @@ export class GameRenderer {
951
1019
  createDepthTexture(w, h) {
952
1020
  if (!this.device)
953
1021
  throw new Error("No device");
954
- const key = `${w}x${h}`;
1022
+ const key = w * 65536 + h;
955
1023
  let tex = this.depthTextures.get(key);
956
1024
  if (tex) {
957
1025
  // LRU: move to most-recently-used by re-inserting.
@@ -1030,9 +1098,7 @@ export class GameRenderer {
1030
1098
  * this helper only runs the compositors.
1031
1099
  */
1032
1100
  renderScreenUiCompositors() {
1033
- const compositors = [...this.uiCompositors]
1034
- .filter((c) => c.hasContent())
1035
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1101
+ const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
1036
1102
  if (compositors.length === 0 || !this.device || !this.context)
1037
1103
  return;
1038
1104
  const canvasView = this.getSurfaceTexture()?.createView();
@@ -46,6 +46,31 @@ export class GridReader {
46
46
  hasChanged(lastSeen) {
47
47
  return this.getSequence() !== lastSeen;
48
48
  }
49
+ /**
50
+ * True while the writer holds an open write window (sequence is odd).
51
+ * Readers can use this to cheaply defer sync work past a publish.
52
+ */
53
+ isWriteInProgress() {
54
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
55
+ }
56
+ /**
57
+ * Run `fn` against a consistent snapshot of the channel. The sequence
58
+ * word is a seqlock: odd while the writer is publishing, even when the
59
+ * data is stable. The read is retried while the word is odd or changes
60
+ * mid-read; after maxRetries it returns whatever the last attempt read
61
+ * rather than starving the reader (writers publish every tick).
62
+ */
63
+ readConsistent(fn, maxRetries = 64) {
64
+ for (let i = 0; i < maxRetries; i++) {
65
+ const s1 = Atomics.load(this.seqArr, 0);
66
+ if ((s1 & 1) === 0) {
67
+ const out = fn();
68
+ if (Atomics.load(this.seqArr, 0) === s1)
69
+ return out;
70
+ }
71
+ }
72
+ return fn();
73
+ }
49
74
  snapshot() {
50
75
  const data = {};
51
76
  data["sequence"] = this.getSequence();
@@ -67,6 +92,8 @@ export class GridWriter {
67
92
  layers;
68
93
  seqArr;
69
94
  layout;
95
+ /** True while a beginWrite() window is open (sequence is odd). */
96
+ writeOpen = false;
70
97
  constructor(sab, layout) {
71
98
  this.layout = layout;
72
99
  this.header = createHeaderViews(sab, layout.header.size);
@@ -82,9 +109,34 @@ export class GridWriter {
82
109
  this.header.u32[this.layout.header.magicIndex] = this.layout.magic;
83
110
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
84
111
  }
85
- bumpSequence() {
112
+ /**
113
+ * Open a write window — marks the sequence word odd so readers know a
114
+ * publish is in progress. Idempotent so mutating paths can call it
115
+ * unconditionally.
116
+ */
117
+ beginWrite() {
118
+ if (this.writeOpen)
119
+ return;
120
+ this.writeOpen = true;
86
121
  Atomics.add(this.seqArr, 0, 1);
87
122
  }
123
+ /** Close the write window opened by beginWrite() — sequence back to even. */
124
+ endWrite() {
125
+ if (!this.writeOpen)
126
+ return;
127
+ this.writeOpen = false;
128
+ Atomics.add(this.seqArr, 0, 1);
129
+ }
130
+ /**
131
+ * Publish a commit. Without an open write window it steps +2 (even→even)
132
+ * so the sequence always lands on a stable value while still advancing
133
+ * once per publish; inside a window it closes it (+1, odd→even).
134
+ */
135
+ bumpSequence() {
136
+ const step = this.writeOpen ? 1 : 2;
137
+ this.writeOpen = false;
138
+ Atomics.add(this.seqArr, 0, step);
139
+ }
88
140
  getSequence() {
89
141
  return Atomics.load(this.seqArr, 0);
90
142
  }
@@ -47,6 +47,31 @@ export class RecordReader {
47
47
  hasChanged(lastSeen) {
48
48
  return this.getSequence() !== lastSeen;
49
49
  }
50
+ /**
51
+ * True while the writer holds an open write window (sequence is odd).
52
+ * Readers can use this to cheaply defer sync work past a publish.
53
+ */
54
+ isWriteInProgress() {
55
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
56
+ }
57
+ /**
58
+ * Run `fn` against a consistent snapshot of the channel. The sequence
59
+ * word is a seqlock: odd while the writer is publishing, even when the
60
+ * data is stable. The read is retried while the word is odd or changes
61
+ * mid-read; after maxRetries it returns whatever the last attempt read
62
+ * rather than starving the reader (writers publish every tick).
63
+ */
64
+ readConsistent(fn, maxRetries = 64) {
65
+ for (let i = 0; i < maxRetries; i++) {
66
+ const s1 = Atomics.load(this.seqArr, 0);
67
+ if ((s1 & 1) === 0) {
68
+ const out = fn();
69
+ if (Atomics.load(this.seqArr, 0) === s1)
70
+ return out;
71
+ }
72
+ }
73
+ return fn();
74
+ }
50
75
  snapshot() {
51
76
  const data = {};
52
77
  if (this.layout.fields) {
@@ -73,6 +98,8 @@ export class RecordWriter {
73
98
  fields;
74
99
  seqArr;
75
100
  layout;
101
+ /** True while a beginWrite() window is open (sequence is odd). */
102
+ writeOpen = false;
76
103
  constructor(sab, layout) {
77
104
  this.layout = layout;
78
105
  this.header = createHeaderViews(sab, layout.header.size);
@@ -88,16 +115,33 @@ export class RecordWriter {
88
115
  this.header.u32[this.layout.header.magicIndex] = this.layout.magic;
89
116
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
90
117
  }
118
+ /**
119
+ * Open a write window — marks the sequence word odd so readers know a
120
+ * publish is in progress. Idempotent so mutating paths can call it
121
+ * unconditionally.
122
+ */
91
123
  beginWrite() {
92
- const seq = Atomics.load(this.seqArr, 0);
93
- Atomics.store(this.seqArr, 0, seq + 1);
124
+ if (this.writeOpen)
125
+ return;
126
+ this.writeOpen = true;
127
+ Atomics.add(this.seqArr, 0, 1);
94
128
  }
129
+ /** Close the write window opened by beginWrite() — sequence back to even. */
95
130
  endWrite() {
96
- // Sequence is incremented in beginWrite() — endWrite() is a no-op
97
- // to avoid double incrementing.
131
+ if (!this.writeOpen)
132
+ return;
133
+ this.writeOpen = false;
134
+ Atomics.add(this.seqArr, 0, 1);
98
135
  }
136
+ /**
137
+ * Publish a commit. Without an open write window it steps +2 (even→even)
138
+ * so the sequence always lands on a stable value while still advancing
139
+ * once per publish; inside a window it closes it (+1, odd→even).
140
+ */
99
141
  bumpSequence() {
100
- Atomics.add(this.seqArr, 0, 1);
142
+ const step = this.writeOpen ? 1 : 2;
143
+ this.writeOpen = false;
144
+ Atomics.add(this.seqArr, 0, step);
101
145
  }
102
146
  getSequence() {
103
147
  return Atomics.load(this.seqArr, 0);