@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
@@ -25,6 +25,12 @@ export class PerformancePanel {
25
25
  </div>`;
26
26
  }
27
27
  renderBody() {
28
+ // `chart + table + prof || note` could never fall through — the table
29
+ // markup is non-empty even with zero slots, so the empty-state hint was
30
+ // dead. Gate the whole thing on having anything to show.
31
+ if (!this.slots.length && !this.profile && !this.recording) {
32
+ return `<div class="empty-note">no metrics — is ProfilingSAB attached?</div>`;
33
+ }
28
34
  const heapSeries = [...this.history.entries()].map(([slot, rec]) => ({
29
35
  name: `heap — ${this.slotName(slot)}`,
30
36
  values: rec.heap,
@@ -33,10 +39,13 @@ export class PerformancePanel {
33
39
  const chart = heapSeries.length
34
40
  ? `<div class="snap-section"><h3>Heap per thread (MB)</h3>${heapSeries.map((s) => chartHtml(s.values, { max: Math.max(64, ...heapSeries.flatMap((x) => x.values)), color: s.color, label: s.name })).join("")}</div>`
35
41
  : "";
36
- let tbl = `<table class="dt"><tr>
42
+ let tbl = "";
43
+ if (this.slots.length) {
44
+ tbl = `<table class="dt"><tr>
37
45
  <th>slot</th><th>name</th><th>cpu%</th><th>heap</th>
38
46
  <th>gc max µs</th><th>task p95 µs</th></tr>`;
39
- for (const s of this.slots) {
47
+ }
48
+ for (const s of this.slots.values()) {
40
49
  const h = s.history?.[0] ?? {};
41
50
  tbl += `<tr><td>${s.slotIndex}</td><td>${esc(s.name)}</td>
42
51
  <td>${(h.cpuPercent ?? 0).toFixed(1)}</td>
@@ -44,25 +53,30 @@ export class PerformancePanel {
44
53
  <td>${(h.gcPauseMaxUs ?? 0).toFixed(0)}</td>
45
54
  <td>${(h.taskLatencyP95Us ?? 0).toFixed(0)}</td></tr>`;
46
55
  }
47
- tbl += `</table>`;
48
- const table = `<div class="snap-section"><h3>Threads</h3>${tbl}</div>`;
56
+ if (this.slots.length)
57
+ tbl += `</table>`;
58
+ const table = tbl ? `<div class="snap-section"><h3>Threads</h3>${tbl}</div>` : "";
49
59
  let prof = "";
50
60
  if (this.recording) {
51
61
  prof = `<div class="status-banner">● recording — press Stop to capture</div>`;
52
62
  }
63
+ else if (this.profile && !Array.isArray(this.profile.nodes)) {
64
+ prof = `<div class="status-banner error">malformed profile payload</div>`;
65
+ }
53
66
  else if (this.profile) {
54
- const top = [...this.profile.nodes].sort((a, b) => b.hitCount - a.hitCount).slice(0, 40);
55
- const total = Math.max(1, top.reduce((s, n) => s + n.hitCount, 0));
56
- const durMs = (this.profile.endUs - this.profile.startUs) / 1000;
57
- prof = `<div class="snap-section"><h3>CPU profile — ${this.profile.nodes.length} nodes, ${durMs.toFixed(0)} ms</h3>
58
- <div class="lines">${top.map((n) => esc(`${(100 * n.hitCount / total).toFixed(1).padStart(5)}% ${n.callFrame} ${n.url}:${n.line}`)).join("\n")}</div></div>`;
67
+ const nodes = this.profile.nodes;
68
+ const top = [...nodes].sort((a, b) => (b.hitCount ?? 0) - (a.hitCount ?? 0)).slice(0, 40);
69
+ const total = Math.max(1, top.reduce((s, n) => s + (n.hitCount ?? 0), 0));
70
+ const durMs = ((this.profile.endUs ?? 0) - (this.profile.startUs ?? 0)) / 1000;
71
+ prof = `<div class="snap-section"><h3>CPU profile — ${nodes.length} nodes, ${durMs.toFixed(0)} ms</h3>
72
+ <div class="lines">${top.map((n) => esc(`${(100 * (n.hitCount ?? 0) / total).toFixed(1).padStart(5)}% ${n.callFrame} ${n.url}:${n.line}`)).join("\n")}</div></div>`;
59
73
  }
60
- return chart + table + prof || `<div class="empty-note">no metrics — is ProfilingSAB attached?</div>`;
74
+ return chart + table + prof;
61
75
  }
62
76
  onBackendEvent(event, data) {
63
77
  if (event === "metrics") {
64
- this.slots = data ?? [];
65
- for (const s of this.slots) {
78
+ this.slots = Array.isArray(data) ? data : [];
79
+ for (const s of this.slots.values()) {
66
80
  const h = s.history?.[0];
67
81
  if (!h)
68
82
  continue;
@@ -71,8 +85,8 @@ export class PerformancePanel {
71
85
  rec = { cpu: [], heap: [] };
72
86
  this.history.set(s.slotIndex, rec);
73
87
  }
74
- rec.cpu.push(h.cpuPercent);
75
- rec.heap.push(h.heapUsed / 1048576);
88
+ rec.cpu.push(h.cpuPercent ?? 0);
89
+ rec.heap.push((h.heapUsed ?? 0) / 1048576);
76
90
  if (rec.cpu.length > HIST) {
77
91
  rec.cpu.shift();
78
92
  rec.heap.shift();
@@ -18,6 +18,7 @@ export class SnapshotPanel {
18
18
  slot;
19
19
  snap = null;
20
20
  updatedAt = "";
21
+ lastError = "";
21
22
  constructor(ctx, slot, name) {
22
23
  this.ctx = ctx;
23
24
  this.slot = slot;
@@ -32,19 +33,22 @@ export class SnapshotPanel {
32
33
  </div>`;
33
34
  }
34
35
  renderBody() {
36
+ const err = this.lastError
37
+ ? `<div class="status-banner error">${esc(this.lastError)}</div>` : "";
35
38
  const snap = this.snap;
36
39
  if (!snap)
37
- return `<div class="empty-note">no data</div>`;
40
+ return err + `<div class="empty-note">no data</div>`;
38
41
  const banner = snap.status && snap.status !== "ok"
39
42
  ? `<div class="status-banner${snap.status === "error" ? " error" : ""}">${esc(snap.statusMsg ?? snap.status)}</div>`
40
43
  : "";
41
- return banner + (snap.sections ?? []).map((s) => this.renderSection(s)).join("");
44
+ return err + banner + (snap.sections ?? []).map((s) => this.renderSection(s)).join("");
42
45
  }
43
46
  onBackendEvent(event, data) {
44
47
  if (event === "snapshot") {
45
48
  const d = data;
46
49
  if (d?.slot === this.slot) {
47
50
  this.snap = d.snap;
51
+ this.lastError = "";
48
52
  this.updatedAt = `updated ${new Date().toLocaleTimeString()}`;
49
53
  this.dirty = true;
50
54
  this.shellDirty = true;
@@ -61,18 +65,32 @@ export class SnapshotPanel {
61
65
  return;
62
66
  void this.ctx.call("command", {
63
67
  panel: this.slot, action: cmd, payload: data.payload ?? "",
64
- }).then(() => this.refresh());
68
+ }).then(() => this.refresh()).catch(() => { });
65
69
  }
66
70
  }
67
71
  activate() {
68
72
  void this.refresh();
69
73
  }
74
+ refreshSeq = 0;
70
75
  async refresh() {
76
+ // Overlapping refreshes (activate + provider push + manual Refresh) are
77
+ // last-call-wins — a slow stale response must not clobber a newer one.
78
+ const req = ++this.refreshSeq;
71
79
  try {
72
- this.snap = (await this.ctx.call("snapshot", { panel: this.slot }));
80
+ const snap = (await this.ctx.call("snapshot", { panel: this.slot }));
81
+ if (req !== this.refreshSeq)
82
+ return;
83
+ this.snap = snap;
84
+ this.lastError = "";
73
85
  this.updatedAt = `updated ${new Date().toLocaleTimeString()}`;
74
86
  }
75
- catch { /* keep last */ }
87
+ catch (err) {
88
+ if (req !== this.refreshSeq)
89
+ return;
90
+ // Keep the last good snapshot; surface the failure instead of
91
+ // pretending the data is current.
92
+ this.lastError = `refresh failed: ${String(err)}`;
93
+ }
76
94
  this.dirty = true;
77
95
  this.shellDirty = true;
78
96
  }
@@ -118,12 +136,17 @@ export class SnapshotPanel {
118
136
  return `<label><span class="dbtn${c.checked ? " on" : ""}" ${cmdAttr} data-payload="${c.checked ? "0" : "1"}">${c.checked ? "☑" : "☐"}</span> ${esc(c.label)}</label>`;
119
137
  }
120
138
  if (c.type === "slider") {
121
- const span = c.max - c.min || 1;
139
+ // Providers can emit a slider without min/max/value — coerce so the
140
+ // stepper never ships "NaN" payloads back through snap.cmd.
141
+ const min = Number.isFinite(c.min) ? c.min : 0;
142
+ const max = Number.isFinite(c.max) ? c.max : min + 1;
143
+ const value = Number.isFinite(c.value) ? c.value : min;
144
+ const span = max - min || 1;
122
145
  const step = span / 20;
123
- const dec = (d) => Math.min(c.max, Math.max(c.min, c.value + d * step)).toFixed(2);
146
+ const dec = (d) => Math.min(max, Math.max(min, value + d * step)).toFixed(2);
124
147
  return `<label>${esc(c.label)}
125
148
  <span class="dbtn" ${cmdAttr} data-payload="${dec(-1)}">−</span>
126
- <span class="val">${c.value.toFixed(2)}</span>
149
+ <span class="val">${value.toFixed(2)}</span>
127
150
  <span class="dbtn" ${cmdAttr} data-payload="${dec(1)}">+</span></label>`;
128
151
  }
129
152
  return esc(c.label ?? "");
@@ -39,7 +39,8 @@ export function chartHtml(values, opts = {}) {
39
39
  const step = Math.max(1, Math.floor(vals.length / cols));
40
40
  const cells = [];
41
41
  for (let i = 0; i < vals.length; i += step) {
42
- const h = Math.max(0, Math.min(100, (vals[i] / max) * 100));
42
+ const v = Number.isFinite(vals[i]) ? vals[i] : 0;
43
+ const h = Math.max(0, Math.min(100, (v / max) * 100));
43
44
  cells.push(`<div class="col" style="height:${h.toFixed(1)}%;background:${color}"></div>`);
44
45
  }
45
46
  if (!cells.length)
@@ -14,7 +14,7 @@ export const DEVTOOLS_CSS = `
14
14
  --warn: #e0b341;
15
15
  --err: #e5534b;
16
16
  --ok: #57ab5a;
17
- --mono: "SF Mono", "Cascadia Mono", Consolas, monospace;
17
+ --mono: "DevTools Mono", "SF Mono", "Cascadia Mono", Consolas, monospace;
18
18
  }
19
19
  * { box-sizing: border-box; margin: 0; padding: 0; }
20
20
  /* height:100% must be on html AND body — Blitz resolves body % against
@@ -22,7 +22,7 @@ export const DEVTOOLS_CSS = `
22
22
  html, body {
23
23
  width: 100%; height: 100%; overflow: hidden;
24
24
  background: var(--bg); color: var(--fg);
25
- font: 12px/1.45 system-ui, sans-serif;
25
+ font: 12px/1.45 "DevTools Sans", system-ui, sans-serif;
26
26
  }
27
27
  #dt-root {
28
28
  /* Absolute inset — Blitz doesn't resolve height:100% against the doc
@@ -50,7 +50,11 @@ html, body {
50
50
  display: flex; align-items: center; gap: 2px;
51
51
  background: var(--bg2); border-bottom: 1px solid var(--line);
52
52
  padding: 0 8px; height: 30px; flex-shrink: 0;
53
+ /* Provider tabs can overflow the strip — wheel-scroll it instead of
54
+ * clipping tabs unreachable. */
55
+ overflow-x: auto; overflow-y: hidden;
53
56
  }
57
+ #dt-tabs .tab, #dt-tabs .brand, #dt-tabs .hint { flex-shrink: 0; }
54
58
  #dt-tabs .brand { color: var(--accent); font-weight: 600; margin-right: 12px; }
55
59
  #dt-tabs .spacer { flex: 1; }
56
60
  #dt-tabs .hint { font-size: 11px; color: var(--fg-dim); }
@@ -124,14 +128,32 @@ html, body {
124
128
  .console-repl input:focus { border-color: var(--accent); }
125
129
  .console-repl .prompt { color: var(--accent); font-family: var(--mono); }
126
130
 
127
- /* ── Tree (scene/dom/ecs) ── */
131
+ /* ── Tree (scene/ecs) ── */
128
132
  .tree { font-family: var(--mono); font-size: 11.5px; }
129
- .trow { padding: 1px 4px; white-space: nowrap; }
133
+ .trow { padding: 1px 4px; white-space: nowrap; cursor: default; }
130
134
  .trow:hover { background: var(--bg3); }
131
135
  .trow.sel { background: #2d4a72; }
136
+ .trow .caret {
137
+ display: inline-block; width: 12px; color: var(--fg-dim);
138
+ cursor: pointer; user-select: none;
139
+ }
140
+ .trow .caret:hover { color: var(--fg); }
132
141
  .trow .detail { color: var(--fg-dim); margin-left: 8px; }
133
142
  .trow .kids { color: var(--fg-dim); }
134
143
 
144
+ /* ── Elements details strip (bottom chrome) ── */
145
+ .el-details { border-top: 1px solid var(--line); background: var(--bg); }
146
+ .el-details-head {
147
+ display: flex; align-items: center; gap: 8px;
148
+ padding: 3px 8px; border-bottom: 1px solid var(--line);
149
+ }
150
+ .el-details-head .dt-title { color: var(--accent); font-weight: 600; }
151
+ .el-details-head .spacer { flex: 1; }
152
+ .el-details-body {
153
+ max-height: 140px; overflow-y: auto; padding: 6px 8px;
154
+ font-family: var(--mono); font-size: 11.5px; white-space: pre-wrap;
155
+ }
156
+
135
157
  /* ── KV / tables / snapshot panels ── */
136
158
  .snap-section { margin-bottom: 14px; }
137
159
  .snap-section > h3 {
@@ -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);
@@ -98,7 +98,7 @@ function renderNode(v) {
98
98
  return out.join("");
99
99
  }
100
100
  if (typeof t === "function") {
101
- const r = t({ ...(p ?? {}) });
101
+ const r = t({ ...p });
102
102
  const out = [];
103
103
  renderInner(r, out);
104
104
  return out.join("");
@@ -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"] }
@@ -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 */ }