@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.
Files changed (62) hide show
  1. package/core/src/index.ts +2 -2
  2. package/core/src/platform/rpc.ts +4 -1
  3. package/core/src/render/game-renderer.ts +127 -64
  4. package/core/src/sab/grid-buffer.ts +55 -2
  5. package/core/src/sab/record-buffer.ts +48 -5
  6. package/core/src/sab/sim-channel.ts +181 -17
  7. package/core/src/sab/slot-buffer.ts +55 -1
  8. package/core/src/sab/types.ts +11 -0
  9. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  10. package/core/src/util/index.ts +1 -0
  11. package/core/src/util/session-timer.ts +23 -0
  12. package/core/src/worker/base-worker-host.ts +2 -1
  13. package/core/src/worker/buffer-sync.ts +20 -13
  14. package/core/src/worker/rpc.ts +54 -5
  15. package/core/src/worker/sim-worker-base.ts +5 -2
  16. package/core/src/worker/task-pool.ts +4 -0
  17. package/dist/.dd-manifest.json +861 -851
  18. package/dist/core/src/index.js +2 -2
  19. package/dist/core/src/platform/rpc.js +2 -1
  20. package/dist/core/src/render/game-renderer.js +125 -59
  21. package/dist/core/src/sab/grid-buffer.js +53 -1
  22. package/dist/core/src/sab/record-buffer.js +49 -5
  23. package/dist/core/src/sab/sim-channel.js +173 -17
  24. package/dist/core/src/sab/slot-buffer.js +54 -1
  25. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  26. package/dist/core/src/util/index.js +1 -0
  27. package/dist/core/src/util/session-timer.js +22 -0
  28. package/dist/core/src/worker/base-worker-host.js +2 -1
  29. package/dist/core/src/worker/buffer-sync.js +16 -9
  30. package/dist/core/src/worker/rpc.js +46 -8
  31. package/dist/core/src/worker/sim-worker-base.js +5 -2
  32. package/dist/core/src/worker/task-pool.js +5 -0
  33. package/dist/libraries/devtools/src/backend.js +35 -9
  34. package/dist/libraries/devtools/src/collectors.js +73 -22
  35. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  36. package/dist/modules/devtools/src/blitz/host.js +124 -30
  37. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  38. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  39. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  40. package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
  41. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  42. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  43. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  44. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  45. package/dist/modules/html-ui/src/host.js +6 -3
  46. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  47. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  48. package/libraries/devtools/src/backend.ts +38 -12
  49. package/libraries/devtools/src/collectors.ts +75 -22
  50. package/modules/devtools/src/blitz/fonts.ts +78 -0
  51. package/modules/devtools/src/blitz/host.ts +107 -31
  52. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  53. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  54. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  55. package/modules/devtools/src/blitz/panels/performance.ts +24 -12
  56. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  57. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  58. package/modules/devtools/src/blitz/theme.ts +26 -4
  59. package/modules/html-ui/src/doc-backend.ts +67 -4
  60. package/modules/html-ui/src/host.ts +11 -3
  61. package/modules/html-ui/src/protocol.ts +1 -1
  62. 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
- 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);
@@ -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 */ }
@@ -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 { DtPanel, DtPanelCtx } from "./panels/types";
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
- this.panel = this.opts.ui.mount(this.shellHtml(), {
158
- id: "devtools",
159
- rect: this.dockRect(),
160
- // Above game overlays — menus/loading screens shouldn't bury the dock.
161
- z: 1000,
162
- scale: 1.5,
163
- interactive: true,
164
- // Devtools UI doesn't need vsync-rate raster — cap it so mutation
165
- // bursts (console streams, snapshot refreshes) coalesce into ~66ms
166
- // rasters instead of one raster per message.
167
- maxFps: 15,
168
- onEvent: (ev) => this.handleDocEvent(ev),
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
- this.syncProviders();
194
- this.flush();
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
- const h = Math.min(this.dockH, this.surfaceH - 60);
248
- return { x: 0, y: this.surfaceH - h, w: this.surfaceW, h };
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
- this.panel.setInnerHtml("#dt-top", p.renderTop?.() ?? "");
284
- this.panel.setInnerHtml("#dt-bottom", p.renderBottom?.() ?? "");
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", p.renderBody());
288
- p.afterRender?.();
289
- p.activate?.();
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
- if (p.flushDom?.() !== true) {
350
- this.panel.setInnerHtml("#dt-body", p.renderBody());
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
- p.afterRender?.();
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 ((ev.t === "click" || ev.t === "contextmenu") && action) {
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
  }