@downdraft/engine 0.2.0 → 0.3.1

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 (138) hide show
  1. package/app/src/renderer/bootstrap.ts +0 -6
  2. package/app/src/renderer/feature-log.ts +1 -9
  3. package/app/src/renderer/mcp-harness.ts +8 -11
  4. package/app/src/renderer/standard-automation-tools.ts +8 -145
  5. package/core/src/index.ts +2 -2
  6. package/core/src/module/host.ts +3 -6
  7. package/core/src/module/index.ts +1 -1
  8. package/core/src/module/module.ts +4 -47
  9. package/core/src/physics/interface.ts +5 -0
  10. package/core/src/platform/render-surface.ts +13 -0
  11. package/core/src/platform/rpc.ts +4 -1
  12. package/core/src/render/canvas-resize-watcher.ts +1 -10
  13. package/core/src/render/game-renderer.ts +164 -79
  14. package/core/src/render/index.ts +0 -2
  15. package/core/src/render/input-manager.ts +7 -7
  16. package/core/src/render/renderer-module-host.ts +2 -4
  17. package/core/src/sab/grid-buffer.ts +55 -2
  18. package/core/src/sab/record-buffer.ts +48 -5
  19. package/core/src/sab/sim-channel.ts +181 -17
  20. package/core/src/sab/slot-buffer.ts +55 -1
  21. package/core/src/sab/types.ts +11 -0
  22. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  23. package/core/src/ui-font-scale.ts +5 -38
  24. package/core/src/util/index.ts +1 -0
  25. package/core/src/util/session-timer.ts +23 -0
  26. package/core/src/worker/base-worker-host.ts +2 -1
  27. package/core/src/worker/buffer-sync.ts +20 -13
  28. package/core/src/worker/rpc.ts +119 -5
  29. package/core/src/worker/sim-worker-base.ts +5 -2
  30. package/core/src/worker/task-pool.ts +4 -0
  31. package/dist/.dd-manifest.json +893 -908
  32. package/dist/app/src/renderer/bootstrap.js +0 -4
  33. package/dist/app/src/renderer/feature-log.js +1 -10
  34. package/dist/app/src/renderer/mcp-harness.js +8 -11
  35. package/dist/app/src/renderer/standard-automation-tools.js +6 -138
  36. package/dist/core/src/index.js +2 -2
  37. package/dist/core/src/module/host.js +3 -6
  38. package/dist/core/src/platform/rpc.js +2 -1
  39. package/dist/core/src/render/canvas-resize-watcher.js +1 -10
  40. package/dist/core/src/render/game-renderer.js +166 -74
  41. package/dist/core/src/render/index.js +0 -1
  42. package/dist/core/src/render/input-manager.js +7 -7
  43. package/dist/core/src/render/renderer-module-host.js +2 -4
  44. package/dist/core/src/sab/grid-buffer.js +53 -1
  45. package/dist/core/src/sab/record-buffer.js +49 -5
  46. package/dist/core/src/sab/sim-channel.js +173 -17
  47. package/dist/core/src/sab/slot-buffer.js +54 -1
  48. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  49. package/dist/core/src/ui-font-scale.js +5 -38
  50. package/dist/core/src/util/index.js +1 -0
  51. package/dist/core/src/util/session-timer.js +22 -0
  52. package/dist/core/src/worker/base-worker-host.js +2 -1
  53. package/dist/core/src/worker/buffer-sync.js +16 -9
  54. package/dist/core/src/worker/rpc.js +105 -8
  55. package/dist/core/src/worker/sim-worker-base.js +5 -2
  56. package/dist/core/src/worker/task-pool.js +5 -0
  57. package/dist/libraries/blitz-ui/src/native-module.js +8 -1
  58. package/dist/libraries/devtools/src/backend.js +35 -9
  59. package/dist/libraries/devtools/src/collectors.js +73 -22
  60. package/dist/libraries/devtools/src/native-providers.js +0 -2
  61. package/dist/libraries/entities/src/model-renderer.js +87 -27
  62. package/dist/libraries/entities/src/shaders/model.wgsl +13 -4
  63. package/dist/libraries/networking/src/index.js +1 -0
  64. package/dist/libraries/networking/src/ws-server.js +387 -0
  65. package/dist/libraries/physics-rapier/src/api.js +15 -0
  66. package/dist/libraries/physics-rapier/src/backend.js +15 -0
  67. package/dist/libraries/physics-rapier/src/rapier-backend.js +5 -0
  68. package/dist/libraries/postfx/src/post-process-stack.js +1 -3
  69. package/dist/libraries/sand/src/materials.js +2 -32
  70. package/dist/libraries/sand/src/sand-world.js +0 -3
  71. package/dist/modules/devtools/src/api.js +8 -23
  72. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  73. package/dist/modules/devtools/src/blitz/host.js +124 -30
  74. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  75. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  76. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  77. package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
  78. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  79. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  80. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  81. package/dist/modules/devtools/src/data-bridge.js +0 -11
  82. package/dist/modules/devtools/src/index.js +0 -6
  83. package/dist/modules/devtools/src/init.js +8 -42
  84. package/dist/modules/devtools/src/scene-inspector.js +1 -4
  85. package/dist/modules/devtools/src/worker-sync.js +3 -11
  86. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  87. package/dist/modules/html-ui/src/host.js +6 -3
  88. package/dist/test/src/vision-test.js +1 -3
  89. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  90. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  91. package/libraries/blitz-ui/src/native-module.ts +8 -1
  92. package/libraries/devtools/src/backend.ts +38 -12
  93. package/libraries/devtools/src/collectors.ts +75 -22
  94. package/libraries/devtools/src/native-providers.ts +0 -2
  95. package/libraries/entities/src/model-renderer.ts +88 -27
  96. package/libraries/entities/src/shaders/model.wgsl +13 -4
  97. package/libraries/networking/src/index.ts +4 -0
  98. package/libraries/networking/src/ws-server.ts +397 -0
  99. package/libraries/physics-rapier/src/api.ts +15 -0
  100. package/libraries/physics-rapier/src/backend.ts +15 -0
  101. package/libraries/physics-rapier/src/rapier-backend.ts +6 -0
  102. package/libraries/postfx/src/post-process-stack.ts +1 -2
  103. package/libraries/sand/src/materials.ts +2 -32
  104. package/libraries/sand/src/sand-world.ts +0 -4
  105. package/modules/devtools/README.md +1 -3
  106. package/modules/devtools/src/api.ts +10 -34
  107. package/modules/devtools/src/blitz/fonts.ts +78 -0
  108. package/modules/devtools/src/blitz/host.ts +107 -31
  109. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  110. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  111. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  112. package/modules/devtools/src/blitz/panels/performance.ts +24 -12
  113. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  114. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  115. package/modules/devtools/src/blitz/theme.ts +26 -4
  116. package/modules/devtools/src/data-bridge.ts +0 -16
  117. package/modules/devtools/src/index.ts +1 -11
  118. package/modules/devtools/src/init.ts +8 -51
  119. package/modules/devtools/src/scene-inspector.ts +2 -6
  120. package/modules/devtools/src/types.ts +5 -90
  121. package/modules/devtools/src/worker-sync.ts +3 -13
  122. package/modules/html-ui/src/doc-backend.ts +67 -4
  123. package/modules/html-ui/src/host.ts +11 -3
  124. package/modules/html-ui/src/protocol.ts +1 -1
  125. package/package.json +4 -5
  126. package/test/src/vision-test.ts +2 -4
  127. package/core/src/render/sab-canvas-overlay.ts +0 -122
  128. package/dist/core/src/render/sab-canvas-overlay.js +0 -83
  129. package/dist/modules/devtools/src/debug-overlay.js +0 -181
  130. package/dist/modules/devtools/src/doctor-panel.js +0 -163
  131. package/dist/modules/devtools/src/label-overlay.js +0 -129
  132. package/dist/modules/devtools/src/material-stats-panel.js +0 -171
  133. package/dist/modules/devtools/src/sim-stats-panel.js +0 -250
  134. package/modules/devtools/src/debug-overlay.ts +0 -238
  135. package/modules/devtools/src/doctor-panel.ts +0 -182
  136. package/modules/devtools/src/label-overlay.ts +0 -154
  137. package/modules/devtools/src/material-stats-panel.ts +0 -188
  138. package/modules/devtools/src/sim-stats-panel.ts +0 -272
