@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
@@ -38,7 +38,7 @@ import "../sab/sab-polyfill.js";
38
38
  import { usingRealSAB } from "../sab/sab-polyfill.js";
39
39
  import { mulberry32 } from "../math/rng.js";
40
40
  import { createLogger } from "../util/logger.js";
41
- import { expose, exposeEvents } from "./rpc.js";
41
+ import { expose, exposeBatchedEvents } from "./rpc.js";
42
42
  const log = createLogger();
43
43
  /**
44
44
  * Detect deterministic/test mode inside a worker. `DOWNDRAFT_DETERMINISTIC`
@@ -158,7 +158,7 @@ export function createSimWorker(opts) {
158
158
  let tickInFlight = null;
159
159
  // Serializes withLoopStopped critical sections.
160
160
  let stopChain = Promise.resolve();
161
- const events = exposeEvents();
161
+ const events = exposeBatchedEvents();
162
162
  // Deterministic RNG — reseedable via control.setSeed(); the stable `rng`
163
163
  // closure is what ctx.rng and the Math.random shim draw from.
164
164
  let rngImpl = mulberry32(opts.seed ?? 0x9e3779b9);
@@ -423,6 +423,9 @@ export function createSimWorker(opts) {
423
423
  syncWorker = null; // release sync worker reference
424
424
  saveStore = null;
425
425
  await opts.onShutdown?.();
426
+ // Drain any events queued during onShutdown so they aren't lost to
427
+ // a flush timer the terminating worker never runs.
428
+ events.flush();
426
429
  },
427
430
  async setSpeed(speed) {
428
431
  speedMultiplier = Math.min(opts.maxSpeed ?? Infinity, Math.max(opts.minSpeed ?? 0, speed));
@@ -550,7 +553,7 @@ export function createSimWorker(opts) {
550
553
  }
551
554
  : {}),
552
555
  };
553
- const api = { ...standardApi, ...(opts.extraApi ?? {}) };
556
+ const api = { ...standardApi, ...opts.extraApi };
554
557
  // Optionally wrap the API before exposing (e.g. with exposeDevToolsApi
555
558
  // to add devtools + profiling RPC methods).
556
559
  expose(opts.wrapExpose ? opts.wrapExpose(api) : api);
@@ -78,6 +78,11 @@ export class TaskPool {
78
78
  for (let i = 0; i < this.workerCount; i++) {
79
79
  const worker = this.createWorker();
80
80
  worker.onerror = (e) => {
81
+ // Session teardown tags tracked workers __ddExpectTerminate and
82
+ // fires synthetic error events to settle waiters — not faults
83
+ // (see session-tracker.ts terminateTrackedWorker).
84
+ if (worker.__ddExpectTerminate || this.destroyed)
85
+ return;
81
86
  if (this.onWorkerError)
82
87
  this.onWorkerError(i, e);
83
88
  else
@@ -44,6 +44,12 @@ export class DevtoolsBackend {
44
44
  providers = new Map();
45
45
  commandHandlers = new Map();
46
46
  globalCommandHandler = null;
47
+ /** Dynamically-allocated slots for provider names outside the fixed PANEL
48
+ * table — panelSlot() alone collapsed every unknown name onto slot 0
49
+ * ("console"), so a second game provider silently replaced the first. */
50
+ dynSlots = new Map();
51
+ dynSlotNames = new Map();
52
+ nextDynSlot = 100;
47
53
  lastProviderPush = new Map();
48
54
  providerIntervalMs = 1500;
