@downdraft/engine 0.3.0 → 0.3.1

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
@@ -111,8 +111,6 @@ export interface BootstrapDevToolsOptions {
111
111
  * Called after the renderer is initialized.
112
112
  */
113
113
  createSimStatsProvider?: (renderer: any) => any;
114
- /** DevTools panels (game-specific). Can be a factory or static array. */
115
- panels?: any[] | ((renderer: any) => any[]);
116
114
  /** Whether to enable scene inspector. Default: false. */
117
115
  sceneInspector?: boolean;
118
116
  /** Custom scene-inspector bridge class (extends BaseSceneInspector).
@@ -236,15 +234,11 @@ export async function bootstrapGame(opts: BootstrapGameOptions): Promise<void> {
236
234
  if (!devtoolsMod) return;
237
235
  const { initDevTools } = devtoolsMod;
238
236
  const simStatsProvider = opts.devtools.createSimStatsProvider?.(renderer);
239
- const panels = typeof opts.devtools.panels === "function"
240
- ? opts.devtools.panels(renderer)
241
- : opts.devtools.panels ?? [];
242
237
  const workerHosts = typeof opts.devtools.workerHosts === "function"
243
238
  ? opts.devtools.workerHosts(renderer)
244
239
  : opts.devtools.workerHosts;
245
240
  const devtoolsBridge = await importOrDead(initDevTools(renderer, {
246
241
  simStatsProvider,
247
- panels,
248
242
  sceneInspector: opts.devtools.sceneInspector,
249
243
  bridgeClass: opts.devtools.bridgeClass,
250
244
  workerHosts,
@@ -86,20 +86,12 @@ export function collectRendererFeatureLog(opts: RendererFeatureLogOptions): Feat
86
86
  if (nav?.deviceMemory) data.mem = `${nav.deviceMemory}G`;
87
87
  } catch { /* navigator unavailable */ }
88
88
 
89
- // --- SharedArrayBuffer + crossOriginIsolated (browser isolation only —
90
- // there is no origin model on the native host) ---
89
+ // --- SharedArrayBuffer ---
91
90
  try {
92
91
  data.sab = typeof SharedArrayBuffer !== "undefined" ? 1 : 0;
93
92
  } catch {
94
93
  data.sab = 0;
95
94
  }
96
- if (getHostCapabilities().hasDom) {
97
- try {
98
- data.coi = (self as any).crossOriginIsolated ? 1 : 0;
99
- } catch {
100
- data.coi = 0;
101
- }
102
- }
103
95
 
104
96
  // --- Worker load status (best-effort; na = not checked) ---
105
97
  data.wk = "na";
