@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
|
@@ -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
|
-
|
|
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 =
|
|
781
|
+
const timer = untrackedTimeout(() => {
|
|
779
782
|
if (this.retiredTex.delete(key))
|
|
780
783
|
t.destroy();
|
|
781
784
|
}, 1000);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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 |
|
|
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(
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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 ${
|
|
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 :
|
|
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 :
|
|
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
|
-
|
|
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 (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 */ }
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// fonts.ts — bind the host's real system fonts into the devtools dock doc.
|
|
3
|
+
//
|
|
4
|
+
// The OSR rasterizer ships a single embedded DejaVu Sans as the fallback for
|
|
5
|
+
// every generic family — legible, but `monospace` resolves to it too, so the
|
|
6
|
+
// console/REPL/tree read as proportional sans. Same fix as cruiser's
|
|
7
|
+
// libraries/app-shell/fonts.ts: resolve the user's actual sans + mono faces
|
|
8
|
+
// via fontconfig (fc-match -f %{file}), register them as `ui://` blobs on the
|
|
9
|
+
// HtmlUiHost before mount, and emit @font-face rules the dock stylesheet
|
|
10
|
+
// binds ahead of the generic fallbacks. Any failure (no fc-match, non-Linux
|
|
11
|
+
// host, unreadable file) leaves the embedded fallback in place.
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
/** Resource sink — HtmlUiHost.registerResource, or anything shaped like it. */
|
|
18
|
+
type ResourceSink = { registerResource(url: string, bytes: Uint8Array | ArrayBuffer): void };
|
|
19
|
+
|
|
20
|
+
interface Face { family: string; pattern: string; urlName: string; weight: number }
|
|
21
|
+
|
|
22
|
+
// Blitz-dom only fetches a @font-face source whose format is detectable —
|
|
23
|
+
// either an explicit format() hint or a file extension on the url — so the
|
|
24
|
+
// urls carry the resolved file's real extension.
|
|
25
|
+
const FACES: Face[] = [
|
|
26
|
+
{ family: "DevTools Sans", pattern: "sans-serif", urlName: "sans", weight: 400 },
|
|
27
|
+
{ family: "DevTools Sans", pattern: "sans-serif:weight=bold", urlName: "sans-bold", weight: 700 },
|
|
28
|
+
{ family: "DevTools Mono", pattern: "monospace", urlName: "mono", weight: 400 },
|
|
29
|
+
{ family: "DevTools Mono", pattern: "monospace:weight=bold", urlName: "mono-bold", weight: 700 },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** `fc-match -f %{file}` for a fontconfig pattern; null when fc-match is
|
|
33
|
+
* absent or the match isn't a font file Blitz can load. */
|
|
34
|
+
function fcFontFile(pattern: string): string | null {
|
|
35
|
+
try {
|
|
36
|
+
const p = execFileSync("fc-match", ["-f", "%{file}", pattern], { timeout: 5000 }).toString().trim();
|
|
37
|
+
return /\.(ttf|otf|woff2?)$/i.test(p) ? p : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fontExt(path: string): string {
|
|
44
|
+
return path.match(/\.(ttf|otf|woff2?)$/i)?.[0] ?? ".ttf";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register system sans + mono faces as `ui://` resources and return the
|
|
49
|
+
* @font-face CSS to prepend to the dock stylesheet. Returns "" when nothing
|
|
50
|
+
* registered — the stylesheet's named families then fail to resolve and the
|
|
51
|
+
* generic fallbacks (embedded DejaVu) apply.
|
|
52
|
+
*
|
|
53
|
+
* Call before mount — the doc backend applies resources synchronously and in
|
|
54
|
+
* order, so the first doc resolve sees them.
|
|
55
|
+
*/
|
|
56
|
+
export function registerDevtoolsFonts(sink: ResourceSink): string {
|
|
57
|
+
try {
|
|
58
|
+
// Both weights of a family are required: a 700 rule pointing at the
|
|
59
|
+
// regular file keeps bold text in-family instead of falling back.
|
|
60
|
+
const files = new Map<string, string>();
|
|
61
|
+
for (const f of FACES) files.set(f.urlName, fcFontFile(f.pattern) ?? "");
|
|
62
|
+
if (!files.get("sans")) return "";
|
|
63
|
+
if (!files.get("mono")) files.set("mono", files.get("sans")!);
|
|
64
|
+
if (!files.get("sans-bold")) files.set("sans-bold", files.get("sans")!);
|
|
65
|
+
if (!files.get("mono-bold")) files.set("mono-bold", files.get("mono")!);
|
|
66
|
+
let css = "";
|
|
67
|
+
for (const f of FACES) {
|
|
68
|
+
const file = files.get(f.urlName)!;
|
|
69
|
+
const url = `ui://devtools-fonts/${f.urlName}${fontExt(file)}`;
|
|
70
|
+
sink.registerResource(url, readFileSync(file));
|
|
71
|
+
css += `@font-face { font-family:'${f.family}'; font-weight:${f.weight}; src:url('${url}'); }\n`;
|
|
72
|
+
}
|
|
73
|
+
return css;
|
|
74
|
+
} catch {
|
|
75
|
+
// fc-match absent / unreadable font file — the embedded fallback applies.
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -16,12 +16,13 @@ import { createLogger } from "@downdraft/engine";
|
|
|
16
16
|
import { CdpBridge, DevtoolsBackend } from "@downdraft/engine/libraries/devtools";
|
|
17
17
|
import { HtmlUiHost, type OsrDomEvent, type UiPanelHandle } from "@downdraft/engine/modules/html-ui";
|
|
18
18
|
|
|
19
|
+
import { registerDevtoolsFonts } from "./fonts";
|
|
19
20
|
import { ConsolePanel } from "./panels/console";
|
|
20
21
|
import { ElementsPanel } from "./panels/elements";
|
|
21
22
|
import { GpuPanel } from "./panels/gpu";
|
|
22
23
|
import { PerformancePanel } from "./panels/performance";
|
|
23
24
|
import { SnapshotPanel } from "./panels/snapshot";
|
|
24
|
-
import type
|
|
25
|
+
import { esc, type DtPanel, type DtPanelCtx } from "./panels/types";
|
|
25
26
|
import { DEVTOOLS_CSS } from "./theme";
|
|
26
27
|
|
|
27
28
|
const log = createLogger("info");
|
|
@@ -35,12 +36,17 @@ export interface DevtoolsUiSurface {
|
|
|
35
36
|
z?: number;
|
|
36
37
|
scale?: number;
|
|
37
38
|
maxFps?: number;
|
|
39
|
+
/** Ease wheel deltas at pump cadence (html-ui PanelSpec.smoothWheel). */
|
|
40
|
+
smoothWheel?: boolean;
|
|
38
41
|
interactive?: boolean;
|
|
39
42
|
onEvent?: (ev: OsrDomEvent) => void;
|
|
40
43
|
}): UiPanelHandle;
|
|
41
44
|
/** Doc-backend capabilities — panels use incremental DOM ops only when
|
|
42
45
|
* the underlying cdylib provides them. */
|
|
43
46
|
docCaps?(): { incrementalDom?: boolean };
|
|
47
|
+
/** Register a `ui://` resource (fonts) served to the dock doc — must run
|
|
48
|
+
* before mount. Absent on surfaces that don't own a resource table. */
|
|
49
|
+
registerResource?(url: string, bytes: Uint8Array | ArrayBuffer): void;
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
export interface BlitzDevtoolsOptions {
|
|
@@ -83,6 +89,9 @@ export class BlitzDevtoolsHost {
|
|
|
83
89
|
private panelCtx: DtPanelCtx;
|
|
84
90
|
private lastPanelRender = -1e9;
|
|
85
91
|
private lastSlowLog = 0;
|
|
92
|
+
/** @font-face css from registerDevtoolsFonts — "" means "embedded font
|
|
93
|
+
* fallback", null means "not yet attempted". */
|
|
94
|
+
private fontCss: string | null = null;
|
|
86
95
|
|
|
87
96
|
constructor(opts: BlitzDevtoolsOptions) {
|
|
88
97
|
this.opts = opts;
|
|
@@ -154,19 +163,41 @@ export class BlitzDevtoolsHost {
|
|
|
154
163
|
|
|
155
164
|
show(): void {
|
|
156
165
|
if (!this._ready || this.visible || this.disposed) return;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
// Register real system fonts once per host — ui:// resources persist on
|
|
167
|
+
// the surface across dock remounts, and must land before the doc mounts
|
|
168
|
+
// so the first resolve sees them (embedded DejaVu fallback otherwise).
|
|
169
|
+
if (this.fontCss === null) {
|
|
170
|
+
const ui = this.opts.ui as DevtoolsUiSurface & { host?: HtmlUiHost };
|
|
171
|
+
const rr = ui.registerResource?.bind(ui)
|
|
172
|
+
?? (ui.host ? ui.host.registerResource.bind(ui.host) : undefined);
|
|
173
|
+
this.fontCss = rr ? registerDevtoolsFonts({ registerResource: rr }) : "";
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
this.panel = this.opts.ui.mount(this.shellHtml(), {
|
|
177
|
+
id: "devtools",
|
|
178
|
+
rect: this.dockRect(),
|
|
179
|
+
// Above game overlays — menus/loading screens shouldn't bury the dock.
|
|
180
|
+
z: 1000,
|
|
181
|
+
// Native-res raster: the vendored glifo/FreeType path grid-fits hinted
|
|
182
|
+
// glyph stems to whole pixels — supersample factors >1 just blur that
|
|
183
|
+
// back out on downscale (and cost 2.25x the texture memory).
|
|
184
|
+
scale: 1,
|
|
185
|
+
// Ease wheel detents at pump cadence instead of jumping per event.
|
|
186
|
+
smoothWheel: true,
|
|
187
|
+
interactive: true,
|
|
188
|
+
// Devtools UI doesn't need vsync-rate raster — cap it so mutation
|
|
189
|
+
// bursts (console streams, snapshot refreshes) coalesce into ~66ms
|
|
190
|
+
// rasters instead of one raster per message.
|
|
191
|
+
maxFps: 15,
|
|
192
|
+
onEvent: (ev) => this.handleDocEvent(ev),
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// Mount fails when the ui worker is dead — stay hidden so the next F12
|
|
196
|
+
// retries cleanly instead of wedging on a half-mounted doc.
|
|
197
|
+
this.panel = null;
|
|
198
|
+
log.error("blitz-devtools-host", `dock mount failed: ${err}`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
170
201
|
this.syncProviders();
|
|
171
202
|
this.renderTabs();
|
|
172
203
|
this.activate(this.activeId);
|
|
@@ -190,8 +221,18 @@ export class BlitzDevtoolsHost {
|
|
|
190
221
|
}
|
|
191
222
|
const t1 = performance.now();
|
|
192
223
|
if (!this.visible) return;
|
|
193
|
-
|
|
194
|
-
this.
|
|
224
|
+
// Guarded like backend.update() above — a throwing panel render can't be
|
|
225
|
+
// allowed to kill the game frame hook that drives this pump.
|
|
226
|
+
try {
|
|
227
|
+
this.syncProviders();
|
|
228
|
+
this.flush();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const tn = performance.now();
|
|
231
|
+
if (tn - this.lastSlowLog > 1000) {
|
|
232
|
+
this.lastSlowLog = tn;
|
|
233
|
+
log.warn("blitz-devtools-host", `panel render error: ${err}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
195
236
|
const t2 = performance.now();
|
|
196
237
|
// ≤1/sec — these diagnostics used to feed the console panel's logger
|
|
197
238
|
// sink, and each row forced another dock re-render: a self-feeding
|
|
@@ -244,12 +285,14 @@ export class BlitzDevtoolsHost {
|
|
|
244
285
|
// ── Dock layout ──
|
|
245
286
|
|
|
246
287
|
private dockRect() {
|
|
247
|
-
|
|
248
|
-
|
|
288
|
+
// A tiny surface (or a pathological resize event) must not produce a
|
|
289
|
+
// negative height — keep at least enough room for the tab strip.
|
|
290
|
+
const h = Math.max(48, Math.min(this.dockH, this.surfaceH - 60));
|
|
291
|
+
return { x: 0, y: Math.max(0, this.surfaceH - h), w: this.surfaceW, h };
|
|
249
292
|
}
|
|
250
293
|
|
|
251
294
|
private shellHtml(): string {
|
|
252
|
-
return `<html><head><style>${DEVTOOLS_CSS}</style></head><body>
|
|
295
|
+
return `<html><head><style>${this.fontCss ?? ""}${DEVTOOLS_CSS}</style></head><body>
|
|
253
296
|
<div id="dt-root">
|
|
254
297
|
<div id="dt-grip" title="drag to resize"></div>
|
|
255
298
|
<div id="dt-chrome">
|
|
@@ -266,8 +309,11 @@ export class BlitzDevtoolsHost {
|
|
|
266
309
|
|
|
267
310
|
private renderTabs(): void {
|
|
268
311
|
if (!this.panel) return;
|
|
312
|
+
// esc() both interpolations — provider names feed panel ids/titles and
|
|
313
|
+
// arrive as raw strings (a game registering "x\"><img" would otherwise
|
|
314
|
+
// inject markup into the tab strip).
|
|
269
315
|
const tabs = [...this.panels.values()].map((p) =>
|
|
270
|
-
`<span class="tab${p.id === this.activeId ? " active" : ""}" data-action="dt.tab" data-tab="${p.id}">${p.title}</span>`
|
|
316
|
+
`<span class="tab${p.id === this.activeId ? " active" : ""}" data-action="dt.tab" data-tab="${esc(p.id)}">${esc(p.title)}</span>`
|
|
271
317
|
).join("");
|
|
272
318
|
this.panel.setInnerHtml("#dt-tabs",
|
|
273
319
|
`<span class="brand">downdraft</span>${tabs}<span class="spacer"></span>
|
|
@@ -280,13 +326,21 @@ export class BlitzDevtoolsHost {
|
|
|
280
326
|
this.activeId = id;
|
|
281
327
|
const p = this.panels.get(id)!;
|
|
282
328
|
this.renderTabs();
|
|
283
|
-
|
|
284
|
-
|
|
329
|
+
try {
|
|
330
|
+
this.panel.setInnerHtml("#dt-top", p.renderTop?.() ?? "");
|
|
331
|
+
this.panel.setInnerHtml("#dt-bottom", p.renderBottom?.() ?? "");
|
|
332
|
+
} catch (err) {
|
|
333
|
+
log.warn("blitz-devtools-host", `panel ${id} chrome render error: ${err}`);
|
|
334
|
+
}
|
|
285
335
|
p.dirty = true;
|
|
286
336
|
p.shellDirty = false;
|
|
287
|
-
this.panel.setInnerHtml("#dt-body",
|
|
288
|
-
p.afterRender?.();
|
|
289
|
-
|
|
337
|
+
this.panel.setInnerHtml("#dt-body", this.safeBody(p));
|
|
338
|
+
try { p.afterRender?.(); } catch (err) {
|
|
339
|
+
log.warn("blitz-devtools-host", `panel ${id} afterRender error: ${err}`);
|
|
340
|
+
}
|
|
341
|
+
try { p.activate?.(); } catch (err) {
|
|
342
|
+
log.warn("blitz-devtools-host", `panel ${id} activate error: ${err}`);
|
|
343
|
+
}
|
|
290
344
|
// Only the snapshot tab being viewed gets periodic provider refreshes;
|
|
291
345
|
// fixed panels suspend auto-refresh entirely (manual Refresh still works).
|
|
292
346
|
this.backend.providerWatch = p.providerSlot ?? -1;
|
|
@@ -346,10 +400,25 @@ export class BlitzDevtoolsHost {
|
|
|
346
400
|
}
|
|
347
401
|
if (p.dirty) {
|
|
348
402
|
p.dirty = false;
|
|
349
|
-
|
|
350
|
-
|
|
403
|
+
let handled = false;
|
|
404
|
+
try { handled = p.flushDom?.() === true; } catch (err) {
|
|
405
|
+
log.warn("blitz-devtools-host", `panel ${p.id} flushDom error: ${err}`);
|
|
406
|
+
}
|
|
407
|
+
if (!handled) this.panel.setInnerHtml("#dt-body", this.safeBody(p));
|
|
408
|
+
try { p.afterRender?.(); } catch (err) {
|
|
409
|
+
log.warn("blitz-devtools-host", `panel ${p.id} afterRender error: ${err}`);
|
|
351
410
|
}
|
|
352
|
-
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** renderBody that can't take the dock down — the error surfaces in-panel
|
|
415
|
+
* (and the next push retries since dirty just flips back on). */
|
|
416
|
+
private safeBody(p: DtPanel): string {
|
|
417
|
+
try {
|
|
418
|
+
return p.renderBody();
|
|
419
|
+
} catch (err) {
|
|
420
|
+
log.warn("blitz-devtools-host", `panel ${p.id} renderBody error: ${err}`);
|
|
421
|
+
return `<div class="status-banner error">panel render error: ${esc(String(err))}</div>`;
|
|
353
422
|
}
|
|
354
423
|
}
|
|
355
424
|
|
|
@@ -387,20 +456,26 @@ export class BlitzDevtoolsHost {
|
|
|
387
456
|
}
|
|
388
457
|
}
|
|
389
458
|
|
|
459
|
+
// Left-click only — routing actions on contextmenu meant a right-click
|
|
460
|
+
// on a tab/button fired it instead of just focusing.
|
|
390
461
|
const action = ev.d?.action;
|
|
391
|
-
if (
|
|
462
|
+
if (ev.t === "click" && action) {
|
|
392
463
|
if (action === "dt.tab") {
|
|
393
464
|
const id = ev.d!.tab;
|
|
394
465
|
if (id) this.activate(id);
|
|
395
466
|
return;
|
|
396
467
|
}
|
|
397
468
|
if (action === "dt.close") { this.hide(); return; }
|
|
398
|
-
this.panels.get(this.activeId)?.onAction?.(ev.d!, ev);
|
|
469
|
+
try { this.panels.get(this.activeId)?.onAction?.(ev.d!, ev); } catch (err) {
|
|
470
|
+
log.warn("blitz-devtools-host", `panel action error: ${err}`);
|
|
471
|
+
}
|
|
399
472
|
return;
|
|
400
473
|
}
|
|
401
474
|
|
|
402
475
|
// Everything else (input, keydown/up, scroll, focus…) → active panel.
|
|
403
|
-
this.panels.get(this.activeId)?.onEvent?.(ev);
|
|
476
|
+
try { this.panels.get(this.activeId)?.onEvent?.(ev); } catch (err) {
|
|
477
|
+
log.warn("blitz-devtools-host", `panel event error: ${err}`);
|
|
478
|
+
}
|
|
404
479
|
}
|
|
405
480
|
}
|
|
406
481
|
|
|
@@ -426,5 +501,6 @@ export function createSelfHostedDevtoolsUi(ctx: RendererModuleContext): Devtools
|
|
|
426
501
|
host,
|
|
427
502
|
mount: (markup, spec) => host.mount({ ...spec, html: markup }),
|
|
428
503
|
docCaps: () => host.docCaps,
|
|
504
|
+
registerResource: (url, bytes) => host.registerResource(url, bytes),
|
|
429
505
|
};
|
|
430
506
|
}
|