49
55
  /** When non-null, the cadence loop only auto-refreshes this provider
@@ -178,37 +184,57 @@ export class DevtoolsBackend {
178
184
  this.emit("console.clear", {});
179
185
  }
180
186
  // ── Providers / commands ──
187
+ /** Resolve a panel id to its slot. Names outside the fixed PANEL table
188
+ * get a stable dynamically-allocated slot so bespoke game providers can
189
+ * coexist instead of colliding on slot 0. */
190
+ slotFor(panel) {
191
+ if (typeof panel === "number")
192
+ return panel;
193
+ const fixed = PANEL[panel];
194
+ if (fixed !== undefined)
195
+ return fixed;
196
+ let s = this.dynSlots.get(panel);
197
+ if (s === undefined) {
198
+ s = this.nextDynSlot++;
199
+ this.dynSlots.set(panel, s);
200
+ this.dynSlotNames.set(s, panel);
201
+ }
202
+ return s;
203
+ }
204
+ nameFor(slot) {
205
+ return this.dynSlotNames.get(slot) ?? slotName(slot);
206
+ }
181
207
  registerProvider(panel, collect) {
182
- this.providers.set(panelSlot(panel), collect);
208
+ this.providers.set(this.slotFor(panel), collect);
183
209
  }
184
210
  registerCommandHandler(panel, handler) {
185
211
  if (panel === "*") {
186
212
  this.globalCommandHandler = handler;
187
213
  }
188
214
  else {
189
- this.commandHandlers.set(panelSlot(panel), handler);
215
+ this.commandHandlers.set(this.slotFor(panel), handler);
190
216
  }
191
217
  }
192
218
  /** Registered provider slots — the dynamic tab list for frontends. */
193
219
  providerSlots() {
194
- return [...this.providers.keys()].map((slot) => ({ slot, name: slotName(slot) }));
220
+ return [...this.providers.keys()].map((slot) => ({ slot, name: this.nameFor(slot) }));
195
221
  }
196
222
  /** Collect + emit one provider's snapshot immediately. */
