@downdraft/engine 0.3.0 → 0.4.0-alpha.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 (82) hide show
  1. package/app/src/renderer/bootstrap.ts +0 -6
  2. package/app/src/renderer/feature-log.ts +1 -9
  3. package/app/src/renderer/mcp-harness.ts +8 -11
  4. package/app/src/renderer/standard-automation-tools.ts +8 -145
  5. package/core/src/module/host.ts +3 -6
  6. package/core/src/module/index.ts +1 -1
  7. package/core/src/module/module.ts +4 -47
  8. package/core/src/physics/interface.ts +5 -0
  9. package/core/src/platform/render-surface.ts +13 -0
  10. package/core/src/render/canvas-resize-watcher.ts +1 -10
  11. package/core/src/render/game-renderer.ts +40 -18
  12. package/core/src/render/index.ts +0 -2
  13. package/core/src/render/input-manager.ts +7 -7
  14. package/core/src/render/renderer-module-host.ts +2 -4
  15. package/core/src/ui-font-scale.ts +5 -38
  16. package/core/src/worker/rpc.ts +65 -0
  17. package/dist/.dd-manifest.json +859 -884
  18. package/dist/app/src/renderer/bootstrap.js +0 -4
  19. package/dist/app/src/renderer/feature-log.js +1 -10
  20. package/dist/app/src/renderer/mcp-harness.js +8 -11
  21. package/dist/app/src/renderer/standard-automation-tools.js +6 -138
  22. package/dist/core/src/module/host.js +3 -6
  23. package/dist/core/src/render/canvas-resize-watcher.js +1 -10
  24. package/dist/core/src/render/game-renderer.js +44 -18
  25. package/dist/core/src/render/index.js +0 -1
  26. package/dist/core/src/render/input-manager.js +7 -7
  27. package/dist/core/src/render/renderer-module-host.js +2 -4
  28. package/dist/core/src/ui-font-scale.js +5 -38
  29. package/dist/core/src/worker/rpc.js +59 -0
  30. package/dist/libraries/blitz-ui/src/native-module.js +8 -1
  31. package/dist/libraries/devtools/src/native-providers.js +0 -2
  32. package/dist/libraries/entities/src/model-renderer.js +87 -27
  33. package/dist/libraries/entities/src/shaders/model.wgsl +13 -4
  34. package/dist/libraries/networking/src/index.js +1 -0
  35. package/dist/libraries/networking/src/ws-server.js +387 -0
  36. package/dist/libraries/physics-rapier/src/api.js +15 -0
  37. package/dist/libraries/physics-rapier/src/backend.js +15 -0
  38. package/dist/libraries/physics-rapier/src/rapier-backend.js +5 -0
  39. package/dist/libraries/postfx/src/post-process-stack.js +1 -3
  40. package/dist/libraries/sand/src/materials.js +2 -32
  41. package/dist/libraries/sand/src/sand-world.js +0 -3
  42. package/dist/modules/devtools/src/api.js +8 -23
  43. package/dist/modules/devtools/src/data-bridge.js +0 -11
  44. package/dist/modules/devtools/src/index.js +0 -6
  45. package/dist/modules/devtools/src/init.js +8 -42
  46. package/dist/modules/devtools/src/scene-inspector.js +1 -4
  47. package/dist/modules/devtools/src/worker-sync.js +3 -11
  48. package/dist/test/src/vision-test.js +1 -3
  49. package/libraries/blitz-ui/src/native-module.ts +8 -1
  50. package/libraries/devtools/src/native-providers.ts +0 -2
  51. package/libraries/entities/src/model-renderer.ts +88 -27
  52. package/libraries/entities/src/shaders/model.wgsl +13 -4
  53. package/libraries/networking/src/index.ts +4 -0
  54. package/libraries/networking/src/ws-server.ts +397 -0
  55. package/libraries/physics-rapier/src/api.ts +15 -0
  56. package/libraries/physics-rapier/src/backend.ts +15 -0
  57. package/libraries/physics-rapier/src/rapier-backend.ts +6 -0
  58. package/libraries/postfx/src/post-process-stack.ts +1 -2
  59. package/libraries/sand/src/materials.ts +2 -32
  60. package/libraries/sand/src/sand-world.ts +0 -4
  61. package/modules/devtools/README.md +1 -3
  62. package/modules/devtools/src/api.ts +10 -34
  63. package/modules/devtools/src/data-bridge.ts +0 -16
  64. package/modules/devtools/src/index.ts +1 -11
  65. package/modules/devtools/src/init.ts +8 -51
  66. package/modules/devtools/src/scene-inspector.ts +2 -6
  67. package/modules/devtools/src/types.ts +5 -90
  68. package/modules/devtools/src/worker-sync.ts +3 -13
  69. package/package.json +4 -5
  70. package/test/src/vision-test.ts +2 -4
  71. package/core/src/render/sab-canvas-overlay.ts +0 -122
  72. package/dist/core/src/render/sab-canvas-overlay.js +0 -83
  73. package/dist/modules/devtools/src/debug-overlay.js +0 -181
  74. package/dist/modules/devtools/src/doctor-panel.js +0 -163
  75. package/dist/modules/devtools/src/label-overlay.js +0 -129
  76. package/dist/modules/devtools/src/material-stats-panel.js +0 -171
  77. package/dist/modules/devtools/src/sim-stats-panel.js +0 -250
  78. package/modules/devtools/src/debug-overlay.ts +0 -238
  79. package/modules/devtools/src/doctor-panel.ts +0 -182
  80. package/modules/devtools/src/label-overlay.ts +0 -154
  81. package/modules/devtools/src/material-stats-panel.ts +0 -188
  82. package/modules/devtools/src/sim-stats-panel.ts +0 -272
