@downdraft/engine 0.2.0 → 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.
- package/core/src/index.ts +2 -2
- package/core/src/platform/rpc.ts +4 -1
- package/core/src/render/game-renderer.ts +127 -64
- package/core/src/sab/grid-buffer.ts +55 -2
- package/core/src/sab/record-buffer.ts +48 -5
- package/core/src/sab/sim-channel.ts +181 -17
- package/core/src/sab/slot-buffer.ts +55 -1
- package/core/src/sab/types.ts +11 -0
- package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
- package/core/src/util/index.ts +1 -0
- package/core/src/util/session-timer.ts +23 -0
- package/core/src/worker/base-worker-host.ts +2 -1
- package/core/src/worker/buffer-sync.ts +20 -13
- package/core/src/worker/rpc.ts +54 -5
- package/core/src/worker/sim-worker-base.ts +5 -2
- package/core/src/worker/task-pool.ts +4 -0
- package/dist/.dd-manifest.json +861 -851
- package/dist/core/src/index.js +2 -2
- package/dist/core/src/platform/rpc.js +2 -1
- package/dist/core/src/render/game-renderer.js +125 -59
- package/dist/core/src/sab/grid-buffer.js +53 -1
- package/dist/core/src/sab/record-buffer.js +49 -5
- package/dist/core/src/sab/sim-channel.js +173 -17
- package/dist/core/src/sab/slot-buffer.js +54 -1
- package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
- package/dist/core/src/util/index.js +1 -0
- package/dist/core/src/util/session-timer.js +22 -0
- package/dist/core/src/worker/base-worker-host.js +2 -1
- package/dist/core/src/worker/buffer-sync.js +16 -9
- package/dist/core/src/worker/rpc.js +46 -8
- package/dist/core/src/worker/sim-worker-base.js +5 -2
- package/dist/core/src/worker/task-pool.js +5 -0
- package/dist/libraries/devtools/src/backend.js +35 -9
- package/dist/libraries/devtools/src/collectors.js +73 -22
- package/dist/modules/devtools/src/blitz/fonts.js +75 -0
- package/dist/modules/devtools/src/blitz/host.js +124 -30
- package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
- package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
- package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
- package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
- package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
- package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
- package/dist/modules/devtools/src/blitz/theme.js +26 -4
- package/dist/modules/html-ui/src/doc-backend.js +52 -3
- package/dist/modules/html-ui/src/host.js +6 -3
- package/libraries/blitz-ui/native/Cargo.toml +6 -0
- package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
- package/libraries/devtools/src/backend.ts +38 -12
- package/libraries/devtools/src/collectors.ts +75 -22
- package/modules/devtools/src/blitz/fonts.ts +78 -0
- package/modules/devtools/src/blitz/host.ts +107 -31
- package/modules/devtools/src/blitz/panels/console.ts +20 -8
- package/modules/devtools/src/blitz/panels/elements.ts +212 -31
- package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
- package/modules/devtools/src/blitz/panels/performance.ts +24 -12
- package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
- package/modules/devtools/src/blitz/panels/types.ts +2 -1
- package/modules/devtools/src/blitz/theme.ts +26 -4
- package/modules/html-ui/src/doc-backend.ts +67 -4
- package/modules/html-ui/src/host.ts +11 -3
- package/modules/html-ui/src/protocol.ts +1 -1
- package/package.json +2 -2
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
|
-
// panels/elements.ts — Elements panel: scene graph /
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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",
|
|
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("
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
162
|
+
// "scene" pushes feed scene mode; "dom" pushes feed ECS mode.
|
|
41
163
|
if (event === "scene" && this.mode === "scene") {
|
|
42
|
-
this.
|
|
43
|
-
this.dirty = true;
|
|
164
|
+
this.applyRows(data ?? []);
|
|
44
165
|
}
|
|
45
|
-
else if (event === "dom" && this.mode
|
|
46
|
-
this.
|
|
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
|
-
|
|
171
|
+
const act = data.action;
|
|
172
|
+
if (act === "el.refresh") {
|
|
52
173
|
void this.refresh();
|
|
53
174
|
}
|
|
54
|
-
else if (
|
|
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:
|
|
71
|
-
this.
|
|
257
|
+
: await this.ctx.call("domTree", { mode: "ecs" });
|
|
258
|
+
this.lastError = "";
|
|
259
|
+
this.applyRows(rows ?? []);
|
|
72
260
|
}
|
|
73
|
-
catch {
|
|
74
|
-
|
|
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;
|
|
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
|
|
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)
|
|
@@ -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,9 +39,12 @@ 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 =
|
|
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>`;
|
|
47
|
+
}
|
|
39
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>
|
|
@@ -44,24 +53,29 @@ 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
|
-
|
|
48
|
-
|
|
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
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
74
|
+
return chart + table + prof;
|
|
61
75
|
}
|
|
62
76
|
onBackendEvent(event, data) {
|
|
63
77
|
if (event === "metrics") {
|
|
64
|
-
this.slots = data
|
|
78
|
+
this.slots = Array.isArray(data) ? data : [];
|
|
65
79
|
for (const s of this.slots.values()) {
|
|
66
80
|
const h = s.history?.[0];
|
|
67
81
|
if (!h)
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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">${
|
|
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
|
|
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/
|
|
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)
|
|
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.
|
|
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);
|