@@ -120,17 +120,14 @@ export function createMcpHarness(opts: McpHarnessOptions): void {
120
120
  // Games import them instead of duplicating the implementation.
121
121
 
122
122
  /** Convert a Blob to a base64 string (without the data: prefix). */
123
- export function blobToBase64(blob: Blob): Promise<string> {
124
- return new Promise((resolve, reject) => {
125
- const reader = new FileReader();
126
- reader.onloadend = () => {
127
- const result = reader.result as string;
128
- const base64 = result.split(",")[1];
129
- resolve(base64 ?? "");
130
- };
131
- reader.onerror = reject;
132
- reader.readAsDataURL(blob);
133
- });
123
+ export async function blobToBase64(blob: Blob): Promise<string> {
124
+ const bytes = new Uint8Array(await blob.arrayBuffer());
125
+ let bin = "";
126
+ const CHUNK = 0x8000;
127
+ for (let i = 0; i < bytes.length; i += CHUNK) {
128
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
129
+ }
130
+ return btoa(bin);
134
131
  }
135
132
 
136
133
  /** MCP result helper: wrap data as a JSON content array. */
@@ -26,7 +26,7 @@
26
26
  // });
27
27
  // ============================================================================
28
28
 
29
- import { getHostCapabilities, KEY, type RenderSurface } from "@downdraft/engine";
29
+ import { KEY, type RenderSurface } from "@downdraft/engine";
30
30
  import type { InspectableRegistry } from "@downdraft/engine/libraries/inspectable";
31
31
  import { createLogger } from "@downdraft/engine/util/logger";
32
32
  import { downdraft } from "./index";
@@ -108,14 +108,6 @@ export interface StandardAutomationContext {
108
108
  /** The game render surface (layer 0). Used by capture_screenshot + dispatch_click. */
109
109
  surface: RenderSurface | (() => RenderSurface | null);
110
110
 
111
- /**
112
- * Extra canvases to composite between the game canvas and the DOM overlay
113
- * in full-page screenshots (e.g. a 2D tile canvas). DOM hosts only —
114
- * native capture returns the composited swapchain via captureFrame().
115
- * Default: none.
116
- */
117
- extraLayers?: () => HTMLCanvasElement[];
118
-
119
111
  /**
120
112
  * Render one frame when the render loop is paused (deterministic mode), so
121
113
  * screenshots reflect current state. Called by capture_screenshot when the
@@ -422,20 +414,13 @@ export function createStandardAutomationTools(ctx: StandardAutomationContext): M
422
414
  const rect = surface.getBoundingClientRect();
423
415
  const clientX = rect.left + x;
424
416
  const clientY = rect.top + y;
425
- const hasDom = getHostCapabilities().hasDom;
426
- // DOM hosts hit-test the topmost element (mimics a real user click
427
- // through UI overlays). On native there is no DOM tree — the surface
428
- // itself is the input target.
429
- const target = hasDom
430
- ? (document.elementFromPoint(clientX, clientY) ?? (surface as unknown as HTMLElement))
431
- : (surface as unknown as EventTarget);
432
- // MiniEventTarget (native event polyfill) has no capture/bubble —
433
- // mirror real propagation by also dispatching on window. In a real
434
- // DOM this would double-fire, so only do it under the polyfill.
435
- const isNativeDom = !hasDom;
417
+ // There is no DOM tree — the surface itself is the input target.
418
+ const target = surface as unknown as EventTarget;
419
+ // MiniEventTarget (native event target) has no capture/bubble —
420
+ // mirror DOM propagation by also dispatching on window.
436
421
  const fire = (ev: Event) => {
437
422
  target.dispatchEvent(ev);
438
- if (isNativeDom) (window as any).dispatchEvent?.(ev);
423
+ (window as any).dispatchEvent?.(ev);
439
424
  };
440
425
  if (type === "wheel") {
441
426
  const deltaX = (params.deltaX as number) ?? 0;
@@ -474,10 +459,10 @@ export function createStandardAutomationTools(ctx: StandardAutomationContext): M
474
459
  if (pointerType) fireOne(pointerType, true, buttons);
475
460
  fireOne(type, false, buttons);
476
461
  }
477
- const targetEl = target as Partial<HTMLElement>;
462
+ const targetEl = target as { tagName?: string; id?: string; className?: unknown };
478
463
  return jsonResult({
479
464
  dispatched: true, x, y, type,
480
- targetTag: targetEl.tagName ?? (targetEl as { id?: string }).id ?? "surface",
465
+ targetTag: targetEl.tagName ?? targetEl.id ?? "surface",
481
466
  targetClass: targetEl.className?.toString().slice(0, 80),
482
467
  });
483
468
  },
@@ -617,128 +602,6 @@ export function createStandardAutomationTools(ctx: StandardAutomationContext): M
617
602
  });
618
603
  }
619
604
 
620
- // ── get_element_bounds ──
621
- if (want("get_element_bounds")) {
622
- tools.push({
623
- def: {
624
- name: "get_element_bounds",
625
- 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.",
626
- inputSchema: {
627
- type: "object",
628
- properties: { selector: { type: "string", description: "CSS selector" } },
629
- required: ["selector"],
630
- },
631
- },
632
- handler: (params: Record<string, unknown>) => {
633
- const selector = params.selector as string;
634
- try {
635
- const el = document.querySelector(selector);
636
- if (!el) return jsonResult({ found: false, selector });
637
- const rect = el.getBoundingClientRect();
638
- return jsonResult({
639
- found: true, selector,
640
- x: rect.x, y: rect.y, width: rect.width, height: rect.height,
641
- top: rect.top, left: rect.left, bottom: rect.bottom, right: rect.right,
642
- });
643
- } catch (e) {
644
- return errorResult(`Failed to query selector "${selector}": ${(e as Error).message}`);
645
- }
646
- },
647
- });
648
- }
649
-
650
- // ── inspect_dom ──
651
- if (want("inspect_dom")) {
652
- tools.push({
653
- def: {
654
- name: "inspect_dom",
655
- description: "Inspect the main thread's DOM for debugging CSS/layout issues. 'stylesheets' lists all <style>/<link> tags; 'element' gets details for a selector.",
656
- inputSchema: {
657
- type: "object",
658
- properties: {
659
- action: { type: "string", enum: ["stylesheets", "element"] },
660
- selector: { type: "string", description: "CSS selector (for action='element')" },
661
- },
662
- required: ["action"],
663
- },
664
- },
665
- handler: (params: Record<string, unknown>) => {
666
- if (!getHostCapabilities().hasDom) {
667
- return errorResult("inspect_dom requires a real DOM — the native host's document is a canvas-compat facade, not a DOM tree");
668
- }
669
- const action = params.action as string;
670
- if (action === "stylesheets") {
671
- const styles = Array.from(document.querySelectorAll("style, link[rel='stylesheet']")).map((el) => {
672
- const tag = el.tagName.toLowerCase();
673
- if (tag === "style") {
674
- const text = el.textContent ?? "";
675
- return { tag, length: text.length, preview: text.slice(0, 200) };
676
- }
677
- return { tag, href: (el as HTMLLinkElement).href };
678
- });
679
- return jsonResult({ stylesheets: styles, count: styles.length });
680
- }
681
- if (action === "element" && params.selector) {
682
- const el = document.querySelector(params.selector as string);
683
- if (!el) return jsonResult({ found: false, selector: params.selector });
684
- const cs = getComputedStyle(el);
685
- return jsonResult({
686
- found: true,
687
- selector: params.selector,
688
- tagName: el.tagName,
689
- className: el.className,
690
- id: el.id,
691
- style: {
692
- width: cs.width, height: cs.height,
693
- maxWidth: cs.maxWidth, maxHeight: cs.maxHeight,
694
- display: cs.display, position: cs.position,
695
- top: cs.top, left: cs.left, overflow: cs.overflow,
696
- },
697
- bounds: el.getBoundingClientRect().toJSON(),
698
- childCount: el.children.length,
699
- });
700
- }
701
- return errorResult("Invalid action or missing selector");
702
- },
703
- });
704
- }
705
-
706
- // ── get_element_style ──
707
- if (want("get_element_style")) {
708
- tools.push({
709
- def: {
710
- name: "get_element_style",
711
- description: "Get computed style of a DOM element matching a CSS selector. Pass properties[] to read specific CSS properties.",
712
- inputSchema: {
713
- type: "object",
714
- properties: {
715
- selector: { type: "string", description: "CSS selector" },
716
- properties: { type: "array", items: { type: "string" }, description: "CSS properties to read" },
717
- },
718
- required: ["selector"],
719
- },
720
- },
721
- handler: (params: Record<string, unknown>) => {
722
- if (!getHostCapabilities().hasDom) {
723
- return errorResult("get_element_style requires a real DOM — no computed styles on the native host");
724
- }
725
- const selector = params.selector as string;
726
- const props = (params.properties as string[]) ?? [];
727
- try {
728
- const el = document.querySelector(selector);
729
- if (!el) return jsonResult({ found: false, selector });
730
- const cs = getComputedStyle(el);
731
- const result: Record<string, unknown> = { found: true, selector };
732
- props.forEach((p) => { result[p] = cs.getPropertyValue(p);; });
733
- result._className = el.className;
734
- return jsonResult(result);
735
- } catch (e) {
736
- return errorResult(`Failed to query style for "${selector}": ${(e as Error).message}`);
737
- }
738
- },
739
- });
740
- }
741
-
742
605
  // ── inspectable_list / inspectable_get / inspectable_set ──
743
606
  const inspectables = ctx.inspectables;
744
607
  if (want("inspectable_list") && inspectables) {
@@ -47,8 +47,8 @@ export class ModuleHost implements ModuleContext {
47
47
 
48
48
  /**
49
49
  * Inject the DevTools API. Called by the game bootstrap after
50
- * `initDevTools()` so plugins can self-register debug panels via
51
- * `ctx.devtools.registerPanel(...)` during their `register()` lifecycle.
50
+ * `initDevTools()` so plugins can self-register data feeds via
51
+ * `ctx.devtools.registerDataFeed(...)` during their `register()` lifecycle.
52
52
  */
53
53
  setDevToolsAPI(api: ModuleDevToolsAPI): void {
54
54
  this._devtools = api;
@@ -56,7 +56,7 @@ export class ModuleHost implements ModuleContext {
56
56
 
57
57
  get devtools(): ModuleDevToolsAPI {
58
58
  // Return the injected API, or a no-op stub if not set (so plugins that
59
- // call ctx.devtools.registerPanel() don't crash if devtools isn't wired).
59
+ // call ctx.devtools.register*() don't crash if devtools isn't wired).
60
60
  return this._devtools ?? NoopDevToolsAPI;
61
61
  }
62
62
 
@@ -488,10 +488,7 @@ export class ModuleHost implements ModuleContext {
488
488
  }
489
489
 
490
490
  // No-op DevTools API stub — used when setDevToolsAPI() hasn't been called.
491
- // Plugins that call ctx.devtools.registerPanel() will silently no-op.
492
491
  const NoopDevToolsAPI: ModuleDevToolsAPI = {
493
- registerPanel: () => {},
494
- registerOverlayToggle: () => {},
495
492
  registerDataFeed: () => {},
496
493
  registerCommand: () => {},
497
494
  registerSABStat: () => {},
@@ -3,7 +3,7 @@ export { buildCrossThreadReport, crossThreadToken } from "./cross-thread";
3
3
  export type { CrossThreadReport, CrossThreadToken, ModuleThreadInfo, ThreadTag } from "./cross-thread";
4
4
  export { DiagnosticError, isStrict, setStrict } from "./diagnostics";
5
5
  export { ModuleHost } from "./host";
6
- export type { DevToolsOverlayToggleRegistration, DevToolsPanelRegistration, Module, ModuleContext, ModuleDevToolsAPI, SABChannel } from "./module";
6
+ export type { Module, ModuleContext, ModuleDevToolsAPI, SABChannel } from "./module";
7
7
  export { ModuleRegistry } from "./registry";
8
8
  export type {
9
9
  CameraControllerLike,
@@ -7,59 +7,16 @@ export interface SABChannel {
7
7
  buffer: SharedArrayBuffer;
8
8
  }
9
9
 
10
- /**
11
- * Panel registration payload for `ctx.devtools.registerPanel()`.
12
- * Structurally compatible with `IDevToolsPanelExtension` from
13
- * `@downdraft/engine/modules/devtools` — core declares the minimal shape
14
- * so it doesn't depend on the devtools module (layering).
15
- */
16
- export interface DevToolsPanelRegistration {
17
- /** Unique id for this extension (e.g. "debug-info", "game-panel"). */
18
- id: string;
19
- /** Tab label shown in the tab bar. */
20
- tabLabel: string;
21
- /** Tab tooltip. */
22
- tabTooltip?: string;
23
- /** Order/priority for tab placement. Core tabs: 0-100, game tabs: 100+. Default: 100. */
24
- order?: number;
25
- /** __sceneInspector methods that must exist for this tab to be shown. */
26
- requiredMethods?: string[];
27
- /** HTML content for the panel body. */
28
- html: string;
29
- /** CSS to inject into the panel document. */
30
- css?: string;
31
- /** JS to eval in the panel context. */
32
- script?: string;
33
- }
34
-
35
- /**
36
- * Overlay toggle registration payload for `ctx.devtools.registerOverlayToggle()`.
37
- * Structurally compatible with `IDevToolsOverlayToggle` from
38
- * `@downdraft/engine/modules/devtools`.
39
- */
40
- export interface DevToolsOverlayToggleRegistration {
41
- /** Unique id (e.g. "chunk-grid", "vel-arrows"). */
42
- id: string;
43
- /** Label shown next to the checkbox. */
44
- label: string;
45
- /** __sceneInspector methods that must exist for this toggle to be shown. */
46
- requiredMethods?: string[];
47
- /** JS to eval in the panel context. */
48
- script?: string;
49
- }
50
-
51
10
  /**
52
11
  * DevTools registration surface exposed on ModuleContext.
53
- * Plugins call `ctx.devtools.registerPanel(...)` etc. to self-register
54
- * debug screens. The same interface works in both main and worker realms.
12
+ * Plugins call `ctx.devtools.registerDataFeed(...)` etc. to self-register
13
+ * debug data. The same interface works in both main and worker realms.
55
14
  *
56
15
  * The actual implementation is provided by @downdraft/engine/modules/devtools via
57
16
  * the `devtools` singleton. Core defines the interface; the host injects
58
17
  * the concrete object at construction time (see ModuleHost.setDevToolsAPI).
59
18
  */
60
19
  export interface ModuleDevToolsAPI {
61
- registerPanel(ext: DevToolsPanelRegistration): void;
62
- registerOverlayToggle(toggle: DevToolsOverlayToggleRegistration): void;
63
20
  registerDataFeed(name: string, fn: () => unknown, writeRateHz?: number): void;
64
21
  registerCommand(name: string, fn: (...args: unknown[]) => unknown): void;
65
22
  registerSABStat(name: string, offset: number, type: "u32" | "f32" | "i32"): void;
@@ -95,8 +52,8 @@ export interface ModuleContext {
95
52
  registerMigration(fromVersion: number, fn: (data: unknown) => unknown): void;
96
53
  onDispose(fn: () => void): void;
97
54
  /**
98
- * DevTools registration surface. Plugins self-register debug panels,
99
- * data feeds, and commands via `ctx.devtools.register*(...)`.
55
+ * DevTools registration surface. Plugins self-register data feeds and
56
+ * commands via `ctx.devtools.register*(...)`.
100
57
  * Works in both sim (worker) and renderer (main) plugin contexts.
101
58
  */
102
59
  readonly devtools: ModuleDevToolsAPI;
@@ -399,6 +399,11 @@ export interface PhysicsBackend {
399
399
  /** Set integration timestep on a realm. */
400
400
  setIntegrationDt(realmId: number, dt: number): void;
401
401
 
402
+ /** Change a live realm's gravity vector. Optional — backends that can't
403
+ * mutate realm gravity simply omit it (callers fall back to per-body
404
+ * gravityScale). */
405
+ setRealmGravity?(realmId: number, gravity: [number, number, number]): void;
406
+
402
407
  /** Per-realm sleep thresholds for graceful degradation (load shedding first line). */
403
408
  setSleepThresholds(realmId: number, linearThreshold: number, angularThreshold: number): void;
404
409
  /** Per-realm solver iteration count (Rapier `IntegrationParameters.numSolverIterations`). */
@@ -29,6 +29,15 @@ export interface RenderSurfaceContext {
29
29
  * the swapchain can't produce a frame (resize in flight, occluded
30
30
  * window) — callers must treat null as "skip this frame". */
31
31
  getCurrentTexture(): GPUTexture | null;
32
+ /** The texture already acquired for the in-flight frame, if any —
33
+ * WITHOUT acquiring one. Null when nothing has acquired this frame.
34
+ * End-of-frame overlay passes (UI composites) should join a frame via
35
+ * peek rather than acquire: on hosts with frame retention (native),
36
+ * acquiring here fabricates a frame the game deliberately skipped and
37
+ * presents the overlay over a blank swapchain image. Optional — DOM
38
+ * GPUCanvasContext has no equivalent; callers fall back to
39
+ * getCurrentTexture() where acquire is idempotent within a frame. */
40
+ peekCurrentTexture?(): GPUTexture | null;
32
41
  }
33
42
 
34
43
  /** Listener signature — the native event target dispatches plain event
@@ -74,4 +83,8 @@ export interface RenderSurface {
74
83
  * concept doesn't exist; presence is the feature check. */
75
84
  requestPointerLock?(): Promise<void> | void;
76
85
  exitPointerLock?(): void;
86
+ /** Current pointer-lock state. Optional — read instead of the DOM's
87
+ * `document.pointerLockElement` (there is no DOM on native). The surface
88
+ * also dispatches a `"pointerlockchange"` event on transitions. */
89
+ readonly pointerLocked?: boolean;
77
90
  }
@@ -5,7 +5,6 @@ export interface CanvasResizeHandler {
5
5
  }
6
6
 
7
7
  export class CanvasResizeWatcher {
8
- private observer: ResizeObserver | null = null;
9
8
  private dpr = 1;
10
9
  private onSurfaceResize = () => this.resize();
11
10
 
@@ -14,13 +13,7 @@ export class CanvasResizeWatcher {
14
13
  private handler: CanvasResizeHandler,
15
14
  ) {
16
15
  this.dpr = (typeof window !== "undefined" ? window.devicePixelRatio : 0) || 1;
17
- // DOM hosts: element box changes fire ResizeObserver. Native surfaces
18
- // aren't Elements — they push a "resize" event instead. Both paths feed
19
- // the same resize() so either host drives layout updates.
20
- if (typeof ResizeObserver !== "undefined") {
21
- this.observer = new ResizeObserver(() => this.resize());
22
- this.observer.observe(canvas as unknown as Element);
23
- }
16
+ // Native surfaces push a "resize" event when the backing store changes.
24
17
  canvas.addEventListener("resize", this.onSurfaceResize);
25
18
  this.resize();
26
19
  }
@@ -39,8 +32,6 @@ export class CanvasResizeWatcher {
39
32
  }
40
33
 
41
34
  destroy(): void {
42
- this.observer?.disconnect();
43
- this.observer = null;
44
35
  this.canvas.removeEventListener("resize", this.onSurfaceResize);
45
36
  }
46
37
  }
@@ -9,7 +9,7 @@
9
9
  import { UIInputRouter } from "../input/ui-router";
10
10
  import type { RendererModule } from "../module/renderer-module";
11
11
  import type { RenderSurface, RenderSurfaceContext } from "../platform/render-surface";
12
- import { getHostCapabilities, getNativeHost } from "../platform/runtime";
12
+ import { getNativeHost } from "../platform/runtime";
13
13
  import { TelemetryCollector } from "../telemetry/collector";
14
14
  import type { GPUAdapterInfo as GPUAdapterInfoData } from "../telemetry/gpu-profiler";
15
15
  import { GPUProfiler, type FrameGraphData, type GPUInfo } from "../telemetry/gpu-profiler";
@@ -630,17 +630,15 @@ export class GameRenderer implements CanvasResizeHandler {
630
630
  */
631
631
  setResolutionScale(scale: number): void {
632
632
  this.resolutionScale = Math.max(0.25, Math.min(1, scale));
633
- if (!getHostCapabilities().hasDom) {
634
- // On the native host the swapchain extent must equal the window's
635
- // real size — wgpu compares the configured size against the surface's
636
- // currentExtent at acquire time and returns Outdated forever when
637
- // they differ. Shrinking canvas.width/height would also corrupt
638
- // clientWidth (UI layout + input coords). Resolution scaling needs
639
- // an offscreen-render + upscale path to work here; for now it is a
640
- // no-op rather than a brick.
641
- if (this.resolutionScale < 1) {
642
- log.warn("game-renderer", "resolutionScale is not supported on the native surface (swapchain must match the window size) — ignoring");
643
- }
633
+ // On the native host the swapchain extent must equal the window's
634
+ // real size — wgpu compares the configured size against the surface's
635
+ // currentExtent at acquire time and returns Outdated forever when
636
+ // they differ. Shrinking canvas.width/height would also corrupt
637
+ // clientWidth (UI layout + input coords). Resolution scaling needs
638
+ // an offscreen-render + upscale path to work here; for now it is a
639
+ // no-op rather than a brick.
640
+ if (this.resolutionScale < 1) {
641
+ log.warn("game-renderer", "resolutionScale is not supported on the native surface (swapchain must match the window size) — ignoring");
644
642
  }
645
643
  if (this.lastCssW > 0) this.onResize(this.lastCssW, this.lastCssH, this.lastRawDpr);
646
644
  }
@@ -657,7 +655,7 @@ export class GameRenderer implements CanvasResizeHandler {
657
655
  // performance knob when fill rate is a concern. On native the
658
656
  // swapchain is window-sized (see setResolutionScale) so the scale is
659
657
  // never applied to the backing store.
660
- const cappedDpr = dpr * (getHostCapabilities().hasDom ? this.resolutionScale : 1);
658
+ const cappedDpr = dpr;
661
659
  this.dpr = cappedDpr;
662
660
  const w = Math.round(cssWidth * cappedDpr);
663
661
  const h = Math.round(cssHeight * cappedDpr);
@@ -1031,11 +1029,12 @@ export class GameRenderer implements CanvasResizeHandler {
1031
1029
  // final image (skip on GPU error to avoid cascade). This must run AFTER
1032
1030
  // afterFrame — 2D games (viewportCount=0) do their custom rendering in
1033
1031
  // the afterFrame callback, so drawing UI earlier would put it underneath
1034
- // their clear pass.
1032
+ // their clear pass. The texture comes from peek: a frame the game
1033
+ // skipped must not be started just to draw UI on a blank swapchain.
1035
1034
  if (!gpuError && this.device && this.context) {
1036
1035
  const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
1037
1036
  const uiCanvasView = compositors.length > 0
1038
- ? this.getSurfaceTexture()?.createView()
1037
+ ? this.getFrameSurfaceTexture()?.createView()
1039
1038
  : undefined;
1040
1039
  if (uiCanvasView) {
1041
1040
  const uiEncoder = this.device.createCommandEncoder();
@@ -1047,7 +1046,10 @@ export class GameRenderer implements CanvasResizeHandler {
1047
1046
  storeOp: "store" as GPUStoreOp,
1048
1047
  }],
1049
1048
  });
1050
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
1049
+ // Panel rects are CSS px — pass the CSS surface size so
1050
+ // rect/surface maps correctly on HiDPI displays (canvas.width is
1051
+ // physical px; identical where scaleFactor=1).
1052
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.clientWidth, this.canvas.clientHeight); });
1051
1053
  uiPass.end();
1052
1054
  // "preUi" slot — worker passes ordered ahead of the UI-composite
1053
1055
  // buffer inside the end-of-frame submit.
@@ -1108,6 +1110,25 @@ export class GameRenderer implements CanvasResizeHandler {
1108
1110
  }
1109
1111
  }
1110
1112
 
1113
+ /**
1114
+ * The surface texture already acquired for the in-flight frame, or null
1115
+ * when nothing has acquired one yet. End-of-frame overlays (the UI
1116
+ * composite) use this so they join a frame rather than start one: on
1117
+ * hosts with frame retention (native), acquiring here would fabricate a
1118
+ * frame the game deliberately skipped (dirty-tracking) and present the
1119
+ * overlay over a blank swapchain image — a full-screen flash. Falls
1120
+ * back to acquiring on contexts without peek (DOM semantics:
1121
+ * getCurrentTexture is idempotent within a frame and present is
1122
+ * implicit, so there is nothing to defeat).
1123
+ */
1124
+ private getFrameSurfaceTexture(): GPUTexture | null {
1125
+ const peek = (this.context as { peekCurrentTexture?: () => GPUTexture | null } | null)?.peekCurrentTexture;
1126
+ if (typeof peek === "function") {
1127
+ try { return peek.call(this.context) ?? null; } catch { return null; }
1128
+ }
1129
+ return this.getSurfaceTexture();
1130
+ }
1131
+
1111
1132
  private renderViewport(viewportIdx: number, dt: number, offscreen: OffscreenMode | null): GPUCommandBuffer | null {
1112
1133
  if (!this.device || !this.context) return null;
1113
1134
 
@@ -1381,7 +1402,7 @@ export class GameRenderer implements CanvasResizeHandler {
1381
1402
  protected renderScreenUiCompositors(): void {
1382
1403
  const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
1383
1404
  if (compositors.length === 0 || !this.device || !this.context) return;
1384
- const canvasView = this.getSurfaceTexture()?.createView();
1405
+ const canvasView = this.getFrameSurfaceTexture()?.createView();
1385
1406
  if (!canvasView) return;
1386
1407
  const uiEncoder = this.device.createCommandEncoder();
1387
1408
  const uiPass = uiEncoder.beginRenderPass({
@@ -1392,7 +1413,8 @@ export class GameRenderer implements CanvasResizeHandler {
1392
1413
  storeOp: "store" as GPUStoreOp,
1393
1414
  }],
1394
1415
  });
1395
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
1416
+ // Panel rects are CSS px — see the main UI composite block above.
1417
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.clientWidth, this.canvas.clientHeight); });
1396
1418
  uiPass.end();
1397
1419
  this.device.queue.submit([uiEncoder.finish()]);
1398
1420
  }
@@ -111,8 +111,6 @@ export type { FrameGraphBuilder } from "./render-pass";
111
111
  export { RendererInputBusImpl } from "./renderer-input-bus";
112
112
  export { RendererModuleHost } from "./renderer-module-host";
113
113
  export type { RendererModuleHostCallbacks } from "./renderer-module-host";
114
- export { SabCanvasOverlay } from "./sab-canvas-overlay";
115
- export type { SabCanvasOverlayOptions } from "./sab-canvas-overlay";
116
114
  export { clearShaderValidationDedup, createValidatedShaderModule, createValidatedShaderModuleAsync, installShaderValidationGuard } from "./shader-validator";
117
115
  export { CSM_SHADER_CHUNK, POINT_SHADOW_SHADER_CHUNK, SPOT_SHADOW_SHADER_CHUNK } from "./shaders/shadow-chunks";
118
116
  export { SkyboxRenderer } from "./skybox";
@@ -79,9 +79,9 @@ export class InputManager {
79
79
  }
80
80
  }) as EventListener);
81
81
 
82
- add(document, "pointerlockchange", (() => {
82
+ add(this.canvas, "pointerlockchange", (() => {
83
83
  const wasLocked = this.pointerLocked;
84
- this.pointerLocked = (document.pointerLockElement as unknown) === this.canvas;
84
+ this.pointerLocked = this.canvas.pointerLocked === true;
85
85
  if (this.pointerLocked) {
86
86
  this.pointerLockRetryCount = 0;
87
87
  if (this.pointerLockRetryTimer) {
@@ -97,7 +97,7 @@ export class InputManager {
97
97
  }
98
98
  }) as EventListener);
99
99
 
100
- add(document, "pointerlockerror", (() => {
100
+ add(this.canvas, "pointerlockerror", (() => {
101
101
  // No action needed — tryLockPointer's fallback timer will retry.
102
102
  }) as EventListener);
103
103
  }
@@ -180,10 +180,10 @@ export class InputManager {
180
180
  }
181
181
  }) as EventListener, { passive: false });
182
182
 
183
- // Use document-level mouseleave: a sibling canvas stacked above (e.g.
184
- // sandjongg's tile canvas) would otherwise trigger the canvas's own
185
- // mouseleave and break UI hover/pressed state.
186
- add(document, "mouseleave", (() => {
183
+ // Window-level mouseleave: a second interactive canvas stacked above
184
+ // (e.g. sandjongg's tile canvas) would otherwise trigger the canvas's
185
+ // own mouseleave and break UI hover/pressed state.
186
+ add(window, "mouseleave", (() => {
187
187
  this.uiInputRouter?.handlePointerLeave();
188
188
  }) as EventListener);
189
189
 
@@ -116,8 +116,8 @@ export class RendererModuleHost {
116
116
  }
117
117
 
118
118
  /**
119
- * Inject the DevTools API so renderer plugins can self-register debug
120
- * panels via `ctx.devtools.registerPanel(...)` during their `register()`.
119
+ * Inject the DevTools API so renderer plugins can self-register data
120
+ * feeds via `ctx.devtools.registerDataFeed(...)` during their `register()`.
121
121
  */
122
122
  setDevToolsAPI(api: ModuleDevToolsAPI): void {
123
123
  this._devtools = api;
@@ -635,8 +635,6 @@ export class RendererModuleHost {
635
635
 
636
636
  // No-op DevTools API stub — used when setDevToolsAPI() hasn't been called.
637
637
  const NoopRendererDevToolsAPI: ModuleDevToolsAPI = {
638
- registerPanel: () => {},
639
- registerOverlayToggle: () => {},
640
638
  registerDataFeed: () => {},
641
639
  registerCommand: () => {},
642
640
  registerSABStat: () => {},