197
223
  refreshPanel(panel) {
198
224
  const trace = [performance.now()];
199
- const slot = panelSlot(panel);
225
+ const slot = this.slotFor(panel);
200
226
  trace.push(performance.now());
201
227
  const collect = this.providers.get(slot);
202
228
  if (!collect)
203
229
  return;
204
230
  const push = (snap) => {
205
231
  if (snap)
206
- this.emit("snapshot", { panel: slotName(slot), slot, snap });
232
+ this.emit("snapshot", { panel: this.nameFor(slot), slot, snap });
207
233
  this.lastProviderPush.set(slot, performance.now());
208
234
  };
209
235
  const fail = (err) => {
210
236
  this.emit("snapshot", {
211
- panel: slotName(slot), slot,
237
+ panel: this.nameFor(slot), slot,
212
238
  snap: { status: "error", statusMsg: String(err), sections: [] },
213
239
  });
214
240
  this.lastProviderPush.set(slot, performance.now());
@@ -231,7 +257,7 @@ export class DevtoolsBackend {
231
257
  const total = trace[4] - trace[0];
232
258
  if (total > 10) {
233
259
  const seg = `pre=${(trace[2] - trace[0]).toFixed(1)} collect=${(trace[3] - trace[2]).toFixed(1)} emit=${(trace[4] - trace[3]).toFixed(1)}`;
234
- this.diagWarn(`slow provider ${slotName(slot)}: ${total.toFixed(1)}ms [${seg}]`);
260
+ this.diagWarn(`slow provider ${this.nameFor(slot)}: ${total.toFixed(1)}ms [${seg}]`);
235
261
  }
236
262
  }
237
263
  dispatchCommand(cmd) {
@@ -374,7 +400,7 @@ export class DevtoolsBackend {
374
400
  return collectThreads(this.ctx, [...this.threadEvals.keys()]);
375
401
  case "snapshot": {
376
402
  const panel = params.panel;
377
- const slot = typeof panel === "number" ? panel : panelSlot(panel);
403
+ const slot = typeof panel === "number" ? panel : this.slotFor(String(panel));
378
404
  const collect = this.providers.get(slot);
379
405
  if (!collect)
380
406
  return { status: "unsupported", statusMsg: `no provider for ${String(panel)}`, sections: [] };
@@ -388,7 +414,7 @@ export class DevtoolsBackend {
388
414
  }
389
415
  case "command": {
390
416
  const panel = params.panel;
391
- const slot = typeof panel === "number" ? panel : panelSlot(panel ?? "console");
417
+ const slot = typeof panel === "number" ? panel : this.slotFor(String(panel ?? "console"));
392
418
  this.dispatchCommand({ panel: slot, action: String(params.action ?? ""), payload: String(params.payload ?? "") });
393
419
  return true;
394
420
  }
@@ -72,31 +72,81 @@ export async function collectThreads(ctx, evalThreadIds) {
72
72
  });
73
73
  return threads;
74
74
  }
75
- /** Scene-graph tree (label + ctor + child counts). */
75
+ function sceneInspectorApi() {
76
+ const g = globalThis;
77
+ return (g.window?.__sceneInspector ?? g.__sceneInspector ?? null);
78
+ }
79
+ /** Scene-graph tree (label + ctor + child counts). Prefers a live
80
+ * `gameScene.stage` walk; falls back to the SceneInspector's scene store
81
+ * (`__sceneInspector.getSceneTree`) so games without a DOM-like stage still
82
+ * get a populated tree — with stable nodeIds for pick/details. */
76
83
  export function collectSceneTree(ctx) {
77
84
  const stage = ctx.gameScene?.stage;
78
- if (!stage)
85
+ if (stage) {
86
+ const nodes = [];
87
+ let idCounter = 1;
88
+ const collect = (node, parentId, depth) => {
89
+ const id = idCounter++;
90
+ // Prefer `label` (some scene-graph hosts warn on `name` access); fall
91
+ // back to `name` for hosts that only set that.
92
+ const nodeLabel = typeof node.label === "string" ? node.label
93
+ : typeof node.name === "string" ? node.name : "";
94
+ const ctorName = node.constructor?.name ?? "Node";
95
+ nodes.push({
96
+ id, parentId, depth,
97
+ childCount: node.children?.length ?? 0,
98
+ kind: 0,
99
+ label: nodeLabel || ctorName,
100
+ detail: nodeLabel ? ctorName : "",
101
+ nodeId: typeof node.id === "string" ? node.id : undefined,
102
+ });
103
+ node.children?.forEach((child) => collect(child, id, depth + 1));
104
+ };
105
+ collect(stage, -1, 0);
106
+ return nodes;
107
+ }
108
+ // Scene-store fallback — the same snapshot the inspector's getSceneTree
109
+ // exposes. Nodes are flat with id/parentId links; walk roots → children.
110
+ try {
111
+ const snap = sceneInspectorApi()?.getSceneTree?.();
112
+ const storeNodes = snap?.nodes;
113
+ if (!Array.isArray(storeNodes) || !storeNodes.length)
114
+ return [];
115
+ const byId = new Map(storeNodes.map((n) => [n.id, n]));
116
+ const roots = storeNodes.filter((n) => n.parentId === null || !byId.has(n.parentId));
117
+ const rows = [];
118
+ const visited = new Set();
119
+ let idCounter = 1;
120
+ const walk = (n, parentId, depth) => {
121
+ if (visited.has(n.id))
122
+ return; // corrupted parent/child links — don't loop
123
+ visited.add(n.id);
124
+ const id = idCounter++;
125
+ const p = n.position;
126
+ rows.push({
127
+ id, parentId, depth,
128
+ childCount: n.children?.length ?? 0,
129
+ kind: 0,
130
+ label: String(n.name ?? n.id),
131
+ detail: `${n.type ?? "node"}${Array.isArray(p) ? ` (${p.map((v) => (+v).toFixed(1)).join(", ")})` : ""}`,
132
+ nodeId: String(n.id),
133
+ });
134
+ for (const cid of n.children ?? []) {
135
+ const c = byId.get(cid);
136
+ if (c)
137
+ walk(c, id, depth + 1);
138
+ }
139
+ };
140
+ roots.forEach((n) => walk(n, -1, 0));
141
+ // Orphans (every node has a parent but no root resolves) — still show them.
142
+ for (const n of storeNodes)
143
+ if (!visited.has(n.id))
144
+ walk(n, -1, 0);
145
+ return rows;
146
+ }
147
+ catch {
79
148
  return [];
80
- const nodes = [];
81
- let idCounter = 1;
82
- const collect = (node, parentId, depth) => {
83
- const id = idCounter++;
84
- // Prefer `label` (some scene-graph hosts warn on `name` access); fall
85
- // back to `name` for hosts that only set that.
86
- const nodeLabel = typeof node.label === "string" ? node.label
87
- : typeof node.name === "string" ? node.name : "";
88
- const ctorName = node.constructor?.name ?? "Node";
89
- nodes.push({
90
- id, parentId, depth,
91
- childCount: node.children?.length ?? 0,
92
- kind: 0,
93
- label: nodeLabel || ctorName,
94
- detail: nodeLabel ? ctorName : "",
95
- });
96
- node.children?.forEach((child) => collect(child, id, depth + 1));
97
- };
98
- collect(stage, -1, 0);
99
- return nodes;
149
+ }
100
150
  }
101
151
  /** DOM tree: scene-graph stage or ECS entities depending on mode. */
102
152
  export function collectDomTree(ctx, mode) {
@@ -122,6 +172,7 @@ export function collectDomTree(ctx, mode) {
122
172
  id, parentId: -1, depth: 0, childCount: 0, kind: 1,
123
173
  label: `Entity ${ent.idx}`,
124
174
  detail: `type=${entityType} pos=(${x.toFixed(1)},${y.toFixed(1)},${z.toFixed(1)})`,
175
+ nodeId: `ent-${ent.idx}`,
125
176
  });
126
177
  });
127
178
  }