@@ -34,7 +34,7 @@ export const SIM_PLAYER_SLOT_SIZE = 256;
34
34
  export const SimChannel = defineChannel({
35
35
  name: "game-sim",
36
36
  magic: 0x53494d42,
37
- version: 1,
37
+ version: 2,
38
38
  mode: "slots",
39
39
  header: {
40
40
  size: 256,
@@ -76,6 +76,12 @@ export const SimChannel = defineChannel({
76
76
  physTimingCcdSolver: { type: "f32" },
77
77
  physTimingIslandConstruction: { type: "f32" },
78
78
  physTimingUserChanges: { type: "f32" },
79
+ // Presentation-interpolation timing, stamped by incrementTick().
80
+ // publishTimeMs is wall-clock epoch ms (timeOrigin + now) so it
81
+ // compares correctly across the worker/renderer thread boundary —
82
+ // performance.now() epochs differ per thread.
83
+ publishTimeMs: { type: "f64" },
84
+ publishDeltaMs: { type: "f32" },
79
85
  },
80
86
  },
81
87
  sections: [
@@ -131,11 +137,33 @@ export const SimChannel = defineChannel({
131
137
  // modifying the core SAB schema.
132
138
  },
133
139
  },
140
+ // Previous-publish transforms for renderer-side interpolation. The
141
+ // writer snapshots pos+rot here before overwriting a slot, and fills
142
+ // the rest from cur at commit — so prev always equals state(t-1)
143
+ // and cur equals state(t). Same field layout as ENT/PLR transforms.
144
+ {
145
+ name: "entitiesPrev",
146
+ maxSlots: MAX_ENTITIES,
147
+ slotSize: 32,
148
+ fields: {
149
+ pos: { type: "f32", count: 4 },
150
+ rot: { type: "f32", count: 4 },
151
+ },
152
+ },
153
+ {
154
+ name: "playersPrev",
155
+ maxSlots: MAX_PLAYERS,
156
+ slotSize: 32,
157
+ fields: {
158
+ pos: { type: "f32", count: 4 },
159
+ rot: { type: "f32", count: 4 },
160
+ },
161
+ },
134
162
  ],
135
163
  });
136
164
 
137
165
  export const SIM_MAGIC = 0x53494d42;
138
- export const SIM_VERSION = 1;
166
+ export const SIM_VERSION = 2;
139
167
 
140
168
  export const SIM_HDR = {
141
169
  MAGIC: 0,
@@ -178,6 +206,8 @@ export const SIM_HDR = {
178
206
  PHYS_TIMING_CCD_SOLVER: 37,
179
207
  PHYS_TIMING_ISLAND_CONSTRUCTION: 38,
180
208
  PHYS_TIMING_USER_CHANGES: 39,
209
+ PUBLISH_TIME_MS: 40, // f64 — occupies u32 words 40–41
210
+ PUBLISH_DELTA_MS: 42,
181
211
  } as const;
182
212
 
183
213
  export const ENT = {
@@ -227,6 +257,14 @@ export const PLR_FLAG = {
227
257
  GROUNDED: 1 << 9,
228
258
  } as const;
229
259
 
260
+ // Field offsets inside the entitiesPrev/playersPrev sections. pos holds
261
+ // [x, y, z, w] where w is entity scale / player heading; rot is the
262
+ // quaternion. Matches ENT.POS_*/ENT.ROT_* and PLR.POS_*/PLR.ROT_* layout.
263
+ export const PREV = {
264
+ POS_X: 0, POS_Y: 1, POS_Z: 2, POS_W: 3,
265
+ ROT_X: 4, ROT_Y: 5, ROT_Z: 6, ROT_W: 7,
266
+ } as const;
267
+
230
268
  export function allocateSimBuffer(): SharedArrayBuffer {
231
269
  return SimChannel.allocate();
232
270
  }
@@ -241,11 +279,15 @@ export class SimBufferReader {
241
279
  private reader: ReturnType<typeof SimChannel.reader>;
242
280
  private entitySlots: ReturnType<typeof SimChannel.reader>["sections"]["entities"];
243
281
  private playerSlots: ReturnType<typeof SimChannel.reader>["sections"]["players"];
282
+ private entityPrevSlots: ReturnType<typeof SimChannel.reader>["sections"]["entitiesPrev"];
283
+ private playerPrevSlots: ReturnType<typeof SimChannel.reader>["sections"]["playersPrev"];
244
284
 
245
285
  constructor(sab: SharedArrayBuffer) {
246
286
  this.reader = SimChannel.reader(sab);
247
287
  this.entitySlots = this.reader.sections.entities;
248
288
  this.playerSlots = this.reader.sections.players;
289
+ this.entityPrevSlots = this.reader.sections.entitiesPrev;
290
+ this.playerPrevSlots = this.reader.sections.playersPrev;
249
291
  }
250
292
 
251
293
  isValid(): boolean {
@@ -258,6 +300,17 @@ export class SimBufferReader {
258
300
 
259
301
  getTick(): number { return this.reader.header.u32[SimChannel.offsets.header.tick]; }
260
302
  getSequence(): number { return this.reader.getSequence(); }
303
+ /** True while the sim worker is mid-publish (sequence word is odd). */
304
+ isWriteInProgress(): boolean { return this.reader.isWriteInProgress(); }
305
+ /**
306
+ * Run `fn` against a consistent snapshot of the sim state — retries
307
+ * while the sim worker is mid-publish or publishes during the read
308
+ * (seqlock). Wrap per-frame read loops (entity iteration, player slot
309
+ * reads) in this to avoid torn reads across a sim commit.
310
+ */
311
+ readConsistent<T>(fn: () => T, maxRetries?: number): T {
312
+ return this.reader.readConsistent(fn, maxRetries);
313
+ }
261
314
  getEntityCount(): number { return this.reader.header.u32[SimChannel.offsets.header.entityCount]; }
262
315
  getPlayerCount(): number { return this.reader.header.u32[SimChannel.offsets.header.playerCount]; }
263
316
  getTimeOfDay(): number { return this.reader.header.f32[SimChannel.offsets.header.timeOfDay]; }
@@ -328,6 +381,46 @@ export class SimBufferReader {
328
381
  yield { idx: i, f32: sv.f32, u32: sv.u32 };
329
382
  }
330
383
  }
384
+
385
+ // ── Presentation interpolation ──
386
+ // prev = state(t-1) transform, cur = state(t); the renderer lerps by
387
+ // getInterpolationAlpha(). Wall-clock epoch ms (timeOrigin + now) —
388
+ // comparable across the worker/renderer thread boundary.
389
+
390
+ getPublishTimeMs(): number {
391
+ const o = SimChannel.offsets.header;
392
+ return this.reader.header.f64[o.publishTimeMs >> 1];
393
+ }
394
+
395
+ getPublishDeltaMs(): number {
396
+ const o = SimChannel.offsets.header;
397
+ return this.reader.header.f32[o.publishDeltaMs];
398
+ }
399
+
400
+ /**
401
+ * Interpolation alpha in [0,1] for the current frame. 0 = exactly at
402
+ * the last publish, 1 = a full publish interval past it (use cur).
403
+ * Clamped — no extrapolation.
404
+ */
405
+ getInterpolationAlpha(nowMs?: number): number {
406
+ const delta = this.getPublishDeltaMs();
407
+ if (delta <= 0) return 1;
408
+ const now = nowMs ?? (performance.timeOrigin ?? 0) + performance.now();
409
+ const a = (now - this.getPublishTimeMs()) / delta;
410
+ return a <= 0 ? 0 : a >= 1 ? 1 : a;
411
+ }
412
+
413
+ /** Previous-publish transform for an entity slot (prev pos+rot, PREV.*). */
414
+ getEntityPrevSlot(idx: number): { f32: Float32Array; u32: Uint32Array } {
415
+ const sv = this.entityPrevSlots.slot(idx);
416
+ return { f32: sv.f32, u32: sv.u32 };
417
+ }
418
+
419
+ /** Previous-publish transform for a player slot (prev pos+rot, PREV.*). */
420
+ getPlayerPrevSlot(idx: number): { f32: Float32Array; u32: Uint32Array } {
421
+ const sv = this.playerPrevSlots.slot(idx);
422
+ return { f32: sv.f32, u32: sv.u32 };
423
+ }
331
424
  }
332
425
 
333
426
  // --- Sim Buffer Writer (sim worker side) ---
@@ -336,52 +429,67 @@ export class SimBufferWriter {
336
429
  private writer: ReturnType<typeof SimChannel.writer>;
337
430
  private entitySlots: ReturnType<typeof SimChannel.writer>["sections"]["entities"];
338
431
  private playerSlots: ReturnType<typeof SimChannel.writer>["sections"]["players"];
432
+ private entityPrevSlots: ReturnType<typeof SimChannel.writer>["sections"]["entitiesPrev"];
433
+ private playerPrevSlots: ReturnType<typeof SimChannel.writer>["sections"]["playersPrev"];
339
434
  // Dirty flags: 1 = entity/player slot has changed since last writeToBuffer.
340
435
  // writeToBuffer skips slots where the flag is 0. Cleared after each writeToBuffer.
341
436
  private dirtyEntities: Uint8Array;
342
437
  private dirtyPlayers: Uint8Array;
438
+ // Snapshot flags: 1 = the slot's prev-transform was already captured this
439
+ // publish (on first write access). Cleared at each incrementTick commit.
440
+ private snapEntities: Uint8Array;
441
+ private snapPlayers: Uint8Array;
343
442
 
344
443
  constructor(sab: SharedArrayBuffer) {
345
444
  this.writer = SimChannel.writer(sab);
346
445
  this.entitySlots = this.writer.sections.entities;
347
446
  this.playerSlots = this.writer.sections.players;
447
+ this.entityPrevSlots = this.writer.sections.entitiesPrev;
448
+ this.playerPrevSlots = this.writer.sections.playersPrev;
348
449
  this.dirtyEntities = new Uint8Array(MAX_ENTITIES);
349
450
  this.dirtyPlayers = new Uint8Array(MAX_PLAYERS);
451
+ this.snapEntities = new Uint8Array(MAX_ENTITIES);
452
+ this.snapPlayers = new Uint8Array(MAX_PLAYERS);
350
453
  }
351
454
 
352
455
  init() {
353
456
  const w = this.writer;
354
457
  const h = SimChannel.offsets.header;
458
+ w.beginWrite();
355
459
  w.header.u32[h.maxEntities] = MAX_ENTITIES;
356
460
  w.header.u32[h.maxPlayers] = MAX_PLAYERS;
357
461
  w.header.u32[h.entityCount] = 0;
358
462
  w.header.u32[h.playerCount] = 0;
359
463
  w.header.u32[h.activePlayers] = 0;
464
+ w.endWrite();
360
465
  }
361
466
 
362
- setEntityCount(n: number) { this.writer.header.u32[SimChannel.offsets.header.entityCount] = n; }
363
- setPlayerCount(n: number) { this.writer.header.u32[SimChannel.offsets.header.playerCount] = n; }
364
- setTimeOfDay(t: number) { this.writer.header.f32[SimChannel.offsets.header.timeOfDay] = t; }
467
+ setEntityCount(n: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.entityCount] = n; }
468
+ setPlayerCount(n: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.playerCount] = n; }
469
+ setTimeOfDay(t: number) { this.writer.beginWrite(); this.writer.header.f32[SimChannel.offsets.header.timeOfDay] = t; }
365
470
  setWeather(type: number, intensity: number) {
471
+ this.writer.beginWrite();
366
472
  this.writer.header.u32[SimChannel.offsets.header.weatherType] = type;
367
473
  this.writer.header.f32[SimChannel.offsets.header.weatherIntensity] = intensity;
368
474
  }
369
475
  setWind(speed: number, dirX: number, dirZ: number) {
476
+ this.writer.beginWrite();
370
477
  this.writer.header.f32[SimChannel.offsets.header.windSpeed] = speed;
371
478
  this.writer.header.f32[SimChannel.offsets.header.windDirX] = dirX;
372
479
  this.writer.header.f32[SimChannel.offsets.header.windDirZ] = dirZ;
373
480
  }
374
- setVisibility(v: number) { this.writer.header.f32[SimChannel.offsets.header.visibility] = v; }
375
- setAmbientTemp(t: number) { this.writer.header.f32[SimChannel.offsets.header.ambientTemp] = t; }
376
- setActivePlayers(mask: number) { this.writer.header.u32[SimChannel.offsets.header.activePlayers] = mask; }
377
- setGamemode(mode: number) { this.writer.header.u32[SimChannel.offsets.header.gamemode] = mode; }
378
- setPhysicsInitialized(v: number) { this.writer.header.u32[SimChannel.offsets.header.physicsInitialized] = v; }
379
- setPhysicsFailed(v: number) { this.writer.header.u32[SimChannel.offsets.header.physicsFailed] = v; }
380
- setPhysicsBodyCount(v: number) { this.writer.header.u32[SimChannel.offsets.header.physicsBodyCount] = v; }
381
- setPhysicsTickCount(v: number) { this.writer.header.u32[SimChannel.offsets.header.physicsTickCount] = v; }
382
- setChunkCount(v: number) { this.writer.header.u32[SimChannel.offsets.header.chunkCount] = v; }
383
- setPhysicsProfilerEnabled(v: number) { this.writer.header.u32[SimChannel.offsets.header.physicsProfilerEnabled] = v; }
481
+ setVisibility(v: number) { this.writer.beginWrite(); this.writer.header.f32[SimChannel.offsets.header.visibility] = v; }
482
+ setAmbientTemp(t: number) { this.writer.beginWrite(); this.writer.header.f32[SimChannel.offsets.header.ambientTemp] = t; }
483
+ setActivePlayers(mask: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.activePlayers] = mask; }
484
+ setGamemode(mode: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.gamemode] = mode; }
485
+ setPhysicsInitialized(v: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsInitialized] = v; }
486
+ setPhysicsFailed(v: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsFailed] = v; }
487
+ setPhysicsBodyCount(v: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsBodyCount] = v; }
488
+ setPhysicsTickCount(v: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsTickCount] = v; }
489
+ setChunkCount(v: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.chunkCount] = v; }
490
+ setPhysicsProfilerEnabled(v: number) { this.writer.beginWrite(); this.writer.header.u32[SimChannel.offsets.header.physicsProfilerEnabled] = v; }
384
491
  setPhysicsTiming(data: PhysicsTimingData) {
492
+ this.writer.beginWrite();
385
493
  const h = this.writer.header.f32;
386
494
  const o = SimChannel.offsets.header;
387
495
  h[o.physTimingStep] = data.step;
@@ -402,10 +510,58 @@ export class SimBufferWriter {
402
510
  h[o.physTimingUserChanges] = data.userChanges;
403
511
  }
404
512
 
513
+ /**
514
+ * Copy the slot's current transform (pos+rot, first 8 f32s) into its
515
+ * prev slot — once per publish, on first write access, so prev always
516
+ * holds the last committed transform for interpolation.
517
+ */
518
+ private snapshotEntityPrev(idx: number): void {
519
+ if (this.snapEntities[idx]) return;
520
+ this.snapEntities[idx] = 1;
521
+ this.entityPrevSlots.slot(idx).f32.set(this.entitySlots.slot(idx).f32.subarray(0, 8));
522
+ }
523
+
524
+ private snapshotPlayerPrev(idx: number): void {
525
+ if (this.snapPlayers[idx]) return;
526
+ this.snapPlayers[idx] = 1;
527
+ this.playerPrevSlots.slot(idx).f32.set(this.playerSlots.slot(idx).f32.subarray(0, 8));
528
+ }
529
+
405
530
  incrementTick() {
406
531
  const h = SimChannel.offsets.header;
407
- Atomics.add(this.writer.header.u32, h.tick, 1);
408
- this.writer.bumpSequence();
532
+ const w = this.writer;
533
+ // Prev-sync: slots not written this publish keep their old cur, so
534
+ // prev must equal cur (identity lerp). Slots that were written were
535
+ // already snapshotted on first access. Runs inside the open write
536
+ // window, before the commit — readers never see a mixed prev/cur.
537
+ const entCount = Math.min(w.header.u32[h.entityCount], MAX_ENTITIES);
538
+ for (let i = 0; i < entCount; i++) {
539
+ if (!this.snapEntities[i]) {
540
+ this.entityPrevSlots.slot(i).f32.set(this.entitySlots.slot(i).f32.subarray(0, 8));
541
+ }
542
+ }
543
+ this.snapEntities.fill(0);
544
+ const plrCount = Math.min(w.header.u32[h.playerCount], MAX_PLAYERS);
545
+ for (let i = 0; i < plrCount; i++) {
546
+ if (!this.snapPlayers[i]) {
547
+ this.playerPrevSlots.slot(i).f32.set(this.playerSlots.slot(i).f32.subarray(0, 8));
548
+ }
549
+ }
550
+ this.snapPlayers.fill(0);
551
+
552
+ // Wall-clock epoch ms — performance.timeOrigin is defined per thread
553
+ // and makes the stamp comparable on the renderer side.
554
+ const now = (performance.timeOrigin ?? 0) + performance.now();
555
+ const tIdx = h.publishTimeMs >> 1; // u32 index 40 → f64 index 20
556
+ const last = w.header.f64[tIdx];
557
+ w.header.f64[tIdx] = now;
558
+ w.header.f32[h.publishDeltaMs] = last > 0 ? now - last : 0;
559
+
560
+ Atomics.add(w.header.u32, h.tick, 1);
561
+ // Closes the beginWrite() window opened by the first mutation of this
562
+ // publish (odd→even) — or steps +2 when nothing wrote this tick, so
563
+ // the sequence still advances once per publish for change detection.
564
+ w.bumpSequence();
409
565
  }
410
566
 
411
567
  markEntityDirty(slot: number): void {
@@ -436,18 +592,26 @@ export class SimBufferWriter {
436
592
  }
437
593
 
438
594
  getEntityF32(idx: number): Float32Array {
595
+ this.writer.beginWrite();
596
+ this.snapshotEntityPrev(idx);
439
597
  return this.entitySlots.slot(idx).f32;
440
598
  }
441
599
 
442
600
  getEntityU32(idx: number): Uint32Array {
601
+ this.writer.beginWrite();
602
+ this.snapshotEntityPrev(idx);
443
603
  return this.entitySlots.slot(idx).u32;
444
604
  }
445
605
 
446
606
  getPlayerF32(idx: number): Float32Array {
607
+ this.writer.beginWrite();
608
+ this.snapshotPlayerPrev(idx);
447
609
  return this.playerSlots.slot(idx).f32;
448
610
  }
449
611
 
450
612
  getPlayerU32(idx: number): Uint32Array {
613
+ this.writer.beginWrite();
614
+ this.snapshotPlayerPrev(idx);
451
615
  return this.playerSlots.slot(idx).u32;
452
616
  }
453
617
  }
@@ -117,6 +117,32 @@ export class SlotReader {
117
117
  return this.getSequence() !== lastSeen;
118
118
  }
119
119
 
120
+ /**
121
+ * True while the writer holds an open write window (sequence is odd).
122
+ * Readers can use this to cheaply defer sync work past a publish.
123
+ */
124
+ isWriteInProgress(): boolean {
125
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
126
+ }
127
+
128
+ /**
129
+ * Run `fn` against a consistent snapshot of the channel. The sequence
130
+ * word is a seqlock: odd while the writer is publishing, even when the
131
+ * data is stable. The read is retried while the word is odd or changes
132
+ * mid-read; after maxRetries it returns whatever the last attempt read
133
+ * rather than starving the reader (writers publish every tick).
134
+ */
135
+ readConsistent<T>(fn: () => T, maxRetries = 64): T {
136
+ for (let i = 0; i < maxRetries; i++) {
137
+ const s1 = Atomics.load(this.seqArr, 0);
138
+ if ((s1 & 1) === 0) {
139
+ const out = fn();
140
+ if (Atomics.load(this.seqArr, 0) === s1) return out;
141
+ }
142
+ }
143
+ return fn();
144
+ }
145
+
120
146
  snapshot(): Record<string, unknown> {
121
147
  const data: Record<string, unknown> = {};
122
148
  data["sequence"] = this.getSequence();
@@ -154,6 +180,8 @@ export class SlotWriter {
154
180
  sections: Record<string, SlotAccessor>;
155
181
  private seqArr: Int32Array;
156
182
  private layout: ChannelLayout;
183
+ /** True while a beginWrite() window is open (sequence is odd). */
184
+ private writeOpen = false;
157
185
 
158
186
  constructor(sab: SharedArrayBuffer, layout: ChannelLayout) {
159
187
  this.layout = layout;
@@ -180,10 +208,36 @@ export class SlotWriter {
180
208
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
181
209
  }
182
210
 
183
- bumpSequence(): void {
211
+ /**
212
+ * Open a write window — marks the sequence word odd so readers know a
213
+ * publish is in progress. Idempotent: nested/extra calls are free, so
214
+ * mutating accessors can call it unconditionally on every write path.
215
+ */
216
+ beginWrite(): void {
217
+ if (this.writeOpen) return;
218
+ this.writeOpen = true;
184
219
  Atomics.add(this.seqArr, 0, 1);
185
220
  }
186
221
 
222
+ /** Close the write window opened by beginWrite() — sequence back to even. */
223
+ endWrite(): void {
224
+ if (!this.writeOpen) return;
225
+ this.writeOpen = false;
226
+ Atomics.add(this.seqArr, 0, 1);
227
+ }
228
+
229
+ /**
230
+ * Publish a commit. Legacy callers use this without a beginWrite()
231
+ * window: it then steps +2 (even→even) so the sequence always lands on
232
+ * a stable value while still advancing once per publish. Inside a
233
+ * beginWrite() window it closes the window (+1, odd→even).
234
+ */
235
+ bumpSequence(): void {
236
+ const step = this.writeOpen ? 1 : 2;
237
+ this.writeOpen = false;
238
+ Atomics.add(this.seqArr, 0, step);
239
+ }
240
+
187
241
  getSequence(): number {
188
242
  return Atomics.load(this.seqArr, 0);
189
243
  }
@@ -126,6 +126,13 @@ export interface ChannelReader {
126
126
  validationError(): string | null;
127
127
  getSequence(): number;
128
128
  hasChanged(lastSeen: number): boolean;
129
+ /** True while the writer holds an open write window (sequence odd). */
130
+ isWriteInProgress(): boolean;
131
+ /**
132
+ * Run `fn` against a consistent snapshot — retries while the writer is
133
+ * mid-publish or publishes during the read (seqlock). Bounded retries.
134
+ */
135
+ readConsistent<T>(fn: () => T, maxRetries?: number): T;
129
136
  header: HeaderViews;
130
137
  sections?: Record<string, SlotAccessor>;
131
138
  layers?: Record<string, Float32Array | Int32Array | Uint32Array | Float64Array>;
@@ -135,6 +142,10 @@ export interface ChannelReader {
135
142
 
136
143
  export interface ChannelWriter {
137
144
  init(): void;
145
+ /** Open a write window (sequence → odd). Idempotent. */
146
+ beginWrite(): void;
147
+ /** Close the current write window (sequence → even). */
148
+ endWrite(): void;
138
149
  bumpSequence(): void;
139
150
  getSequence(): number;
140
151
  header: HeaderViews;
@@ -168,6 +168,26 @@ export class GPUResourceTracker {
168
168
  * inform. Enable via constructor for leak-hunting sessions.
169
169
  */
170
170
  private captureCallsites: boolean;
171
+ /**
172
+ * Resources dropped without destroy() are released through the native
173
+ * FinalizationRegistry (platform-native/gpu/registry.ts) — the wrapped
174
+ * destroy() never runs, so without this second registry their tracked
175
+ * entries (and reported VRAM) would grow forever. The held value is the
176
+ * tracked id; the resource object itself is the unregister token.
177
+ */
178
+ private gcRegistry = new FinalizationRegistry<number>((id) => {
179
+ const r = this.resources.get(id);
180
+ if (!r) return;
181
+ this.resources.delete(id);
182
+ if (r.type === "texture") {
183
+ this.textureBytes -= r.size;
184
+ this.textureCount--;
185
+ } else {
186
+ this.bufferBytes -= r.size;
187
+ this.bufferCount--;
188
+ }
189
+ this.dirty = true;
190
+ });
171
191
 
172
192
  constructor(opts: { captureCallsites?: boolean } = {}) {
173
193
  this.captureCallsites = opts.captureCallsites ?? false;
@@ -201,6 +221,7 @@ export class GPUResourceTracker {
201
221
  tracker.textureBytes += bytes;
202
222
  tracker.textureCount++;
203
223
  tracker.dirty = true;
224
+ tracker.gcRegistry.register(tex, id, tex);
204
225
 
205
226
  // Wrap destroy to remove from tracking
206
227
  const origDestroy = tex.destroy.bind(tex);
@@ -212,6 +233,7 @@ export class GPUResourceTracker {
212
233
  tracker.textureCount--;
213
234
  tracker.dirty = true;
214
235
  }
236
+ tracker.gcRegistry.unregister(tex);
215
237
  origDestroy();
216
238
  };
217
239
 
@@ -234,6 +256,7 @@ export class GPUResourceTracker {
234
256
  tracker.bufferBytes += bytes;
235
257
  tracker.bufferCount++;
236
258
  tracker.dirty = true;
259
+ tracker.gcRegistry.register(buf, id, buf);
237
260
 
238
261
  // Wrap destroy to remove from tracking
239
262
  const origDestroy = buf.destroy.bind(buf);
@@ -245,6 +268,7 @@ export class GPUResourceTracker {
245
268
  tracker.bufferCount--;
246
269
  tracker.dirty = true;
247
270
  }
271
+ tracker.gcRegistry.unregister(buf);
248
272
  origDestroy();
249
273
  };
250
274
 
@@ -15,46 +15,13 @@
15
15
  let cachedSystemScale: number | null = null;
16
16
 
17
17
  /**
18
- * Detect the system/browser default font size and return a scale factor
19
- * relative to 16px (the browser default). Always returns >= 1.0.
20
- *
21
- * Example: if the user set their browser default font to 20px, this returns
22
- * 1.25. If the browser default is 16px (normal), this returns 1.0.
23
- *
24
- * Must be called on the main thread (requires `document`).
18
+ * Detect the system default font size and return a scale factor relative to
19
+ * 16px. On the native host there is no CSS engine and no system font-size
20
+ * preference to measure — always 1.
25
21
  */
26
22
  export function detectSystemFontScale(): number {
27
- if (cachedSystemScale !== null) return cachedSystemScale;
28
- // On the native host the document is a canvas-compat facade — there is no
29
- // CSS engine and no getComputedStyle, so there is no "system font size"
30
- // to measure. Always 1.
31
- if (typeof document === "undefined" || !document.body || typeof getComputedStyle !== "function") {
32
- cachedSystemScale = 1;
33
- return 1;
34
- }
35
-
36
- const el = document.createElement("div");
37
- el.style.fontSize = "1rem";
38
- el.style.position = "absolute";
39
- el.style.visibility = "hidden";
40
- el.style.lineHeight = "0";
41
- el.style.margin = "0";
42
- el.style.padding = "0";
43
- document.body.appendChild(el);
44
-
45
- let scale = 1;
46
- try {
47
- const size = parseFloat(getComputedStyle(el).fontSize);
48
- // 16px is the browser default. Only scale up (never down).
49
- scale = size > 0 ? Math.max(1, size / 16) : 1;
50
- } catch {
51
- scale = 1;
52
- } finally {
53
- document.body.removeChild(el);
54
- }
55
-
56
- cachedSystemScale = scale;
57
- return scale;
23
+ cachedSystemScale = 1;
24
+ return 1;
58
25
  }
59
26
 
60
27
  /**
@@ -14,4 +14,5 @@ export {
14
14
  export type { FeatureLogData } from "./feature-log";
15
15
  export { addLogSink, ConsoleLogger, createLogger, getLogTheme, getRecentLogs, setThreadTag } from "./logger";
16
16
  export type { Logger, LogSink, LogSinkEntry } from "./logger";
17
+ export { untrackedTimeout } from "./session-timer";
17
18
 
@@ -0,0 +1,23 @@
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
+ /**
14
+ * setTimeout that is exempt from dev-session tracking. The returned id pairs
15
+ * with plain clearTimeout (the wrapped clear delegates to the platform clear).
16
+ */
17
+ export function untrackedTimeout(fn: () => void, ms: number): ReturnType<typeof setTimeout> {
18
+ const tracker = (globalThis as any).__ddSession;
19
+ if (typeof tracker?.untrackedTimeout === "function") {
20
+ return tracker.untrackedTimeout(fn, ms);
21
+ }
22
+ return setTimeout(fn, ms);
23
+ }
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { usingRealSAB } from "../sab/sab-polyfill";
26
26
  import { createLogger } from "../util/logger";
27
+ import { untrackedTimeout } from "../util/session-timer";
27
28
  import type { BufferSyncConfig, BufferSyncHost } from "./buffer-sync";
28
29
  import { wrap, type WorkerApi, type WorkerProxy } from "./rpc";
29
30
 
@@ -171,7 +172,7 @@ export abstract class BaseWorkerHost<TApi extends WorkerApi> {
171
172
  await Promise.race([
172
173
  this.proxy.proxy.shutdown(),
173
174
  new Promise((_, rej) => {
174
- timer = setTimeout(() => rej(new Error("shutdown RPC timed out")), 2_000);
175
+ timer = untrackedTimeout(() => rej(new Error("shutdown RPC timed out")), 2_000);
175
176
  }),
176
177
  ]);
177
178
  } finally {
@@ -113,10 +113,10 @@ export interface BufferSyncMessage {
113
113
  regions: Record<string, RegionCopy[]>;
114
114
  /**
115
115
  * Multi-message transfer metadata. Large transfers are split into one
116
- * region-copy per message with a 16ms gap so the main thread can render
117
- * between chunks; the receiver buffers the parts and applies them
118
- * ATOMICALLY once all arrive — otherwise the buffer visibly tears for
119
- * `total × 16ms` while partially-updated regions are rendered.
116
+ * region-copy per message (each its own macrotask, so the main thread
117
+ * can render between deliveries); the receiver buffers the parts and
118
+ * applies them ATOMICALLY once all arrive — otherwise the buffer would
119
+ * visibly tear while partially-updated regions are rendered.
120
120
  */
121
121
  batch?: { id: number; index: number; total: number };
122
122
  }
@@ -457,10 +457,11 @@ export class BufferSyncWorker {
457
457
  len = Math.min(r.length, count * r.lengthMultiplier);
458
458
  }
459
459
  // Chunked transfer: if the region is large, split into chunks
460
- // and send with setTimeout between them so the main thread
461
- // can process rAF/render between chunks. This prevents 100-900ms
462
- // main-thread blocks where SAB is unavailable
463
- // and postMessage structured clone is ~10MB/s).
460
+ // sent as separate postMessages so the main thread interleaves
461
+ // receive+apply tasks with rAF/render instead of blocking on one
462
+ // giant structured clone. This prevents 100-900ms main-thread
463
+ // blocks where SAB is unavailable (and postMessage structured
464
+ // clone is ~10MB/s).
464
465
  const CHUNK_SIZE = 64 * 1024; // 64KB per chunk — small enough to avoid >50ms blocks
465
466
  if (len > CHUNK_SIZE) {
466
467
  const numChunks = Math.ceil(len / CHUNK_SIZE);
@@ -497,10 +498,16 @@ export class BufferSyncWorker {
497
498
  // main thread to render between them.
498
499
  const allCopies = Object.values(regions).flat();
499
500
  if (allCopies.length > 1 && totalBytes > 256 * 1024) {
500
- // Large transfer: send chunks with setTimeout between them so the
501
- // main thread can render between chunks. Each message carries batch
502
- // metadata — the receiver buffers parts and applies them atomically
503
- // once all arrive, so the buffer never renders partially-updated.
501
+ // Large transfer: send chunks as separate postMessages so the main
502
+ // thread interleaves receive+apply tasks with rAF/render. Each
503
+ // message carries batch metadata — the receiver buffers parts and
504
+ // applies them atomically once all arrive, so the buffer never
505
+ // renders partially-updated. A setTimeout(0) between posts yields
506
+ // the worker's macrotask queue once per chunk — letting urgent
507
+ // worker-side messages (RPC responses, other sync traffic) insert
508
+ // between chunks rather than queueing behind the whole batch — but
509
+ // adds no fixed delay: measured delivery was ~16ms/chunk with a
510
+ // 16ms gap (~1s for a 4MB region) vs sub-ms/chunk now.
504
511
  // Precompute chunk→buffer ownership once (was O(chunks×regions) before).
505
512
  const chunkOwner: string[] = [];
506
513
  for (const [name, regionList] of Object.entries(regions)) {
@@ -520,7 +527,7 @@ export class BufferSyncWorker {
520
527
  [copy.data],
521
528
  );
522
529
  if (chunkIdx < total) {
523
- setTimeout(sendNextChunk, 16); // 16ms delay lets main thread render between chunks
530
+ setTimeout(sendNextChunk, 0); // yield the macrotask queue between chunk posts
524
531
  }
525
532
  };
526
533
  sendNextChunk();