@volter/editor-game 0.5.66 → 0.5.67

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 (236) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/canvas/component-board.service.ts +16 -0
  5. package/contributions/canvas/design-time-mount.service.ts +45 -0
  6. package/contributions/canvas-story-capture.service.ts +12 -0
  7. package/contributions/edit-mode-audio.service.ts +2 -2
  8. package/contributions/edit-mode-networking.service.ts +1 -1
  9. package/contributions/gameplay.command.ts +4 -4
  10. package/contributions/generation.service.ts +1 -1
  11. package/contributions/godot.style.ts +26 -6
  12. package/contributions/godot.view.ts +6 -0
  13. package/contributions/ingest.service.ts +2 -2
  14. package/contributions/instances.command.ts +1 -1
  15. package/contributions/navmesh.menu.ts +1 -1
  16. package/contributions/network-observer.service.ts +14 -0
  17. package/contributions/play.command.ts +10 -4
  18. package/contributions/react/component-board.service.ts +1 -1
  19. package/contributions/react/design-time-mount.service.ts +1 -1
  20. package/contributions/scene-document.service.ts +3 -3
  21. package/contributions/state-watch.menu.ts +1 -1
  22. package/contributions/state-watch.utility.tsx +1 -1
  23. package/contributions/team-playtest.service.ts +4 -4
  24. package/contributions/three/component-board.service.ts +1 -1
  25. package/contributions/three/component-verbs.command.ts +6 -6
  26. package/contributions/three/three-authoring.service.ts +8 -5
  27. package/contributions/three-story-capture.service.ts +12 -0
  28. package/contributions/unity.style.ts +17 -7
  29. package/contributions/unity.view.ts +6 -0
  30. package/contributions/unreal.style.ts +10 -2
  31. package/contributions/unreal.view.ts +5 -0
  32. package/package.json +20 -10
  33. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  34. package/src/asset-budget/asset-budget-model.ts +1 -1
  35. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  36. package/src/bridge/dispatch.ts +14 -14
  37. package/src/bridge/live-frames.ts +1 -1
  38. package/src/bridge/screenshot.ts +3 -3
  39. package/src/build/BuildProfilesPanel.tsx +3 -3
  40. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
  41. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  42. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  43. package/src/canvas/canvas-design-mount.ts +524 -0
  44. package/src/canvas/design-time-canvas-mount.ts +79 -0
  45. package/src/coverage/live-authoring-surface.ts +5 -5
  46. package/src/coverage/live-project-verbs.ts +1 -1
  47. package/src/coverage/native-system-coverage.ts +5 -5
  48. package/src/coverage/root-coverage.ts +1 -1
  49. package/src/coverage/session-coverage.ts +3 -3
  50. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  51. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  52. package/src/edit-mode/edit-mode-audio.ts +4 -4
  53. package/src/edit-mode/edit-mode-networking.ts +2 -2
  54. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  55. package/src/game-document/GameDocument.tsx +2 -2
  56. package/src/game-document/GamePanel.tsx +4 -4
  57. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  58. package/src/game-document/crowd-debug.ts +1 -1
  59. package/src/game-document/device-preview.ts +10 -11
  60. package/src/game-document/physics-debug.ts +1 -1
  61. package/src/generation/GenerationActivity.tsx +3 -3
  62. package/src/generation/generation-documents.tsx +5 -5
  63. package/src/generation/generation-jobs.ts +1 -1
  64. package/src/host/adapter-runtime-bindings.ts +96 -5
  65. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  66. package/src/host/authoring/gesture-persist.ts +1 -1
  67. package/src/host/authoring/ingest-data-writer.ts +1 -1
  68. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  69. package/src/host/authoring/mounted-authoring.ts +4 -4
  70. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  71. package/src/host/authoring/pixi-authoring-adapter.ts +40 -15
  72. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  73. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  74. package/src/host/authoring/pixi-source-identity.ts +3 -3
  75. package/src/host/authoring/pixi-source-write-target.ts +1614 -0
  76. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  77. package/src/host/authoring/pixi-structure-history.ts +2 -2
  78. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  79. package/src/host/authoring/source-persistence-backend.ts +3 -3
  80. package/src/host/authoring/struct-write-pipe.ts +1 -1
  81. package/src/host/binding-resolver.ts +8 -9
  82. package/src/host/browser-transpile.ts +1 -1
  83. package/src/host/canvas-entry-runtime.ts +58 -47
  84. package/src/host/canvas-preview-frames.ts +482 -0
  85. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  86. package/src/host/components/HeaderTelemetry.tsx +4 -4
  87. package/src/host/components/PixiIsolationSceneContent.tsx +10 -10
  88. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  89. package/src/host/components/frame-debugger-model.ts +2 -2
  90. package/src/host/components/header-telemetry-model.ts +2 -2
  91. package/src/host/components/scene-document.tsx +14 -14
  92. package/src/host/components/utility-view-state.ts +1 -1
  93. package/src/host/components/world-root-stage-binding.tsx +12 -12
  94. package/src/host/components/world-root-stage.ts +70 -49
  95. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  96. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  97. package/src/host/document-preview-three.ts +1 -1
  98. package/src/host/entry-adjudication.ts +6 -6
  99. package/src/host/game-css-scope-transform.ts +4 -0
  100. package/src/host/game-realm-page.ts +1 -1
  101. package/src/host/gameplay-export.ts +25 -14
  102. package/src/host/gameplay-recording.ts +5 -5
  103. package/src/host/gated-globals.ts +2 -2
  104. package/src/host/history/json-history-resource.ts +1 -1
  105. package/src/host/projection/pixi.ts +24 -2
  106. package/src/host/r3f-entry-runtime.ts +65 -34
  107. package/src/host/react-mount-runtime.ts +7 -48
  108. package/src/host/realm-services.ts +1 -1
  109. package/src/host/roots/canvas-root.tsx +361 -0
  110. package/src/host/roots/r3f-root.tsx +473 -0
  111. package/src/host/roots/react-root.ts +9 -43
  112. package/src/host/served-bundle-runtime-modules.ts +1 -19
  113. package/src/host/server-log-bridge.ts +2 -2
  114. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  115. package/src/host/stories/pixi-story-model.ts +30 -0
  116. package/src/host/stories/story-media-captures.ts +46 -0
  117. package/src/host/stories/story-media-presence.ts +3 -3
  118. package/src/host/stories/story-pixi-preview.ts +408 -0
  119. package/src/host/stories/story-three-preview.ts +806 -0
  120. package/src/host/stories/three-story-captures.ts +35 -0
  121. package/src/host/story-three-preview-runtime.ts +56 -0
  122. package/src/host/use-active-performance-source.ts +2 -2
  123. package/src/host/viewport-pose-memory.ts +1 -1
  124. package/src/host/viewport-root-presentation.ts +6 -5
  125. package/src/ingest/active-ingest.ts +1 -1
  126. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  127. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  128. package/src/ingest/deferred-ingest-play.ts +6 -6
  129. package/src/ingest/discovery-public-ingest.ts +2 -2
  130. package/src/ingest/ingest-boot-viewport.ts +2 -2
  131. package/src/ingest/ingest-canvas-scene-document.tsx +8 -8
  132. package/src/ingest/ingest-canvas-scene.ts +3 -3
  133. package/src/ingest/ingest-evidence-hook.ts +1 -1
  134. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  135. package/src/ingest/ingest-play-control.ts +1 -1
  136. package/src/ingest/ingest-render-debug.ts +10 -10
  137. package/src/ingest/ingest-siblings.ts +13 -25
  138. package/src/ingest/module-mode.ts +14 -14
  139. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  140. package/src/ingest/mount-coverage.ts +2 -2
  141. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  142. package/src/ingest/mount-ingest-root.ts +8 -8
  143. package/src/ingest/mount-three-ingest-root.ts +8 -8
  144. package/src/ingest/resolve-canvas.ts +1 -1
  145. package/src/ingest/served-html-boot.ts +1 -1
  146. package/src/ingest/surface-canvas.ts +1 -1
  147. package/src/ingest/unmount-ingest-root.ts +5 -5
  148. package/src/navmesh/navmesh-handler.ts +24 -16
  149. package/src/network/NetworkInspectorPanel.tsx +449 -14
  150. package/src/network/network-inspector-model.ts +14 -2
  151. package/src/play/play-log-events.ts +1 -1
  152. package/src/play/play-mode.ts +76 -133
  153. package/src/play/play-recording.ts +1 -1
  154. package/src/play/react-play-live-authoring.ts +3 -3
  155. package/src/play/run-selection.ts +93 -0
  156. package/src/play-bar/PlayBar.tsx +20 -39
  157. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  158. package/src/profiler/PerformancePanel.tsx +3 -3
  159. package/src/react/design-time-react-mount.ts +23 -65
  160. package/src/react/dom-authoring-adapter.ts +9 -9
  161. package/src/react/react-inspector-section.tsx +5 -5
  162. package/src/react/react-world-authoring-adapter.ts +11 -11
  163. package/src/react/story-documents/story-documents.tsx +9 -9
  164. package/src/react/ui-board-document.tsx +7 -7
  165. package/src/react/ui-component-board.ts +2 -2
  166. package/src/runtime/adapter/audio-meter.ts +21 -0
  167. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  168. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  169. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  170. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  171. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  172. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  173. package/src/runtime/adapter/native-debug-module.ts +498 -0
  174. package/src/runtime/audio/bus-mixer.ts +161 -0
  175. package/src/runtime/audio/pose-guard.ts +80 -0
  176. package/src/runtime/core/frame-pacing.ts +126 -0
  177. package/src/runtime/core/game-loop.ts +225 -0
  178. package/src/runtime/core/game-scoped-slot.ts +28 -0
  179. package/src/runtime/core/seeded-random.ts +162 -0
  180. package/src/runtime/core/sim-clock.ts +391 -0
  181. package/src/runtime/core/system-runner.ts +269 -0
  182. package/src/runtime/core/types.ts +104 -0
  183. package/src/runtime/create-runtime.ts +1128 -0
  184. package/src/runtime/debug-bridge.ts +570 -0
  185. package/src/runtime/debug-registry.ts +899 -0
  186. package/src/runtime/dev/chrome-trace.ts +153 -0
  187. package/src/runtime/dev/instruments.ts +403 -0
  188. package/src/runtime/dev/logger.ts +119 -0
  189. package/src/runtime/dev/performance-profiler.ts +367 -0
  190. package/src/runtime/dev/register-render-vitals.ts +276 -0
  191. package/src/runtime/dev/render-census.ts +354 -0
  192. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  193. package/src/runtime/dev/render-memory.ts +226 -0
  194. package/src/runtime/dev/render-vitals.ts +338 -0
  195. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  196. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  197. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  198. package/src/runtime/dev-build.ts +47 -0
  199. package/src/runtime/game.ts +1636 -0
  200. package/src/runtime/gameplay-rng-trap.ts +135 -0
  201. package/src/runtime/host-context.ts +64 -0
  202. package/src/runtime/input-router.ts +169 -0
  203. package/src/runtime/mount-manifest.ts +480 -0
  204. package/src/runtime/pixi/authoring.ts +706 -0
  205. package/src/runtime/pixi/ingest.ts +116 -0
  206. package/src/runtime/pixi/physics-registry.ts +49 -0
  207. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  208. package/src/runtime/pixi/scene-capture.ts +179 -0
  209. package/src/runtime/pixi/system-adapters.ts +69 -0
  210. package/src/runtime/playtest.ts +22 -0
  211. package/src/runtime/presentation.ts +141 -0
  212. package/src/runtime/render-control.ts +642 -0
  213. package/src/runtime/render-seed.ts +77 -0
  214. package/src/runtime/run-ticks-settled.ts +73 -0
  215. package/src/runtime/setup/setup-audio.ts +72 -0
  216. package/src/services/audio-pose-guard.ts +2 -2
  217. package/src/services/game-audio.ts +152 -0
  218. package/src/services/game-network.ts +657 -0
  219. package/src/services/game-physics.ts +334 -0
  220. package/src/state-watch/StateWatchPanel.tsx +1 -1
  221. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  222. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  223. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  224. package/src/three/authoring/oid-source-persistence.ts +7 -7
  225. package/src/three/authoring/r3f-design-session.ts +58 -54
  226. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  227. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  228. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  229. package/src/three/component-verbs/extract-menu.ts +7 -6
  230. package/src/three/component-verbs/fork-menu.ts +7 -6
  231. package/src/three/component-verbs/internals-menu.ts +2 -2
  232. package/src/three/story-documents/three-story-documents.tsx +13 -13
  233. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  234. package/src/three/three-board/board-scene.ts +6 -6
  235. package/src/three/three-board/three-component-board.ts +2 -2
  236. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,706 @@
