@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
@@ -11,7 +11,7 @@ export const SIM_PLAYER_SLOT_SIZE = 256;
11
11
  export const SimChannel = defineChannel({
12
12
  name: "game-sim",
13
13
  magic: 0x53494d42,
14
- version: 1,
14
+ version: 2,
15
15
  mode: "slots",
16
16
  header: {
17
17
  size: 256,
@@ -53,6 +53,12 @@ export const SimChannel = defineChannel({
53
53
  physTimingCcdSolver: { type: "f32" },
54
54
  physTimingIslandConstruction: { type: "f32" },
55
55
  physTimingUserChanges: { type: "f32" },
56
+ // Presentation-interpolation timing, stamped by incrementTick().
57
+ // publishTimeMs is wall-clock epoch ms (timeOrigin + now) so it
58
+ // compares correctly across the worker/renderer thread boundary —
59
+ // performance.now() epochs differ per thread.
60
+ publishTimeMs: { type: "f64" },
61
+ publishDeltaMs: { type: "f32" },
56
62
  },
57
63
  },
58
64
  sections: [
@@ -108,10 +114,32 @@ export const SimChannel = defineChannel({
108
114
  // modifying the core SAB schema.
109
115
  },
110
116
  },
117
+ // Previous-publish transforms for renderer-side interpolation. The
118
+ // writer snapshots pos+rot here before overwriting a slot, and fills
119
+ // the rest from cur at commit — so prev always equals state(t-1)
120
+ // and cur equals state(t). Same field layout as ENT/PLR transforms.
121
+ {
122
+ name: "entitiesPrev",
123
+ maxSlots: MAX_ENTITIES,
124
+ slotSize: 32,
125
+ fields: {
126
+ pos: { type: "f32", count: 4 },
127
+ rot: { type: "f32", count: 4 },
128
+ },
129
+ },
130
+ {
131
+ name: "playersPrev",
132
+ maxSlots: MAX_PLAYERS,
133
+ slotSize: 32,
134
+ fields: {
135
+ pos: { type: "f32", count: 4 },
136
+ rot: { type: "f32", count: 4 },
137
+ },
138
+ },
111
139
  ],
112
140
  });
113
141
  export const SIM_MAGIC = 0x53494d42;
114
- export const SIM_VERSION = 1;
142
+ export const SIM_VERSION = 2;
115
143
  export const SIM_HDR = {
116
144
  MAGIC: 0,
117
145
  VERSION: 1,
@@ -153,6 +181,8 @@ export const SIM_HDR = {
153
181
  PHYS_TIMING_CCD_SOLVER: 37,
154
182
  PHYS_TIMING_ISLAND_CONSTRUCTION: 38,
155
183
  PHYS_TIMING_USER_CHANGES: 39,
184
+ PUBLISH_TIME_MS: 40, // f64 — occupies u32 words 40–41
185
+ PUBLISH_DELTA_MS: 42,
156
186
  };
157
187
  export const ENT = {
158
188
  POS_X: 0, POS_Y: 1, POS_Z: 2, SCALE: 3,
@@ -198,6 +228,13 @@ export const PLR_FLAG = {
198
228
  NOCLIP: 1 << 8,
199
229
  GROUNDED: 1 << 9,
200
230
  };
231
+ // Field offsets inside the entitiesPrev/playersPrev sections. pos holds
232
+ // [x, y, z, w] where w is entity scale / player heading; rot is the
233
+ // quaternion. Matches ENT.POS_*/ENT.ROT_* and PLR.POS_*/PLR.ROT_* layout.
234
+ export const PREV = {
235
+ POS_X: 0, POS_Y: 1, POS_Z: 2, POS_W: 3,
236
+ ROT_X: 4, ROT_Y: 5, ROT_Z: 6, ROT_W: 7,
237
+ };
201
238
  export function allocateSimBuffer() {
202
239
  return SimChannel.allocate();
203
240
  }
@@ -209,10 +246,14 @@ export class SimBufferReader {
209
246
  reader;
210
247
  entitySlots;
211
248
  playerSlots;
249
+ entityPrevSlots;
250
+ playerPrevSlots;
212
251
  constructor(sab) {
213
252
  this.reader = SimChannel.reader(sab);
214
253
  this.entitySlots = this.reader.sections.entities;
215
254
  this.playerSlots = this.reader.sections.players;
255
+ this.entityPrevSlots = this.reader.sections.entitiesPrev;
256
+ this.playerPrevSlots = this.reader.sections.playersPrev;
216
257
  }
217
258
  isValid() {
218
259
  return this.reader.isValid();
@@ -222,6 +263,17 @@ export class SimBufferReader {
222
263
  }
223
264
  getTick() { return this.reader.header.u32[SimChannel.offsets.header.tick]; }
224
265
  getSequence() { return this.reader.getSequence(); }
266
+ /** True while the sim worker is mid-publish (sequence word is odd). */
267
+ isWriteInProgress() { return this.reader.isWriteInProgress(); }
268
+ /**
269
+ * Run `fn` against a consistent snapshot of the sim state — retries
270
+ * while the sim worker is mid-publish or publishes during the read
271
+ * (seqlock). Wrap per-frame read loops (entity iteration, player slot
272
+ * reads) in this to avoid torn reads across a sim commit.
273
+ */
274
+ readConsistent(fn, maxRetries) {
275
+ return this.reader.readConsistent(fn, maxRetries);
276
+ }
225
277
  getEntityCount() { return this.reader.header.u32[SimChannel.offsets.header.entityCount]; }
226
278
  getPlayerCount() { return this.reader.header.u32[SimChannel.offsets.header.playerCount]; }
227
279
  getTimeOfDay() { return this.reader.header.f32[SimChannel.offsets.header.timeOfDay]; }
@@ -288,55 +340,105 @@ export class SimBufferReader {
288
340
  yield { idx: i, f32: sv.f32, u32: sv.u32 };
289
341
  }
290
342
  }
343
+ // ── Presentation interpolation ──
344
+ // prev = state(t-1) transform, cur = state(t); the renderer lerps by
345
+ // getInterpolationAlpha(). Wall-clock epoch ms (timeOrigin + now) —
346
+ // comparable across the worker/renderer thread boundary.
347
+ getPublishTimeMs() {
348
+ const o = SimChannel.offsets.header;
349
+ return this.reader.header.f64[o.publishTimeMs >> 1];
350
+ }
351
+ getPublishDeltaMs() {
352
+ const o = SimChannel.offsets.header;
353
+ return this.reader.header.f32[o.publishDeltaMs];
354
+ }
355
+ /**
356
+ * Interpolation alpha in [0,1] for the current frame. 0 = exactly at
357
+ * the last publish, 1 = a full publish interval past it (use cur).
358
+ * Clamped — no extrapolation.
359
+ */
360
+ getInterpolationAlpha(nowMs) {
361
+ const delta = this.getPublishDeltaMs();
362
+ if (delta <= 0)
363
+ return 1;
364
+ const now = nowMs ?? (performance.timeOrigin ?? 0) + performance.now();
365
+ const a = (now - this.getPublishTimeMs()) / delta;
366
+ return a <= 0 ? 0 : a >= 1 ? 1 : a;
367
+ }
368
+ /** Previous-publish transform for an entity slot (prev pos+rot, PREV.*). */
369
+ getEntityPrevSlot(idx) {
370
+ const sv = this.entityPrevSlots.slot(idx);
371
+ return { f32: sv.f32, u32: sv.u32 };
372
+ }
373
+ /** Previous-publish transform for a player slot (prev pos+rot, PREV.*). */
374
+ getPlayerPrevSlot(idx) {
375
+ const sv = this.playerPrevSlots.slot(idx);
376
+ return { f32: sv.f32, u32: sv.u32 };
377
+ }
291
378
  }
292
379
  // --- Sim Buffer Writer (sim worker side) ---
293
380
  export class SimBufferWriter {
294
381
  writer;
295
382
  entitySlots;
296
383
  playerSlots;
384
+ entityPrevSlots;
385
+ playerPrevSlots;
297
386
  // Dirty flags: 1 = entity/player slot has changed since last writeToBuffer.
298
387
  // writeToBuffer skips slots where the flag is 0. Cleared after each writeToBuffer.
299
388
  dirtyEntities;
300
389
  dirtyPlayers;
390
+ // Snapshot flags: 1 = the slot's prev-transform was already captured this
391
+ // publish (on first write access). Cleared at each incrementTick commit.
392
+ snapEntities;
393
+ snapPlayers;
301
394
  constructor(sab) {
302
395
  this.writer = SimChannel.writer(sab);
303
396
  this.entitySlots = this.writer.sections.entities;
304
397
  this.playerSlots = this.writer.sections.players;
398
+ this.entityPrevSlots = this.writer.sections.entitiesPrev;
399
+ this.playerPrevSlots = this.writer.sections.playersPrev;
305
400
  this.dirtyEntities = new Uint8Array(MAX_ENTITIES);
306
401
  this.dirtyPlayers = new Uint8Array(MAX_PLAYERS);
402
+ this.snapEntities = new Uint8Array(MAX_ENTITIES);
403
+ this.snapPlayers = new Uint8Array(MAX_PLAYERS);
307
404
  }
308
405
  init() {
309
406
  const w = this.writer;
310
407
  const h = SimChannel.offsets.header;
408
+ w.beginWrite();
311
409
  w.header.u32[h.maxEntities] = MAX_ENTITIES;
312
410
  w.header.u32[h.maxPlayers] = MAX_PLAYERS;
313
411
  w.header.u32[h.entityCount] = 0;
314
412
  w.header.u32[h.playerCount] = 0;
315
413
  w.header.u32[h.activePlayers] = 0;
414
+ w.endWrite();
316
415
  }
317
- setEntityCount(n) { this.writer.header.u32[SimChannel.offsets.header.entityCount] = n; }
318
- setPlayerCount(n) { this.writer.header.u32[SimChannel.offsets.header.playerCount] = n; }
319
- setTimeOfDay(t) { this.writer.header.f32[SimChannel.offsets.header.timeOfDay] = t; }
416
+ setEntityCount(n) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.entityCount] = n; }
417
+ setPlayerCount(n) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.playerCount] = n; }
418
+ setTimeOfDay(t) { this.writer.beginWrite(); this.writer.header.f32[SimChannel.offsets.header.timeOfDay] = t; }
320
419
  setWeather(type, intensity) {
420
+ this.writer.beginWrite();
321
421
  this.writer.header.u32[SimChannel.offsets.header.weatherType] = type;
322
422
  this.writer.header.f32[SimChannel.offsets.header.weatherIntensity] = intensity;
323
423
  }
