@downdraft/engine 0.1.2-alpha.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/app/src/renderer/bootstrap.ts +1 -1
  2. package/app/src/renderer/feature-log.ts +1 -1
  3. package/core/src/index.ts +2 -2
  4. package/core/src/input/local-player-manager.ts +2 -2
  5. package/core/src/platform/rpc.ts +4 -1
  6. package/core/src/render/game-renderer.ts +127 -64
  7. package/core/src/sab/grid-buffer.ts +55 -2
  8. package/core/src/sab/record-buffer.ts +48 -5
  9. package/core/src/sab/sim-channel.ts +181 -17
  10. package/core/src/sab/slot-buffer.ts +55 -1
  11. package/core/src/sab/types.ts +11 -0
  12. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  13. package/core/src/util/index.ts +1 -0
  14. package/core/src/util/logger.ts +2 -2
  15. package/core/src/util/session-timer.ts +23 -0
  16. package/core/src/worker/base-worker-host.ts +2 -1
  17. package/core/src/worker/buffer-sync.ts +20 -13
  18. package/core/src/worker/rpc.ts +54 -5
  19. package/core/src/worker/sim-worker-base.ts +6 -3
  20. package/core/src/worker/task-pool.ts +4 -0
  21. package/dist/.dd-manifest.json +879 -869
  22. package/dist/app/src/renderer/bootstrap.js +1 -1
  23. package/dist/app/src/renderer/feature-log.js +1 -1
  24. package/dist/core/src/index.js +2 -2
  25. package/dist/core/src/input/local-player-manager.js +2 -2
  26. package/dist/core/src/platform/rpc.js +2 -1
  27. package/dist/core/src/render/game-renderer.js +125 -59
  28. package/dist/core/src/sab/grid-buffer.js +53 -1
  29. package/dist/core/src/sab/record-buffer.js +49 -5
  30. package/dist/core/src/sab/sim-channel.js +173 -17
  31. package/dist/core/src/sab/slot-buffer.js +54 -1
  32. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  33. package/dist/core/src/util/index.js +1 -0
  34. package/dist/core/src/util/logger.js +2 -2
  35. package/dist/core/src/util/session-timer.js +22 -0
  36. package/dist/core/src/worker/base-worker-host.js +2 -1
  37. package/dist/core/src/worker/buffer-sync.js +16 -9
  38. package/dist/core/src/worker/rpc.js +46 -8
  39. package/dist/core/src/worker/sim-worker-base.js +6 -3
  40. package/dist/core/src/worker/task-pool.js +5 -0
  41. package/dist/libraries/devtools/src/backend.js +35 -9
  42. package/dist/libraries/devtools/src/collectors.js +73 -22
  43. package/dist/libraries/gamepad/src/feed-input.js +1 -1
  44. package/dist/libraries/gamepad/src/hub.js +2 -2
  45. package/dist/libraries/gamepad/src/source-sab.js +2 -2
  46. package/dist/libraries/gamepad/src/sysfs-enrich.js +2 -2
  47. package/dist/libraries/gpu-kernels/src/kernel.js +1 -1
  48. package/dist/libraries/gpu-kernels/src/transpile.js +1 -1
  49. package/dist/libraries/html-ui-kit/src/nav/nav-controller.js +1 -1
  50. package/dist/libraries/html-ui-kit/src/nav/nav-input.js +2 -2
  51. package/dist/libraries/networking/src/discovery.js +8 -8
  52. package/dist/libraries/persistence/src/file-save-store.js +5 -5
  53. package/dist/libraries/persistence/src/opfs-save-store.js +2 -2
  54. package/dist/modules/controller-ui/src/index.js +1 -1
  55. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  56. package/dist/modules/devtools/src/blitz/host.js +127 -33
  57. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  58. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  59. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  60. package/dist/modules/devtools/src/blitz/panels/performance.js +28 -14
  61. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  62. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  63. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  64. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  65. package/dist/modules/html-ui/src/host.js +6 -3
  66. package/dist/modules/html-ui/src/jsx-runtime.js +1 -1
  67. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  68. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  69. package/libraries/devtools/src/backend.ts +38 -12
  70. package/libraries/devtools/src/collectors.ts +75 -22
  71. package/libraries/gamepad/src/feed-input.ts +1 -1
  72. package/libraries/gamepad/src/hub.ts +2 -2
  73. package/libraries/gamepad/src/source-sab.ts +2 -2
  74. package/libraries/gamepad/src/sysfs-enrich.ts +2 -2
  75. package/libraries/gpu-kernels/src/kernel.ts +2 -2
  76. package/libraries/gpu-kernels/src/transpile.ts +1 -1
  77. package/libraries/html-ui-kit/src/nav/nav-controller.ts +1 -1
  78. package/libraries/html-ui-kit/src/nav/nav-input.ts +2 -2
  79. package/libraries/networking/src/discovery.ts +8 -8
  80. package/libraries/persistence/src/file-save-store.ts +5 -5
  81. package/libraries/persistence/src/opfs-save-store.ts +2 -2
  82. package/libraries/physics-rapier/src/realm-worker-script.ts +1 -1
  83. package/modules/controller-ui/src/index.ts +1 -1
  84. package/modules/devtools/src/blitz/fonts.ts +78 -0
  85. package/modules/devtools/src/blitz/host.ts +110 -34
  86. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  87. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  88. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  89. package/modules/devtools/src/blitz/panels/performance.ts +26 -14
  90. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  91. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  92. package/modules/devtools/src/blitz/theme.ts +26 -4
  93. package/modules/html-ui/src/doc-backend.ts +67 -4
  94. package/modules/html-ui/src/host.ts +11 -3
  95. package/modules/html-ui/src/jsx-runtime.ts +1 -1
  96. package/modules/html-ui/src/protocol.ts +1 -1
  97. package/package.json +2 -2
