@downdraft/engine 0.1.2-alpha.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/app/src/renderer/bootstrap.ts +1 -1
  2. package/app/src/renderer/feature-log.ts +1 -1
  3. package/core/src/index.ts +2 -2
  4. package/core/src/input/local-player-manager.ts +2 -2
  5. package/core/src/platform/rpc.ts +4 -1
  6. package/core/src/render/game-renderer.ts +127 -64
  7. package/core/src/sab/grid-buffer.ts +55 -2
  8. package/core/src/sab/record-buffer.ts +48 -5
  9. package/core/src/sab/sim-channel.ts +181 -17
  10. package/core/src/sab/slot-buffer.ts +55 -1
  11. package/core/src/sab/types.ts +11 -0
  12. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  13. package/core/src/util/index.ts +1 -0
  14. package/core/src/util/logger.ts +2 -2
  15. package/core/src/util/session-timer.ts +23 -0
  16. package/core/src/worker/base-worker-host.ts +2 -1
  17. package/core/src/worker/buffer-sync.ts +20 -13
  18. package/core/src/worker/rpc.ts +54 -5
  19. package/core/src/worker/sim-worker-base.ts +6 -3
  20. package/core/src/worker/task-pool.ts +4 -0
  21. package/dist/.dd-manifest.json +879 -869
  22. package/dist/app/src/renderer/bootstrap.js +1 -1
  23. package/dist/app/src/renderer/feature-log.js +1 -1
  24. package/dist/core/src/index.js +2 -2
  25. package/dist/core/src/input/local-player-manager.js +2 -2
  26. package/dist/core/src/platform/rpc.js +2 -1
  27. package/dist/core/src/render/game-renderer.js +125 -59
  28. package/dist/core/src/sab/grid-buffer.js +53 -1
  29. package/dist/core/src/sab/record-buffer.js +49 -5
  30. package/dist/core/src/sab/sim-channel.js +173 -17
  31. package/dist/core/src/sab/slot-buffer.js +54 -1
  32. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  33. package/dist/core/src/util/index.js +1 -0
  34. package/dist/core/src/util/logger.js +2 -2
  35. package/dist/core/src/util/session-timer.js +22 -0
  36. package/dist/core/src/worker/base-worker-host.js +2 -1
  37. package/dist/core/src/worker/buffer-sync.js +16 -9
  38. package/dist/core/src/worker/rpc.js +46 -8
  39. package/dist/core/src/worker/sim-worker-base.js +6 -3
  40. package/dist/core/src/worker/task-pool.js +5 -0
  41. package/dist/libraries/devtools/src/backend.js +35 -9
  42. package/dist/libraries/devtools/src/collectors.js +73 -22
  43. package/dist/libraries/gamepad/src/feed-input.js +1 -1
  44. package/dist/libraries/gamepad/src/hub.js +2 -2
  45. package/dist/libraries/gamepad/src/source-sab.js +2 -2
  46. package/dist/libraries/gamepad/src/sysfs-enrich.js +2 -2
  47. package/dist/libraries/gpu-kernels/src/kernel.js +1 -1
  48. package/dist/libraries/gpu-kernels/src/transpile.js +1 -1
  49. package/dist/libraries/html-ui-kit/src/nav/nav-controller.js +1 -1
  50. package/dist/libraries/html-ui-kit/src/nav/nav-input.js +2 -2
  51. package/dist/libraries/networking/src/discovery.js +8 -8
  52. package/dist/libraries/persistence/src/file-save-store.js +5 -5
  53. package/dist/libraries/persistence/src/opfs-save-store.js +2 -2
  54. package/dist/modules/controller-ui/src/index.js +1 -1
  55. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  56. package/dist/modules/devtools/src/blitz/host.js +127 -33
  57. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  58. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  59. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  60. package/dist/modules/devtools/src/blitz/panels/performance.js +28 -14
  61. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  62. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  63. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  64. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  65. package/dist/modules/html-ui/src/host.js +6 -3
  66. package/dist/modules/html-ui/src/jsx-runtime.js +1 -1
  67. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  68. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  69. package/libraries/devtools/src/backend.ts +38 -12
  70. package/libraries/devtools/src/collectors.ts +75 -22
  71. package/libraries/gamepad/src/feed-input.ts +1 -1
  72. package/libraries/gamepad/src/hub.ts +2 -2
  73. package/libraries/gamepad/src/source-sab.ts +2 -2
  74. package/libraries/gamepad/src/sysfs-enrich.ts +2 -2
  75. package/libraries/gpu-kernels/src/kernel.ts +2 -2
  76. package/libraries/gpu-kernels/src/transpile.ts +1 -1
  77. package/libraries/html-ui-kit/src/nav/nav-controller.ts +1 -1
  78. package/libraries/html-ui-kit/src/nav/nav-input.ts +2 -2
  79. package/libraries/networking/src/discovery.ts +8 -8
  80. package/libraries/persistence/src/file-save-store.ts +5 -5
  81. package/libraries/persistence/src/opfs-save-store.ts +2 -2
  82. package/libraries/physics-rapier/src/realm-worker-script.ts +1 -1
  83. package/modules/controller-ui/src/index.ts +1 -1
  84. package/modules/devtools/src/blitz/fonts.ts +78 -0
  85. package/modules/devtools/src/blitz/host.ts +110 -34
  86. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  87. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  88. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  89. package/modules/devtools/src/blitz/panels/performance.ts +26 -14
  90. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  91. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  92. package/modules/devtools/src/blitz/theme.ts +26 -4
  93. package/modules/html-ui/src/doc-backend.ts +67 -4
  94. package/modules/html-ui/src/host.ts +11 -3
  95. package/modules/html-ui/src/jsx-runtime.ts +1 -1
  96. package/modules/html-ui/src/protocol.ts +1 -1
  97. package/package.json +2 -2
