@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
@@ -5,10 +5,10 @@
5
5
  // 1. Creating an IDevToolsDataRenderer adapter
6
6
  // 2. Instantiating DevToolsDataBridge (or BaseSceneInspector)
7
7
  // 3. Calling init()
8
- // 4. Wiring sim stats provider, panel extensions, overlay toggles, etc.
8
+ // 4. Wiring sim stats provider, debug overlay provider, etc.
9
9
  //
10
- // Also merges globally-registered panels/feeds/commands from the `devtools`
11
- // singleton (registered by plugins via ctx.devtools.registerPanel() etc.)
10
+ // Also merges globally-registered feeds/commands from the `devtools`
11
+ // singleton (registered by plugins via ctx.devtools.registerDataFeed() etc.)
12
12
  // and from worker manifests (via syncWorkerManifests).
13
13
  //
14
14
  // Returns the bridge instance for games that need post-init calls
@@ -22,7 +22,7 @@ import { BaseSceneInspector } from "./scene-inspector.js";
22
22
  import { syncWorkerManifests } from "./worker-sync.js";
23
23
  /**
24
24
  * Initialize DevTools with one call. Creates the bridge, wires all providers,
25
- merges globally-registered panels/feeds/commands, syncs worker manifests,
25
+ * merges globally-registered feeds/commands, syncs worker manifests,
26
26
  * and exposes the API on window.__sceneInspector.
27
27
  *
28
28
  * Returns the bridge instance.
@@ -34,19 +34,7 @@ export async function initDevTools(renderer, options = {}) {
34
34
  const useSceneInspector = options.sceneInspector
35
35
  || !!options.bridgeClass
36
36
  || isSceneRenderer(renderer);
37
- // 3. Register game-declared panels/toggles into the global registry
38
- // (these merge with plugin-registered ones)
39
- if (options.panels) {
40
- options.panels.forEach((panel) => {
41
- devtools.registerPanel(panel);
42
- });
43
- }
44
- if (options.overlayToggles) {
45
- options.overlayToggles.forEach((toggle) => {
46
- devtools.registerOverlayToggle(toggle);
47
- });
48
- }
49
- // 4. Sync worker manifests (fetches panels/feeds/commands from sim workers)
37
+ // 4. Sync worker manifests (fetches feeds/commands from sim workers)
50
38
  if (options.workerHosts && options.workerHosts.length > 0) {
51
39
  await syncWorkerManifests(options.workerHosts);
52
40
  }
@@ -85,10 +73,8 @@ export async function initDevTools(renderer, options = {}) {
85
73
  if (options.performanceMetricsProvider) {
86
74
  wirePerformanceMetricsProvider(bridge, options.performanceMetricsProvider);
87
75
  }
88
- // 7. Merge globally-registered panels/toggles/feeds/commands into the bridge
89
- // The bridge's getPanelExtensions()/getOverlayToggles() already returns
90
- // game-declared ones; we augment the __sceneInspector API with
91
- // plugin-registered data feeds and commands.
76
+ // 7. Merge globally-registered feeds/commands/SAB stats into the bridge —
77
+ // augments the __sceneInspector API with plugin-registered methods.
92
78
  mergeGlobalRegistrations(bridge);
93
79
  // 8. Initialize the profiling system (if enabled)
94
80
  if (options.profiling) {
@@ -134,35 +120,15 @@ function wirePerformanceMetricsProvider(bridge, provider) {
134
120
  bridge.getPerformanceMetricsProvider = () => provider;
135
121
  }
136
122
  /**
137
- * Merge globally-registered panels, data feeds, and commands from the `devtools`
123
+ * Merge globally-registered data feeds and commands from the `devtools`
138
124
  * singleton into the bridge's __sceneInspector API.
139
125
  *
140
- * - Panels: merged into getPanelExtensions() (game panels + plugin panels)
141
126
  * - Data feeds: added as methods on __sceneInspector (called synchronously)
142
127
  * - Commands: added as methods on __sceneInspector (forwarded to worker or called directly)
143
128
  * - SAB stats: added as methods on __sceneInspector (read from SAB)
144
129
  */
