@vgai/engine 0.5.18 → 0.5.19
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.
- package/dist/pixi/authoring.d.ts.map +1 -1
- package/dist/pixi/authoring.js +134 -26
- package/package.json +1 -1
- package/src/pixi/authoring.ts +137 -27
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authoring.d.ts","sourceRoot":"","sources":["../../src/pixi/authoring.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC;;;;;;GAMG;AAEH,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,gBAAgB,GAAG,MAAM,GAAG,WAAW,GAAG,MAAM,CAAC;IAClE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;IACzD,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,kEAAkE;AAClE,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AACD,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"authoring.d.ts","sourceRoot":"","sources":["../../src/pixi/authoring.ts"],"names":[],"mappings":"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"}
|
package/dist/pixi/authoring.js
CHANGED
|
@@ -120,62 +120,141 @@ function isPixiDisplay(value) {
|
|
|
120
120
|
* never reached `_cannon` inside the budget.
|
|
121
121
|
*/
|
|
122
122
|
function viewOwnerLabel(view) {
|
|
123
|
-
const
|
|
123
|
+
const search = { seen: new WeakSet(), budget: OWNER_SEARCH_BUDGET };
|
|
124
124
|
let host = view.parent;
|
|
125
125
|
for (let hops = 0; hops < 8 && host && typeof host === 'object'; hops++) {
|
|
126
|
-
|
|
126
|
+
if (search.budget <= 0)
|
|
127
|
+
return undefined;
|
|
128
|
+
const found = labelFromHolder(host, view, search) ?? findHeldOwner(host, view, 2, search);
|
|
127
129
|
if (found)
|
|
128
130
|
return found;
|
|
129
131
|
host = host.parent;
|
|
130
132
|
}
|
|
131
133
|
return undefined;
|
|
132
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* THE CEILING ON ONE NODE'S OWNER HUNT — what keeps a hierarchy read LINEAR in
|
|
137
|
+
* the size of the tree instead of quadratic in it.
|
|
138
|
+
*
|
|
139
|
+
* The hunt below is a search of the GAME's object graph, and a game's object
|
|
140
|
+
* graph is small: `this._cannon`, `allSystems.get('hud')`, `_decor[3]` are all
|
|
141
|
+
* found within a few dozen examinations. What is not small is anything the
|
|
142
|
+
* search can leak into — Pixi's own live render graph is the measured one, and
|
|
143
|
+
* naming a key blacklist is a fix for the leak you already found, not for the
|
|
144
|
+
* class. So every examination is charged against this budget and the search
|
|
145
|
+
* simply gives up when it runs out: the node then falls through to its texture
|
|
146
|
+
* alias / constructor / kind name, which is the same honest answer it gets
|
|
147
|
+
* today whenever the hunt finds nothing.
|
|
148
|
+
*
|
|
149
|
+
* MEASURED (2026-08-20, `packages/editor/scripts/scale-harness`): entering play
|
|
150
|
+
* on a 20 000-node `@pixi/react` world blocked the main thread for 199.5
|
|
151
|
+
* SECONDS in one synchronous run, 69% of all profiler samples inside this
|
|
152
|
+
* search, because `parentRenderGroup` reaches an array of every node in the
|
|
153
|
+
* tree and the search walked it once per unlabeled node. `vgai play`,
|
|
154
|
+
* `screenshot` and `stop` all timed out against a tab that was heartbeating
|
|
155
|
+
* normally the whole time.
|
|
156
|
+
*/
|
|
157
|
+
const OWNER_SEARCH_BUDGET = 2_000;
|
|
158
|
+
/** Charge one examination. `false` means the hunt is over. */
|
|
159
|
+
function spend(search) {
|
|
160
|
+
if (search.budget <= 0)
|
|
161
|
+
return false;
|
|
162
|
+
search.budget -= 1;
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
133
165
|
function isGameFieldKey(key) {
|
|
134
166
|
// Skip display-list indices (`0`, `1`) and Pixi internals (`children`).
|
|
135
|
-
|
|
167
|
+
// The membership test is on the UNDERSCORED-STRIPPED key because that is how
|
|
168
|
+
// Pixi v8 spells most of them (`_position`, `_bounds`, `_texture`), and a
|
|
169
|
+
// set listing the bare names was silently matching none of those.
|
|
170
|
+
if (!/^_?[A-Za-z][A-Za-zA-Z0-9]*$/.test(key))
|
|
171
|
+
return false;
|
|
172
|
+
return !PIXI_INTERNAL_KEY.has(key.replace(/^_+/, ''));
|
|
136
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Field names that belong to Pixi (or to its EventEmitter base), never to the
|
|
176
|
+
* game. Read off a live `pixi.js` v8 `Container`/`Sprite`'s own keys, so this
|
|
177
|
+
* is a transcription rather than a guess — but it is the OPTIMIZATION, not the
|
|
178
|
+
* correctness boundary: {@link OWNER_SEARCH_BUDGET} is what bounds the search
|
|
179
|
+
* on a Pixi version whose internals this list has never heard of. Spending the
|
|
180
|
+
* budget on plausible game fields instead of on the render graph is what keeps
|
|
181
|
+
* the heuristic's hit rate while it is bounded.
|
|
182
|
+
*
|
|
183
|
+
* `view` is deliberately absent: `.view` is the very field the hunt is looking
|
|
184
|
+
* for.
|
|
185
|
+
*/
|
|
137
186
|
const PIXI_INTERNAL_KEY = new Set([
|
|
138
187
|
'parent',
|
|
139
188
|
'children',
|
|
140
189
|
'transform',
|
|
141
190
|
'position',
|
|
191
|
+
'origin',
|
|
142
192
|
'scale',
|
|
143
193
|
'pivot',
|
|
144
194
|
'skew',
|
|
195
|
+
'anchor',
|
|
145
196
|
'worldTransform',
|
|
146
197
|
'localTransform',
|
|
198
|
+
'groupTransform',
|
|
199
|
+
'relativeGroupTransform',
|
|
200
|
+
// The render graph — the measured leak. `parentRenderGroup` is on every
|
|
201
|
+
// container once a world has rendered and reaches every node in the tree.
|
|
202
|
+
'renderGroup',
|
|
203
|
+
'parentRenderGroup',
|
|
204
|
+
'parentRenderGroupIndex',
|
|
205
|
+
'parentRenderLayer',
|
|
206
|
+
'relativeRenderGroupDepth',
|
|
207
|
+
'renderPipeId',
|
|
208
|
+
'instructionSet',
|
|
209
|
+
'childrenToUpdate',
|
|
210
|
+
'childrenRenderablesToUpdate',
|
|
211
|
+
'effects',
|
|
212
|
+
'filters',
|
|
213
|
+
'mask',
|
|
214
|
+
'bounds',
|
|
215
|
+
'boundsArea',
|
|
216
|
+
'visualBounds',
|
|
217
|
+
'gpuData',
|
|
218
|
+
'texture',
|
|
219
|
+
'events',
|
|
220
|
+
'eventsCount',
|
|
221
|
+
'updateFlags',
|
|
147
222
|
]);
|
|
148
|
-
function labelFromHolder(host, view,
|
|
149
|
-
if (seen.has(host))
|
|
223
|
+
function labelFromHolder(host, view, search) {
|
|
224
|
+
if (search.seen.has(host))
|
|
150
225
|
return undefined;
|
|
151
|
-
seen.add(host);
|
|
152
|
-
const asBag = nameInBag(host, view, undefined,
|
|
226
|
+
search.seen.add(host);
|
|
227
|
+
const asBag = nameInBag(host, view, undefined, search);
|
|
153
228
|
if (asBag)
|
|
154
229
|
return asBag;
|
|
155
230
|
for (const [key, value] of Object.entries(host)) {
|
|
231
|
+
if (!spend(search))
|
|
232
|
+
return undefined;
|
|
156
233
|
if (!isGameFieldKey(key))
|
|
157
234
|
continue;
|
|
158
235
|
const named = nameIfHoldsView(host, key, value, view);
|
|
159
236
|
if (named)
|
|
160
237
|
return named;
|
|
161
|
-
const nested = nameInBag(value, view, key,
|
|
238
|
+
const nested = nameInBag(value, view, key, search);
|
|
162
239
|
if (nested)
|
|
163
240
|
return nested;
|
|
164
241
|
}
|
|
165
242
|
return undefined;
|
|
166
243
|
}
|
|
167
|
-
function nameInBag(value, view, fieldKey,
|
|
244
|
+
function nameInBag(value, view, fieldKey, search) {
|
|
168
245
|
const entries = bagEntries(value);
|
|
169
246
|
if (!entries)
|
|
170
247
|
return undefined;
|
|
171
248
|
for (const [itemKey, item] of entries) {
|
|
172
|
-
|
|
249
|
+
if (!spend(search))
|
|
250
|
+
return undefined;
|
|
251
|
+
const named = nameBagItem(value, item, usableBagKey(itemKey) ?? fieldKey, view, search);
|
|
173
252
|
if (named)
|
|
174
253
|
return named;
|
|
175
254
|
}
|
|
176
255
|
return undefined;
|
|
177
256
|
}
|
|
178
|
-
function nameBagItem(bag, item, key, view,
|
|
257
|
+
function nameBagItem(bag, item, key, view, search) {
|
|
179
258
|
if (key) {
|
|
180
259
|
const named = nameIfHoldsView(item && typeof item === 'object' ? item : bag, key, item, view);
|
|
181
260
|
if (named)
|
|
@@ -187,7 +266,7 @@ function nameBagItem(bag, item, key, view, seen) {
|
|
|
187
266
|
return named;
|
|
188
267
|
}
|
|
189
268
|
if (item && typeof item === 'object' && !isPixiDisplay(item)) {
|
|
190
|
-
return labelFromHolder(item, view,
|
|
269
|
+
return labelFromHolder(item, view, search);
|
|
191
270
|
}
|
|
192
271
|
return undefined;
|
|
193
272
|
}
|
|
@@ -196,6 +275,29 @@ function usableBagKey(key) {
|
|
|
196
275
|
return undefined;
|
|
197
276
|
return isGameFieldKey(key) || key.includes('-') ? (fieldLabel(key) ?? key) : undefined;
|
|
198
277
|
}
|
|
278
|
+
function* indexedBagEntries(value) {
|
|
279
|
+
for (let index = 0; index < value.length; index++)
|
|
280
|
+
yield [String(index), value[index]];
|
|
281
|
+
}
|
|
282
|
+
function* mapBagEntries(value) {
|
|
283
|
+
for (const [key, item] of value.entries())
|
|
284
|
+
yield [String(key), item];
|
|
285
|
+
}
|
|
286
|
+
function* setBagEntries(value) {
|
|
287
|
+
let index = 0;
|
|
288
|
+
for (const item of value) {
|
|
289
|
+
yield [String(index), item];
|
|
290
|
+
index += 1;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* A bag's entries, LAZILY.
|
|
295
|
+
*
|
|
296
|
+
* Lazy because the caller is budgeted ({@link OWNER_SEARCH_BUDGET}) and must be
|
|
297
|
+
* able to stop: materializing the pairs first made a 20 000-element array cost
|
|
298
|
+
* 20 000 allocations before the first one was even looked at, which is a cost
|
|
299
|
+
* no budget above it can decline.
|
|
300
|
+
*/
|
|
199
301
|
function bagEntries(value) {
|
|
200
302
|
if (!value || typeof value !== 'object')
|
|
201
303
|
return undefined;
|
|
@@ -203,18 +305,13 @@ function bagEntries(value) {
|
|
|
203
305
|
// their overridable `map`: one shipped Pixi game returns its elements rather
|
|
204
306
|
// than `[key, value]` pairs there, which made hierarchy labeling throw while
|
|
205
307
|
// destructuring the result. The authoring seam treats game objects as
|
|
206
|
-
// untrusted observations, so normalize with indexed reads
|
|
207
|
-
if (Array.isArray(value))
|
|
208
|
-
|
|
209
|
-
for (let index = 0; index < value.length; index++) {
|
|
210
|
-
entries.push([String(index), value[index]]);
|
|
211
|
-
}
|
|
212
|
-
return entries;
|
|
213
|
-
}
|
|
308
|
+
// untrusted observations, so normalize with indexed reads of our own.
|
|
309
|
+
if (Array.isArray(value))
|
|
310
|
+
return indexedBagEntries(value);
|
|
214
311
|
if (value instanceof Map)
|
|
215
|
-
return
|
|
312
|
+
return mapBagEntries(value);
|
|
216
313
|
if (value instanceof Set)
|
|
217
|
-
return
|
|
314
|
+
return setBagEntries(value);
|
|
218
315
|
if (Object.getPrototypeOf(value) !== Object.prototype)
|
|
219
316
|
return undefined;
|
|
220
317
|
return Object.entries(value);
|
|
@@ -232,16 +329,27 @@ function nameIfHoldsView(host, key, value, view) {
|
|
|
232
329
|
}
|
|
233
330
|
return undefined;
|
|
234
331
|
}
|
|
235
|
-
function findHeldOwner(held, view, depth,
|
|
236
|
-
const direct = labelFromHolder(held, view,
|
|
332
|
+
function findHeldOwner(held, view, depth, search) {
|
|
333
|
+
const direct = labelFromHolder(held, view, search);
|
|
237
334
|
if (direct)
|
|
238
335
|
return direct;
|
|
239
336
|
if (depth <= 0)
|
|
240
337
|
return undefined;
|
|
241
|
-
|
|
338
|
+
// BY KEY, exactly like `labelFromHolder`'s own scan. Iterating `Object.values`
|
|
339
|
+
// here was the leak that made a hierarchy read quadratic: `parentRenderGroup`
|
|
340
|
+
// sits on EVERY container once a world has rendered, and descending into it
|
|
341
|
+
// reaches `childrenToUpdate[depth].list` — an array of every node in the
|
|
342
|
+
// tree — so the search for one node's owner walked the whole tree, for every
|
|
343
|
+
// unlabeled node. `isGameFieldKey` already knows which keys belong to Pixi
|
|
344
|
+
// rather than to the game; this scan simply has to ask it too.
|
|
345
|
+
for (const [key, value] of Object.entries(held)) {
|
|
346
|
+
if (!spend(search))
|
|
347
|
+
return undefined;
|
|
348
|
+
if (!isGameFieldKey(key))
|
|
349
|
+
continue;
|
|
242
350
|
if (!value || typeof value !== 'object' || isPixiDisplay(value))
|
|
243
351
|
continue;
|
|
244
|
-
const found = findHeldOwner(value, view, depth - 1,
|
|
352
|
+
const found = findHeldOwner(value, view, depth - 1, search);
|
|
245
353
|
if (found)
|
|
246
354
|
return found;
|
|
247
355
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@vgai/engine",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
|
-
"version": "0.5.
|
|
5
|
+
"version": "0.5.19",
|
|
6
6
|
"description": "Readable TypeScript game engine and universal host for Three.js, PixiJS, and React games.",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"game-engine",
|
package/src/pixi/authoring.ts
CHANGED
|
@@ -159,43 +159,125 @@ function isPixiDisplay(value: object): boolean {
|
|
|
159
159
|
* never reached `_cannon` inside the budget.
|
|
160
160
|
*/
|
|
161
161
|
function viewOwnerLabel(view: Container): string | undefined {
|
|
162
|
-
const
|
|
162
|
+
const search: OwnerSearch = { seen: new WeakSet<object>(), budget: OWNER_SEARCH_BUDGET };
|
|
163
163
|
let host: unknown = view.parent;
|
|
164
164
|
for (let hops = 0; hops < 8 && host && typeof host === 'object'; hops++) {
|
|
165
|
-
|
|
165
|
+
if (search.budget <= 0) return undefined;
|
|
166
|
+
const found = labelFromHolder(host, view, search) ?? findHeldOwner(host, view, 2, search);
|
|
166
167
|
if (found) return found;
|
|
167
168
|
host = (host as { parent?: unknown }).parent;
|
|
168
169
|
}
|
|
169
170
|
return undefined;
|
|
170
171
|
}
|
|
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
|
+
|
|
172
211
|
function isGameFieldKey(key: string): boolean {
|
|
173
212
|
// Skip display-list indices (`0`, `1`) and Pixi internals (`children`).
|
|
174
|
-
|
|
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(/^_+/, ''));
|
|
175
218
|
}
|
|
176
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
|
+
*/
|
|
177
232
|
const PIXI_INTERNAL_KEY = new Set([
|
|
178
233
|
'parent',
|
|
179
234
|
'children',
|
|
180
235
|
'transform',
|
|
181
236
|
'position',
|
|
237
|
+
'origin',
|
|
182
238
|
'scale',
|
|
183
239
|
'pivot',
|
|
184
240
|
'skew',
|
|
241
|
+
'anchor',
|
|
185
242
|
'worldTransform',
|
|
186
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',
|
|
187
268
|
]);
|
|
188
269
|
|
|
189
|
-
function labelFromHolder(host: object, view: Container,
|
|
190
|
-
if (seen.has(host)) return undefined;
|
|
191
|
-
seen.add(host);
|
|
192
|
-
const asBag = nameInBag(host, view, undefined,
|
|
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);
|
|
193
274
|
if (asBag) return asBag;
|
|
194
275
|
for (const [key, value] of Object.entries(host as Record<string, unknown>)) {
|
|
276
|
+
if (!spend(search)) return undefined;
|
|
195
277
|
if (!isGameFieldKey(key)) continue;
|
|
196
278
|
const named = nameIfHoldsView(host, key, value, view);
|
|
197
279
|
if (named) return named;
|
|
198
|
-
const nested = nameInBag(value, view, key,
|
|
280
|
+
const nested = nameInBag(value, view, key, search);
|
|
199
281
|
if (nested) return nested;
|
|
200
282
|
}
|
|
201
283
|
return undefined;
|
|
@@ -205,12 +287,13 @@ function nameInBag(
|
|
|
205
287
|
value: unknown,
|
|
206
288
|
view: Container,
|
|
207
289
|
fieldKey: string | undefined,
|
|
208
|
-
|
|
290
|
+
search: OwnerSearch,
|
|
209
291
|
): string | undefined {
|
|
210
292
|
const entries = bagEntries(value);
|
|
211
293
|
if (!entries) return undefined;
|
|
212
294
|
for (const [itemKey, item] of entries) {
|
|
213
|
-
|
|
295
|
+
if (!spend(search)) return undefined;
|
|
296
|
+
const named = nameBagItem(value, item, usableBagKey(itemKey) ?? fieldKey, view, search);
|
|
214
297
|
if (named) return named;
|
|
215
298
|
}
|
|
216
299
|
return undefined;
|
|
@@ -221,7 +304,7 @@ function nameBagItem(
|
|
|
221
304
|
item: unknown,
|
|
222
305
|
key: string | undefined,
|
|
223
306
|
view: Container,
|
|
224
|
-
|
|
307
|
+
search: OwnerSearch,
|
|
225
308
|
): string | undefined {
|
|
226
309
|
if (key) {
|
|
227
310
|
const named = nameIfHoldsView(
|
|
@@ -236,7 +319,7 @@ function nameBagItem(
|
|
|
236
319
|
if (named) return named;
|
|
237
320
|
}
|
|
238
321
|
if (item && typeof item === 'object' && !isPixiDisplay(item)) {
|
|
239
|
-
return labelFromHolder(item, view,
|
|
322
|
+
return labelFromHolder(item, view, search);
|
|
240
323
|
}
|
|
241
324
|
return undefined;
|
|
242
325
|
}
|
|
@@ -246,22 +329,40 @@ function usableBagKey(key: string): string | undefined {
|
|
|
246
329
|
return isGameFieldKey(key) || key.includes('-') ? (fieldLabel(key) ?? key) : undefined;
|
|
247
330
|
}
|
|
248
331
|
|
|
249
|
-
function
|
|
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 {
|
|
250
357
|
if (!value || typeof value !== 'object') return undefined;
|
|
251
358
|
// Game arrays are allowed to be exotic subclasses. Do not dispatch through
|
|
252
359
|
// their overridable `map`: one shipped Pixi game returns its elements rather
|
|
253
360
|
// than `[key, value]` pairs there, which made hierarchy labeling throw while
|
|
254
361
|
// destructuring the result. The authoring seam treats game objects as
|
|
255
|
-
// untrusted observations, so normalize with indexed reads
|
|
256
|
-
if (Array.isArray(value))
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
entries.push([String(index), value[index]]);
|
|
260
|
-
}
|
|
261
|
-
return entries;
|
|
262
|
-
}
|
|
263
|
-
if (value instanceof Map) return [...value.entries()].map(([key, item]) => [String(key), item]);
|
|
264
|
-
if (value instanceof Set) return [...value].map((item, index) => [String(index), item]);
|
|
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);
|
|
265
366
|
if (Object.getPrototypeOf(value) !== Object.prototype) return undefined;
|
|
266
367
|
return Object.entries(value as Record<string, unknown>);
|
|
267
368
|
}
|
|
@@ -289,14 +390,23 @@ function findHeldOwner(
|
|
|
289
390
|
held: object,
|
|
290
391
|
view: Container,
|
|
291
392
|
depth: number,
|
|
292
|
-
|
|
393
|
+
search: OwnerSearch,
|
|
293
394
|
): string | undefined {
|
|
294
|
-
const direct = labelFromHolder(held, view,
|
|
395
|
+
const direct = labelFromHolder(held, view, search);
|
|
295
396
|
if (direct) return direct;
|
|
296
397
|
if (depth <= 0) return undefined;
|
|
297
|
-
|
|
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;
|
|
298
408
|
if (!value || typeof value !== 'object' || isPixiDisplay(value)) continue;
|
|
299
|
-
const found = findHeldOwner(value, view, depth - 1,
|
|
409
|
+
const found = findHeldOwner(value, view, depth - 1, search);
|
|
300
410
|
if (found) return found;
|
|
301
411
|
}
|
|
302
412
|
return undefined;
|