@@ -57,7 +57,7 @@ export function writePadToInput(
57
57
  writer.setGamepadButton(playerIdx, b, (d.buttonsLo & (1 << b)) !== 0);
58
58
  }
59
59
  if (bindings) {
60
- for (const bind of bindings) {
60
+ for (const bind of bindings.values()) {
61
61
  let active = false;
62
62
  if (bind.button !== undefined) {
63
63
  active = (d.buttonsLo & (1 << bind.button)) !== 0;
@@ -171,10 +171,10 @@ export class GamepadHub {
171
171
  ffSupported: (s.flags & PAD_FLAG.FF_SUPPORTED) !== 0,
172
172
  });
173
173
  this.devices.set(slot, dev);
174
- for (const cb of this.listeners.connect) cb(dev);
174
+ for (const cb of this.listeners.connect.values()) cb(dev);
175
175
  } else if (!connected && existing) {
176
176
  this.devices.delete(slot);
177
- for (const cb of this.listeners.disconnect) cb(existing);
177
+ for (const cb of this.listeners.disconnect.values()) cb(existing);
178
178
  }
179
179
  }
180
180
  this.lastSeq = this.reader.getSequence();
@@ -53,9 +53,9 @@ export class SabGamepadSource implements GamepadSource {
53
53
  for (let i = 0; i < GAMEPAD_MAX_SLOTS; i++) {
54
54
  if (!(changed & (1 << i))) continue;
55
55
  if (mask & (1 << i)) {
56
- for (const cb of this.connectCbs) cb(i);
56
+ for (const cb of this.connectCbs.values()) cb(i);
57
57
  } else {
58
- for (const cb of this.disconnectCbs) cb(i);
58
+ for (const cb of this.disconnectCbs.values()) cb(i);
59
59
  }
60
60
  }
61
61
  this.knownMask = mask;
@@ -58,7 +58,7 @@ async function findBatteryLevel(macHint: string | null): Promise<number | undefi
58
58
  try {
59
59
  const psDir = "/sys/class/power_supply";
60
60
  if (!existsSync(psDir)) return undefined;
61
- for (const entry of await fs.readdir(psDir)) {
61
+ for (const entry of (await fs.readdir(psDir)).values()) {
62
62
  const macPath = join(psDir, entry, "mac");
63
63
  if (existsSync(macPath)) {
64
64
  const mac = (await fs.readFile(macPath, "utf-8")).trim().toLowerCase();
@@ -98,7 +98,7 @@ export class SysfsGamepadEnricher {
98
98
  async enrich(slots: GamepadDeviceSlot[]): Promise<EnrichResult[]> {
99
99
  if (process.platform !== "linux") return [];
100
100
  const out: EnrichResult[] = [];
101
- for (const s of slots) {
101
+ for (const s of slots.values()) {
102
102
  if (s.eventNode < 0) continue;
103
103
  const r = await this.enrichSlot(s);
104
104
  out.push(r);
@@ -177,6 +177,7 @@ export class GpuKernel {
177
177
  const [, oy, oz] = this.outputSize;
178
178
  this.workgroupSize = opts.workgroupSize ?? (oy === 1 && oz === 1 ? [64, 1, 1] : [8, 8, 1]);
179
179
  const [wx, wy, wz] = this.workgroupSize;
180
+ this.label = opts.label ?? "gpu-kernel";
180
181
  const lim = this.device?.limits as {
181
182
  maxComputeWorkgroupSizeX?: number;
182
183
  maxComputeWorkgroupSizeY?: number;
@@ -192,7 +193,6 @@ export class GpuKernel {
192
193
  throw new Error(`kernel "${this.label}": workgroupSize ${wx}×${wy}×${wz} exceeds device limits`);
193
194
  }
194
195
  }
195
- this.label = opts.label ?? "gpu-kernel";
196
196
  this.memoryBudget = opts.memoryBudget;
197
197
  this.extraUsage = opts.outputUsage ?? 0;
198
198
  this.externalResult = opts.outputBuffer ?? null;
@@ -586,7 +586,7 @@ export class GpuKernel {
586
586
  // Pre-validate against device limits — Adreno-class GPUs allow as few as
587
587
  // 16 storage buffers/stage and 256MB/binding, and wgpu otherwise reports a
588
588
  // generic "command buffer failed validation" at submit time.
589
- const limits = this.device.limits as Record<string, number | undefined> | undefined;
589
+ const limits = this.device.limits as unknown as Record<string, number | undefined> | undefined;
590
590
  const maxStorage = limits?.maxStorageBuffersPerShaderStage ?? Infinity;
591
591
  const maxBinding = limits?.maxStorageBufferBindingSize ?? Infinity;
592
592
  const storageCount = unit.transpiled.bindings.length
@@ -720,7 +720,7 @@ class Emitter {
720
720
  if (e.ty === "vec") {
721
721
  throw new KernelSyntaxError(`arrays can't live in locals — return them directly`, d.init);
722
722
  }
723
- const widen = this.widened.has(name) && e.ty !== "f32" && e.ty !== "bool" && e.ty !== "vec";
723
+ const widen = this.widened.has(name) && e.ty !== "f32" && e.ty !== "bool";
724
724
  this.declare(name, widen ? "f32" : e.ty);
725
725
  if (node.kind === "const") this.consts.add(name);
726
726
  const kw = node.kind === "const" ? "let" : "var";
@@ -209,7 +209,7 @@ export class NavController {
209
209
 
210
210
  let best: NavNodeInfo | null = null;
211
211
  let bestScore = Infinity;
212
- for (const n of cands) {
212
+ for (const n of cands.values()) {
213
213
  if (n === cur || !n.rect) continue;
214
214
  const r = n.rect;
215
215
  const nx = r.x + r.w / 2, ny = r.y + r.h / 2;
@@ -50,7 +50,7 @@ export class PadNavDriver {
50
50
  repeatDelayMs: opts.repeatDelayMs ?? 400,
51
51
  repeatIntervalMs: opts.repeatIntervalMs ?? 130,
52
52
  };
53
- for (const d of ["up", "down", "left", "right"] as const) {
53
+ for (const d of (["up", "down", "left", "right"] as const).values()) {
54
54
  this.dirs.set(d, { held: false, since: 0, lastFire: 0 });
55
55
  }
56
56
  }
@@ -82,7 +82,7 @@ export class PadNavDriver {
82
82
  this.prevDirAxis = stickDir;
83
83
  if (stickDir) pressed.add(stickDir);
84
84
 
85
- for (const [dir, st] of this.dirs) {
85
+ for (const [dir, st] of this.dirs.entries()) {
86
86
  const isDown = pressed.has(dir);
87
87
  if (isDown && !st.held) {
88
88
  st.held = true; st.since = now; st.lastFire = now;
@@ -206,19 +206,19 @@ export class LanDiscovery {
206
206
  const existing = this.peers.get(pkt.id);
207
207
  const peer: LanPeer = { ...pkt, host: rinfo.address, lastSeenMs: this.opts.now() };
208
208
  this.peers.set(pkt.id, peer);
209
- if (!existing) for (const f of this.listeners.join) f(peer);
209
+ if (!existing) for (const f of this.listeners.join.values()) f(peer);
210
210
  else if (existing.name !== peer.name || existing.port !== peer.port
211
211
  || JSON.stringify(existing.meta) !== JSON.stringify(peer.meta)) {
212
- for (const f of this.listeners.update) f(peer);
212
+ for (const f of this.listeners.update.values()) f(peer);
213
213
  }
214
214
  }
215
215
 
216
216
  private expire() {
217
217
  const cutoff = this.opts.now() - this.opts.ttlMs;
218
- for (const [id, p] of this.peers) {
218
+ for (const [id, p] of this.peers.entries()) {
219
219
  if (p.lastSeenMs < cutoff) {
220
220
  this.peers.delete(id);
221
- for (const f of this.listeners.leave) f(p);
221
+ for (const f of this.listeners.leave.values()) f(p);
222
222
  }
223
223
  }
224
224
  }
@@ -292,7 +292,7 @@ export class MdnsBrowser {
292
292
  }
293
293
 
294
294
  private requery() {
295
- for (const t of this.watched) this.sendQuery(t);
295
+ for (const t of this.watched.values()) this.sendQuery(t);
296
296
  this.expire();
297
297
  }
298
298
 
@@ -320,7 +320,7 @@ export class MdnsBrowser {
320
320
 
321
321
  private expire() {
322
322
  const now = this.opts.now();
323
- for (const [k, s] of this.table) {
323
+ for (const [k, s] of this.table.entries()) {
324
324
  if (s.lastSeenMs + s.ttlMs < now) this.table.delete(k);
325
325
  }
326
326
  }
@@ -334,7 +334,7 @@ export class MdnsBrowser {
334
334
  .filter((r): r is Extract<typeof r, { data: unknown }> => "data" in r);
335
335
  const now = this.opts.now();
336
336
  // PTR gives instance names; SRV/TXT/A for the same instance fill detail.
337
- for (const r of recs) {
337
+ for (const r of recs.values()) {
338
338
  if (r.type !== "PTR" || !this.watched.has(r.name)) continue;
339
339
  const instance = r.data as string;
340
340
  const key = `${instance}@${r.name}`;
@@ -350,7 +350,7 @@ export class MdnsBrowser {
350
350
  );
351
351
  const txtMap: Record<string, string> = {};
352
352
  if (txt) {
353
- for (const item of (txt.data as Buffer[])) {
353
+ for (const item of (txt.data as Buffer[]).values()) {
354
354
  const s = item.toString("utf8");
355
355
  const eq = s.indexOf("=");
356
356
  txtMap[eq < 0 ? s : s.slice(0, eq)] = eq < 0 ? "" : s.slice(eq + 1);
@@ -205,7 +205,7 @@ export class FileSaveStore implements ISaveStore {
205
205
  if (opts?.blobs && Object.keys(opts.blobs).length > 0) {
206
206
  await fs.mkdir(tmpBlobsDir, { recursive: true });
207
207
  for (let _i = 0, _it = Object.entries(opts.blobs) as Array<[string, ArrayBuffer]>, _n = _it.length; _i < _n; _i++) { const [key, buf] = _it[_i];
208
- const safeKey = key.replace(/[^a-zA-Z0-9_\-]/g, "_");
208
+ const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
209
209
  await fs.writeFile(join(tmpBlobsDir, safeKey), new Uint8Array(buf));
210
210
  blobBytes += buf.byteLength;
211
211
  }
@@ -524,22 +524,22 @@ export class FileSaveStore implements ISaveStore {
524
524
 
525
525
  private slotPath(slot: string): string {
526
526
  // Sanitize slot name to prevent path traversal
527
- const safe = slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
527
+ const safe = slot.replace(/[^a-zA-Z0-9_-]/g, "_");
528
528
  return join(this.saveDir, safe + SAVE_EXT);
529
529
  }
530
530
 
531
531
  private blobsDirPath(slot: string): string {
532
- const safe = slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
532
+ const safe = slot.replace(/[^a-zA-Z0-9_-]/g, "_");
533
533
  return join(this.saveDir, safe + BLOBS_DIR_SUFFIX);
534
534
  }
535
535
 
536
536
  private thumbPath(slot: string): string {
537
- const safe = slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
537
+ const safe = slot.replace(/[^a-zA-Z0-9_-]/g, "_");
538
538
  return join(this.saveDir, safe + THUMB_EXT);
539
539
  }
540
540
 
541
541
  private propsPath(slot: string): string {
542
- const safe = slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
542
+ const safe = slot.replace(/[^a-zA-Z0-9_-]/g, "_");
543
543
  return join(this.saveDir, safe + PROPS_EXT);
544
544
  }
545
545
 
@@ -262,7 +262,7 @@ export class OpfsSaveStore implements ISaveStore {
262
262
  // ── Path helpers ────────────────────────────────────────────────────────
263
263
 
264
264
  private sanitizeSlot(slot: string): string {
265
- return slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
265
+ return slot.replace(/[^a-zA-Z0-9_-]/g, "_");
266
266
  }
267
267
 
268
268
  private genDirName(gen: number): string {
@@ -371,7 +371,7 @@ export class OpfsSaveStore implements ISaveStore {
371
371
  if (opts?.blobs && Object.keys(opts.blobs).length > 0) {
372
372
  const blobsDir = await this.getBlobsDir(genDir, true);
373
373
  for (const [key, buf] of Object.entries(opts.blobs)) {
374
- const safeKey = key.replace(/[^a-zA-Z0-9_\-]/g, "_");
374
+ const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
375
375
  await this.writeBinaryFile(blobsDir, safeKey, new Uint8Array(buf));
376
376
  blobCount++;
377
377
  }
@@ -233,5 +233,5 @@ self.onmessage = async (e: MessageEvent) => {
233
233
  }
234
234
  };
235
235
 
236
- export { };
236
+
237
237
 
@@ -138,7 +138,7 @@ export function createControllerUi(): RendererModule {
138
138
  const dev = selected();
139
139
  if (!p || !dev) return;
140
140
  const ops = [];
141
- for (const [bit] of BTN_LABELS) {
141
+ for (const [bit] of BTN_LABELS.values()) {
142
142
  ops.push({ op: "attr", sel: `#ctl-btn-${bit}`, name: "data-on",
143
143
  value: dev.pressed(bit) ? "true" : "false" } as const);
144
144
  }
@@ -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;
@@ -119,7 +128,7 @@ export class BlitzDevtoolsHost {
119
128
  new ElementsPanel(ctx),
120
129
  new PerformancePanel(ctx),
121
130
  new GpuPanel(ctx),
122
- ]) this.panels.set(p.id, p);
131
+ ].values()) this.panels.set(p.id, p);
123
132
  }
124
133
 
125
134
  get visible(): boolean { return this.panel !== null; }
@@ -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;
@@ -298,7 +352,7 @@ export class BlitzDevtoolsHost {
298
352
  const slots = this.backend.providerSlots();
299
353
  let changed = false;
300
354
  const seen = new Set<number>();
301
- for (const { slot, name } of slots) {
355
+ for (const { slot, name } of slots.values()) {
302
356
  seen.add(slot);
303
357
  if (!this.snapshotPanels.has(slot)) {
304
358
  const p = new SnapshotPanel(this.panelCtx, slot, name);
@@ -307,7 +361,7 @@ export class BlitzDevtoolsHost {
307
361
  changed = true;
308
362
  }
309
363
  }
310
- for (const [slot, p] of this.snapshotPanels) {
364
+ for (const [slot, p] of this.snapshotPanels.entries()) {
311
365
  if (!seen.has(slot)) {
312
366
  this.snapshotPanels.delete(slot);
313
367
  this.panels.delete(p.id);
@@ -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
  }