@@ -13,11 +13,13 @@
13
13
  import { createLogger } from "@downdraft/engine";
14
14
  import { CdpBridge, DevtoolsBackend } from "@downdraft/engine/libraries/devtools";
15
15
  import { HtmlUiHost } from "@downdraft/engine/modules/html-ui";
16
+ import { registerDevtoolsFonts } from "./fonts.js";
16
17
  import { ConsolePanel } from "./panels/console.js";
17
18
  import { ElementsPanel } from "./panels/elements.js";
18
19
  import { GpuPanel } from "./panels/gpu.js";
19
20
  import { PerformancePanel } from "./panels/performance.js";
20
21
  import { SnapshotPanel } from "./panels/snapshot.js";
22
+ import { esc } from "./panels/types.js";
21
23
  import { DEVTOOLS_CSS } from "./theme.js";
22
24
  const log = createLogger("info");
23
25
  const MIN_DOCK = 120;
@@ -42,6 +44,9 @@ export class BlitzDevtoolsHost {
42
44
  panelCtx;
43
45
  lastPanelRender = -1e9;
44
46
  lastSlowLog = 0;
47
+ /** @font-face css from registerDevtoolsFonts — "" means "embedded font
48
+ * fallback", null means "not yet attempted". */
49
+ fontCss = null;
45
50
  constructor(opts) {
46
51
  this.opts = opts;
47
52
  this.surfaceW = opts.surface.clientWidth;
@@ -76,7 +81,7 @@ export class BlitzDevtoolsHost {
76
81
  new ElementsPanel(ctx),
77
82
  new PerformancePanel(ctx),
78
83
  new GpuPanel(ctx),
79
- ])
84
+ ].values())
80
85
  this.panels.set(p.id, p);
81
86
  }
82
87
  get visible() { return this.panel !== null; }