324
424
  setWind(speed, dirX, dirZ) {
425
+ this.writer.beginWrite();
325
426
  this.writer.header.f32[SimChannel.offsets.header.windSpeed] = speed;
326
427
  this.writer.header.f32[SimChannel.offsets.header.windDirX] = dirX;
327
428
  this.writer.header.f32[SimChannel.offsets.header.windDirZ] = dirZ;
328
429
  }
329
- setVisibility(v) { this.writer.header.f32[SimChannel.offsets.header.visibility] = v; }
330
- setAmbientTemp(t) { this.writer.header.f32[SimChannel.offsets.header.ambientTemp] = t; }
331
- setActivePlayers(mask) { this.writer.header.u32[SimChannel.offsets.header.activePlayers] = mask; }
332
- setGamemode(mode) { this.writer.header.u32[SimChannel.offsets.header.gamemode] = mode; }
333
- setPhysicsInitialized(v) { this.writer.header.u32[SimChannel.offsets.header.physicsInitialized] = v; }
334
- setPhysicsFailed(v) { this.writer.header.u32[SimChannel.offsets.header.physicsFailed] = v; }
335
- setPhysicsBodyCount(v) { this.writer.header.u32[SimChannel.offsets.header.physicsBodyCount] = v; }
336
- setPhysicsTickCount(v) { this.writer.header.u32[SimChannel.offsets.header.physicsTickCount] = v; }
337
- setChunkCount(v) { this.writer.header.u32[SimChannel.offsets.header.chunkCount] = v; }
338
- setPhysicsProfilerEnabled(v) { this.writer.header.u32[SimChannel.offsets.header.physicsProfilerEnabled] = v; }
430
+ setVisibility(v) { this.writer.beginWrite(); this.writer.header.f32[SimChannel.offsets.header.visibility] = v; }
431
+ setAmbientTemp(t) { this.writer.beginWrite(); this.writer.header.f32[SimChannel.offsets.header.ambientTemp] = t; }
432
+ setActivePlayers(mask) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.activePlayers] = mask; }
433
+ setGamemode(mode) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.gamemode] = mode; }
434
+ setPhysicsInitialized(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsInitialized] = v; }
435
+ setPhysicsFailed(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsFailed] = v; }
436
+ setPhysicsBodyCount(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsBodyCount] = v; }
437
+ setPhysicsTickCount(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsTickCount] = v; }
438
+ setChunkCount(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.chunkCount] = v; }
439
+ setPhysicsProfilerEnabled(v) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsProfilerEnabled] = v; }
339
440
  setPhysicsTiming(data) {
441
+ this.writer.beginWrite();
340
442
  const h = this.writer.header.f32;
341
443
  const o = SimChannel.offsets.header;
342
444
  h[o.physTimingStep] = data.step;
@@ -356,10 +458,56 @@ export class SimBufferWriter {
356
458
  h[o.physTimingIslandConstruction] = data.islandConstruction;
357
459
  h[o.physTimingUserChanges] = data.userChanges;
358
460
  }
461
+ /**
462
+ * Copy the slot's current transform (pos+rot, first 8 f32s) into its
463
+ * prev slot — once per publish, on first write access, so prev always
464
+ * holds the last committed transform for interpolation.
465
+ */
466
+ snapshotEntityPrev(idx) {
467
+ if (this.snapEntities[idx])
468
+ return;
469
+ this.snapEntities[idx] = 1;
470
+ this.entityPrevSlots.slot(idx).f32.set(this.entitySlots.slot(idx).f32.subarray(0, 8));
471
+ }
472
+ snapshotPlayerPrev(idx) {
473
+ if (this.snapPlayers[idx])
474
+ return;
475
+ this.snapPlayers[idx] = 1;
476
+ this.playerPrevSlots.slot(idx).f32.set(this.playerSlots.slot(idx).f32.subarray(0, 8));
477
+ }
359
478
  incrementTick() {
360
479
  const h = SimChannel.offsets.header;
361
- Atomics.add(this.writer.header.u32, h.tick, 1);
362
- this.writer.bumpSequence();
480
+ const w = this.writer;
481
+ // Prev-sync: slots not written this publish keep their old cur, so
482
+ // prev must equal cur (identity lerp). Slots that were written were
483
+ // already snapshotted on first access. Runs inside the open write
484
+ // window, before the commit — readers never see a mixed prev/cur.
485
+ const entCount = Math.min(w.header.u32[h.entityCount], MAX_ENTITIES);
486
+ for (let i = 0; i < entCount; i++) {
487
+ if (!this.snapEntities[i]) {
488
+ this.entityPrevSlots.slot(i).f32.set(this.entitySlots.slot(i).f32.subarray(0, 8));
489
+ }
490
+ }
491
+ this.snapEntities.fill(0);
492
+ const plrCount = Math.min(w.header.u32[h.playerCount], MAX_PLAYERS);
493
+ for (let i = 0; i < plrCount; i++) {
494
+ if (!this.snapPlayers[i]) {
495
+ this.playerPrevSlots.slot(i).f32.set(this.playerSlots.slot(i).f32.subarray(0, 8));
496
+ }
497
+ }
498
+ this.snapPlayers.fill(0);
499
+ // Wall-clock epoch ms — performance.timeOrigin is defined per thread
500
+ // and makes the stamp comparable on the renderer side.
501
+ const now = (performance.timeOrigin ?? 0) + performance.now();
502
+ const tIdx = h.publishTimeMs >> 1; // u32 index 40 → f64 index 20
503
+ const last = w.header.f64[tIdx];
504
+ w.header.f64[tIdx] = now;
505
+ w.header.f32[h.publishDeltaMs] = last > 0 ? now - last : 0;
506
+ Atomics.add(w.header.u32, h.tick, 1);
507
+ // Closes the beginWrite() window opened by the first mutation of this
508
+ // publish (odd→even) — or steps +2 when nothing wrote this tick, so
509
+ // the sequence still advances once per publish for change detection.
510
+ w.bumpSequence();
363
511
  }
364
512
  markEntityDirty(slot) {
365
513
  this.dirtyEntities[slot] = 1;
@@ -383,15 +531,23 @@ export class SimBufferWriter {
383
531
  this.dirtyPlayers.fill(1);
384
532
  }
385
533
  getEntityF32(idx) {
534
+ this.writer.beginWrite();
535
+ this.snapshotEntityPrev(idx);
386
536
  return this.entitySlots.slot(idx).f32;
387
537
  }
388
538
  getEntityU32(idx) {
539
+ this.writer.beginWrite();
540
+ this.snapshotEntityPrev(idx);
389
541
  return this.entitySlots.slot(idx).u32;
390
542
  }
391
543
  getPlayerF32(idx) {
544
+ this.writer.beginWrite();
545
+ this.snapshotPlayerPrev(idx);
392
546
  return this.playerSlots.slot(idx).f32;
393
547
  }
394
548
  getPlayerU32(idx) {
549
+ this.writer.beginWrite();
550
+ this.snapshotPlayerPrev(idx);
395
551
  return this.playerSlots.slot(idx).u32;
396
552
  }
397
553
  }
@@ -79,6 +79,31 @@ export class SlotReader {
79
79
  hasChanged(lastSeen) {
80
80
  return this.getSequence() !== lastSeen;
81
81
  }
82
+ /**
83
+ * True while the writer holds an open write window (sequence is odd).
84
+ * Readers can use this to cheaply defer sync work past a publish.
85
+ */
86
+ isWriteInProgress() {
87
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
88
+ }
89
+ /**
90
+ * Run `fn` against a consistent snapshot of the channel. The sequence
91
+ * word is a seqlock: odd while the writer is publishing, even when the
92
+ * data is stable. The read is retried while the word is odd or changes
93
+ * mid-read; after maxRetries it returns whatever the last attempt read
94
+ * rather than starving the reader (writers publish every tick).
95
+ */
96
+ readConsistent(fn, maxRetries = 64) {
97
+ for (let i = 0; i < maxRetries; i++) {
98
+ const s1 = Atomics.load(this.seqArr, 0);
99
+ if ((s1 & 1) === 0) {
100
+ const out = fn();
101
+ if (Atomics.load(this.seqArr, 0) === s1)
102
+ return out;
103
+ }
104
+ }
105
+ return fn();
106
+ }
82
107
  snapshot() {
83
108
  const data = {};
84
109
  data["sequence"] = this.getSequence();
@@ -116,6 +141,8 @@ export class SlotWriter {
116
141
  sections;
117
142
  seqArr;
118
143
  layout;
144
+ /** True while a beginWrite() window is open (sequence is odd). */
145
+ writeOpen = false;
119
146
  constructor(sab, layout) {
120
147
  this.layout = layout;
121
148
  this.header = createHeaderViews(sab, layout.header.size);
@@ -131,9 +158,35 @@ export class SlotWriter {
131
158
  this.header.u32[this.layout.header.magicIndex] = this.layout.magic;
132
159
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
133
160
  }
134
- bumpSequence() {
161
+ /**
162
+ * Open a write window — marks the sequence word odd so readers know a
163
+ * publish is in progress. Idempotent: nested/extra calls are free, so
164
+ * mutating accessors can call it unconditionally on every write path.
165
+ */
166
+ beginWrite() {
167
+ if (this.writeOpen)
168
+ return;
169
+ this.writeOpen = true;
135
170
  Atomics.add(this.seqArr, 0, 1);
136
171
  }
172
+ /** Close the write window opened by beginWrite() — sequence back to even. */
173
+ endWrite() {
174
+ if (!this.writeOpen)
175
+ return;
176
+ this.writeOpen = false;
177
+ Atomics.add(this.seqArr, 0, 1);
178
+ }
179
+ /**
180
+ * Publish a commit. Legacy callers use this without a beginWrite()
181
+ * window: it then steps +2 (even→even) so the sequence always lands on
182
+ * a stable value while still advancing once per publish. Inside a
183
+ * beginWrite() window it closes the window (+1, odd→even).
184
+ */
185
+ bumpSequence() {
186
+ const step = this.writeOpen ? 1 : 2;
187
+ this.writeOpen = false;
188
+ Atomics.add(this.seqArr, 0, step);
189
+ }
137
190
  getSequence() {
138
191
  return Atomics.load(this.seqArr, 0);
139
192
  }
@@ -131,6 +131,28 @@ export class GPUResourceTracker {
131
131
  * inform. Enable via constructor for leak-hunting sessions.
132
132
  */
133
133
  captureCallsites;
134
+ /**
135
+ * Resources dropped without destroy() are released through the native
136
+ * FinalizationRegistry (platform-native/gpu/registry.ts) — the wrapped
137
+ * destroy() never runs, so without this second registry their tracked
138
+ * entries (and reported VRAM) would grow forever. The held value is the
139
+ * tracked id; the resource object itself is the unregister token.
140
+ */
141
+ gcRegistry = new FinalizationRegistry((id) => {
142
+ const r = this.resources.get(id);
143
+ if (!r)
144
+ return;
145
+ this.resources.delete(id);
146
+ if (r.type === "texture") {
147
+ this.textureBytes -= r.size;
148
+ this.textureCount--;
149
+ }
150
+ else {
151
+ this.bufferBytes -= r.size;
152
+ this.bufferCount--;
153
+ }
154
+ this.dirty = true;
155
+ });
134
156
  constructor(opts = {}) {
135
157
  this.captureCallsites = opts.captureCallsites ?? false;
136
158
  }
@@ -160,6 +182,7 @@ export class GPUResourceTracker {
160
182
  tracker.textureBytes += bytes;
161
183
  tracker.textureCount++;
162
184
  tracker.dirty = true;
185
+ tracker.gcRegistry.register(tex, id, tex);
163
186
  // Wrap destroy to remove from tracking
164
187
  const origDestroy = tex.destroy.bind(tex);
165
188
  tex.destroy = function () {
@@ -170,6 +193,7 @@ export class GPUResourceTracker {
170
193
  tracker.textureCount--;
171
194
  tracker.dirty = true;
172
195
  }
196
+ tracker.gcRegistry.unregister(tex);
173
197
  origDestroy();
174
198
  };
175
199
  return tex;
@@ -189,6 +213,7 @@ export class GPUResourceTracker {
189
213
  tracker.bufferBytes += bytes;
190
214
  tracker.bufferCount++;
191
215
  tracker.dirty = true;
216
+ tracker.gcRegistry.register(buf, id, buf);
192
217
  // Wrap destroy to remove from tracking
193
218
  const origDestroy = buf.destroy.bind(buf);
194
219
  buf.destroy = function () {
@@ -199,6 +224,7 @@ export class GPUResourceTracker {
199
224
  tracker.bufferCount--;
200
225
  tracker.dirty = true;
201
226
  }
227
+ tracker.gcRegistry.unregister(buf);
202
228
  origDestroy();
203
229
  };
204
230
  return buf;
@@ -2,3 +2,4 @@
2
2
  export { BroadPhaseGrid } from "./broad-phase-grid.js";
3
3
  export { condenseText, decodeFeatureLogLine, encodeFeatureLogJSON, encodeFeatureLogLine, encodeFeatureLogLines, encodeFeatures, FEATURE_LOG_SCHEMA_VERSION, featureCode, formatBytesShort } from "./feature-log.js";
4
4
  export { addLogSink, ConsoleLogger, createLogger, getLogTheme, getRecentLogs, setThreadTag } from "./logger.js";
5
+ export { untrackedTimeout } from "./session-timer.js";
@@ -0,0 +1,22 @@
1
+ // ============================================================================
2
+ // session-timer.ts — scheduling that survives session teardown
3
+ //
4
+ // Under the native dev shell the SessionTracker wraps global setTimeout:
5
+ // callbacks armed while the session is dead (teardown in flight) self-disarm
6
+ // via WeakRef and never run — correct for session-owned work, fatal for
7
+ // bound/grace-period timers whose whole job is to fire DURING teardown
8
+ // (shutdown-RPC bounds, retire fallbacks, response timeouts). Those must
9
+ // route through the tracker's untrackedTimeout. Outside the dev shell
10
+ // __ddSession is absent and this is a plain setTimeout.
11
+ // ============================================================================
12
+ /**
13
+ * setTimeout that is exempt from dev-session tracking. The returned id pairs
14
+ * with plain clearTimeout (the wrapped clear delegates to the platform clear).
15
+ */
16
+ export function untrackedTimeout(fn, ms) {
17
+ const tracker = globalThis.__ddSession;
18
+ if (typeof tracker?.untrackedTimeout === "function") {
19
+ return tracker.untrackedTimeout(fn, ms);
20
+ }
21
+ return setTimeout(fn, ms);
22
+ }
@@ -23,6 +23,7 @@
23
23
  // ============================================================================
24
24
  import { usingRealSAB } from "../sab/sab-polyfill.js";
25
25
  import { createLogger } from "../util/logger.js";
26
+ import { untrackedTimeout } from "../util/session-timer.js";
26
27
  import { wrap } from "./rpc.js";
27
28
  const log = createLogger();
28
29
  export class BaseWorkerHost {
@@ -155,7 +156,7 @@ export class BaseWorkerHost {
155
156
  await Promise.race([
156
157
  this.proxy.proxy.shutdown(),
157
158
  new Promise((_, rej) => {
158
- timer = setTimeout(() => rej(new Error("shutdown RPC timed out")), 2_000);
159
+ timer = untrackedTimeout(() => rej(new Error("shutdown RPC timed out")), 2_000);
159
160
  }),
160
161
  ]);
161
162
  }
@@ -360,10 +360,11 @@ export class BufferSyncWorker {
360
360
  len = Math.min(r.length, count * r.lengthMultiplier);
361
361
  }
362
362
  // Chunked transfer: if the region is large, split into chunks
363
- // and send with setTimeout between them so the main thread
364
- // can process rAF/render between chunks. This prevents 100-900ms
365
- // main-thread blocks where SAB is unavailable
366
- // and postMessage structured clone is ~10MB/s).
363
+ // sent as separate postMessages so the main thread interleaves
364
+ // receive+apply tasks with rAF/render instead of blocking on one
365
+ // giant structured clone. This prevents 100-900ms main-thread
366
+ // blocks where SAB is unavailable (and postMessage structured
367
+ // clone is ~10MB/s).
367
368
  const CHUNK_SIZE = 64 * 1024; // 64KB per chunk — small enough to avoid >50ms blocks
368
369
  if (len > CHUNK_SIZE) {
369
370
  const numChunks = Math.ceil(len / CHUNK_SIZE);
@@ -401,10 +402,16 @@ export class BufferSyncWorker {
401
402
  // main thread to render between them.
402
403
  const allCopies = Object.values(regions).flat();
403
404
  if (allCopies.length > 1 && totalBytes > 256 * 1024) {
404
- // Large transfer: send chunks with setTimeout between them so the
405
- // main thread can render between chunks. Each message carries batch
406
- // metadata — the receiver buffers parts and applies them atomically
407
- // once all arrive, so the buffer never renders partially-updated.
405
+ // Large transfer: send chunks as separate postMessages so the main
406
+ // thread interleaves receive+apply tasks with rAF/render. Each
407
+ // message carries batch metadata — the receiver buffers parts and
408
+ // applies them atomically once all arrive, so the buffer never
409
+ // renders partially-updated. A setTimeout(0) between posts yields
410
+ // the worker's macrotask queue once per chunk — letting urgent
411
+ // worker-side messages (RPC responses, other sync traffic) insert
412
+ // between chunks rather than queueing behind the whole batch — but
413
+ // adds no fixed delay: measured delivery was ~16ms/chunk with a
414
+ // 16ms gap (~1s for a 4MB region) vs sub-ms/chunk now.
408
415
  // Precompute chunk→buffer ownership once (was O(chunks×regions) before).
409
416
  const chunkOwner = [];
410
417
  for (const [name, regionList] of Object.entries(regions)) {
@@ -423,7 +430,7 @@ export class BufferSyncWorker {
423
430
  chunkIdx++;
424
431
  self.postMessage({ __bufferSync: true, regions: batchRegions, batch: { id: batchId, index: chunkIdx - 1, total } }, [copy.data]);
425
432
  if (chunkIdx < total) {
426
- setTimeout(sendNextChunk, 16); // 16ms delay lets main thread render between chunks
433
+ setTimeout(sendNextChunk, 0); // yield the macrotask queue between chunk posts
427
434
  }
428
435
  };
429
436
  sendNextChunk();
@@ -4,6 +4,7 @@
4
4
  // Works in both Web Workers and Node.js worker_threads.
5
5
  // ============================================================================
6
6
  import { createLogger } from "../util/logger.js";
7
+ import { untrackedTimeout } from "../util/session-timer.js";
7
8
  const log = createLogger();
8
9
  // In Node.js/Bun worker_threads, `parentPort` is non-null only inside a
9
10
  // worker connected to a parent — its presence IS the worker check. (The old
@@ -63,6 +64,9 @@ function isRpcResponse(msg) {
63
64
  function isEventMessage(msg) {
64
65
  return msg && msg.__event === true;
65
66
  }
67
+ function isEventBatchMessage(msg) {
68
+ return msg && msg.__eventBatch === true;
69
+ }
66
70
  export function expose(api, options) {
67
71
  const h = options?.host ?? getWorkerHost();
68
72
  h.onHostMessage(async (msg) => {
@@ -90,6 +94,37 @@ export function exposeEvents(host) {
90
94
  },
91
95
  };
92
96
  }
97
+ /**
98
+ * Event emitter that coalesces emit() calls into a single `__eventBatch`
99
+ * postMessage per macrotask. A sim emitting per-tick or per-entity events
100
+ * otherwise produces one structured clone + one main-thread task per event,
101
+ * and the task burst competes with rAF. Events emitted during a tick batch
102
+ * (the worker never yields to timers mid-tick) all land in one message;
103
+ * events emitted between ticks flush on the next macrotask, so latency for
104
+ * isolated events is unchanged.
105
+ */
106
+ export function exposeBatchedEvents(host) {
107
+ const h = host ?? getWorkerHost();
108
+ let queue = [];
109
+ let flushScheduled = false;
110
+ const flush = () => {
111
+ flushScheduled = false;
112
+ if (queue.length === 0)
113
+ return;
114
+ h.postToHost({ __eventBatch: true, events: queue });
115
+ queue = [];
116
+ };
117
+ return {
118
+ emit: (kind, data) => {
119
+ queue.push({ kind, data });
120
+ if (!flushScheduled) {
121
+ flushScheduled = true;
122
+ setTimeout(flush, 0);
123
+ }
124
+ },
125
+ flush,
126
+ };
127
+ }
93
128
  const DEFAULT_TIMEOUT_MS = 30_000;
94
129
  export function wrap(worker, options) {
95
130
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -114,13 +149,16 @@ export function wrap(worker, options) {
114
149
  p.resolve(msg.result);
115
150
  return;
116
151
  }
117
- if (isEventMessage(msg)) {
118
- for (const cb of eventListeners.values()) {
119
- try {
120
- cb(msg.kind, msg.data);
121
- }
122
- catch (err) {
123
- log.error("worker-proxy", `Event listener error: ${err}`);
152
+ if (isEventMessage(msg) || isEventBatchMessage(msg)) {
153
+ const events = isEventBatchMessage(msg) ? msg.events : [msg];
154
+ for (const ev of events) {
155
+ for (const cb of eventListeners.values()) {
156
+ try {
157
+ cb(ev.kind, ev.data);
158
+ }
159
+ catch (err) {
160
+ log.error("worker-proxy", `Event listener error: ${err}`);
161
+ }
124
162
  }
125
163
  }
126
164
  }
@@ -172,7 +210,7 @@ export function wrap(worker, options) {
172
210
  const id = ++reqId;
173
211
  const entry = { resolve, reject };
174
212
  if (timeoutMs > 0) {
175
- entry.timer = setTimeout(() => {
213
+ entry.timer = untrackedTimeout(() => {
176
214
  if (pending.delete(id)) {
177
215
  reject(new Error(`RPC '${method}' timed out after ${timeoutMs}ms`));
178
216
  }
@@ -38,7 +38,7 @@ import "../sab/sab-polyfill.js";
38
38
  import { usingRealSAB } from "../sab/sab-polyfill.js";
39
39
  import { mulberry32 } from "../math/rng.js";
40
40
  import { createLogger } from "../util/logger.js";
41
- import { expose, exposeEvents } from "./rpc.js";
41
+ import { expose, exposeBatchedEvents } from "./rpc.js";
42
42
  const log = createLogger();
43
43
  /**
44
44
  * Detect deterministic/test mode inside a worker. `DOWNDRAFT_DETERMINISTIC`
@@ -158,7 +158,7 @@ export function createSimWorker(opts) {
158
158
  let tickInFlight = null;
159
159
  // Serializes withLoopStopped critical sections.
160
160
  let stopChain = Promise.resolve();
161
- const events = exposeEvents();
161
+ const events = exposeBatchedEvents();
162
162
  // Deterministic RNG — reseedable via control.setSeed(); the stable `rng`
163
163
  // closure is what ctx.rng and the Math.random shim draw from.
164
164
  let rngImpl = mulberry32(opts.seed ?? 0x9e3779b9);
@@ -423,6 +423,9 @@ export function createSimWorker(opts) {
423
423
  syncWorker = null; // release sync worker reference
424
424
  saveStore = null;
425
425
  await opts.onShutdown?.();
426
+ // Drain any events queued during onShutdown so they aren't lost to
427
+ // a flush timer the terminating worker never runs.
428
+ events.flush();
426
429
  },
427
430
  async setSpeed(speed) {
428
431
  speedMultiplier = Math.min(opts.maxSpeed ?? Infinity, Math.max(opts.minSpeed ?? 0, speed));
@@ -78,6 +78,11 @@ export class TaskPool {
78
78
  for (let i = 0; i < this.workerCount; i++) {
79
79
  const worker = this.createWorker();
80
80
  worker.onerror = (e) => {
81
+ // Session teardown tags tracked workers __ddExpectTerminate and
82
+ // fires synthetic error events to settle waiters — not faults
83
+ // (see session-tracker.ts terminateTrackedWorker).
84
+ if (worker.__ddExpectTerminate || this.destroyed)
85
+ return;
81
86
  if (this.onWorkerError)
82
87
  this.onWorkerError(i, e);
83
88
  else