1
+ import type { Container } from 'pixi.js';
2
+
3
+ /**
4
+ * AuthoringAdapter-2D — the Pixi analog of the editor AuthoringAdapter /
5
+ * ThreeAuthoringAdapter, implemented DIRECTLY over a live PixiJS display tree
6
+ * (no fabricated document — the anti-shim rule). Used by the editor to
7
+ * inspect + edit Pixi display-tree entities (first-party OR an ingested unmodified game),
8
+ * with stable structural-path ids so edits re-bind after the tree rebuilds.
9
+ */
10
+
11
+ export interface EditorNode2D {
12
+ id: string;
13
+ label: string;
14
+ kind: 'sprite' | 'animatedsprite' | 'text' | 'container' | string;
15
+ parentId: string | null;
16
+ childIds: string[];
17
+ }
18
+
19
+ export interface Transform2DValue {
20
+ position: [number, number];
21
+ rotation: number;
22
+ scale: [number, number];
23
+ }
24
+
25
+ export interface Property2D {
26
+ path: string;
27
+ label: string;
28
+ type: 'string' | 'number' | 'boolean' | 'vec2' | 'color';
29
+ value: unknown;
30
+ }
31
+
32
+ /** An in-memory edit overlay keyed by structural-path id (2D). */
33
+ export interface Override2D {
34
+ label?: string;
35
+ alpha?: number;
36
+ position?: [number, number];
37
+ rotation?: number;
38
+ scale?: [number, number];
39
+ tint?: number;
40
+ visible?: boolean;
41
+ }
42
+ export type Overlay2D = Record<string, Override2D>;
43
+
44
+ function kindOf(o: Container): string {
45
+ const ctor = o.constructor?.name?.toLowerCase() ?? 'container';
46
+ if (ctor.includes('animatedsprite')) return 'animatedsprite';
47
+ if (ctor.includes('nineslice')) return 'nineslicesprite';
48
+ if (ctor.includes('tiling')) return 'tilingsprite';
49
+ if (ctor.includes('sprite')) return 'sprite';
50
+ if (ctor.includes('text')) return 'text';
51
+ if (ctor.includes('graphics')) return 'graphics';
52
+ return 'container';
53
+ }
54
+
55
+ /**
56
+ * Hierarchy row text — the Pixi sibling of three's `object.name || object.type`.
57
+ *
58
+ * Authored `.label` first. Then a GAME name: a field, a bag key, or a class
59
+ * that owns this `.view`. Stock Pixi types (`Sprite`, `Graphics`, `_Container`)
60
+ * are not names — they used to win and hide every owner. Then the texture
61
+ * alias (`Sprite.from('satellite')`). Then a remaining game constructor
62
+ * (`TitleScreen`, `PrimaryButton`). Bare `new Container()` still reads as
63
+ * `container` — plus its child count when it has children, because ten
64
+ * unnamed siblings that all read `container` name nothing at all; the
65
+ * editor loads games that never labelled anything and the panel still has
66
+ * to be legible. The count is DISPLAY only (row ids keep using raw
67
+ * `.label`) and rides only this last fallthrough: a row that earned a real
68
+ * name is never decorated.
69
+ */
70
+ function displayLabel(o: Container): string {
71
+ const authored = authoredLabel(o);
72
+ if (authored) return authored;
73
+ const owner = viewOwnerLabel(o);
74
+ if (owner) return owner;
75
+ const asset = textureLabel(o);
76
+ if (asset) return asset;
77
+ const ctor = usefulCtorName(o);
78
+ if (ctor) return ctor;
79
+ const kind = kindOf(o);
80
+ const childCount = o.children?.length ?? 0;
81
+ return childCount > 0 ? `${kind} (${childCount})` : kind;
82
+ }
83
+
84
+ /**
85
+ * Pixi v8 stamps Sprite/Graphics `.label` with the class name. That is not an
86
+ * authored name.
87
+ *
88
+ * The minified-constructor guard is about the SAME leak: a shipped build's
89
+ * `_Container` becomes `e`, and that `e` is a class name wearing a label's
90
+ * clothes. It is only that leak when the label IS this object's own class
91
+ * name — `container.label = 'fx'` (or 'ui', 'hp') is a name the game
92
+ * actually authored, and a game gets to be terse.
93
+ */
94
+ function authoredLabel(o: Container): string | undefined {
95
+ const authored = o.label?.trim();
96
+ if (!authored) return undefined;
97
+ if (isMinifiedCtor(authored) && authored !== o.constructor?.name) return authored;
98
+ if (isGenericPixiCtor(authored)) return undefined;
99
+ return authored;
100
+ }
101
+
102
+ function isMinifiedCtor(name: string): boolean {
103
+ return name.length <= 2 && name === name.toLowerCase();
104
+ }
105
+
106
+ /** Pixi's own display types. The shipped build prefixes them `_`. */
107
+ const STOCK_PIXI_CTOR = new Set([
108
+ 'container',
109
+ 'displayobject',
110
+ 'sprite',
111
+ 'animatedsprite',
112
+ 'nineslicesprite',
113
+ 'tilingsprite',
114
+ 'text',
115
+ 'bitmaptext',
116
+ 'htmltext',
117
+ 'graphics',
118
+ 'mesh',
119
+ ]);
120
+
121
+ function isGenericPixiCtor(name: string): boolean {
122
+ if (isMinifiedCtor(name) || name === 'Object' || name === 'Function') return true;
123
+ return STOCK_PIXI_CTOR.has(name.replace(/^_+/, '').toLowerCase());
124
+ }
125
+
126
+ function textureLabel(object: Container): string | undefined {
127
+ const texture = (object as Container & { texture?: { label?: string } }).texture;
128
+ const raw = texture?.label?.trim();
129
+ if (!raw || /^(_?texture)?(empty|white)$/i.test(raw)) return undefined;
130
+ const leaf = raw.split('/').pop() ?? raw;
131
+ return leaf.replace(/\.[a-z0-9]+$/i, '') || undefined;
132
+ }
133
+
134
+ function usefulCtorName(value: object): string | undefined {
135
+ const name = value.constructor?.name;
136
+ if (!name || isGenericPixiCtor(name)) return undefined;
137
+ return name.replace(/^_+/, '') || undefined;
138
+ }
139
+
140
+ function fieldLabel(key: string): string | undefined {
141
+ const trimmed = key.replace(/^_+/, '');
142
+ return trimmed.length > 0 ? trimmed : undefined;
143
+ }
144
+
145
+ function isPixiDisplay(value: object): boolean {
146
+ return (
147
+ 'addChild' in value &&
148
+ typeof (value as { addChild?: unknown }).addChild === 'function' &&
149
+ 'children' in value
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Bubbo-style owner, identity only. TitleScreen keeps `this._cannon` and
155
+ * parents `this._cannon.view`. A grouping node is the field itself
156
+ * (`this._bottomAnimContainer`). A pool or atlas lives in a bag — Array,
157
+ * Map, Set, or an object literal (`_decor[]`, `allSystems`, `_parts[id]`).
158
+ * Scan each ancestor's own fields first — a flood walk of Pixi internals
159
+ * never reached `_cannon` inside the budget.
160
+ */
161
+ function viewOwnerLabel(view: Container): string | undefined {
162
+ const search: OwnerSearch = { seen: new WeakSet<object>(), budget: OWNER_SEARCH_BUDGET };
163
+ let host: unknown = view.parent;
164
+ for (let hops = 0; hops < 8 && host && typeof host === 'object'; hops++) {
165
+ if (search.budget <= 0) return undefined;
166
+ const found = labelFromHolder(host, view, search) ?? findHeldOwner(host, view, 2, search);
167
+ if (found) return found;
168
+ host = (host as { parent?: unknown }).parent;
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ /**
174
+ * THE CEILING ON ONE NODE'S OWNER HUNT — what keeps a hierarchy read LINEAR in
175
+ * the size of the tree instead of quadratic in it.
176
+ *
177
+ * The hunt below is a search of the GAME's object graph, and a game's object
178
+ * graph is small: `this._cannon`, `allSystems.get('hud')`, `_decor[3]` are all
179
+ * found within a few dozen examinations. What is not small is anything the
180
+ * search can leak into — Pixi's own live render graph is the measured one, and
181
+ * naming a key blacklist is a fix for the leak you already found, not for the
182
+ * class. So every examination is charged against this budget and the search
183
+ * simply gives up when it runs out: the node then falls through to its texture
184
+ * alias / constructor / kind name, which is the same honest answer it gets
185
+ * today whenever the hunt finds nothing.
186
+ *
187
+ * MEASURED (2026-08-20, `packages/editor/scripts/scale-harness`): entering play
188
+ * on a 20 000-node `@pixi/react` world blocked the main thread for 199.5
189
+ * SECONDS in one synchronous run, 69% of all profiler samples inside this
190
+ * search, because `parentRenderGroup` reaches an array of every node in the
191
+ * tree and the search walked it once per unlabeled node. `vgai play`,
192
+ * `screenshot` and `stop` all timed out against a tab that was heartbeating
193
+ * normally the whole time.
194
+ */
195
+ const OWNER_SEARCH_BUDGET = 2_000;
196
+
197
+ /** One node's owner hunt: what it has already looked at, and what it has left. */
198
+ interface OwnerSearch {
199
+ readonly seen: WeakSet<object>;
200
+ /** Counts DOWN. Zero ends the hunt for this node, wherever it has reached. */
201
+ budget: number;
202
+ }
203
+
204
+ /** Charge one examination. `false` means the hunt is over. */
205
+ function spend(search: OwnerSearch): boolean {
206
+ if (search.budget <= 0) return false;
207
+ search.budget -= 1;
208
+ return true;
209
+ }
210
+
211
+ function isGameFieldKey(key: string): boolean {
212
+ // Skip display-list indices (`0`, `1`) and Pixi internals (`children`).
213
+ // The membership test is on the UNDERSCORED-STRIPPED key because that is how
214
+ // Pixi v8 spells most of them (`_position`, `_bounds`, `_texture`), and a
215
+ // set listing the bare names was silently matching none of those.
216
+ if (!/^_?[A-Za-z][A-Za-zA-Z0-9]*$/.test(key)) return false;
217
+ return !PIXI_INTERNAL_KEY.has(key.replace(/^_+/, ''));
218
+ }
219
+
220
+ /**
221
+ * Field names that belong to Pixi (or to its EventEmitter base), never to the
222
+ * game. Read off a live `pixi.js` v8 `Container`/`Sprite`'s own keys, so this
223
+ * is a transcription rather than a guess — but it is the OPTIMIZATION, not the
224
+ * correctness boundary: {@link OWNER_SEARCH_BUDGET} is what bounds the search
225
+ * on a Pixi version whose internals this list has never heard of. Spending the
226
+ * budget on plausible game fields instead of on the render graph is what keeps
227
+ * the heuristic's hit rate while it is bounded.
228
+ *
229
+ * `view` is deliberately absent: `.view` is the very field the hunt is looking
230
+ * for.
231
+ */
232
+ const PIXI_INTERNAL_KEY = new Set([
233
+ 'parent',
234
+ 'children',
235
+ 'transform',
236
+ 'position',
237
+ 'origin',
238
+ 'scale',
239
+ 'pivot',
240
+ 'skew',
241
+ 'anchor',
242
+ 'worldTransform',
243
+ 'localTransform',
244
+ 'groupTransform',
245
+ 'relativeGroupTransform',
246
+ // The render graph — the measured leak. `parentRenderGroup` is on every
247
+ // container once a world has rendered and reaches every node in the tree.
248
+ 'renderGroup',
249
+ 'parentRenderGroup',
250
+ 'parentRenderGroupIndex',
251
+ 'parentRenderLayer',
252
+ 'relativeRenderGroupDepth',
253
+ 'renderPipeId',
254
+ 'instructionSet',
255
+ 'childrenToUpdate',
256
+ 'childrenRenderablesToUpdate',
257
+ 'effects',
258
+ 'filters',
259
+ 'mask',
260
+ 'bounds',
261
+ 'boundsArea',
262
+ 'visualBounds',
263
+ 'gpuData',
264
+ 'texture',
265
+ 'events',
266
+ 'eventsCount',
267
+ 'updateFlags',
268
+ ]);
269
+
270
+ function labelFromHolder(host: object, view: Container, search: OwnerSearch): string | undefined {
271
+ if (search.seen.has(host)) return undefined;
272
+ search.seen.add(host);
273
+ const asBag = nameInBag(host, view, undefined, search);
274
+ if (asBag) return asBag;
275
+ for (const [key, value] of Object.entries(host as Record<string, unknown>)) {
276
+ if (!spend(search)) return undefined;
277
+ if (!isGameFieldKey(key)) continue;
278
+ const named = nameIfHoldsView(host, key, value, view);
279
+ if (named) return named;
280
+ const nested = nameInBag(value, view, key, search);
281
+ if (nested) return nested;
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ function nameInBag(
287
+ value: unknown,
288
+ view: Container,
289
+ fieldKey: string | undefined,
290
+ search: OwnerSearch,
291
+ ): string | undefined {
292
+ const entries = bagEntries(value);
293
+ if (!entries) return undefined;
294
+ for (const [itemKey, item] of entries) {
295
+ if (!spend(search)) return undefined;
296
+ const named = nameBagItem(value, item, usableBagKey(itemKey) ?? fieldKey, view, search);
297
+ if (named) return named;
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ function nameBagItem(
303
+ bag: unknown,
304
+ item: unknown,
305
+ key: string | undefined,
306
+ view: Container,
307
+ search: OwnerSearch,
308
+ ): string | undefined {
309
+ if (key) {
310
+ const named = nameIfHoldsView(
311
+ item && typeof item === 'object' ? item : (bag as object),
312
+ key,
313
+ item,
314
+ view,
315
+ );
316
+ if (named) return named;
317
+ } else if (item && typeof item === 'object' && (item as { view?: unknown }).view === view) {
318
+ const named = usefulCtorName(item);
319
+ if (named) return named;
320
+ }
321
+ if (item && typeof item === 'object' && !isPixiDisplay(item)) {
322
+ return labelFromHolder(item, view, search);
323
+ }
324
+ return undefined;
325
+ }
326
+
327
+ function usableBagKey(key: string): string | undefined {
328
+ if (!key || /^\d+$/.test(key)) return undefined;
329
+ return isGameFieldKey(key) || key.includes('-') ? (fieldLabel(key) ?? key) : undefined;
330
+ }
331
+
332
+ function* indexedBagEntries(value: ArrayLike<unknown>): Generator<[string, unknown]> {
333
+ for (let index = 0; index < value.length; index++) yield [String(index), value[index]];
334
+ }
335
+
336
+ function* mapBagEntries(value: Map<unknown, unknown>): Generator<[string, unknown]> {
337
+ for (const [key, item] of value.entries()) yield [String(key), item];
338
+ }
339
+
340
+ function* setBagEntries(value: Set<unknown>): Generator<[string, unknown]> {
341
+ let index = 0;
342
+ for (const item of value) {
343
+ yield [String(index), item];
344
+ index += 1;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * A bag's entries, LAZILY.
350
+ *
351
+ * Lazy because the caller is budgeted ({@link OWNER_SEARCH_BUDGET}) and must be
352
+ * able to stop: materializing the pairs first made a 20 000-element array cost
353
+ * 20 000 allocations before the first one was even looked at, which is a cost
354
+ * no budget above it can decline.
355
+ */
356
+ function bagEntries(value: unknown): Iterable<readonly [string, unknown]> | undefined {
357
+ if (!value || typeof value !== 'object') return undefined;
358
+ // Game arrays are allowed to be exotic subclasses. Do not dispatch through
359
+ // their overridable `map`: one shipped Pixi game returns its elements rather
360
+ // than `[key, value]` pairs there, which made hierarchy labeling throw while
361
+ // destructuring the result. The authoring seam treats game objects as
362
+ // untrusted observations, so normalize with indexed reads of our own.
363
+ if (Array.isArray(value)) return indexedBagEntries(value as ArrayLike<unknown>);
364
+ if (value instanceof Map) return mapBagEntries(value);
365
+ if (value instanceof Set) return setBagEntries(value);
366
+ if (Object.getPrototypeOf(value) !== Object.prototype) return undefined;
367
+ return Object.entries(value as Record<string, unknown>);
368
+ }
369
+
370
+ function nameIfHoldsView(
371
+ host: object,
372
+ key: string,
373
+ value: unknown,
374
+ view: Container,
375
+ ): string | undefined {
376
+ if (value === view) {
377
+ const field = fieldLabel(key);
378
+ if (field && field.toLowerCase() !== 'view') {
379
+ return usefulCtorName(value as object) ?? field;
380
+ }
381
+ return usefulCtorName(value as object) ?? usefulCtorName(host) ?? field;
382
+ }
383
+ if (value && typeof value === 'object' && (value as { view?: unknown }).view === view) {
384
+ return usefulCtorName(value) ?? fieldLabel(key);
385
+ }
386
+ return undefined;
387
+ }
388
+
389
+ function findHeldOwner(
390
+ held: object,
391
+ view: Container,
392
+ depth: number,
393
+ search: OwnerSearch,
394
+ ): string | undefined {
395
+ const direct = labelFromHolder(held, view, search);
396
+ if (direct) return direct;
397
+ if (depth <= 0) return undefined;
398
+ // BY KEY, exactly like `labelFromHolder`'s own scan. Iterating `Object.values`
399
+ // here was the leak that made a hierarchy read quadratic: `parentRenderGroup`
400
+ // sits on EVERY container once a world has rendered, and descending into it
401
+ // reaches `childrenToUpdate[depth].list` — an array of every node in the
402
+ // tree — so the search for one node's owner walked the whole tree, for every
403
+ // unlabeled node. `isGameFieldKey` already knows which keys belong to Pixi
404
+ // rather than to the game; this scan simply has to ask it too.
405
+ for (const [key, value] of Object.entries(held as Record<string, unknown>)) {
406
+ if (!spend(search)) return undefined;
407
+ if (!isGameFieldKey(key)) continue;
408
+ if (!value || typeof value !== 'object' || isPixiDisplay(value)) continue;
409
+ const found = findHeldOwner(value, view, depth - 1, search);
410
+ if (found) return found;
411
+ }
412
+ return undefined;
413
+ }
414
+
415
+ /**
416
+ * WHERE A ROW'S ID COMES FROM — the identity axis of the canvas authoring
417
+ * surface.
418
+ *
419
+ * The tree walk is the same whatever authored the tree; what differs is
420
+ * whether the display objects carry an authored address. A first-party
421
+ * `@pixi/react` world's containers do (the `data-oid` the editor's transform
422
+ * stamps, which the reconciler lands on the instance), and keying on it is
423
+ * what makes selection survive a remount. A tree the editor did NOT author has
424
+ * no such address, so its ids are structural paths — stable for as long as the
425
+ * tree's shape is, which is the best that graph can honestly offer.
426
+ *
427
+ * Both are the SAME adapter over the SAME surface; only this collaborator
428
+ * changes. {@link STRUCTURAL_CANVAS_IDENTITY} is the default.
429
+ */
430
+ export interface CanvasIdentity {
431
+ /** Called once at the start of every walk — where an implementation that
432
+ * disambiguates repeats (one JSX element rendered ninety times) resets its
433
+ * per-walk counters. */
434
+ beginWalk?(): void;
435
+ /** The id for one display object at one position in the tree. */
436
+ idFor(object: Container, path: readonly number[]): string;
437
+ }
438
+
439
+ /** Deterministic structural-path ids: `w2d:<path>:<kind>:<label>`. */
440
+ export const STRUCTURAL_CANVAS_IDENTITY: CanvasIdentity = {
441
+ idFor: (object, path) => `w2d:${path.join('/')}:${kindOf(object)}:${object.label ?? ''}`,
442
+ };
443
+
444
+ /** One remembered row name, with every input it was derived from. */
445
+ interface NameMemo {
446
+ readonly label: string;
447
+ readonly kind: string;
448
+ readonly parent: Container | null;
449
+ readonly type: unknown;
450
+ readonly own: string | null;
451
+ readonly texture: unknown;
452
+ readonly childCount: number;
453
+ }
454
+
455
+ export class AuthoringAdapter2D {
456
+ private readonly byId = new Map<string, Container>();
457
+ private overlay: Overlay2D = {};
458
+ private dirty = false;
459
+ private readonly identity: CanvasIdentity;
460
+ /**
461
+ * THE ROW-NAME MEMO — what makes a hierarchy read linear instead of a
462
+ * reflective flood walk per node per read.
463
+ *
464
+ * {@link displayLabel} is not a field read. For anything the game did not
465
+ * label itself it calls `viewOwnerLabel`, which climbs eight ancestors and
466
+ * reflects over each one's own fields, its bags, and two levels of whatever
467
+ * those hold — a deliberate search for the game object that owns this
468
+ * `.view`. Measured 2026-08-20 on a 1822-node `@pixi/react` canvas world,
469
+ * with the tab VISIBLE and the world rendering, one full projection walk
470
+ * cost ~800ms, essentially all of it here: once a world has rendered, the
471
+ * ancestors' own fields include Pixi's live render-group graph, so the
472
+ * search walks that too and finds nothing, every time, for every node.
473
+ *
474
+ * And it was asked four times per node per derivation — the projection
475
+ * walk, the conformance probe and the hierarchy facet each re-ask — which
476
+ * is how a status snapshot reached 115,433 `toNode` calls on one tree.
477
+ *
478
+ * ## What invalidates a remembered name
479
+ *
480
+ * Every input the derivation reads OFF THE OBJECT: its authored `.label`,
481
+ * its class, its texture, its parent, and how many children it has. Any of
482
+ * those changing re-derives — so a rename, a reparent, a retexture, and a
483
+ * sibling added or removed are all seen. What a remembered name does NOT
484
+ * re-notice is a game reassigning an ANCESTOR's field to point at this view
485
+ * after the fact, with the tree otherwise untouched; `viewOwnerLabel` is a
486
+ * naming heuristic, not a verdict, and the alternative is the 800ms walk
487
+ * above on every read of the tree.
488
+ */
489
+ private readonly names = new WeakMap<Container, NameMemo>();
490
+
491
+ constructor(
492
+ private readonly root: Container,
493
+ opts: { overlay?: Overlay2D; identity?: CanvasIdentity } = {},
494
+ ) {
495
+ if (opts.overlay) this.overlay = opts.overlay;
496
+ this.identity = opts.identity ?? STRUCTURAL_CANVAS_IDENTITY;
497
+ this.refresh();
498
+ }
499
+
500
+ /**
501
+ * Walk the live tree assigning deterministic structural-path ids.
502
+ *
503
+ * Separate from {@link refresh} because the two are wanted at different rates: the ids have to be
504
+ * current whenever the tree is READ (a world that mounts its content asynchronously — a
505
+ * translated port waiting on its resources, anything behind Suspense — commits real nodes after
506
+ * the adapter was handed its root, and an unindexed node is one the hierarchy cannot name), while
507
+ * re-applying the edit overlay is a WRITE and must happen only when something asked for it.
508
+ */
509
+ reindex(): { count: number; sprites: number } {
510
+ this.byId.clear();
511
+ this.identity.beginWalk?.();
512
+ let count = 0;
513
+ let sprites = 0;
514
+ const visit = (o: Container, path: readonly number[]): void => {
515
+ const id = this.identity.idFor(o, path);
516
+ (o as Container & { __authId?: string }).__authId = id;
517
+ this.byId.set(id, o);
518
+ count++;
519
+ if (kindOf(o).includes('sprite')) sprites++;
520
+ o.children.forEach((c, i) => {
521
+ visit(c as Container, [...path, i]);
522
+ });
523
+ };
524
+ this.root.children.forEach((c, i) => {
525
+ visit(c as Container, [i]);
526
+ });
527
+ return { count, sprites };
528
+ }
529
+
530
+ /** {@link reindex}, then reapply every saved overlay edit to the live tree. */
531
+ refresh(): { count: number; sprites: number } {
532
+ const counts = this.reindex();
533
+ for (const [id, ov] of Object.entries(this.overlay)) this.applyOverride(id, ov);
534
+ return counts;
535
+ }
536
+
537
+ roots(): EditorNode2D[] {
538
+ return this.root.children.map((c) => this.toNode(c as Container));
539
+ }
540
+
541
+ node(id: string): EditorNode2D | null {
542
+ const o = this.byId.get(id);
543
+ return o ? this.toNode(o) : null;
544
+ }
545
+
546
+ displayObject(id: string): Container | null {
547
+ return this.byId.get(id) ?? null;
548
+ }
549
+
550
+ /** This object's row name, derived once and remembered until one of the
551
+ * inputs it is derived from changes. See {@link names}. */
552
+ private nameOf(o: Container): NameMemo {
553
+ const cached = this.names.get(o);
554
+ const parent = o.parent ?? null;
555
+ const type = o.constructor;
556
+ const own = o.label ?? null;
557
+ const texture = (o as Container & { texture?: unknown }).texture ?? null;
558
+ const childCount = o.children?.length ?? 0;
559
+ if (
560
+ cached &&
561
+ cached.parent === parent &&
562
+ cached.type === type &&
563
+ cached.own === own &&
564
+ cached.texture === texture &&
565
+ cached.childCount === childCount
566
+ ) {
567
+ return cached;
568
+ }
569
+ const derived: NameMemo = {
570
+ label: displayLabel(o),
571
+ kind: kindOf(o),
572
+ parent,
573
+ type,
574
+ own,
575
+ texture,
576
+ childCount,
577
+ };
578
+ this.names.set(o, derived);
579
+ return derived;
580
+ }
581
+
582
+ private toNode(o: Container): EditorNode2D {
583
+ const self = o as Container & { __authId?: string };
584
+ const { label, kind } = this.nameOf(o);
585
+ return {
586
+ id: self.__authId ?? '',
587
+ label,
588
+ kind,
589
+ parentId: (o.parent as (Container & { __authId?: string }) | null)?.__authId ?? null,
590
+ childIds: o.children.map((c) => (c as Container & { __authId?: string }).__authId ?? ''),
591
+ };
592
+ }
593
+
594
+ // --- Transforms ---
595
+ getTransform(id: string): Transform2DValue | null {
596
+ const o = this.byId.get(id);
597
+ // A tracked node isn't guaranteed to be a real PixiJS display object with
598
+ // a transform (e.g. test doubles / non-positional fixture nodes walked
599
+ // via the composite adapter's overlay path) — treat a missing position
600
+ // the same as a missing node: no transform to report, not a crash.
601
+ if (!o || !o.position || !o.scale) return null;
602
+ return {
603
+ position: [o.position.x, o.position.y],
604
+ rotation: o.rotation,
605
+ scale: [o.scale.x, o.scale.y],
606
+ };
607
+ }
608
+
609
+ setTransform(id: string, t: Partial<Transform2DValue>): void {
610
+ const o = this.byId.get(id);
611
+ if (!o) return;
612
+ if (t.position) o.position.set(t.position[0], t.position[1]);
613
+ if (t.rotation !== undefined) o.rotation = t.rotation;
614
+ if (t.scale) o.scale.set(t.scale[0], t.scale[1]);
615
+ this.overlay[id] ??= {};
616
+ const ov = this.overlay[id];
617
+ if (t.position) ov.position = t.position;
618
+ if (t.rotation !== undefined) ov.rotation = t.rotation;
619
+ if (t.scale) ov.scale = t.scale;
620
+ this.dirty = true;
621
+ }
622
+
623
+ // --- Inspector (reflected fields) ---
624
+ properties(id: string): Property2D[] {
625
+ const o = this.byId.get(id);
626
+ if (!o) return [];
627
+ const props: Property2D[] = [
628
+ { path: 'label', label: 'Name', type: 'string', value: o.label ?? '' },
629
+ { path: 'visible', label: 'Visible', type: 'boolean', value: o.visible },
630
+ { path: 'alpha', label: 'Alpha', type: 'number', value: o.alpha },
631
+ ];
632
+ if ('tint' in o)
633
+ props.push({
634
+ path: 'tint',
635
+ label: 'Tint',
636
+ type: 'color',
637
+ value: (o as { tint: number }).tint,
638
+ });
639
+ return props;
640
+ }
641
+
642
+ set(id: string, path: string, value: unknown): void {
643
+ const o = this.byId.get(id) as (Container & Record<string, unknown>) | undefined;
644
+ if (!o) return;
645
+ o[path] = value;
646
+ this.overlay[id] ??= {};
647
+ if (path === 'label') this.overlay[id].label = value as string;
648
+ if (path === 'alpha') this.overlay[id].alpha = value as number;
649
+ if (path === 'tint') this.overlay[id].tint = value as number;
650
+ if (path === 'visible') this.overlay[id].visible = value as boolean;
651
+ this.dirty = true;
652
+ }
653
+
654
+ private applyOverride(id: string, ov: Override2D): void {
655
+ const o = this.byId.get(id);
656
+ if (!o) return;
657
+ if (ov.label !== undefined) o.label = ov.label;
658
+ if (ov.alpha !== undefined) o.alpha = ov.alpha;
659
+ if (ov.position) o.position.set(ov.position[0], ov.position[1]);
660
+ if (ov.rotation !== undefined) o.rotation = ov.rotation;
661
+ if (ov.scale) o.scale.set(ov.scale[0], ov.scale[1]);
662
+ if (ov.tint !== undefined && 'tint' in o) (o as { tint: number }).tint = ov.tint;
663
+ if (ov.visible !== undefined) o.visible = ov.visible;
664
+ }
665
+
666
+ /**
667
+ * Re-apply a saved/external overlay onto the (already-walked) live tree, merging
668
+ * it into the in-memory overlay so a later `serializeOverlay()`/save preserves it
669
+ * and a later `refresh()` re-applies it again. Ids absent from the current tree
670
+ * are skipped (orphaned, not an error) — the 2D analog of
671
+ * `ThreeAuthoringAdapter.applyOverlay` (T3.2 slice 3). Used both for the initial
672
+ * reload (constructor `opts.overlay`) and for `PersistenceProvider.applyExternal`
673
+ * (re-apply a changed overlay file without a full remount).
674
+ */
675
+ applyOverlay(overlay: Overlay2D): { applied: number; orphaned: number } {
676
+ let applied = 0;
677
+ let orphaned = 0;
678
+ for (const [id, ov] of Object.entries(overlay)) {
679
+ if (!this.byId.has(id)) {
680
+ orphaned++;
681
+ continue;
682
+ }
683
+ this.applyOverride(id, ov);
684
+ this.overlay[id] = { ...this.overlay[id], ...ov };
685
+ applied++;
686
+ }
687
+ return { applied, orphaned };
688
+ }
689
+
690
+ isDirty(): boolean {
691
+ return this.dirty;
692
+ }
693
+ serializeOverlay(): Overlay2D {
694
+ return this.overlay;
695
+ }
696
+ /** Replace, rather than merge, overlay state after an exact history restore. */
697
+ replaceOverlay(overlay: Overlay2D): void {
698
+ this.overlay = structuredClone(overlay);
699
+ for (const [id, ov] of Object.entries(this.overlay)) this.applyOverride(id, ov);
700
+ this.dirty = true;
701
+ }
702
+ /** Clear the dirty flag after a successful save (mirrors ThreeAuthoringAdapter). */
703
+ markClean(): void {
704
+ this.dirty = false;
705
+ }
706
+ }