@@ -113,19 +118,42 @@ export class BlitzDevtoolsHost {
113
118
  show() {
114
119
  if (!this._ready || this.visible || this.disposed)
115
120
  return;
116
- this.panel = this.opts.ui.mount(this.shellHtml(), {
117
- id: "devtools",
118
- rect: this.dockRect(),
119
- // Above game overlays — menus/loading screens shouldn't bury the dock.
120
- z: 1000,
121
- scale: 1.5,
122
- interactive: true,
123
- // Devtools UI doesn't need vsync-rate raster — cap it so mutation
124
- // bursts (console streams, snapshot refreshes) coalesce into ~66ms
125
- // rasters instead of one raster per message.
126
- maxFps: 15,
127
- onEvent: (ev) => this.handleDocEvent(ev),
128
- });
121
+ // Register real system fonts once per host — ui:// resources persist on
122
+ // the surface across dock remounts, and must land before the doc mounts
123
+ // so the first resolve sees them (embedded DejaVu fallback otherwise).
124
+ if (this.fontCss === null) {
125
+ const ui = this.opts.ui;
126
+ const rr = ui.registerResource?.bind(ui)
127
+ ?? (ui.host ? ui.host.registerResource.bind(ui.host) : undefined);
128
+ this.fontCss = rr ? registerDevtoolsFonts({ registerResource: rr }) : "";
129
+ }
130
+ try {
131
+ this.panel = this.opts.ui.mount(this.shellHtml(), {
132
+ id: "devtools",
133
+ rect: this.dockRect(),
134
+ // Above game overlays — menus/loading screens shouldn't bury the dock.
135
+ z: 1000,
136
+ // Native-res raster: the vendored glifo/FreeType path grid-fits hinted
137
+ // glyph stems to whole pixels — supersample factors >1 just blur that
138
+ // back out on downscale (and cost 2.25x the texture memory).
139
+ scale: 1,
140
+ // Ease wheel detents at pump cadence instead of jumping per event.
141
+ smoothWheel: true,
142
+ interactive: true,
143
+ // Devtools UI doesn't need vsync-rate raster — cap it so mutation
144
+ // bursts (console streams, snapshot refreshes) coalesce into ~66ms
145
+ // rasters instead of one raster per message.
146
+ maxFps: 15,
147
+ onEvent: (ev) => this.handleDocEvent(ev),
148
+ });
149
+ }
150
+ catch (err) {
151
+ // Mount fails when the ui worker is dead — stay hidden so the next F12
152
+ // retries cleanly instead of wedging on a half-mounted doc.
153
+ this.panel = null;
154
+ log.error("blitz-devtools-host", `dock mount failed: ${err}`);
155
+ return;
156
+ }
129
157
  this.syncProviders();
130
158
  this.renderTabs();
131
159
  this.activate(this.activeId);
@@ -150,8 +178,19 @@ export class BlitzDevtoolsHost {
150
178
  const t1 = performance.now();
151
179
  if (!this.visible)
152
180
  return;
153
- this.syncProviders();
154
- this.flush();
181
+ // Guarded like backend.update() above — a throwing panel render can't be
182
+ // allowed to kill the game frame hook that drives this pump.
183
+ try {
184
+ this.syncProviders();
185
+ this.flush();
186
+ }
187
+ catch (err) {
188
+ const tn = performance.now();
189
+ if (tn - this.lastSlowLog > 1000) {
190
+ this.lastSlowLog = tn;
191
+ log.warn("blitz-devtools-host", `panel render error: ${err}`);
192
+ }
193
+ }
155
194
  const t2 = performance.now();
156
195
  // ≤1/sec — these diagnostics used to feed the console panel's logger
157
196
  // sink, and each row forced another dock re-render: a self-feeding
@@ -204,11 +243,13 @@ export class BlitzDevtoolsHost {
204
243
  }
205
244
  // ── Dock layout ──
206
245
  dockRect() {
207
- const h = Math.min(this.dockH, this.surfaceH - 60);
208
- return { x: 0, y: this.surfaceH - h, w: this.surfaceW, h };
246
+ // A tiny surface (or a pathological resize event) must not produce a
247
+ // negative height — keep at least enough room for the tab strip.
248
+ const h = Math.max(48, Math.min(this.dockH, this.surfaceH - 60));
249
+ return { x: 0, y: Math.max(0, this.surfaceH - h), w: this.surfaceW, h };
209
250
  }
210
251
  shellHtml() {
211
- return `<html><head><style>${DEVTOOLS_CSS}</style></head><body>
252
+ return `<html><head><style>${this.fontCss ?? ""}${DEVTOOLS_CSS}</style></head><body>
212
253
  <div id="dt-root">
213
254
  <div id="dt-grip" title="drag to resize"></div>
214
255
  <div id="dt-chrome">
@@ -224,7 +265,10 @@ export class BlitzDevtoolsHost {
224
265
  renderTabs() {
225
266
  if (!this.panel)
226
267
  return;
227
- const tabs = [...this.panels.values()].map((p) => `<span class="tab${p.id === this.activeId ? " active" : ""}" data-action="dt.tab" data-tab="${p.id}">${p.title}</span>`).join("");
268
+ // esc() both interpolations — provider names feed panel ids/titles and
269
+ // arrive as raw strings (a game registering "x\"><img" would otherwise
270
+ // inject markup into the tab strip).
271
+ const tabs = [...this.panels.values()].map((p) => `<span class="tab${p.id === this.activeId ? " active" : ""}" data-action="dt.tab" data-tab="${esc(p.id)}">${esc(p.title)}</span>`).join("");
228
272
  this.panel.setInnerHtml("#dt-tabs", `<span class="brand">downdraft</span>${tabs}<span class="spacer"></span>
229
273
  <span class="hint">docked · F12</span><span id="dt-close" data-action="dt.close">✕</span>`);
230
274
  this.tabsDirty = false;
@@ -235,13 +279,28 @@ export class BlitzDevtoolsHost {
235
279
  this.activeId = id;
236
280
  const p = this.panels.get(id);
237
281
  this.renderTabs();
238
- this.panel.setInnerHtml("#dt-top", p.renderTop?.() ?? "");
239
- this.panel.setInnerHtml("#dt-bottom", p.renderBottom?.() ?? "");
282
+ try {
283
+ this.panel.setInnerHtml("#dt-top", p.renderTop?.() ?? "");
284
+ this.panel.setInnerHtml("#dt-bottom", p.renderBottom?.() ?? "");
285
+ }
286
+ catch (err) {
287
+ log.warn("blitz-devtools-host", `panel ${id} chrome render error: ${err}`);
288
+ }
240
289
  p.dirty = true;
241
290
  p.shellDirty = false;
242
- this.panel.setInnerHtml("#dt-body", p.renderBody());
243
- p.afterRender?.();
244
- p.activate?.();
291
+ this.panel.setInnerHtml("#dt-body", this.safeBody(p));
292
+ try {
293
+ p.afterRender?.();
294
+ }
295
+ catch (err) {
296
+ log.warn("blitz-devtools-host", `panel ${id} afterRender error: ${err}`);
297
+ }
298
+ try {
299
+ p.activate?.();
300
+ }
301
+ catch (err) {
302
+ log.warn("blitz-devtools-host", `panel ${id} activate error: ${err}`);
303
+ }
245
304
  // Only the snapshot tab being viewed gets periodic provider refreshes;
246
305
  // fixed panels suspend auto-refresh entirely (manual Refresh still works).
247
306
  this.backend.providerWatch = p.providerSlot ?? -1;
@@ -252,7 +311,7 @@ export class BlitzDevtoolsHost {
252
311
  const slots = this.backend.providerSlots();
253
312
  let changed = false;
254
313
  const seen = new Set();
255
- for (const { slot, name } of slots) {
314
+ for (const { slot, name } of slots.values()) {
256
315
  seen.add(slot);
257
316
  if (!this.snapshotPanels.has(slot)) {
258
317
  const p = new SnapshotPanel(this.panelCtx, slot, name);
@@ -261,7 +320,7 @@ export class BlitzDevtoolsHost {
261
320
  changed = true;
262
321
  }
263
322
  }
264
- for (const [slot, p] of this.snapshotPanels) {
323
+ for (const [slot, p] of this.snapshotPanels.entries()) {
265
324
  if (!seen.has(slot)) {
266
325
  this.snapshotPanels.delete(slot);
267
326
  this.panels.delete(p.id);
@@ -307,10 +366,32 @@ export class BlitzDevtoolsHost {
307
366
  }
308
367
  if (p.dirty) {
309
368
  p.dirty = false;
310
- if (p.flushDom?.() !== true) {
311
- this.panel.setInnerHtml("#dt-body", p.renderBody());
369
+ let handled = false;
370
+ try {
371
+ handled = p.flushDom?.() === true;
312
372
  }
313
- p.afterRender?.();
373
+ catch (err) {
374
+ log.warn("blitz-devtools-host", `panel ${p.id} flushDom error: ${err}`);
375
+ }
376
+ if (!handled)
377
+ this.panel.setInnerHtml("#dt-body", this.safeBody(p));
378
+ try {
379
+ p.afterRender?.();
380
+ }
381
+ catch (err) {
382
+ log.warn("blitz-devtools-host", `panel ${p.id} afterRender error: ${err}`);
383
+ }
384
+ }
385
+ }
386
+ /** renderBody that can't take the dock down — the error surfaces in-panel
387
+ * (and the next push retries since dirty just flips back on). */
388
+ safeBody(p) {
389
+ try {
390
+ return p.renderBody();
391
+ }
392
+ catch (err) {
393
+ log.warn("blitz-devtools-host", `panel ${p.id} renderBody error: ${err}`);
394
+ return `<div class="status-banner error">panel render error: ${esc(String(err))}</div>`;
314
395
  }
315
396
  }
316
397
  setStatus(text) {
@@ -345,8 +426,10 @@ export class BlitzDevtoolsHost {
345
426
  return;
346
427
  }
347
428
  }
429
+ // Left-click only — routing actions on contextmenu meant a right-click
430
+ // on a tab/button fired it instead of just focusing.
348
431
  const action = ev.d?.action;
349
- if ((ev.t === "click" || ev.t === "contextmenu") && action) {
432
+ if (ev.t === "click" && action) {
350
433
  if (action === "dt.tab") {
351
434
  const id = ev.d.tab;
352
435
  if (id)
@@ -357,11 +440,21 @@ export class BlitzDevtoolsHost {
357
440
  this.hide();
358
441
  return;
359
442
  }
360
- this.panels.get(this.activeId)?.onAction?.(ev.d, ev);
443
+ try {
444
+ this.panels.get(this.activeId)?.onAction?.(ev.d, ev);
445
+ }
446
+ catch (err) {
447
+ log.warn("blitz-devtools-host", `panel action error: ${err}`);
448
+ }
361
449
  return;
362
450
  }
363
451
  // Everything else (input, keydown/up, scroll, focus…) → active panel.
364
- this.panels.get(this.activeId)?.onEvent?.(ev);
452
+ try {
453
+ this.panels.get(this.activeId)?.onEvent?.(ev);
454
+ }
455
+ catch (err) {
456
+ log.warn("blitz-devtools-host", `panel event error: ${err}`);
457
+ }
365
458
  }
366
459
  }
367
460
  /**
@@ -387,5 +480,6 @@ export function createSelfHostedDevtoolsUi(ctx) {
387
480
  host,
388
481
  mount: (markup, spec) => host.mount({ ...spec, html: markup }),
389
482
  docCaps: () => host.docCaps,
483
+ registerResource: (url, bytes) => host.registerResource(url, bytes),
390
484
  };
391
485
  }
@@ -101,7 +101,7 @@ export class ConsolePanel {
101
101
  onBackendEvent(event, data) {
102
102
  if (event === "console") {
103
103
  const e = data;
104
- this.rows.push({ text: e.text ?? "", severity: e.severity ?? 0, thread: e.thread ?? "main", seq: this.seq++ });
104
+ this.rows.push({ text: String(e?.text ?? ""), severity: e?.severity ?? 0, thread: e?.thread ?? "main", seq: this.seq++ });
105
105
  if (this.rows.length > MAX_ROWS)
106
106
  this.rows.splice(0, this.rows.length - MAX_ROWS);
107
107
  this.dirty = true;
@@ -113,15 +113,24 @@ export class ConsolePanel {
113
113
  }
114
114
  else if (event === "threads") {
115
115
  const list = data ?? [];
116
- this.threads = [{ id: "main", name: "main", kind: 0 }, ...list.filter((t) => t.id !== "main")];
117
- if (!this.threads.some((t) => t.id === this.replThread))
116
+ const next = [{ id: "main", name: "main", kind: 0 }, ...list.filter((t) => t.id !== "main")];
117
+ // Thread chips live in the bottom chrome — flag it so the row
118
+ // actually re-renders when a worker appears or disappears.
119
+ const changed = next.length !== this.threads.length
120
+ || next.some((t, i) => t.id !== this.threads[i].id || t.name !== this.threads[i].name);
121
+ this.threads = next;
122
+ if (!this.threads.some((t) => t.id === this.replThread)) {
118
123
  this.replThread = "main";
124
+ this.shellDirty = true;
125
+ }
126
+ if (changed)
127
+ this.shellDirty = true;
119
128
  }
120
129
  }
121
130
  onAction(data, _ev) {
122
131
  const act = data.action;
123
132
  if (act === "console.clear") {
124
- void this.ctx.call("console.clear");
133
+ void this.ctx.call("console.clear").catch(() => { });
125
134
  }
126
135
  else if (act === "console.sev") {
127
136
  this.minSev = (this.minSev + 1) % SEV_NAMES.length;
@@ -171,8 +180,11 @@ export class ConsolePanel {
171
180
  this.replValue = this.history[--this.histIdx];
172
181
  this.ctx.setValue("[data-dt=repl]", this.replValue);
173
182
  }
174
- else if (ev.k === "ArrowDown" && this.histIdx < this.history.length - 1) {
175
- this.replValue = this.history[++this.histIdx];
183
+ else if (ev.k === "ArrowDown" && this.histIdx >= 0 && this.histIdx < this.history.length) {
184
+ // Stepping past the newest entry restores the empty input —
185
+ // previously the last history item was unreachable-to-leave.
186
+ this.histIdx++;
187
+ this.replValue = this.histIdx < this.history.length ? this.history[this.histIdx] : "";
176
188
  this.ctx.setValue("[data-dt=repl]", this.replValue);
177
189
  }
178
190
  }
@@ -205,7 +217,7 @@ export class ConsolePanel {
205
217
  else {
206
218
  const r = res?.result;
207
219
  this.addRow({
208
- text: typeof r === "string" ? r : JSON.stringify(r, null, 2),
220
+ text: typeof r === "string" ? r : (r === undefined ? "undefined" : JSON.stringify(r, null, 2)),
209
221
  severity: 0, thread: this.replThread,
210
222
  });
211
223
  }
@@ -1,10 +1,14 @@
1
1
  // ============================================================================
2
- // panels/elements.ts — Elements panel: scene graph / DOM / ECS entity tree.
3
- // Merges the web frontend's scene + dom tabs behind a mode switch, matching
4
- // the Chrome Elements panel's single-tree presentation.
2
+ // panels/elements.ts — Elements panel: scene graph / ECS entity tree.
3
+ //
4
+ // Clicking a row selects it (Chrome-style): the .sel class flips via
5
+ // incremental attr mutation so the scrollport survives, and a details strip
6
+ // in the bottom chrome shows the row fields plus `getNodeJSON` output when
7
+ // the node has a stable id and the scene inspector is live. The caret on
8
+ // parent rows folds/unfolds the subtree.
5
9
  // ============================================================================
6
10
  import { esc } from "./types.js";
7
- const MODE_LABEL = { scene: "Scene graph", dom: "DOM", ecs: "ECS entities" };
11
+ const MODE_LABEL = { scene: "Scene graph", ecs: "ECS entities" };
8
12
  const MAX_ROWS = 4000;
9
13
  export class ElementsPanel {
10
14
  id = "elements";
@@ -14,66 +18,251 @@ export class ElementsPanel {
14
18
  ctx;
15
19
  mode = "scene";
16
20
  rows = [];
21
+ /** Stable per-row key (nodeId or label path), parallel to rows. */
22
+ keys = [];
23
+ collapsed = new Set();
24
+ /** Logical selection — survives refreshes via keys. */
25
+ selKey = null;
26
+ /** Ephemeral DOM id of the row currently carrying .sel — lets pick flip
27
+ * the class without a body reparse. Stale after a refresh; harmless —
28
+ * renderBody re-emits .sel from selKey. */
29
+ selRowId = null;
30
+ details = null;
31
+ lastError = "";
32
+ lastSig = "";
33
+ /** Scroll offset of #dt-body — restored after every body re-render, since
34
+ * setInnerHtml replaces the subtree and drops scrollTop. */
35
+ lastScrollTop = 0;
17
36
  constructor(ctx) { this.ctx = ctx; }
18
37
  renderTop() {
19
38
  const btn = (m) => `<span class="dbtn${this.mode === m ? " on" : ""}" data-action="el.mode" data-mode="${m}">${MODE_LABEL[m]}</span>`;
20
39
  return `<div class="panel-toolbar">
21
40
  <span class="dbtn" data-action="el.refresh">Refresh</span>
22
- ${btn("scene")}${btn("dom")}${btn("ecs")}
41
+ ${btn("scene")}${btn("ecs")}
23
42
  <span class="spacer"></span>
24
- <span class="count">${this.rows.length} nodes</span>
43
+ <span class="count">${this.rows.length} nodes${this.collapsed.size ? ` · ${this.collapsed.size} folded` : ""}</span>
25
44
  </div>`;
26
45
  }
27
46
  renderBody() {
28
- if (!this.rows.length)
29
- return `<div class="empty-note">no nodes</div>`;
30
- const html = this.rows.slice(0, MAX_ROWS).map((n) => `<div class="trow" style="padding-left:${4 + n.depth * 14}px" data-action="el.pick" data-node="${n.id}">
31
- <span>${n.childCount > 0 ? "▾" : " "} ${esc(n.label)}</span>
47
+ const err = this.lastError
48
+ ? `<div class="status-banner error">${esc(this.lastError)}</div>` : "";
49
+ const vis = this.visibleRows();
50
+ if (!vis.length)
51
+ return err + `<div class="empty-note">no nodes</div>`;
52
+ const html = vis.slice(0, MAX_ROWS).map(({ row: n, key }) => {
53
+ const isSel = key === this.selKey;
54
+ const folded = this.collapsed.has(key);
55
+ const caret = n.childCount > 0
56
+ ? `<span class="caret" data-action="el.fold" data-node="${n.id}">${folded ? "▸" : "▾"}</span>`
57
+ : `<span class="caret leaf"></span>`;
58
+ return `<div class="trow${isSel ? " sel" : ""}" style="padding-left:${4 + n.depth * 14}px" data-action="el.pick" data-node="${n.id}">
59
+ ${caret}<span>${esc(n.label)}</span>
32
60
  ${n.detail ? `<span class="detail">${esc(n.detail)}</span>` : ""}
33
61
  ${n.childCount > 0 ? `<span class="kids">(${n.childCount})</span>` : ""}
34
- </div>`).join("");
35
- const more = this.rows.length > MAX_ROWS
36
- ? `<div class="empty-note">… ${this.rows.length - MAX_ROWS} more rows</div>` : "";
37
- return `<div class="tree">${html}${more}</div>`;
62
+ </div>`;
63
+ }).join("");
64
+ const more = vis.length > MAX_ROWS
65
+ ? `<div class="empty-note">… ${vis.length - MAX_ROWS} more rows</div>` : "";
66
+ return err + `<div class="tree">${html}${more}</div>`;
38
67
  }
68
+ renderBottom() {
69
+ if (!this.details)
70
+ return "";
71
+ return `<div class="el-details">
72
+ <div class="el-details-head"><span class="dt-title">${esc(this.details.title)}</span>
73
+ <span class="spacer"></span><span class="dbtn" data-action="el.unpick">✕</span></div>
74
+ <div class="el-details-body lines">${esc(this.details.body)}</div>
75
+ </div>`;
76
+ }
77
+ // ── Data ──
78
+ /** Cheap change-detection — scene/ecs pushes arrive every ~2s while the
79
+ * dock is visible; identical payloads must not force a re-render (it
80
+ * would bounce the scrollport twice per second). */
81
+ applyRows(rows) {
82
+ const sig = JSON.stringify(rows.map((r) => [r.label, r.detail, r.childCount, r.depth, r.nodeId ?? ""]));
83
+ if (sig === this.lastSig)
84
+ return;
85
+ this.lastSig = sig;
86
+ this.setRows(rows);
87
+ this.dirty = true;
88
+ this.shellDirty = true;
89
+ }
90
+ setRows(rows) {
91
+ this.rows = rows;
92
+ // Rows arrive pre-order flattened — the parent of a row is the most
93
+ // recent row at depth-1. Keys: nodeId when the collector provided one,
94
+ // else a positional label path (stable while the tree shape holds).
95
+ const stack = [];
96
+ this.keys = rows.map((r, i) => {
97
+ const parent = r.depth > 0 ? (stack[r.depth - 1] ?? "") : "";
98
+ const k = r.nodeId ?? `${parent}/${r.label}@${i}`;
99
+ stack[r.depth] = k;
100
+ stack.length = r.depth + 1;
101
+ return k;
102
+ });
103
+ const present = new Set(this.keys);
104
+ for (const k of [...this.collapsed])
105
+ if (!present.has(k))
106
+ this.collapsed.delete(k);
107
+ if (this.selKey && !present.has(this.selKey)) {
108
+ this.selKey = null;
109
+ this.selRowId = null;
110
+ this.details = null;
111
+ }
112
+ }
113
+ /** Rows minus descendants of folded parents. */
114
+ visibleRows() {
115
+ const out = [];
116
+ const skipDepths = [];
117
+ for (let i = 0; i < this.rows.length; i++) {
118
+ const r = this.rows[i];
119
+ const k = this.keys[i];
120
+ while (skipDepths.length && r.depth <= skipDepths[skipDepths.length - 1])
121
+ skipDepths.pop();
122
+ if (!skipDepths.length)
123
+ out.push({ row: r, key: k });
124
+ if (r.childCount > 0 && this.collapsed.has(k))
125
+ skipDepths.push(r.depth);
126
+ }
127
+ return out;
128
+ }
129
+ localDetails(r) {
130
+ return [
131
+ `label: ${r.label}`,
132
+ r.detail ? `detail: ${r.detail}` : "",
133
+ `kind: ${r.kind === 1 ? "ecs entity" : "scene node"}`,
134
+ `children: ${r.childCount}`,
135
+ `depth: ${r.depth}`,
136
+ r.nodeId ? `nodeId: ${r.nodeId}` : "nodeId: (none — pick can't reach the inspector)",
137
+ ].filter(Boolean).join("\n");
138
+ }
139
+ async loadDetails(row, key) {
140
+ this.details = { title: row.label, body: this.localDetails(row) };
141
+ this.shellDirty = true;
142
+ const nodeId = row.nodeId;
143
+ if (!nodeId)
144
+ return;
145
+ // Best-effort inspector enrichment + in-scene selection highlight.
146
+ try {
147
+ await this.ctx.call("inspector.call", { method: "selectNode", args: [nodeId] });
148
+ }
149
+ catch { /* inspector absent or id unknown — local details stand */ }
150
+ try {
151
+ const json = await this.ctx.call("inspector.call", { method: "getNodeJSON", args: [nodeId] });
152
+ // A newer pick while this call was in flight wins.
153
+ if (this.selKey === key && typeof json === "string" && json) {
154
+ this.details = { title: row.label, body: json };
155
+ this.shellDirty = true;
156
+ }
157
+ }
158
+ catch { /* no inspector — keep the row-level details */ }
159
+ }
160
+ // ── Events ──
39
161
  onBackendEvent(event, data) {
40
- // "scene" pushes feed the Scene-graph mode; "dom" pushes feed DOM/ECS.
162
+ // "scene" pushes feed scene mode; "dom" pushes feed ECS mode.
41
163
  if (event === "scene" && this.mode === "scene") {
42
- this.rows = data ?? [];
43
- this.dirty = true;
164
+ this.applyRows(data ?? []);
44
165
  }
45
- else if (event === "dom" && this.mode !== "scene") {
46
- this.rows = data ?? [];
47
- this.dirty = true;
166
+ else if (event === "dom" && this.mode === "ecs") {
167
+ this.applyRows(data ?? []);
48
168
  }
49
169
  }
50
170
  onAction(data, _ev) {
51
- if (data.action === "el.refresh") {
171
+ const act = data.action;
172
+ if (act === "el.refresh") {
52
173
  void this.refresh();
53
174
  }
54
- else if (data.action === "el.mode") {
175
+ else if (act === "el.mode") {
55
176
  const m = data.mode;
56
- if (m && m !== this.mode) {
177
+ if ((m === "scene" || m === "ecs") && m !== this.mode) {
57
178
  this.mode = m;
179
+ this.selKey = null;
180
+ this.selRowId = null;
181
+ this.details = null;
182
+ this.collapsed.clear();
58
183
  this.shellDirty = true;
184
+ // Point the backend's periodic push at the mode being viewed.
185
+ void this.ctx.call("domTree.mode", { mode: m }).catch(() => { });
59
186
  void this.refresh();
60
187
  }
61
188
  }
189
+ else if (act === "el.fold") {
190
+ const id = Number(data.node);
191
+ const i = this.rows.findIndex((r) => r.id === id);
192
+ if (i < 0)
193
+ return;
194
+ const key = this.keys[i];
195
+ if (this.collapsed.has(key))
196
+ this.collapsed.delete(key);
197
+ else
198
+ this.collapsed.add(key);
199
+ this.dirty = true;
200
+ this.shellDirty = true;
201
+ }
202
+ else if (act === "el.pick") {
203
+ this.pick(Number(data.node));
204
+ }
205
+ else if (act === "el.unpick") {
206
+ this.unpick();
207
+ }
208
+ }
209
+ pick(id) {
210
+ const i = this.rows.findIndex((r) => r.id === id);
211
+ if (i < 0)
212
+ return;
213
+ const row = this.rows[i];
214
+ const key = this.keys[i];
215
+ this.selKey = key;
216
+ // Flip .sel via targeted mutations instead of a body re-render —
217
+ // reparse would reset the scrollport mid-browse. Falls back to a full
218
+ // render on backends without incremental ops.
219
+ const ops = [];
220
+ if (this.selRowId !== null && this.selRowId !== id) {
221
+ ops.push({ op: "attr", sel: `[data-node="${this.selRowId}"]`, name: "class", value: "trow" });
222
+ }
223
+ ops.push({ op: "attr", sel: `[data-node="${id}"]`, name: "class", value: "trow sel" });
224
+ this.ctx.mutate(ops);
225
+ this.selRowId = id;
226
+ void this.loadDetails(row, key);
227
+ }
228
+ unpick() {
229
+ if (this.selRowId !== null) {
230
+ this.ctx.mutate([{ op: "attr", sel: `[data-node="${this.selRowId}"]`, name: "class", value: "trow" }]);
231
+ }
232
+ this.selKey = null;
233
+ this.selRowId = null;
234
+ this.details = null;
235
+ this.shellDirty = true;
236
+ void this.ctx.call("inspector.call", { method: "selectNode", args: [null] }).catch(() => { });
237
+ }
238
+ onEvent(ev) {
239
+ if (ev.t === "scroll" && ev.id === "dt-body") {
240
+ this.lastScrollTop = ev.st ?? 0;
241
+ }
62
242
  }
63
243
  activate() {
244
+ // Keep the backend's push mode aligned with what's on screen.
245
+ void this.ctx.call("domTree.mode", { mode: this.mode }).catch(() => { });
64
246
  void this.refresh();
65
247
  }
248
+ afterRender() {
249
+ // Body reparse dropped the scroll position — put it back.
250
+ if (this.lastScrollTop > 0)
251
+ this.ctx.scrollTo("#dt-body", 0, this.lastScrollTop);
252
+ }
66
253
  async refresh() {
67
254
  try {
68
255
  const rows = this.mode === "scene"
69
256
  ? await this.ctx.call("sceneTree")
70
- : await this.ctx.call("domTree", { mode: this.mode === "ecs" ? "ecs" : "scene" });
71
- this.rows = rows ?? [];
257
+ : await this.ctx.call("domTree", { mode: "ecs" });
258
+ this.lastError = "";
259
+ this.applyRows(rows ?? []);
72
260
  }
73
- catch {
74
- this.rows = [];
261
+ catch (err) {
262
+ // Keep the last good tree; surface the failure instead of blanking.
263
+ this.lastError = `refresh failed: ${String(err)}`;
75
264
  }
76
265
  this.dirty = true;
77
- this.shellDirty = true; // node count in the toolbar
266
+ this.shellDirty = true;
78
267
  }
79
268
  }
@@ -19,10 +19,13 @@ export class GpuPanel {
19
19
  renderBody() {
20
20
  if (!this.info)
21
21
  return `<div class="empty-note">no GPU info</div>`;
22
- const times = (this.info.frameTimes ?? []).map((t) => t[0]);
22
+ const times = (Array.isArray(this.info.frameTimes) ? this.info.frameTimes : [])
23
+ .map((t) => (Array.isArray(t) ? t[0] : 0));
23
24
  const chart = `<div class="snap-section"><h3>Frame times (ms)</h3>${chartHtml(times, { max: Math.max(16.7, ...times), label: "" })}</div>`;
24
25
  const sections = [];
25
- for (const e of this.info.entries ?? []) {
26
+ for (const e of (Array.isArray(this.info.entries) ? this.info.entries : []).values()) {
27
+ if (!e || typeof e !== "object")
28
+ continue;
26
29
  if (e.isHeader || !sections.length) {
27
30
  sections.push({ header: e.isHeader ? e.key : null, rows: [] });
28
31
  if (e.isHeader)