@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
@@ -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
@@ -54,6 +55,54 @@ export function getWorkerHost() {
54
55
  },
55
56
  };
56
57
  }
58
+ // --- Event channel stats ---
59
+ // Cheap counters the DOWNDRAFT_EVENT_STATS logger (and QA tooling) read to
60
+ // verify batching is actually engaging: emitted events vs postMessage posts
61
+ // on the worker side, messages vs delivered events on the host side.
62
+ const eventChannelStats = {
63
+ /** Worker side: emit() calls. */
64
+ emitted: 0,
65
+ /** Worker side: postMessage calls carrying events (batch or single). */
66
+ posts: 0,
67
+ /** Host side: messages received carrying events. */
68
+ messages: 0,
69
+ /** Host side: events delivered to listeners. */
70
+ events: 0,
71
+ /** Host side: largest observed __eventBatch. */
72
+ maxBatch: 0,
73
+ };
74
+ export function getEventChannelStats() {
75
+ return eventChannelStats;
76
+ }
77
+ function envFlag(name) {
78
+ try {
79
+ return (globalThis.process?.env?.[name] === "1");
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ let statsLoggerStarted = false;
86
+ function startEventStatsLogger() {
87
+ if (statsLoggerStarted || !envFlag("DOWNDRAFT_EVENT_STATS"))
88
+ return;
89
+ statsLoggerStarted = true;
90
+ let prev = { emitted: 0, posts: 0, messages: 0, events: 0 };
91
+ const t = setInterval(() => {
92
+ const s = eventChannelStats;
93
+ const d = {
94
+ emitted: s.emitted - prev.emitted,
95
+ posts: s.posts - prev.posts,
96
+ messages: s.messages - prev.messages,
97
+ events: s.events - prev.events,
98
+ };
99
+ prev = { emitted: s.emitted, posts: s.posts, messages: s.messages, events: s.events };
100
+ if (d.emitted || d.posts || d.messages || d.events) {
101
+ log.info("rpc-events", `5s Δ emitted=${d.emitted} posts=${d.posts} msgs=${d.messages} events=${d.events} maxBatch=${s.maxBatch}`);
102
+ }
103
+ }, 5000);
104
+ t.unref?.();
105
+ }
57
106
  function isRpcRequest(msg) {
58
107
  return msg && msg.__rpc === true && typeof msg.method === "string";
59
108
  }
@@ -63,6 +112,9 @@ function isRpcResponse(msg) {
63
112
  function isEventMessage(msg) {
64
113
  return msg && msg.__event === true;
65
114
  }
115
+ function isEventBatchMessage(msg) {
116
+ return msg && msg.__eventBatch === true;
117
+ }
66
118
  export function expose(api, options) {
67
119
  const h = options?.host ?? getWorkerHost();
68
120
  h.onHostMessage(async (msg) => {
@@ -84,14 +136,52 @@ export function expose(api, options) {
84
136
  }
85
137
  export function exposeEvents(host) {
86
138
  const h = host ?? getWorkerHost();
139
+ startEventStatsLogger();
87
140
  return {
88
141
  emit: (kind, data) => {
142
+ eventChannelStats.emitted++;
143
+ eventChannelStats.posts++;
89
144
  h.postToHost({ __event: true, kind, data });
90
145
  },
91
146
  };
92
147
  }
148
+ /**
149
+ * Event emitter that coalesces emit() calls into a single `__eventBatch`
150
+ * postMessage per macrotask. A sim emitting per-tick or per-entity events
151
+ * otherwise produces one structured clone + one main-thread task per event,
152
+ * and the task burst competes with rAF. Events emitted during a tick batch
153
+ * (the worker never yields to timers mid-tick) all land in one message;
154
+ * events emitted between ticks flush on the next macrotask, so latency for
155
+ * isolated events is unchanged.
156
+ */
157
+ export function exposeBatchedEvents(host) {
158
+ const h = host ?? getWorkerHost();
159
+ startEventStatsLogger();
160
+ let queue = [];
161
+ let flushScheduled = false;
162
+ const flush = () => {
163
+ flushScheduled = false;
164
+ if (queue.length === 0)
165
+ return;
166
+ eventChannelStats.posts++;
167
+ h.postToHost({ __eventBatch: true, events: queue });
168
+ queue = [];
169
+ };
170
+ return {
171
+ emit: (kind, data) => {
172
+ eventChannelStats.emitted++;
173
+ queue.push({ kind, data });
174
+ if (!flushScheduled) {
175
+ flushScheduled = true;
176
+ setTimeout(flush, 0);
177
+ }
178
+ },
179
+ flush,
180
+ };
181
+ }
93
182
  const DEFAULT_TIMEOUT_MS = 30_000;
94
183
  export function wrap(worker, options) {
184
+ startEventStatsLogger();
95
185
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
96
186
  // A worker nobody terminates must not pin the runtime after the host's
97
187
  // window closes — in-flight RPCs still hold the loop via their timeout.
@@ -114,13 +204,20 @@ export function wrap(worker, options) {
114
204
  p.resolve(msg.result);
115
205
  return;
116
206
  }
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}`);
207
+ if (isEventMessage(msg) || isEventBatchMessage(msg)) {
208
+ const events = isEventBatchMessage(msg) ? msg.events : [msg];
209
+ eventChannelStats.messages++;
210
+ eventChannelStats.events += events.length;
211
+ if (events.length > eventChannelStats.maxBatch)
212
+ eventChannelStats.maxBatch = events.length;
213
+ for (const ev of events) {
214
+ for (const cb of eventListeners.values()) {
215
+ try {
216
+ cb(ev.kind, ev.data);
217
+ }
218
+ catch (err) {
219
+ log.error("worker-proxy", `Event listener error: ${err}`);
220
+ }
124
221
  }
125
222
  }
126
223
  }
@@ -172,7 +269,7 @@ export function wrap(worker, options) {
172
269
  const id = ++reqId;
173
270
  const entry = { resolve, reject };
174
271
  if (timeoutMs > 0) {
175
- entry.timer = setTimeout(() => {
272
+ entry.timer = untrackedTimeout(() => {
176
273
  if (pending.delete(id)) {
177
274
  reject(new Error(`RPC '${method}' timed out after ${timeoutMs}ms`));
178
275
  }
@@ -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
@@ -116,7 +116,14 @@ export function createBlitzUiNativeModule(options) {
116
116
  const device = ctx.getDevice();
117
117
  const view = ensureTexture(device);
118
118
  const context = ctx.getSurface().getContext("webgpu");
119
- const targetTex = context?.getCurrentTexture();
119
+ // Join the in-flight frame, never start one: on a frame the
120
+ // game skipped (dirty-tracking), acquiring the surface here
121
+ // would present the UI blit over a blank swapchain image.
122
+ const peek = context
123
+ ?.peekCurrentTexture;
124
+ const targetTex = typeof peek === "function"
125
+ ? peek.call(context)
126
+ : context?.getCurrentTexture() ?? null;
120
127
  const target = targetTex?.createView();
121
128
  if (!view || !target || !targetTex)
122
129
  return;
@@ -44,6 +44,12 @@ export class DevtoolsBackend {
44
44
  providers = new Map();
45
45
  commandHandlers = new Map();
46
46
  globalCommandHandler = null;
47
+ /** Dynamically-allocated slots for provider names outside the fixed PANEL
48
+ * table — panelSlot() alone collapsed every unknown name onto slot 0
49
+ * ("console"), so a second game provider silently replaced the first. */
50
+ dynSlots = new Map();
51
+ dynSlotNames = new Map();
52
+ nextDynSlot = 100;
47
53
  lastProviderPush = new Map();
48
54
  providerIntervalMs = 1500;
49
55
  /** When non-null, the cadence loop only auto-refreshes this provider
@@ -178,37 +184,57 @@ export class DevtoolsBackend {
178
184
  this.emit("console.clear", {});
179
185
  }
180
186
  // ── Providers / commands ──
187
+ /** Resolve a panel id to its slot. Names outside the fixed PANEL table
188
+ * get a stable dynamically-allocated slot so bespoke game providers can
189
+ * coexist instead of colliding on slot 0. */
190
+ slotFor(panel) {
191
+ if (typeof panel === "number")
192
+ return panel;
193
+ const fixed = PANEL[panel];
194
+ if (fixed !== undefined)
195
+ return fixed;
196
+ let s = this.dynSlots.get(panel);
197
+ if (s === undefined) {
198
+ s = this.nextDynSlot++;
199
+ this.dynSlots.set(panel, s);
200
+ this.dynSlotNames.set(s, panel);
201
+ }
202
+ return s;
203
+ }
204
+ nameFor(slot) {
205
+ return this.dynSlotNames.get(slot) ?? slotName(slot);
206
+ }
181
207
  registerProvider(panel, collect) {
182
- this.providers.set(panelSlot(panel), collect);
208
+ this.providers.set(this.slotFor(panel), collect);
183
209
  }
184
210
  registerCommandHandler(panel, handler) {
185
211
  if (panel === "*") {
186
212
  this.globalCommandHandler = handler;
187
213
  }
188
214
  else {
189
- this.commandHandlers.set(panelSlot(panel), handler);
215
+ this.commandHandlers.set(this.slotFor(panel), handler);
190
216
  }
191
217
  }
192
218
  /** Registered provider slots — the dynamic tab list for frontends. */
193
219
  providerSlots() {
194
- return [...this.providers.keys()].map((slot) => ({ slot, name: slotName(slot) }));
220
+ return [...this.providers.keys()].map((slot) => ({ slot, name: this.nameFor(slot) }));
195
221
  }
196
222
  /** Collect + emit one provider's snapshot immediately. */
197
223
  refreshPanel(panel) {
198
224
  const trace = [performance.now()];
199
- const slot = panelSlot(panel);
225
+ const slot = this.slotFor(panel);
200
226
  trace.push(performance.now());
201
227
  const collect = this.providers.get(slot);
202
228
  if (!collect)
203
229
  return;
204
230
  const push = (snap) => {
205
231
  if (snap)
206
- this.emit("snapshot", { panel: slotName(slot), slot, snap });
232
+ this.emit("snapshot", { panel: this.nameFor(slot), slot, snap });
207
233
  this.lastProviderPush.set(slot, performance.now());
208
234
  };
209
235
  const fail = (err) => {
210
236
  this.emit("snapshot", {
211
- panel: slotName(slot), slot,
237
+ panel: this.nameFor(slot), slot,
212
238
  snap: { status: "error", statusMsg: String(err), sections: [] },
213
239
  });
214
240
  this.lastProviderPush.set(slot, performance.now());
@@ -231,7 +257,7 @@ export class DevtoolsBackend {
231
257
  const total = trace[4] - trace[0];
232
258
  if (total > 10) {
233
259
  const seg = `pre=${(trace[2] - trace[0]).toFixed(1)} collect=${(trace[3] - trace[2]).toFixed(1)} emit=${(trace[4] - trace[3]).toFixed(1)}`;
234
- this.diagWarn(`slow provider ${slotName(slot)}: ${total.toFixed(1)}ms [${seg}]`);
260
+ this.diagWarn(`slow provider ${this.nameFor(slot)}: ${total.toFixed(1)}ms [${seg}]`);
235
261
  }
236
262
  }
237
263
  dispatchCommand(cmd) {
@@ -374,7 +400,7 @@ export class DevtoolsBackend {
374
400
  return collectThreads(this.ctx, [...this.threadEvals.keys()]);
375
401
  case "snapshot": {
376
402
  const panel = params.panel;
377
- const slot = typeof panel === "number" ? panel : panelSlot(panel);
403
+ const slot = typeof panel === "number" ? panel : this.slotFor(String(panel));
378
404
  const collect = this.providers.get(slot);
379
405
  if (!collect)
380
406
  return { status: "unsupported", statusMsg: `no provider for ${String(panel)}`, sections: [] };
@@ -388,7 +414,7 @@ export class DevtoolsBackend {
388
414
  }
389
415
  case "command": {
390
416
  const panel = params.panel;
391
- const slot = typeof panel === "number" ? panel : panelSlot(panel ?? "console");
417
+ const slot = typeof panel === "number" ? panel : this.slotFor(String(panel ?? "console"));
392
418
  this.dispatchCommand({ panel: slot, action: String(params.action ?? ""), payload: String(params.payload ?? "") });
393
419
  return true;
394
420
  }
@@ -72,31 +72,81 @@ export async function collectThreads(ctx, evalThreadIds) {
72
72
  });
73
73
  return threads;
74
74
  }
75
- /** Scene-graph tree (label + ctor + child counts). */
75
+ function sceneInspectorApi() {
76
+ const g = globalThis;
77
+ return (g.window?.__sceneInspector ?? g.__sceneInspector ?? null);
78
+ }
79
+ /** Scene-graph tree (label + ctor + child counts). Prefers a live
80
+ * `gameScene.stage` walk; falls back to the SceneInspector's scene store
81
+ * (`__sceneInspector.getSceneTree`) so games without a DOM-like stage still
82
+ * get a populated tree — with stable nodeIds for pick/details. */
76
83
  export function collectSceneTree(ctx) {
77
84
  const stage = ctx.gameScene?.stage;
78
- if (!stage)
85
+ if (stage) {
86
+ const nodes = [];
87
+ let idCounter = 1;
88
+ const collect = (node, parentId, depth) => {
89
+ const id = idCounter++;
90
+ // Prefer `label` (some scene-graph hosts warn on `name` access); fall
91
+ // back to `name` for hosts that only set that.
92
+ const nodeLabel = typeof node.label === "string" ? node.label
93
+ : typeof node.name === "string" ? node.name : "";
94
+ const ctorName = node.constructor?.name ?? "Node";
95
+ nodes.push({
96
+ id, parentId, depth,
97
+ childCount: node.children?.length ?? 0,
98
+ kind: 0,
99
+ label: nodeLabel || ctorName,
100
+ detail: nodeLabel ? ctorName : "",
101
+ nodeId: typeof node.id === "string" ? node.id : undefined,
102
+ });
103
+ node.children?.forEach((child) => collect(child, id, depth + 1));
104
+ };
105
+ collect(stage, -1, 0);
106
+ return nodes;
107
+ }
108
+ // Scene-store fallback — the same snapshot the inspector's getSceneTree
109
+ // exposes. Nodes are flat with id/parentId links; walk roots → children.
110
+ try {
111
+ const snap = sceneInspectorApi()?.getSceneTree?.();
112
+ const storeNodes = snap?.nodes;
113
+ if (!Array.isArray(storeNodes) || !storeNodes.length)
114
+ return [];
115
+ const byId = new Map(storeNodes.map((n) => [n.id, n]));
116
+ const roots = storeNodes.filter((n) => n.parentId === null || !byId.has(n.parentId));
117
+ const rows = [];
118
+ const visited = new Set();
119
+ let idCounter = 1;
120
+ const walk = (n, parentId, depth) => {
121
+ if (visited.has(n.id))
122
+ return; // corrupted parent/child links — don't loop
123
+ visited.add(n.id);
124
+ const id = idCounter++;
125
+ const p = n.position;
126
+ rows.push({
127
+ id, parentId, depth,
128
+ childCount: n.children?.length ?? 0,
129
+ kind: 0,
130
+ label: String(n.name ?? n.id),
131
+ detail: `${n.type ?? "node"}${Array.isArray(p) ? ` (${p.map((v) => (+v).toFixed(1)).join(", ")})` : ""}`,
132
+ nodeId: String(n.id),
133
+ });
134
+ for (const cid of n.children ?? []) {
135
+ const c = byId.get(cid);
136
+ if (c)
137
+ walk(c, id, depth + 1);
138
+ }
139
+ };
140
+ roots.forEach((n) => walk(n, -1, 0));
141
+ // Orphans (every node has a parent but no root resolves) — still show them.
142
+ for (const n of storeNodes)
143
+ if (!visited.has(n.id))
144
+ walk(n, -1, 0);
145
+ return rows;
146
+ }
147
+ catch {
79
148
  return [];
80
- const nodes = [];
81
- let idCounter = 1;
82
- const collect = (node, parentId, depth) => {
83
- const id = idCounter++;
84
- // Prefer `label` (some scene-graph hosts warn on `name` access); fall
85
- // back to `name` for hosts that only set that.
86
- const nodeLabel = typeof node.label === "string" ? node.label
87
- : typeof node.name === "string" ? node.name : "";
88
- const ctorName = node.constructor?.name ?? "Node";
89
- nodes.push({
90
- id, parentId, depth,
91
- childCount: node.children?.length ?? 0,
92
- kind: 0,
93
- label: nodeLabel || ctorName,
94
- detail: nodeLabel ? ctorName : "",
95
- });
96
- node.children?.forEach((child) => collect(child, id, depth + 1));
97
- };
98
- collect(stage, -1, 0);
99
- return nodes;
149
+ }
100
150
  }
101
151
  /** DOM tree: scene-graph stage or ECS entities depending on mode. */
102
152
  export function collectDomTree(ctx, mode) {
@@ -122,6 +172,7 @@ export function collectDomTree(ctx, mode) {
122
172
  id, parentId: -1, depth: 0, childCount: 0, kind: 1,
123
173
  label: `Entity ${ent.idx}`,
124
174
  detail: `type=${entityType} pos=(${x.toFixed(1)},${y.toFixed(1)},${z.toFixed(1)})`,
175
+ nodeId: `ent-${ent.idx}`,
125
176
  });
126
177
  });
127
178
  }
@@ -515,8 +515,6 @@ async function collectWorkers(ctx) {
515
515
  kind: "kv",
516
516
  name: `Worker manifest (v${m.manifestVersion ?? "?"})`,
517
517
  rows: [
518
- kv("Panels", (m.panels ?? []).map((p) => p.id ?? p.name ?? "?").join(", ") || "(none)"),
519
- kv("Toggles", (m.toggles ?? []).map((t) => t.id ?? "?").join(", ") || "(none)"),
520
518
  kv("Data feeds", (m.dataFeeds ?? []).map((f) => `${f.name}@${f.writeRateHz}hz`).join(", ") || "(none)"),
521
519
  kv("Commands", (m.commands ?? []).join(", ") || "(none)"),
522
520
  ],