145
130
  function mergeGlobalRegistrations(bridge) {
146
131
  const manifest = devtools.getManifest();
147
- // Augment getPanelExtensions to merge global registry panels
148
- const originalGetPanels = bridge.getPanelExtensions?.bind(bridge);
149
- bridge.getPanelExtensions = () => {
150
- const gamePanels = originalGetPanels?.() ?? [];
151
- // Merge: game panels take precedence on id collisions
152
- const merged = new Map();
153
- manifest.panels.forEach((p) => { merged.set(p.id, p); ; });
154
- gamePanels.forEach((p) => { merged.set(p.id, p); ; });
155
- return Array.from(merged.values()).sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
156
- };
157
- // Augment getOverlayToggles to merge global registry toggles
158
- const originalGetToggles = bridge.getOverlayToggles?.bind(bridge);
159
- bridge.getOverlayToggles = () => {
160
- const gameToggles = originalGetToggles?.() ?? [];
161
- const merged = new Map();
162
- manifest.toggles.forEach((t) => { merged.set(t.id, t); ; });
163
- gameToggles.forEach((t) => { merged.set(t.id, t); ; });
164
- return Array.from(merged.values());
165
- };
166
132
  // Add data feed methods to the __sceneInspector API
167
133
  // These are called synchronously by the panel via callInspector()
168
134
  const api = window.__sceneInspector;
@@ -27,7 +27,6 @@ export class BaseSceneInspector extends DevToolsDataBridge {
27
27
  getDebugOverlayProvider() {
28
28
  return null;
29
29
  }
30
- // getPanelExtensions() and getOverlayToggles() inherited from DevToolsDataBridge.
31
30
  // --- Init ---
32
31
  init(renderer) {
33
32
  super.init(renderer);
@@ -510,9 +509,7 @@ export class BaseSceneInspector extends DevToolsDataBridge {
510
509
  const cosY = Math.cos(angleY), sinY = Math.sin(angleY);
511
510
  const cosX = Math.cos(angleX), sinX = Math.sin(angleX);
512
511
  const THUMB_SIZE = 192;
513
- const canvas = document.createElement("canvas");
514
- canvas.width = THUMB_SIZE;
515
- canvas.height = THUMB_SIZE;
512
+ const canvas = new OffscreenCanvas(THUMB_SIZE, THUMB_SIZE);
516
513
  const ctx = canvas.getContext("2d");
517
514
  if (!ctx)
518
515
  return null;
@@ -1,14 +1,13 @@
1
1
  // ============================================================================
2
2
  // Renderer-side worker manifest sync — fetches the devtools manifest from
3
- // one or more sim workers via worker RPC, then wires the worker-registered panels,
4
- // data feeds, and commands into the renderer-side __sceneInspector API.
3
+ // one or more sim workers via worker RPC, then wires the worker-registered
4
+ // data feeds and commands into the renderer-side __sceneInspector API.
5
5
  //
6
6
  // Data feeds from workers are read from the devtools SAB (zero-copy).
7
7
  // Commands are forwarded via worker RPC.
8
- // Panel declarations are merged into the global registry.
9
8
  // ============================================================================
10
9
  import { createLogger } from "@downdraft/engine/util/logger";
11
- import { _devtoolsImpl, devtools } from "./api.js";
10
+ import { _devtoolsImpl } from "./api.js";
12
11
  const log = createLogger("info");
13
12
  /**
14
13
  * Sync manifests from one or more workers into the renderer-side devtools
@@ -54,13 +53,6 @@ async function syncOneWorker(entry) {
54
53
  // Worker may not have a devtools SAB (older workers)
55
54
  }
56
55
  }
57
- // Register panels (namespaced by prefix to avoid collisions)
58
- manifest.panels.forEach((panel) => {
59
- const namespaced = prefix
60
- ? { ...panel, id: `${prefix}:${panel.id}` }
61
- : panel;
62
- devtools.registerPanel(namespaced);
63
- });
64
56
  // Register data feed readers — these read from the SAB synchronously
65
57
  // We register them as main-realm data feeds that read from the worker SAB
66
58
  if (sab) {
@@ -147,9 +147,32 @@ export function createDocCore(emit, gpuHooks) {
147
147
  s.statsLast = now;
148
148
  }
149
149
  }
150
+ // Smooth-wheel ease: fraction of the remainder applied per elapsed ms.
151
+ // τ≈45ms → ~30% per 16ms tick, ~95% drained in ~135ms — snappy but
152
+ // animated. A 1/MAX_TICKS floor bounds any backlog at ~320ms of drain —
153
+ // a fast wheel can't make scroll trail input indefinitely. Dispatching
154
+ // real wheel events keeps Blitz's hit-testing, scroll chaining, scrollbar
155
+ // reveal, and scroll DOM events intact.
156
+ const WHEEL_TAU_MS = 45;
157
+ const WHEEL_MAX_TICKS = 20;
158
+ function stepWheel(s, now) {
159
+ const dt = Math.min(50, Math.max(1, now - s.wheelT));
160
+ s.wheelT = now;
161
+ const f = Math.max(1 - Math.exp(-dt / WHEEL_TAU_MS), 1 / WHEEL_MAX_TICKS);
162
+ // Snap the tail: a step below ~¾px/axis applies the whole residue instead
163
+ // of asymptoting through invisible sub-pixel repaints.
164
+ const dx = Math.abs(s.wheelRx) * f < 0.75 ? s.wheelRx : s.wheelRx * f;
165
+ const dy = Math.abs(s.wheelRy) * f < 0.75 ? s.wheelRy : s.wheelRy * f;
166
+ s.wheelRx -= dx;
167
+ s.wheelRy -= dy;
168
+ const k = s.scale;
169
+ s.doc.wheel(s.wheelX * k, s.wheelY * k, dx * k, dy * k, s.wheelMods);
170
+ }
150
171
  function pump() {
151
172
  const now = performance.now();
152
173
  for (const [id, s] of docs.entries()) {
174
+ if (s.smoothWheel && (s.wheelRx !== 0 || s.wheelRy !== 0))
175
+ stepWheel(s, now);
153
176
  const events = s.doc.pollEvents();
154
177
  if (events.length)
155
178
  emit({ type: "events", id, events });
@@ -233,6 +256,8 @@ export function createDocCore(emit, gpuHooks) {
233
256
  doc, scale: m.scale, cssW: m.cssW, cssH: m.cssH,
234
257
  maxFps: m.maxFps ?? 0, lastFrame: -1e9,
235
258
  sab: null, sabU32: null, sabDisabled: false, gpuTex: null,
259
+ smoothWheel: m.smoothWheel === true,
260
+ wheelRx: 0, wheelRy: 0, wheelX: 0, wheelY: 0, wheelT: 0,
236
261
  stats: freshStats(), statsLast: 0,
237
262
  };
238
263
  docs.set(m.id, s);
@@ -245,11 +270,18 @@ export function createDocCore(emit, gpuHooks) {
245
270
  s.maxFps = m.maxFps;
246
271
  break;
247
272
  }
248
- case "setHtml":
273
+ case "setHtml": {
249
274
  if (/float\s*:/.test(m.html))
250
275
  emit({ type: "error", id: m.id, message: "CSS float hangs Blitz layout — use flex instead" });
251
- docs.get(m.id)?.doc.setHtml(m.html);
276
+ const s = docs.get(m.id);
277
+ if (!s)
278
+ break;
279
+ // Drop any in-flight smooth-wheel drain — its remainder belongs to
280
+ // the document being replaced.
281
+ s.wheelRx = s.wheelRy = 0;
282
+ s.doc.setHtml(m.html);
252
283
  break;
284
+ }
253
285
  case "resize": {
254
286
  const s = docs.get(m.id);
255
287
  if (s) {
@@ -374,13 +406,30 @@ export function createDocCore(emit, gpuHooks) {
374
406
  s.doc.pointerMove(msg.x * k, msg.y * k, msg.mods);
375
407
  break;
376
408
  case "down":
409
+ // A press interrupts any in-flight wheel scroll (mirrors
410
+ // Blitz's interrupt_animation semantics for user input).
411
+ s.wheelRx = 0;
412
+ s.wheelRy = 0;
377
413
  s.doc.pointerDown(msg.x * k, msg.y * k, msg.button, msg.mods);
378
414
  break;
379
415
  case "up":
380
416
  s.doc.pointerUp(msg.x * k, msg.y * k, msg.button, msg.mods);
381
417
  break;
382
418
  case "wheel":
383
- s.doc.wheel(msg.x * k, msg.y * k, msg.deltaX * k, msg.deltaY * k, msg.mods);
419
+ if (s.smoothWheel) {
420
+ // A fresh gesture back-dates wheelT so the first pump tick
421
+ // applies a full ~16ms step immediately — no dead frame.
422
+ if (s.wheelRx === 0 && s.wheelRy === 0)
423
+ s.wheelT = performance.now() - 16;
424
+ s.wheelRx += msg.deltaX;
425
+ s.wheelRy += msg.deltaY;
426
+ s.wheelX = msg.x;
427
+ s.wheelY = msg.y;
428
+ s.wheelMods = msg.mods;
429
+ }
430
+ else {
431
+ s.doc.wheel(msg.x * k, msg.y * k, msg.deltaX * k, msg.deltaY * k, msg.mods);
432
+ }
384
433
  break;
385
434
  case "key":
386
435
  s.doc.key(msg.down, msg.key, msg.code, msg.text, msg.mods);
@@ -7,6 +7,7 @@
7
7
  // ============================================================================
8
8
  import { getHostCapabilities, getNativeHost } from "@downdraft/engine/platform/runtime";
9
9
  import { createLogger } from "@downdraft/engine/util/logger";
10
+ import { untrackedTimeout } from "@downdraft/engine/util/session-timer";
10
11
  import { PanelBlitPass } from "./composite.js";
11
12
  import { createLocalBackend, createWorkerBackend } from "./doc-backend.js";
12
13
  const log = createLogger("info");
@@ -136,7 +137,7 @@ export class HtmlUiHost {
136
137
  this.order.push(id);
137
138
  // Doc viewport scale = raster px per CSS px = scale * zoom; layout size
138
139
  // is rect/zoom CSS px so the raster still covers rect*scale texture px.
139
- this.send({ type: "create", id, cssW: spec.rect.w / zoom, cssH: spec.docH ?? spec.rect.h / zoom, scale: scale * zoom, html: spec.html, maxFps: spec.maxFps });
140
+ this.send({ type: "create", id, cssW: spec.rect.w / zoom, cssH: spec.docH ?? spec.rect.h / zoom, scale: scale * zoom, html: spec.html, maxFps: spec.maxFps, smoothWheel: spec.smoothWheel });
140
141
  // GPU-direct: create the panel texture eagerly so the worker's texBind
141
142
  // lands before its first upload — the host knows raster dims at mount.
142
143
  if (this.gpuReady)
@@ -301,6 +302,8 @@ export class HtmlUiHost {
301
302
  /** Doc-backend capabilities (populated by the `ready` handshake —
302
303
  * `incrementalDom` is false until then, and forever on old cdylibs). */
303
304
  get docCaps() { return this.caps; }
305
+ /** Which doc backend is live — "worker" (raster off-thread) or "local". */
306
+ get backendKind() { return this.backend.kind; }
304
307
  /** Compositor surface for GameRenderer's end-of-frame UI pass. */
305
308
  get compositor() {
306
309
  return {
@@ -349,7 +352,7 @@ export class HtmlUiHost {
349
352
  this.terminating = true;
350
353
  this.send({ type: "gpuDetach" });
351
354
  const backend = this.backend;
352
- setTimeout(() => {
355
+ untrackedTimeout(() => {
353
356
  if (!this.gpuDetachAcked)
354
357
  backend.dispose();
355
358
  this.destroyRetired();
@@ -775,7 +778,7 @@ export class HtmlUiHost {
775
778
  || (p.postedBindPtr !== null && BigInt(ptr) === p.postedBindPtr));
776
779
  if (borrowed) {
777
780
  const key = BigInt(ptr);
778
- const timer = setTimeout(() => {
781
+ const timer = untrackedTimeout(() => {
779
782
  if (this.retiredTex.delete(key))
780
783
  t.destroy();
781
784
  }, 1000);
@@ -155,9 +155,7 @@ export class VisionTest {
155
155
  }
156
156
  async screenshotToDataURL(texture, width, height) {
157
157
  const data = await this.captureTexture(texture, width, height);
158
- const canvas = document.createElement("canvas");
159
- canvas.width = width;
160
- canvas.height = height;
158
+ const canvas = new OffscreenCanvas(width, height);
161
159
  const ctx = canvas.getContext("2d");
162
160
  const imageData = ctx.createImageData(width, height);
163
161
  imageData.data.set(data);
@@ -31,3 +31,9 @@ web-time = "1"
31
31
  # Workaround copied from blitz examples: disable unicode URL support
32
32
  # (see https://github.com/hsivonen/idna_adapter)
33
33
  idna_adapter = "=1.0.0"
34
+
35
+ # Enable the vendored glifo's FreeType mask path (see [patch.crates-io] in the
36
+ # workspace root). Feature unification turns it on inside vello_cpu's copy.
37
+ # Target-gated: freetype-sys compiles bundled C, which wasm32 can't build.
38
+ [target.'cfg(not(target_family = "wasm"))'.dependencies]
39
+ glifo = { version = "0.2", features = ["freetype"] }
@@ -13,7 +13,9 @@ crate-type = ["cdylib"]
13
13
  blitz-html = { git = "https://github.com/DioxusLabs/blitz", rev = "efef6519fe6520caf66dc82df557580eecbf652b" }
14
14
  blitz-dom = { git = "https://github.com/DioxusLabs/blitz", rev = "efef6519fe6520caf66dc82df557580eecbf652b", default-features = false, features = ["woff"] }
15
15
  blitz-traits = { git = "https://github.com/DioxusLabs/blitz", rev = "efef6519fe6520caf66dc82df557580eecbf652b" }
16
- blitz-paint = { git = "https://github.com/DioxusLabs/blitz", rev = "efef6519fe6520caf66dc82df557580eecbf652b", features = ["custom-widget"] }
16
+ # scrollbars: painted overlay thumbs on scroll containers + thumb drag/hover
17
+ # hit-testing (pulls blitz-dom/scrollbars transitively).
18
+ blitz-paint = { git = "https://github.com/DioxusLabs/blitz", rev = "efef6519fe6520caf66dc82df557580eecbf652b", features = ["custom-widget", "scrollbars"] }
17
19
  # Enable the vendored glifo's FreeType mask path (see [patch.crates-io] in the
18
20
  # workspace root). Feature unification turns it on inside vello_cpu's copy.
19
21
  glifo = { version = "0.2", features = ["freetype"] }
@@ -169,7 +169,14 @@ export function createBlitzUiNativeModule<S>(options: BlitzUiNativeOptions<S>):
169
169
  const device = ctx.getDevice();
170
170
  const view = ensureTexture(device);
171
171
  const context = ctx.getSurface().getContext("webgpu");
172
- const targetTex = context?.getCurrentTexture();
172
+ // Join the in-flight frame, never start one: on a frame the
173
+ // game skipped (dirty-tracking), acquiring the surface here
174
+ // would present the UI blit over a blank swapchain image.
175
+ const peek = (context as { peekCurrentTexture?: () => GPUTexture | null } | null)
176
+ ?.peekCurrentTexture;
177
+ const targetTex = typeof peek === "function"
178
+ ? peek.call(context)
179
+ : context?.getCurrentTexture() ?? null;
173
180
  const target = targetTex?.createView();
174
181
  if (!view || !target || !targetTex) return;
175
182
 
@@ -77,6 +77,12 @@ export class DevtoolsBackend {
77
77
  private providers = new Map<number, PanelProvider>();
78
78
  private commandHandlers = new Map<number, PanelCommandHandler>();
79
79
  private globalCommandHandler: PanelCommandHandler | null = null;
80
+ /** Dynamically-allocated slots for provider names outside the fixed PANEL
81
+ * table — panelSlot() alone collapsed every unknown name onto slot 0
82
+ * ("console"), so a second game provider silently replaced the first. */
83
+ private dynSlots = new Map<string, number>();
84
+ private dynSlotNames = new Map<number, string>();
85
+ private nextDynSlot = 100;
80
86
  private lastProviderPush = new Map<number, number>();
81
87
  providerIntervalMs = 1500;
82
88
  /** When non-null, the cadence loop only auto-refreshes this provider
@@ -237,37 +243,57 @@ export class DevtoolsBackend {
237
243
 
238
244
  // ── Providers / commands ──
239
245
 
240
- registerProvider(panel: PanelName | number, collect: PanelProvider): void {
241
- this.providers.set(panelSlot(panel), collect);
246
+ /** Resolve a panel id to its slot. Names outside the fixed PANEL table
247
+ * get a stable dynamically-allocated slot so bespoke game providers can
248
+ * coexist instead of colliding on slot 0. */
249
+ private slotFor(panel: PanelName | number | string): number {
250
+ if (typeof panel === "number") return panel;
251
+ const fixed = PANEL[panel as PanelName];
252
+ if (fixed !== undefined) return fixed;
253
+ let s = this.dynSlots.get(panel);
254
+ if (s === undefined) {
255
+ s = this.nextDynSlot++;
256
+ this.dynSlots.set(panel, s);
257
+ this.dynSlotNames.set(s, panel);
258
+ }
259
+ return s;
260
+ }
261
+
262
+ private nameFor(slot: number): string {
263
+ return this.dynSlotNames.get(slot) ?? slotName(slot);
264
+ }
265
+
266
+ registerProvider(panel: PanelName | number | string, collect: PanelProvider): void {
267
+ this.providers.set(this.slotFor(panel), collect);
242
268
  }
243
269
 
244
- registerCommandHandler(panel: PanelName | number | "*", handler: PanelCommandHandler): void {
270
+ registerCommandHandler(panel: PanelName | number | string, handler: PanelCommandHandler): void {
245
271
  if (panel === "*") {
246
272
  this.globalCommandHandler = handler;
247
273
  } else {
248
- this.commandHandlers.set(panelSlot(panel), handler);
274
+ this.commandHandlers.set(this.slotFor(panel), handler);
249
275
  }
250
276
  }
251
277
 
252
278
  /** Registered provider slots — the dynamic tab list for frontends. */
253
279
  providerSlots(): { slot: number; name: string }[] {
254
- return [...this.providers.keys()].map((slot) => ({ slot, name: slotName(slot) }));
280
+ return [...this.providers.keys()].map((slot) => ({ slot, name: this.nameFor(slot) }));
255
281
  }
256
282
 
257
283
  /** Collect + emit one provider's snapshot immediately. */
258
- refreshPanel(panel: PanelName | number): void {
284
+ refreshPanel(panel: PanelName | number | string): void {
259
285
  const trace: number[] = [performance.now()];
260
- const slot = panelSlot(panel);
286
+ const slot = this.slotFor(panel);
261
287
  trace.push(performance.now());
262
288
  const collect = this.providers.get(slot);
263
289
  if (!collect) return;
264
290
  const push = (snap: PanelSnapshot | null | undefined) => {
265
- if (snap) this.emit("snapshot", { panel: slotName(slot), slot, snap });
291
+ if (snap) this.emit("snapshot", { panel: this.nameFor(slot), slot, snap });
266
292
  this.lastProviderPush.set(slot, performance.now());
267
293
  };
268
294
  const fail = (err: unknown) => {
269
295
  this.emit("snapshot", {
270
- panel: slotName(slot), slot,
296
+ panel: this.nameFor(slot), slot,
271
297
  snap: { status: "error", statusMsg: String(err), sections: [] },
272
298
  });
273
299
  this.lastProviderPush.set(slot, performance.now());
@@ -288,7 +314,7 @@ export class DevtoolsBackend {
288
314
  const total = trace[4]! - trace[0]!;
289
315
  if (total > 10) {
290
316
  const seg = `pre=${(trace[2]! - trace[0]!).toFixed(1)} collect=${(trace[3]! - trace[2]!).toFixed(1)} emit=${(trace[4]! - trace[3]!).toFixed(1)}`;
291
- this.diagWarn(`slow provider ${slotName(slot)}: ${total.toFixed(1)}ms [${seg}]`);
317
+ this.diagWarn(`slow provider ${this.nameFor(slot)}: ${total.toFixed(1)}ms [${seg}]`);
292
318
  }
293
319
  }
294
320
 
@@ -431,7 +457,7 @@ export class DevtoolsBackend {
431
457
  return collectThreads(this.ctx, [...this.threadEvals.keys()]);
432
458
  case "snapshot": {
433
459
  const panel = params.panel;
434
- const slot = typeof panel === "number" ? panel : panelSlot(panel as PanelName);
460
+ const slot = typeof panel === "number" ? panel : this.slotFor(String(panel));
435
461
  const collect = this.providers.get(slot);
436
462
  if (!collect) return { status: "unsupported", statusMsg: `no provider for ${String(panel)}`, sections: [] };
437
463
  try {
@@ -443,7 +469,7 @@ export class DevtoolsBackend {
443
469
  }
444
470
  case "command": {
445
471
  const panel = params.panel;
446
- const slot = typeof panel === "number" ? panel : panelSlot((panel as PanelName) ?? "console");
472
+ const slot = typeof panel === "number" ? panel : this.slotFor(String(panel ?? "console"));
447
473
  this.dispatchCommand({ panel: slot, action: String(params.action ?? ""), payload: String(params.payload ?? "") });
448
474
  return true;
449
475
  }
@@ -18,6 +18,11 @@ export interface ThreadInfo { id: string; name: string; kind: number }
18
18
  export interface TreeRow {
19
19
  id: number; parentId: number; depth: number; childCount: number;
20
20
  kind: number; label: string; detail: string;
21
+ /** Stable node identity for pick/inspect — scene-store id, stage node id,
22
+ * or ECS entity index ("ent-N") when the collector can provide one. The
23
+ * numeric `id` is ephemeral (re-assigned per collect); `nodeId` survives
24
+ * refreshes and is what `inspector.call` methods take. */
25
+ nodeId?: string;
21
26
  }
22
27
  export interface GpuInfoJson {
23
28
  entries: { key: string; value: string; isHeader: boolean }[];
@@ -100,30 +105,77 @@ export async function collectThreads(
100
105
  return threads;
101
106
  }
102
107
 
103
- /** Scene-graph tree (label + ctor + child counts). */
108
+ function sceneInspectorApi(): Record<string, any> | null {
109
+ const g = globalThis as Record<string, any>;
110
+ return (g.window?.__sceneInspector ?? g.__sceneInspector ?? null) as Record<string, any> | null;
111
+ }
112
+
113
+ /** Scene-graph tree (label + ctor + child counts). Prefers a live
114
+ * `gameScene.stage` walk; falls back to the SceneInspector's scene store
115
+ * (`__sceneInspector.getSceneTree`) so games without a DOM-like stage still
116
+ * get a populated tree — with stable nodeIds for pick/details. */
104
117
  export function collectSceneTree(ctx: CollectorContext): TreeRow[] {
105
118
  const stage = ctx.gameScene?.stage;
106
- if (!stage) return [];
107
- const nodes: TreeRow[] = [];
108
- let idCounter = 1;
109
- const collect = (node: any, parentId: number, depth: number): void => {
110
- const id = idCounter++;
111
- // Prefer `label` (some scene-graph hosts warn on `name` access); fall
112
- // back to `name` for hosts that only set that.
113
- const nodeLabel = typeof node.label === "string" ? node.label
114
- : typeof node.name === "string" ? node.name : "";
115
- const ctorName = node.constructor?.name ?? "Node";
116
- nodes.push({
117
- id, parentId, depth,
118
- childCount: node.children?.length ?? 0,
119
- kind: 0,
120
- label: nodeLabel || ctorName,
121
- detail: nodeLabel ? ctorName : "",
122
- });
123
- node.children?.forEach((child: any) => collect(child, id, depth + 1));
124
- };
125
- collect(stage, -1, 0);
126
- return nodes;
119
+ if (stage) {
120
+ const nodes: TreeRow[] = [];
121
+ let idCounter = 1;
122
+ const collect = (node: any, parentId: number, depth: number): void => {
123
+ const id = idCounter++;
124
+ // Prefer `label` (some scene-graph hosts warn on `name` access); fall
125
+ // back to `name` for hosts that only set that.
126
+ const nodeLabel = typeof node.label === "string" ? node.label
127
+ : typeof node.name === "string" ? node.name : "";
128
+ const ctorName = node.constructor?.name ?? "Node";
129
+ nodes.push({
130
+ id, parentId, depth,
131
+ childCount: node.children?.length ?? 0,
132
+ kind: 0,
133
+ label: nodeLabel || ctorName,
134
+ detail: nodeLabel ? ctorName : "",
135
+ nodeId: typeof node.id === "string" ? node.id : undefined,
136
+ });
137
+ node.children?.forEach((child: any) => collect(child, id, depth + 1));
138
+ };
139
+ collect(stage, -1, 0);
140
+ return nodes;
141
+ }
142
+
143
+ // Scene-store fallback — the same snapshot the inspector's getSceneTree
144
+ // exposes. Nodes are flat with id/parentId links; walk roots → children.
145
+ try {
146
+ const snap = sceneInspectorApi()?.getSceneTree?.();
147
+ const storeNodes = snap?.nodes;
148
+ if (!Array.isArray(storeNodes) || !storeNodes.length) return [];
149
+ const byId = new Map<string, any>(storeNodes.map((n: any) => [n.id, n]));
150
+ const roots = storeNodes.filter((n: any) => n.parentId === null || !byId.has(n.parentId));
151
+ const rows: TreeRow[] = [];
152
+ const visited = new Set<string>();
153
+ let idCounter = 1;
154
+ const walk = (n: any, parentId: number, depth: number): void => {
155
+ if (visited.has(n.id)) return; // corrupted parent/child links — don't loop
156
+ visited.add(n.id);
157
+ const id = idCounter++;
158
+ const p = n.position;
159
+ rows.push({
160
+ id, parentId, depth,
161
+ childCount: n.children?.length ?? 0,
162
+ kind: 0,
163
+ label: String(n.name ?? n.id),
164
+ detail: `${n.type ?? "node"}${Array.isArray(p) ? ` (${p.map((v: number) => (+v).toFixed(1)).join(", ")})` : ""}`,
165
+ nodeId: String(n.id),
166
+ });
167
+ for (const cid of n.children ?? []) {
168
+ const c = byId.get(cid);
169
+ if (c) walk(c, id, depth + 1);
170
+ }
171
+ };
172
+ roots.forEach((n: any) => walk(n, -1, 0));
173
+ // Orphans (every node has a parent but no root resolves) — still show them.
174
+ for (const n of storeNodes) if (!visited.has(n.id)) walk(n, -1, 0);
175
+ return rows;
176
+ } catch {
177
+ return [];
178
+ }
127
179
  }
128
180
 
129
181
  /** DOM tree: scene-graph stage or ECS entities depending on mode. */
@@ -148,6 +200,7 @@ export function collectDomTree(ctx: CollectorContext, mode: "scene" | "ecs"): Tr
148
200
  id, parentId: -1, depth: 0, childCount: 0, kind: 1,
149
201
  label: `Entity ${ent.idx}`,
150
202
  detail: `type=${entityType} pos=(${x.toFixed(1)},${y.toFixed(1)},${z.toFixed(1)})`,
203
+ nodeId: `ent-${ent.idx}`,
151
204
  });
152
205
  });
153
206
  } catch { /* sim reader hiccup */ }
@@ -525,8 +525,6 @@ async function collectWorkers(ctx: EngineProviderContext): Promise<PanelSnapshot
525
525
  kind: "kv",
526
526
  name: `Worker manifest (v${m.manifestVersion ?? "?"})`,
527
527
  rows: [
528
- kv("Panels", (m.panels ?? []).map((p: any) => p.id ?? p.name ?? "?").join(", ") || "(none)"),
529
- kv("Toggles", (m.toggles ?? []).map((t: any) => t.id ?? "?").join(", ") || "(none)"),
530
528
  kv("Data feeds", (m.dataFeeds ?? []).map((f: any) => `${f.name}@${f.writeRateHz}hz`).join(", ") || "(none)"),
531
529
  kv("Commands", (m.commands ?? []).join(", ") || "(none)"),
532
530
  ],