@@ -29,7 +29,7 @@ export function writePadToInput(writer, reader, playerIdx, deviceSlot, bindings,
29
29
  writer.setGamepadButton(playerIdx, b, (d.buttonsLo & (1 << b)) !== 0);
30
30
  }
31
31
  if (bindings) {
32
- for (const bind of bindings) {
32
+ for (const bind of bindings.values()) {
33
33
  let active = false;
34
34
  if (bind.button !== undefined) {
35
35
  active = (d.buttonsLo & (1 << bind.button)) !== 0;
@@ -131,12 +131,12 @@ export class GamepadHub {
131
131
  ffSupported: (s.flags & PAD_FLAG.FF_SUPPORTED) !== 0,
132
132
  });
133
133
  this.devices.set(slot, dev);
134
- for (const cb of this.listeners.connect)
134
+ for (const cb of this.listeners.connect.values())
135
135
  cb(dev);
136
136
  }
137
137
  else if (!connected && existing) {
138
138
  this.devices.delete(slot);
139
- for (const cb of this.listeners.disconnect)
139
+ for (const cb of this.listeners.disconnect.values())
140
140
  cb(existing);
141
141
  }
142
142
  }
@@ -44,11 +44,11 @@ export class SabGamepadSource {
44
44
  if (!(changed & (1 << i)))
45
45
  continue;
46
46
  if (mask & (1 << i)) {
47
- for (const cb of this.connectCbs)
47
+ for (const cb of this.connectCbs.values())
48
48
  cb(i);
49
49
  }
50
50
  else {
51
- for (const cb of this.disconnectCbs)
51
+ for (const cb of this.disconnectCbs.values())
52
52
  cb(i);
53
53
  }
54
54
  }
@@ -54,7 +54,7 @@ async function findBatteryLevel(macHint) {
54
54
  const psDir = "/sys/class/power_supply";
55
55
  if (!existsSync(psDir))
56
56
  return undefined;
57
- for (const entry of await fs.readdir(psDir)) {
57
+ for (const entry of (await fs.readdir(psDir)).values()) {
58
58
  const macPath = join(psDir, entry, "mac");
59
59
  if (existsSync(macPath)) {
60
60
  const mac = (await fs.readFile(macPath, "utf-8")).trim().toLowerCase();
@@ -84,7 +84,7 @@ export class SysfsGamepadEnricher {
84
84
  if (process.platform !== "linux")
85
85
  return [];
86
86
  const out = [];
87
- for (const s of slots) {
87
+ for (const s of slots.values()) {
88
88
  if (s.eventNode < 0)
89
89
  continue;
90
90
  const r = await this.enrichSlot(s);
@@ -100,6 +100,7 @@ export class GpuKernel {
100
100
  const [, oy, oz] = this.outputSize;
101
101
  this.workgroupSize = opts.workgroupSize ?? (oy === 1 && oz === 1 ? [64, 1, 1] : [8, 8, 1]);
102
102
  const [wx, wy, wz] = this.workgroupSize;
103
+ this.label = opts.label ?? "gpu-kernel";
103
104
  const lim = this.device?.limits;
104
105
  if (lim) {
105
106
  const perDim = wx > (lim.maxComputeWorkgroupSizeX ?? wx)
@@ -110,7 +111,6 @@ export class GpuKernel {
110
111
  throw new Error(`kernel "${this.label}": workgroupSize ${wx}×${wy}×${wz} exceeds device limits`);
111
112
  }
112
113
  }
113
- this.label = opts.label ?? "gpu-kernel";
114
114
  this.memoryBudget = opts.memoryBudget;
115
115
  this.extraUsage = opts.outputUsage ?? 0;
116
116
  this.externalResult = opts.outputBuffer ?? null;
@@ -659,7 +659,7 @@ class Emitter {
659
659
  if (e.ty === "vec") {
660
660
  throw new KernelSyntaxError(`arrays can't live in locals — return them directly`, d.init);
661
661
  }
662
- const widen = this.widened.has(name) && e.ty !== "f32" && e.ty !== "bool" && e.ty !== "vec";
662
+ const widen = this.widened.has(name) && e.ty !== "f32" && e.ty !== "bool";
663
663
  this.declare(name, widen ? "f32" : e.ty);
664
664
  if (node.kind === "const")
665
665
  this.consts.add(name);
@@ -181,7 +181,7 @@ export class NavController {
181
181
  const sign = dir === "down" || dir === "right" ? 1 : -1;
182
182
  let best = null;
183
183
  let bestScore = Infinity;
184
- for (const n of cands) {
184
+ for (const n of cands.values()) {
185
185
  if (n === cur || !n.rect)
186
186
  continue;
187
187
  const r = n.rect;
@@ -29,7 +29,7 @@ export class PadNavDriver {
29
29
  repeatDelayMs: opts.repeatDelayMs ?? 400,
30
30
  repeatIntervalMs: opts.repeatIntervalMs ?? 130,
31
31
  };
32
- for (const d of ["up", "down", "left", "right"]) {
32
+ for (const d of ["up", "down", "left", "right"].values()) {
33
33
  this.dirs.set(d, { held: false, since: 0, lastFire: 0 });
34
34
  }
35
35
  }
@@ -67,7 +67,7 @@ export class PadNavDriver {
67
67
  this.prevDirAxis = stickDir;
68
68
  if (stickDir)
69
69
  pressed.add(stickDir);
70
- for (const [dir, st] of this.dirs) {
70
+ for (const [dir, st] of this.dirs.entries()) {
71
71
  const isDown = pressed.has(dir);
72
72
  if (isDown && !st.held) {
73
73
  st.held = true;
@@ -165,20 +165,20 @@ export class LanDiscovery {
165
165
  const peer = { ...pkt, host: rinfo.address, lastSeenMs: this.opts.now() };
166
166
  this.peers.set(pkt.id, peer);
167
167
  if (!existing)
168
- for (const f of this.listeners.join)
168
+ for (const f of this.listeners.join.values())
169
169
  f(peer);
170
170
  else if (existing.name !== peer.name || existing.port !== peer.port
171
171
  || JSON.stringify(existing.meta) !== JSON.stringify(peer.meta)) {
172
- for (const f of this.listeners.update)
172
+ for (const f of this.listeners.update.values())
173
173
  f(peer);
174
174
  }
175
175
  }
176
176
  expire() {
177
177
  const cutoff = this.opts.now() - this.opts.ttlMs;
178
- for (const [id, p] of this.peers) {
178
+ for (const [id, p] of this.peers.entries()) {
179
179
  if (p.lastSeenMs < cutoff) {
180
180
  this.peers.delete(id);
181
- for (const f of this.listeners.leave)
181
+ for (const f of this.listeners.leave.values())
182
182
  f(p);
183
183
  }
184
184
  }
@@ -227,7 +227,7 @@ export class MdnsBrowser {
227
227
  this.sendQuery(type);
228
228
  }
229
229
  requery() {
230
- for (const t of this.watched)
230
+ for (const t of this.watched.values())
231
231
  this.sendQuery(t);
232
232
  this.expire();
233
233
  }
@@ -257,7 +257,7 @@ export class MdnsBrowser {
257
257
  }
258
258
  expire() {
259
259
  const now = this.opts.now();
260
- for (const [k, s] of this.table) {
260
+ for (const [k, s] of this.table.entries()) {
261
261
  if (s.lastSeenMs + s.ttlMs < now)
262
262
  this.table.delete(k);
263
263
  }
@@ -276,7 +276,7 @@ export class MdnsBrowser {
276
276
  .filter((r) => "data" in r);
277
277
  const now = this.opts.now();
278
278
  // PTR gives instance names; SRV/TXT/A for the same instance fill detail.
279
- for (const r of recs) {
279
+ for (const r of recs.values()) {
280
280
  if (r.type !== "PTR" || !this.watched.has(r.name))
281
281
  continue;
282
282
  const instance = r.data;
@@ -291,7 +291,7 @@ export class MdnsBrowser {
291
291
  .map((x) => x.ttl ?? 120)));
292
292
  const txtMap = {};
293
293
  if (txt) {
294
- for (const item of txt.data) {
294
+ for (const item of txt.data.values()) {
295
295
  const s = item.toString("utf8");
296
296
  const eq = s.indexOf("=");
297
297
  txtMap[eq < 0 ? s : s.slice(0, eq)] = eq < 0 ? "" : s.slice(eq + 1);
@@ -152,7 +152,7 @@ export class FileSaveStore {
152
152
  await fs.mkdir(tmpBlobsDir, { recursive: true });
153
153
  for (let _i = 0, _it = Object.entries(opts.blobs), _n = _it.length; _i < _n; _i++) {
154
154
  const [key, buf] = _it[_i];
155
- const safeKey = key.replace(/[^a-zA-Z0-9_\-]/g, "_");
155
+ const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
156
156
  await fs.writeFile(join(tmpBlobsDir, safeKey), new Uint8Array(buf));
157
157
  blobBytes += buf.byteLength;
158
158
  }
@@ -480,19 +480,19 @@ export class FileSaveStore {
480
480
  }
481
481
  slotPath(slot) {
482
482
  // Sanitize slot name to prevent path traversal
483
- const safe = slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
483
+ const safe = slot.replace(/[^a-zA-Z0-9_-]/g, "_");
484
484
  return join(this.saveDir, safe + SAVE_EXT);
485
485
  }
486
486
  blobsDirPath(slot) {
487
- const safe = slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
487
+ const safe = slot.replace(/[^a-zA-Z0-9_-]/g, "_");
488
488
  return join(this.saveDir, safe + BLOBS_DIR_SUFFIX);
489
489
  }
490
490
  thumbPath(slot) {
491
- const safe = slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
491
+ const safe = slot.replace(/[^a-zA-Z0-9_-]/g, "_");
492
492
  return join(this.saveDir, safe + THUMB_EXT);
493
493
  }
494
494
  propsPath(slot) {
495
- const safe = slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
495
+ const safe = slot.replace(/[^a-zA-Z0-9_-]/g, "_");
496
496
  return join(this.saveDir, safe + PROPS_EXT);
497
497
  }
498
498
  hashEqual(a, b) {
@@ -147,7 +147,7 @@ export class OpfsSaveStore {
147
147
  }
148
148
  // ── Path helpers ────────────────────────────────────────────────────────
149
149
  sanitizeSlot(slot) {
150
- return slot.replace(/[^a-zA-Z0-9_\-]/g, "_");
150
+ return slot.replace(/[^a-zA-Z0-9_-]/g, "_");
151
151
  }
152
152
  genDirName(gen) {
153
153
  return String(gen).padStart(4, "0");
@@ -239,7 +239,7 @@ export class OpfsSaveStore {
239
239
  if (opts?.blobs && Object.keys(opts.blobs).length > 0) {
240
240
  const blobsDir = await this.getBlobsDir(genDir, true);
241
241
  for (const [key, buf] of Object.entries(opts.blobs)) {
242
- const safeKey = key.replace(/[^a-zA-Z0-9_\-]/g, "_");
242
+ const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
243
243
  await this.writeBinaryFile(blobsDir, safeKey, new Uint8Array(buf));
244
244
  blobCount++;
245
245
  }
@@ -125,7 +125,7 @@ export function createControllerUi() {
125
125
  if (!p || !dev)
126
126
  return;
127
127
  const ops = [];
128
- for (const [bit] of BTN_LABELS) {
128
+ for (const [bit] of BTN_LABELS.values()) {
129
129
  ops.push({ op: "attr", sel: `#ctl-btn-${bit}`, name: "data-on",
130
130
  value: dev.pressed(bit) ? "true" : "false" });
131
131
  }
@@ -0,0 +1,75 @@
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
+ import { execFileSync } from "node:child_process";
14
+ import { readFileSync } from "node:fs";
15
+ // Blitz-dom only fetches a @font-face source whose format is detectable —
16
+ // either an explicit format() hint or a file extension on the url — so the
17
+ // urls carry the resolved file's real extension.
18
+ const FACES = [
19
+ { family: "DevTools Sans", pattern: "sans-serif", urlName: "sans", weight: 400 },
20
+ { family: "DevTools Sans", pattern: "sans-serif:weight=bold", urlName: "sans-bold", weight: 700 },
21
+ { family: "DevTools Mono", pattern: "monospace", urlName: "mono", weight: 400 },
22
+ { family: "DevTools Mono", pattern: "monospace:weight=bold", urlName: "mono-bold", weight: 700 },
23
+ ];
24
+ /** `fc-match -f %{file}` for a fontconfig pattern; null when fc-match is
25
+ * absent or the match isn't a font file Blitz can load. */
26
+ function fcFontFile(pattern) {
27
+ try {
28
+ const p = execFileSync("fc-match", ["-f", "%{file}", pattern], { timeout: 5000 }).toString().trim();
29
+ return /\.(ttf|otf|woff2?)$/i.test(p) ? p : null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function fontExt(path) {
36
+ return path.match(/\.(ttf|otf|woff2?)$/i)?.[0] ?? ".ttf";
37
+ }
38
+ /**
39
+ * Register system sans + mono faces as `ui://` resources and return the
40
+ * @font-face CSS to prepend to the dock stylesheet. Returns "" when nothing
41
+ * registered — the stylesheet's named families then fail to resolve and the
42
+ * generic fallbacks (embedded DejaVu) apply.
43
+ *
44
+ * Call before mount — the doc backend applies resources synchronously and in
45
+ * order, so the first doc resolve sees them.
46
+ */
47
+ export function registerDevtoolsFonts(sink) {
48
+ try {
49
+ // Both weights of a family are required: a 700 rule pointing at the
50
+ // regular file keeps bold text in-family instead of falling back.
51
+ const files = new Map();
52
+ for (const f of FACES)
53
+ files.set(f.urlName, fcFontFile(f.pattern) ?? "");
54
+ if (!files.get("sans"))
55
+ return "";
56
+ if (!files.get("mono"))
57
+ files.set("mono", files.get("sans"));
58
+ if (!files.get("sans-bold"))
59
+ files.set("sans-bold", files.get("sans"));
60
+ if (!files.get("mono-bold"))
61
+ files.set("mono-bold", files.get("mono"));
62
+ let css = "";
63
+ for (const f of FACES) {
64
+ const file = files.get(f.urlName);
65
+ const url = `ui://devtools-fonts/${f.urlName}${fontExt(file)}`;
66
+ sink.registerResource(url, readFileSync(file));
67
+ css += `@font-face { font-family:'${f.family}'; font-weight:${f.weight}; src:url('${url}'); }\n`;
68
+ }
69
+ return css;
70
+ }
71
+ catch {
72
+ // fc-match absent / unreadable font file — the embedded fallback applies.
73
+ return "";
74
+ }
75
+ }