@@ -132,15 +132,11 @@ export async function bootstrapGame(opts) {
132
132
  return;
133
133
  const { initDevTools } = devtoolsMod;
134
134
  const simStatsProvider = opts.devtools.createSimStatsProvider?.(renderer);
135
- const panels = typeof opts.devtools.panels === "function"
136
- ? opts.devtools.panels(renderer)
137
- : opts.devtools.panels ?? [];
138
135
  const workerHosts = typeof opts.devtools.workerHosts === "function"
139
136
  ? opts.devtools.workerHosts(renderer)
140
137
  : opts.devtools.workerHosts;
141
138
  const devtoolsBridge = await importOrDead(initDevTools(renderer, {
142
139
  simStatsProvider,
143
- panels,
144
140
  sceneInspector: opts.devtools.sceneInspector,
145
141
  bridgeClass: opts.devtools.bridgeClass,
146
142
  workerHosts,
@@ -82,22 +82,13 @@ export function collectRendererFeatureLog(opts) {
82
82
  data.mem = `${nav.deviceMemory}G`;
83
83
  }
84
84
  catch { /* navigator unavailable */ }
85
- // --- SharedArrayBuffer + crossOriginIsolated (browser isolation only —
86
- // there is no origin model on the native host) ---
85
+ // --- SharedArrayBuffer ---
87
86
  try {
88
87
  data.sab = typeof SharedArrayBuffer !== "undefined" ? 1 : 0;
89
88
  }
90
89
  catch {
91
90
  data.sab = 0;
92
91
  }
93
- if (getHostCapabilities().hasDom) {
94
- try {
95
- data.coi = self.crossOriginIsolated ? 1 : 0;
96
- }
97
- catch {
98
- data.coi = 0;
99
- }
100
- }
101
92
  // --- Worker load status (best-effort; na = not checked) ---
102
93
  data.wk = "na";
103
94
  // --- Active plugins ---
@@ -90,17 +90,14 @@ export function createMcpHarness(opts) {
90
90
  // These utilities are used by multiple games' MCP automation harnesses.
91
91
  // Games import them instead of duplicating the implementation.
92
92
  /** Convert a Blob to a base64 string (without the data: prefix). */
93
- export function blobToBase64(blob) {
94
- return new Promise((resolve, reject) => {
95
- const reader = new FileReader();
96
- reader.onloadend = () => {
97
- const result = reader.result;
98
- const base64 = result.split(",")[1];
99
- resolve(base64 ?? "");
100
- };
101
- reader.onerror = reject;
102
- reader.readAsDataURL(blob);
103
- });
93
+ export async function blobToBase64(blob) {
94
+ const bytes = new Uint8Array(await blob.arrayBuffer());
95
+ let bin = "";
96
+ const CHUNK = 0x8000;
97
+ for (let i = 0; i < bytes.length; i += CHUNK) {
98
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
99
+ }
100
+ return btoa(bin);
104
101
  }
105
102
  /** MCP result helper: wrap data as a JSON content array. */
106
103
  export function jsonResult(data) {
@@ -25,7 +25,7 @@
25
25
  // }),
26
26
  // });
27
27
  // ============================================================================
28
- import { getHostCapabilities, KEY } from "@downdraft/engine";
28
+ import { KEY } from "@downdraft/engine";
29
29
  import { createLogger } from "@downdraft/engine/util/logger";
30
30
  import { downdraft } from "./index.js";
31
31
  import { blobToBase64, createMcpHarness, errorResult, jsonResult } from "./mcp-harness.js";
@@ -290,21 +290,13 @@ export function createStandardAutomationTools(ctx) {
290
290
  const rect = surface.getBoundingClientRect();
291
291
  const clientX = rect.left + x;
292
292
  const clientY = rect.top + y;
293
- const hasDom = getHostCapabilities().hasDom;
294
- // DOM hosts hit-test the topmost element (mimics a real user click
295
- // through UI overlays). On native there is no DOM tree — the surface
296
- // itself is the input target.
297
- const target = hasDom
298
- ? (document.elementFromPoint(clientX, clientY) ?? surface)
299
- : surface;
300
- // MiniEventTarget (native event polyfill) has no capture/bubble —
301
- // mirror real propagation by also dispatching on window. In a real
302
- // DOM this would double-fire, so only do it under the polyfill.
303
- const isNativeDom = !hasDom;
293
+ // There is no DOM tree — the surface itself is the input target.
294
+ const target = surface;
295
+ // MiniEventTarget (native event target) has no capture/bubble —
296
+ // mirror DOM propagation by also dispatching on window.
304
297
  const fire = (ev) => {
305
298
  target.dispatchEvent(ev);
306
- if (isNativeDom)
307
- window.dispatchEvent?.(ev);
299
+ window.dispatchEvent?.(ev);
308
300
  };
309
301
  if (type === "wheel") {
310
302
  const deltaX = params.deltaX ?? 0;
@@ -487,130 +479,6 @@ export function createStandardAutomationTools(ctx) {
487
479
  handler: () => jsonResult(ctx.getUiState()),
488
480
  });
489
481
  }
490
- // ── get_element_bounds ──
491
- if (want("get_element_bounds")) {
492
- tools.push({
493
- def: {
494
- name: "get_element_bounds",
495
- description: "Get the bounding box of a DOM element matching a CSS selector. Returns {x, y, width, height, top, left, bottom, right} or found:false.",
496
- inputSchema: {
497
- type: "object",
498
- properties: { selector: { type: "string", description: "CSS selector" } },
499
- required: ["selector"],
500
- },
501
- },
502
- handler: (params) => {
503
- const selector = params.selector;
504
- try {
505
- const el = document.querySelector(selector);
506
- if (!el)
507
- return jsonResult({ found: false, selector });
508
- const rect = el.getBoundingClientRect();
509
- return jsonResult({
510
- found: true, selector,
511
- x: rect.x, y: rect.y, width: rect.width, height: rect.height,
512
- top: rect.top, left: rect.left, bottom: rect.bottom, right: rect.right,
513
- });
514
- }
515
- catch (e) {
516
- return errorResult(`Failed to query selector "${selector}": ${e.message}`);
517
- }
518
- },
519
- });
520
- }
521
- // ── inspect_dom ──
522
- if (want("inspect_dom")) {
523
- tools.push({
524
- def: {
525
- name: "inspect_dom",
526
- description: "Inspect the main thread's DOM for debugging CSS/layout issues. 'stylesheets' lists all <style>/<link> tags; 'element' gets details for a selector.",
527
- inputSchema: {
528
- type: "object",
529
- properties: {
530
- action: { type: "string", enum: ["stylesheets", "element"] },
531
- selector: { type: "string", description: "CSS selector (for action='element')" },
532
- },
533
- required: ["action"],
534
- },
535
- },
536
- handler: (params) => {
537
- if (!getHostCapabilities().hasDom) {
538
- return errorResult("inspect_dom requires a real DOM — the native host's document is a canvas-compat facade, not a DOM tree");
539
- }
540
- const action = params.action;
541
- if (action === "stylesheets") {
542
- const styles = Array.from(document.querySelectorAll("style, link[rel='stylesheet']")).map((el) => {
543
- const tag = el.tagName.toLowerCase();
544
- if (tag === "style") {
545
- const text = el.textContent ?? "";
546
- return { tag, length: text.length, preview: text.slice(0, 200) };
547
- }
548
- return { tag, href: el.href };
549
- });
550
- return jsonResult({ stylesheets: styles, count: styles.length });
551
- }
552
- if (action === "element" && params.selector) {
553
- const el = document.querySelector(params.selector);
554
- if (!el)
555
- return jsonResult({ found: false, selector: params.selector });
556
- const cs = getComputedStyle(el);
557
- return jsonResult({
558
- found: true,
559
- selector: params.selector,
560
- tagName: el.tagName,
561
- className: el.className,
562
- id: el.id,
563
- style: {
564
- width: cs.width, height: cs.height,
565
- maxWidth: cs.maxWidth, maxHeight: cs.maxHeight,
566
- display: cs.display, position: cs.position,
567
- top: cs.top, left: cs.left, overflow: cs.overflow,
568
- },
569
- bounds: el.getBoundingClientRect().toJSON(),
570
- childCount: el.children.length,
571
- });
572
- }
573
- return errorResult("Invalid action or missing selector");
574
- },
575
- });
576
- }
577
- // ── get_element_style ──
578
- if (want("get_element_style")) {
579
- tools.push({
580
- def: {
581
- name: "get_element_style",
582
- description: "Get computed style of a DOM element matching a CSS selector. Pass properties[] to read specific CSS properties.",
583
- inputSchema: {
584
- type: "object",
585
- properties: {
586
- selector: { type: "string", description: "CSS selector" },
587
- properties: { type: "array", items: { type: "string" }, description: "CSS properties to read" },
588
- },
589
- required: ["selector"],
590
- },
591
- },
592
- handler: (params) => {
593
- if (!getHostCapabilities().hasDom) {
594
- return errorResult("get_element_style requires a real DOM — no computed styles on the native host");
595
- }
596
- const selector = params.selector;
597
- const props = params.properties ?? [];
598
- try {
599
- const el = document.querySelector(selector);
600
- if (!el)
601
- return jsonResult({ found: false, selector });
602
- const cs = getComputedStyle(el);
603
- const result = { found: true, selector };
604
- props.forEach((p) => { result[p] = cs.getPropertyValue(p); ; });
605
- result._className = el.className;
606
- return jsonResult(result);
607
- }
608
- catch (e) {
609
- return errorResult(`Failed to query style for "${selector}": ${e.message}`);
610
- }
611
- },
612
- });
613
- }
614
482
  // ── inspectable_list / inspectable_get / inspectable_set ──
615
483
  const inspectables = ctx.inspectables;
616
484
  if (want("inspectable_list") && inspectables) {
@@ -23,15 +23,15 @@ export class ModuleHost {
23
23
  }
24
24
  /**
25
25
  * Inject the DevTools API. Called by the game bootstrap after
26
- * `initDevTools()` so plugins can self-register debug panels via
27
- * `ctx.devtools.registerPanel(...)` during their `register()` lifecycle.
26
+ * `initDevTools()` so plugins can self-register data feeds via
27
+ * `ctx.devtools.registerDataFeed(...)` during their `register()` lifecycle.
28
28
  */
29
29
  setDevToolsAPI(api) {
30
30
  this._devtools = api;
31
31
  }
32
32
  get devtools() {
33
33
  // Return the injected API, or a no-op stub if not set (so plugins that
34
- // call ctx.devtools.registerPanel() don't crash if devtools isn't wired).
34
+ // call ctx.devtools.register*() don't crash if devtools isn't wired).
35
35
  return this._devtools ?? NoopDevToolsAPI;
36
36
  }
37
37
  getRegistry() {
@@ -439,10 +439,7 @@ export class ModuleHost {
439
439
  }
440
440
  }
441
441
  // No-op DevTools API stub — used when setDevToolsAPI() hasn't been called.
442
- // Plugins that call ctx.devtools.registerPanel() will silently no-op.
443
442
  const NoopDevToolsAPI = {
444
- registerPanel: () => { },
445
- registerOverlayToggle: () => { },
446
443
  registerDataFeed: () => { },
447
444
  registerCommand: () => { },
448
445
  registerSABStat: () => { },
@@ -1,20 +1,13 @@
1
1
  export class CanvasResizeWatcher {
2
2
  canvas;
3
3
  handler;
4
- observer = null;
5
4
  dpr = 1;
6
5
  onSurfaceResize = () => this.resize();
7
6
  constructor(canvas, handler) {
8
7
  this.canvas = canvas;
9
8
  this.handler = handler;
10
9
  this.dpr = (typeof window !== "undefined" ? window.devicePixelRatio : 0) || 1;
11
- // DOM hosts: element box changes fire ResizeObserver. Native surfaces
12
- // aren't Elements — they push a "resize" event instead. Both paths feed
13
- // the same resize() so either host drives layout updates.
14
- if (typeof ResizeObserver !== "undefined") {
15
- this.observer = new ResizeObserver(() => this.resize());
16
- this.observer.observe(canvas);
17
- }
10
+ // Native surfaces push a "resize" event when the backing store changes.
18
11
  canvas.addEventListener("resize", this.onSurfaceResize);
19
12
  this.resize();
20
13
  }
@@ -29,8 +22,6 @@ export class CanvasResizeWatcher {
29
22
  return this.dpr;
30
23
  }
31
24
  destroy() {
32
- this.observer?.disconnect();
33
- this.observer = null;
34
25
  this.canvas.removeEventListener("resize", this.onSurfaceResize);
35
26
  }
36
27
  }
@@ -6,7 +6,7 @@
6
6
  // logic through callback hooks.
7
7
  // ============================================================================
8
8
  import { UIInputRouter } from "../input/ui-router.js";
9
- import { getHostCapabilities, getNativeHost } from "../platform/runtime.js";
9
+ import { getNativeHost } from "../platform/runtime.js";
10
10
  import { TelemetryCollector } from "../telemetry/collector.js";
11
11
  import { GPUProfiler } from "../telemetry/gpu-profiler.js";
12
12
  import { GPUResourceTracker } from "../telemetry/gpu-resource-tracker.js";
@@ -405,17 +405,15 @@ export class GameRenderer {
405
405
  */
406
406
  setResolutionScale(scale) {
407
407
  this.resolutionScale = Math.max(0.25, Math.min(1, scale));
408
- if (!getHostCapabilities().hasDom) {
409
- // On the native host the swapchain extent must equal the window's
410
- // real size — wgpu compares the configured size against the surface's
411
- // currentExtent at acquire time and returns Outdated forever when
412
- // they differ. Shrinking canvas.width/height would also corrupt
413
- // clientWidth (UI layout + input coords). Resolution scaling needs
414
- // an offscreen-render + upscale path to work here; for now it is a
415
- // no-op rather than a brick.
416
- if (this.resolutionScale < 1) {
417
- log.warn("game-renderer", "resolutionScale is not supported on the native surface (swapchain must match the window size) — ignoring");
418
- }
408
+ // On the native host the swapchain extent must equal the window's
409
+ // real size — wgpu compares the configured size against the surface's
410
+ // currentExtent at acquire time and returns Outdated forever when
411
+ // they differ. Shrinking canvas.width/height would also corrupt
412
+ // clientWidth (UI layout + input coords). Resolution scaling needs
413
+ // an offscreen-render + upscale path to work here; for now it is a
414
+ // no-op rather than a brick.
415
+ if (this.resolutionScale < 1) {
416
+ log.warn("game-renderer", "resolutionScale is not supported on the native surface (swapchain must match the window size) — ignoring");
419
417
  }
420
418
  if (this.lastCssW > 0)
421
419
  this.onResize(this.lastCssW, this.lastCssH, this.lastRawDpr);
@@ -431,7 +429,7 @@ export class GameRenderer {
431
429
  // performance knob when fill rate is a concern. On native the
432
430
  // swapchain is window-sized (see setResolutionScale) so the scale is
433
431
  // never applied to the backing store.
434
- const cappedDpr = dpr * (getHostCapabilities().hasDom ? this.resolutionScale : 1);
432
+ const cappedDpr = dpr;
435
433
  this.dpr = cappedDpr;
436
434
  const w = Math.round(cssWidth * cappedDpr);
437
435
  const h = Math.round(cssHeight * cappedDpr);
@@ -777,11 +775,12 @@ export class GameRenderer {
777
775
  // final image (skip on GPU error to avoid cascade). This must run AFTER
778
776
  // afterFrame — 2D games (viewportCount=0) do their custom rendering in
779
777
  // the afterFrame callback, so drawing UI earlier would put it underneath
780
- // their clear pass.
778
+ // their clear pass. The texture comes from peek: a frame the game
779
+ // skipped must not be started just to draw UI on a blank swapchain.
781
780
  if (!gpuError && this.device && this.context) {
782
781
  const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
783
782
  const uiCanvasView = compositors.length > 0
784
- ? this.getSurfaceTexture()?.createView()
783
+ ? this.getFrameSurfaceTexture()?.createView()
785
784
  : undefined;
786
785
  if (uiCanvasView) {
787
786
  const uiEncoder = this.device.createCommandEncoder();
@@ -793,7 +792,10 @@ export class GameRenderer {
793
792
  storeOp: "store",
794
793
  }],
795
794
  });
796
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
795
+ // Panel rects are CSS px — pass the CSS surface size so
796
+ // rect/surface maps correctly on HiDPI displays (canvas.width is
797
+ // physical px; identical where scaleFactor=1).
798
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.clientWidth, this.canvas.clientHeight); });
797
799
  uiPass.end();
798
800
  // "preUi" slot — worker passes ordered ahead of the UI-composite
799
801
  // buffer inside the end-of-frame submit.
@@ -853,6 +855,29 @@ export class GameRenderer {
853
855
  return null;
854
856
  }
855
857
  }
858
+ /**
859
+ * The surface texture already acquired for the in-flight frame, or null
860
+ * when nothing has acquired one yet. End-of-frame overlays (the UI
861
+ * composite) use this so they join a frame rather than start one: on
862
+ * hosts with frame retention (native), acquiring here would fabricate a
863
+ * frame the game deliberately skipped (dirty-tracking) and present the
864
+ * overlay over a blank swapchain image — a full-screen flash. Falls
865
+ * back to acquiring on contexts without peek (DOM semantics:
866
+ * getCurrentTexture is idempotent within a frame and present is
867
+ * implicit, so there is nothing to defeat).
868
+ */
869
+ getFrameSurfaceTexture() {
870
+ const peek = this.context?.peekCurrentTexture;
871
+ if (typeof peek === "function") {
872
+ try {
873
+ return peek.call(this.context) ?? null;
874
+ }
875
+ catch {
876
+ return null;
877
+ }
878
+ }
879
+ return this.getSurfaceTexture();
880
+ }
856
881
  renderViewport(viewportIdx, dt, offscreen) {
857
882
  if (!this.device || !this.context)
858
883
  return null;
@@ -1101,7 +1126,7 @@ export class GameRenderer {
1101
1126
  const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
1102
1127
  if (compositors.length === 0 || !this.device || !this.context)
1103
1128
  return;
1104
- const canvasView = this.getSurfaceTexture()?.createView();
1129
+ const canvasView = this.getFrameSurfaceTexture()?.createView();
1105
1130
  if (!canvasView)
1106
1131
  return;
1107
1132
  const uiEncoder = this.device.createCommandEncoder();
@@ -1113,7 +1138,8 @@ export class GameRenderer {
1113
1138
  storeOp: "store",
1114
1139
  }],
1115
1140
  });
1116
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
1141
+ // Panel rects are CSS px — see the main UI composite block above.
1142
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.clientWidth, this.canvas.clientHeight); });
1117
1143
  uiPass.end();
1118
1144
  this.device.queue.submit([uiEncoder.finish()]);
1119
1145
  }
@@ -57,7 +57,6 @@ export { PipelineCache } from "./pipeline.js";
57
57
  export { RenderPass } from "./render-pass.js";
58
58
  export { RendererInputBusImpl } from "./renderer-input-bus.js";
59
59
  export { RendererModuleHost } from "./renderer-module-host.js";
60
- export { SabCanvasOverlay } from "./sab-canvas-overlay.js";
61
60
  export { clearShaderValidationDedup, createValidatedShaderModule, createValidatedShaderModuleAsync, installShaderValidationGuard } from "./shader-validator.js";
62
61
  export { CSM_SHADER_CHUNK, POINT_SHADOW_SHADER_CHUNK, SPOT_SHADOW_SHADER_CHUNK } from "./shaders/shadow-chunks.js";
63
62
  export { SkyboxRenderer } from "./skybox.js";
@@ -58,9 +58,9 @@ export class InputManager {
58
58
  this.tryLockPointer();
59
59
  }
60
60
  }));
61
- add(document, "pointerlockchange", (() => {
61
+ add(this.canvas, "pointerlockchange", (() => {
62
62
  const wasLocked = this.pointerLocked;
63
- this.pointerLocked = document.pointerLockElement === this.canvas;
63
+ this.pointerLocked = this.canvas.pointerLocked === true;
64
64
  if (this.pointerLocked) {
65
65
  this.pointerLockRetryCount = 0;
66
66
  if (this.pointerLockRetryTimer) {
@@ -76,7 +76,7 @@ export class InputManager {
76
76
  this.mouseDelta.dy = 0;
77
77
  }
78
78
  }));
79
- add(document, "pointerlockerror", (() => {
79
+ add(this.canvas, "pointerlockerror", (() => {
80
80
  // No action needed — tryLockPointer's fallback timer will retry.
81
81
  }));
82
82
  }
@@ -150,10 +150,10 @@ export class InputManager {
150
150
  e.preventDefault();
151
151
  }
152
152
  }), { passive: false });
153
- // Use document-level mouseleave: a sibling canvas stacked above (e.g.
154
- // sandjongg's tile canvas) would otherwise trigger the canvas's own
155
- // mouseleave and break UI hover/pressed state.
156
- add(document, "mouseleave", (() => {
153
+ // Window-level mouseleave: a second interactive canvas stacked above
154
+ // (e.g. sandjongg's tile canvas) would otherwise trigger the canvas's
155
+ // own mouseleave and break UI hover/pressed state.
156
+ add(window, "mouseleave", (() => {
157
157
  this.uiInputRouter?.handlePointerLeave();
158
158
  }));
159
159
  add(this.canvas, "contextmenu", ((e) => {
@@ -50,8 +50,8 @@ export class RendererModuleHost {
50
50
  this.inputBus = new RendererInputBusImpl(canvas);
51
51
  }
52
52
  /**
53
- * Inject the DevTools API so renderer plugins can self-register debug
54
- * panels via `ctx.devtools.registerPanel(...)` during their `register()`.
53
+ * Inject the DevTools API so renderer plugins can self-register data
54
+ * feeds via `ctx.devtools.registerDataFeed(...)` during their `register()`.
55
55
  */
56
56
  setDevToolsAPI(api) {
57
57
  this._devtools = api;
@@ -531,8 +531,6 @@ export class RendererModuleHost {
531
531
  }
532
532
  // No-op DevTools API stub — used when setDevToolsAPI() hasn't been called.
533
533
  const NoopRendererDevToolsAPI = {
534
- registerPanel: () => { },
535
- registerOverlayToggle: () => { },
536
534
  registerDataFeed: () => { },
537
535
  registerCommand: () => { },
538
536
  registerSABStat: () => { },
@@ -13,46 +13,13 @@
13
13
  // ============================================================================
14
14
  let cachedSystemScale = null;
15
15
  /**
16
- * Detect the system/browser default font size and return a scale factor
17
- * relative to 16px (the browser default). Always returns >= 1.0.
18
- *
19
- * Example: if the user set their browser default font to 20px, this returns
20
- * 1.25. If the browser default is 16px (normal), this returns 1.0.
21
- *
22
- * Must be called on the main thread (requires `document`).
16
+ * Detect the system default font size and return a scale factor relative to
17
+ * 16px. On the native host there is no CSS engine and no system font-size
18
+ * preference to measure — always 1.
23
19
  */
24
20
  export function detectSystemFontScale() {
25
- if (cachedSystemScale !== null)
26
- return cachedSystemScale;
27
- // On the native host the document is a canvas-compat facade — there is no
28
- // CSS engine and no getComputedStyle, so there is no "system font size"
29
- // to measure. Always 1.
30
- if (typeof document === "undefined" || !document.body || typeof getComputedStyle !== "function") {
31
- cachedSystemScale = 1;
32
- return 1;
33
- }
34
- const el = document.createElement("div");
35
- el.style.fontSize = "1rem";
36
- el.style.position = "absolute";
37
- el.style.visibility = "hidden";
38
- el.style.lineHeight = "0";
39
- el.style.margin = "0";
40
- el.style.padding = "0";
41
- document.body.appendChild(el);
42
- let scale = 1;
43
- try {
44
- const size = parseFloat(getComputedStyle(el).fontSize);
45
- // 16px is the browser default. Only scale up (never down).
46
- scale = size > 0 ? Math.max(1, size / 16) : 1;
47
- }
48
- catch {
49
- scale = 1;
50
- }
51
- finally {
52
- document.body.removeChild(el);
53
- }
54
- cachedSystemScale = scale;
55
- return scale;
21
+ cachedSystemScale = 1;
22
+ return 1;
56
23
  }
57
24
  /**
58
25
  * Cached accessor for the system font scale. After the first call to
@@ -55,6 +55,54 @@ export function getWorkerHost() {
55
55
  },
56
56
  };
57
57
  }
58
+ // --- Event channel stats ---
59
+ // Cheap counters the DOWNDRAFT_EVENT_STATS logger (and QA tooling) read to
60
+ // verify batching is actually engaging: emitted events vs postMessage posts
61
+ // on the worker side, messages vs delivered events on the host side.
62
+ const eventChannelStats = {
63
+ /** Worker side: emit() calls. */
64
+ emitted: 0,
65
+ /** Worker side: postMessage calls carrying events (batch or single). */
66
+ posts: 0,
67
+ /** Host side: messages received carrying events. */
68
+ messages: 0,
69
+ /** Host side: events delivered to listeners. */
70
+ events: 0,
71
+ /** Host side: largest observed __eventBatch. */
72
+ maxBatch: 0,
73
+ };
74
+ export function getEventChannelStats() {
75
+ return eventChannelStats;
76
+ }
77
+ function envFlag(name) {
78
+ try {
79
+ return (globalThis.process?.env?.[name] === "1");
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ let statsLoggerStarted = false;
86
+ function startEventStatsLogger() {
87
+ if (statsLoggerStarted || !envFlag("DOWNDRAFT_EVENT_STATS"))
88
+ return;
89
+ statsLoggerStarted = true;
90
+ let prev = { emitted: 0, posts: 0, messages: 0, events: 0 };
91
+ const t = setInterval(() => {
92
+ const s = eventChannelStats;
93
+ const d = {
94
+ emitted: s.emitted - prev.emitted,
95
+ posts: s.posts - prev.posts,
96
+ messages: s.messages - prev.messages,
97
+ events: s.events - prev.events,
98
+ };
99
+ prev = { emitted: s.emitted, posts: s.posts, messages: s.messages, events: s.events };
100
+ if (d.emitted || d.posts || d.messages || d.events) {
101
+ log.info("rpc-events", `5s Δ emitted=${d.emitted} posts=${d.posts} msgs=${d.messages} events=${d.events} maxBatch=${s.maxBatch}`);
102
+ }
103
+ }, 5000);
104
+ t.unref?.();
105
+ }
58
106
  function isRpcRequest(msg) {
59
107
  return msg && msg.__rpc === true && typeof msg.method === "string";
60
108
  }
@@ -88,8 +136,11 @@ export function expose(api, options) {
88
136
  }
89
137
  export function exposeEvents(host) {
90
138
  const h = host ?? getWorkerHost();
139
+ startEventStatsLogger();
91
140
  return {
92
141
  emit: (kind, data) => {
142
+ eventChannelStats.emitted++;
143
+ eventChannelStats.posts++;
93
144
  h.postToHost({ __event: true, kind, data });
94
145
  },
95
146
  };
@@ -105,17 +156,20 @@ export function exposeEvents(host) {
105
156
  */
106
157
  export function exposeBatchedEvents(host) {
107
158
  const h = host ?? getWorkerHost();
159
+ startEventStatsLogger();
108
160
  let queue = [];
109
161
  let flushScheduled = false;
110
162
  const flush = () => {
111
163
  flushScheduled = false;
112
164
  if (queue.length === 0)
113
165
  return;
166
+ eventChannelStats.posts++;
114
167
  h.postToHost({ __eventBatch: true, events: queue });
115
168
  queue = [];
116
169
  };
117
170
  return {
118
171
  emit: (kind, data) => {
172
+ eventChannelStats.emitted++;
119
173
  queue.push({ kind, data });
120
174
  if (!flushScheduled) {
121
175
  flushScheduled = true;
@@ -127,6 +181,7 @@ export function exposeBatchedEvents(host) {
127
181
  }
128
182
  const DEFAULT_TIMEOUT_MS = 30_000;
129
183
  export function wrap(worker, options) {
184
+ startEventStatsLogger();
130
185
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
131
186
  // A worker nobody terminates must not pin the runtime after the host's
132
187
  // window closes — in-flight RPCs still hold the loop via their timeout.
@@ -151,6 +206,10 @@ export function wrap(worker, options) {
151
206
  }
152
207
  if (isEventMessage(msg) || isEventBatchMessage(msg)) {
153
208
  const events = isEventBatchMessage(msg) ? msg.events : [msg];
209
+ eventChannelStats.messages++;
210
+ eventChannelStats.events += events.length;
211
+ if (events.length > eventChannelStats.maxBatch)
212
+ eventChannelStats.maxBatch = events.length;
154
213
  for (const ev of events) {
155
214
  for (const cb of eventListeners.values()) {
156
215
  try {
@@ -116,7 +116,14 @@ export function createBlitzUiNativeModule(options) {
116
116
  const device = ctx.getDevice();
117
117
  const view = ensureTexture(device);
118
118
  const context = ctx.getSurface().getContext("webgpu");
119
- const targetTex = context?.getCurrentTexture();
119
+ // Join the in-flight frame, never start one: on a frame the
120
+ // game skipped (dirty-tracking), acquiring the surface here
121
+ // would present the UI blit over a blank swapchain image.
122
+ const peek = context
123
+ ?.peekCurrentTexture;
124
+ const targetTex = typeof peek === "function"
125
+ ? peek.call(context)
126
+ : context?.getCurrentTexture() ?? null;
120
127
  const target = targetTex?.createView();
121
128
  if (!view || !target || !targetTex)
122
129
  return;