cocos-web-inspector-mcp 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -11
- package/dist/src/bridge.d.ts +40 -0
- package/dist/src/bridge.js +854 -34
- package/dist/src/browser-data.d.ts +23 -0
- package/dist/src/browser-data.js +166 -0
- package/dist/src/browser.d.ts +1 -1
- package/dist/src/cli.js +1 -1
- package/dist/src/index.js +12 -2
- package/dist/src/server.d.ts +2 -0
- package/dist/src/server.js +151 -57
- package/package.json +1 -1
package/dist/src/bridge.js
CHANGED
|
@@ -39,25 +39,85 @@ export async function captureNode(page, uuid) {
|
|
|
39
39
|
}
|
|
40
40
|
return { captured: false, reason: 'RESPONSE_LIMIT' };
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
async function nodeCenter(page, uuid) {
|
|
43
43
|
const result = await page.evaluate(inspectCocos, { action: 'getNodeBounds', uuid });
|
|
44
44
|
if (!result.available || !result.visible)
|
|
45
|
-
return {
|
|
45
|
+
return { reason: result.reason ?? 'OUTSIDE_VIEWPORT' };
|
|
46
46
|
const clip = result.clippedViewport;
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
return { x: clip.x + clip.width / 2, y: clip.y + clip.height / 2 };
|
|
48
|
+
}
|
|
49
|
+
// Under touch emulation Chrome turns mouse input into touch and never acknowledges Playwright's mouse calls, so send touch directly.
|
|
50
|
+
function pointer(page) {
|
|
51
|
+
const emulation = emulations.get(page);
|
|
52
|
+
if (!emulation?.state.device?.mobile)
|
|
53
|
+
return { down: async (x, y) => { await page.mouse.move(x, y); await page.mouse.down(); }, move: (x, y) => page.mouse.move(x, y), up: () => page.mouse.up() };
|
|
54
|
+
let last = { x: 0, y: 0 };
|
|
55
|
+
const touch = async (type, x = last.x, y = last.y) => {
|
|
56
|
+
last = { x, y };
|
|
57
|
+
await emulation.session.send('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y }] });
|
|
58
|
+
};
|
|
59
|
+
return { down: (x, y) => touch('touchStart', x, y), move: (x, y) => touch('touchMove', x, y), up: () => touch('touchEnd') };
|
|
60
|
+
}
|
|
61
|
+
export async function clickNode(page, uuid) {
|
|
62
|
+
const point = await nodeCenter(page, uuid);
|
|
63
|
+
if ('reason' in point)
|
|
64
|
+
return { clicked: false, reason: point.reason };
|
|
65
|
+
const input = pointer(page);
|
|
66
|
+
await input.down(point.x, point.y);
|
|
67
|
+
await input.up();
|
|
49
68
|
return { clicked: true, target: { nodeUuid: uuid }, point, runtimeOnly: true };
|
|
50
69
|
}
|
|
70
|
+
// Real pointer input, so ScrollView, PageView, Slider, and custom touch handlers see a drag.
|
|
71
|
+
export async function dragNode(page, uuid, dx, dy, steps = 10, durationMs = 300) {
|
|
72
|
+
const from = await nodeCenter(page, uuid);
|
|
73
|
+
if ('reason' in from)
|
|
74
|
+
return { dragged: false, reason: from.reason };
|
|
75
|
+
const to = { x: from.x + dx, y: from.y + dy };
|
|
76
|
+
const input = pointer(page);
|
|
77
|
+
await input.down(from.x, from.y);
|
|
78
|
+
for (let step = 1; step <= steps; step++) {
|
|
79
|
+
await new Promise(resolve => setTimeout(resolve, durationMs / steps));
|
|
80
|
+
await input.move(from.x + dx * step / steps, from.y + dy * step / steps);
|
|
81
|
+
}
|
|
82
|
+
await input.up();
|
|
83
|
+
return { dragged: true, target: { nodeUuid: uuid }, from, to, runtimeOnly: true };
|
|
84
|
+
}
|
|
85
|
+
// EditBox.string is an accessor and assigning it skips text-changed/editing events, so type into the DOM input the EditBox opens on tap.
|
|
86
|
+
export async function typeText(page, uuid, text, submit) {
|
|
87
|
+
const { components } = await runBridge(page, { action: 'getComponents', uuid });
|
|
88
|
+
if (!components?.some(component => component.type === 'EditBox'))
|
|
89
|
+
throw new Error('Component not found');
|
|
90
|
+
const clicked = await clickNode(page, uuid);
|
|
91
|
+
if (!clicked.clicked)
|
|
92
|
+
return { typed: false, reason: clicked.reason };
|
|
93
|
+
const focused = await page.waitForFunction(() => {
|
|
94
|
+
const element = document.activeElement;
|
|
95
|
+
return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement;
|
|
96
|
+
}, undefined, { timeout: 2_000 }).then(() => true, () => false);
|
|
97
|
+
if (!focused)
|
|
98
|
+
return { typed: false, reason: 'NOT_FOCUSED' };
|
|
99
|
+
await page.evaluate(() => document.activeElement.select());
|
|
100
|
+
if (text)
|
|
101
|
+
await page.keyboard.insertText(text);
|
|
102
|
+
else
|
|
103
|
+
await page.keyboard.press('Delete');
|
|
104
|
+
if (submit)
|
|
105
|
+
await page.keyboard.press('Enter');
|
|
106
|
+
const { properties } = await runBridge(page, { action: 'getProperties', uuid, componentType: 'EditBox', maxDepth: 0 });
|
|
107
|
+
// get_properties drops the text of password boxes.
|
|
108
|
+
const after = typeof properties?.string === 'string' ? { string: properties.string } : { redacted: true };
|
|
109
|
+
return { typed: true, target: { nodeUuid: uuid }, length: text.length, submitted: submit, after, runtimeOnly: true };
|
|
110
|
+
}
|
|
51
111
|
const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
|
52
112
|
const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
|
53
113
|
const androidUa = (model) => `Mozilla/5.0 (Linux; Android 14; ${model}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36`;
|
|
54
114
|
export const devicePresets = {
|
|
55
|
-
'iphone-se': { width: 375, height: 667, deviceScaleFactor: 2, mobile: true, userAgent: IOS_UA },
|
|
56
|
-
'iphone-14': { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, userAgent: IOS_UA },
|
|
57
|
-
'iphone-14-pro-max': { width: 430, height: 932, deviceScaleFactor: 3, mobile: true, userAgent: IOS_UA },
|
|
58
|
-
'pixel-7': { width: 412, height: 915, deviceScaleFactor: 2.625, mobile: true, userAgent: androidUa('Pixel 7') },
|
|
59
|
-
'galaxy-s20': { width: 360, height: 800, deviceScaleFactor: 3, mobile: true, userAgent: androidUa('SM-G981B') },
|
|
60
|
-
'ipad-mini': { width: 768, height: 1024, deviceScaleFactor: 2, mobile: true, userAgent: IPAD_UA },
|
|
115
|
+
'iphone-se': { width: 375, height: 667, deviceScaleFactor: 2, mobile: true, userAgent: IOS_UA, platform: 'iPhone' },
|
|
116
|
+
'iphone-14': { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, userAgent: IOS_UA, platform: 'iPhone' },
|
|
117
|
+
'iphone-14-pro-max': { width: 430, height: 932, deviceScaleFactor: 3, mobile: true, userAgent: IOS_UA, platform: 'iPhone' },
|
|
118
|
+
'pixel-7': { width: 412, height: 915, deviceScaleFactor: 2.625, mobile: true, userAgent: androidUa('Pixel 7'), platform: 'Linux armv81' },
|
|
119
|
+
'galaxy-s20': { width: 360, height: 800, deviceScaleFactor: 3, mobile: true, userAgent: androidUa('SM-G981B'), platform: 'Linux armv81' },
|
|
120
|
+
'ipad-mini': { width: 768, height: 1024, deviceScaleFactor: 2, mobile: true, userAgent: IPAD_UA, platform: 'iPad' },
|
|
61
121
|
};
|
|
62
122
|
export const devicePresetNames = Object.keys(devicePresets);
|
|
63
123
|
// Chrome DevTools throttling presets, in bytes per second and milliseconds.
|
|
@@ -101,10 +161,19 @@ export async function emulateDevice(page, request) {
|
|
|
101
161
|
}
|
|
102
162
|
if (request.reload)
|
|
103
163
|
await page.reload();
|
|
164
|
+
// Cocos resizes its canvas a frame or more after the viewport changes; wait so the next bounds, click, or capture sees the new layout.
|
|
165
|
+
await page.waitForFunction(() => {
|
|
166
|
+
const canvas = document.querySelector('canvas');
|
|
167
|
+
if (!canvas)
|
|
168
|
+
return true;
|
|
169
|
+
const rect = canvas.getBoundingClientRect();
|
|
170
|
+
return Math.round(rect.width) <= innerWidth && Math.round(rect.height) <= innerHeight && (Math.round(rect.width) === innerWidth || Math.round(rect.height) === innerHeight);
|
|
171
|
+
}, undefined, { timeout: 3_000 }).catch(() => undefined);
|
|
172
|
+
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))).catch(() => undefined);
|
|
104
173
|
const report = (state) => {
|
|
105
174
|
if (!state.device)
|
|
106
175
|
return state;
|
|
107
|
-
const { userAgent: _userAgent, ...device } = state.device;
|
|
176
|
+
const { userAgent: _userAgent, platform: _platform, ...device } = state.device;
|
|
108
177
|
const landscape = device.orientation === 'landscape';
|
|
109
178
|
return { ...state, device: { ...device, width: landscape ? device.height : device.width, height: landscape ? device.width : device.height } };
|
|
110
179
|
};
|
|
@@ -128,7 +197,10 @@ async function applyEmulation(session, state) {
|
|
|
128
197
|
await session.send('Emulation.setTouchEmulationEnabled', touch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
|
|
129
198
|
// Like the Chrome device toolbar: mouse input arrives as touch, which mobile Cocos builds listen for.
|
|
130
199
|
await session.send('Emulation.setEmitTouchEventsForMouse', { enabled: touch, configuration: 'mobile' });
|
|
131
|
-
|
|
200
|
+
// Engines such as Cocos read navigator.platform too: a touch-enabled MacIntel counts as iPad, whatever the user agent says.
|
|
201
|
+
await session.send('Emulation.setUserAgentOverride', device?.userAgent
|
|
202
|
+
? { userAgent: device.userAgent, platform: device.platform ?? '' }
|
|
203
|
+
: { userAgent: (await session.send('Browser.getVersion')).userAgent });
|
|
132
204
|
await session.send('Emulation.setCPUThrottlingRate', { rate: state.cpuSlowdown ?? 1 });
|
|
133
205
|
await session.send('Network.emulateNetworkConditions', networkProfiles[state.network ?? 'online']);
|
|
134
206
|
}
|
|
@@ -184,8 +256,8 @@ export function inspectCocos(request) {
|
|
|
184
256
|
return undefined;
|
|
185
257
|
};
|
|
186
258
|
const componentName = (component) => {
|
|
187
|
-
//
|
|
188
|
-
const name = dataProperty(component, '
|
|
259
|
+
// Release builds minify constructor names ("e"); the registered __classname__ survives, so it wins.
|
|
260
|
+
const name = dataProperty(component, '__classname__') || dataProperty(component, 'constructor')?.name || 'Component';
|
|
189
261
|
return String(name).replace(/^cc\./, '').slice(0, 120);
|
|
190
262
|
};
|
|
191
263
|
const components = (node) => {
|
|
@@ -441,8 +513,21 @@ export function inspectCocos(request) {
|
|
|
441
513
|
if (request.action === 'pickAt') {
|
|
442
514
|
// Cocos draws 2D nodes in pre-order, so the last hit in traversal order is the topmost one.
|
|
443
515
|
const hits = [];
|
|
516
|
+
// Full-screen blockers often sit on top at opacity 0; read the backing field, never the computing getter.
|
|
517
|
+
const transparent = (node) => {
|
|
518
|
+
for (let current = node, depth = 0; current && depth < 100; current = current.parent, depth++) {
|
|
519
|
+
const local = dataProperty(dataProperty(current, '_uiProps') ?? {}, '_localOpacity');
|
|
520
|
+
if (typeof local === 'number' && local <= 0)
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
};
|
|
525
|
+
// Full-screen layout containers draw nothing; only nodes that render or take input can be what the user pointed at.
|
|
526
|
+
const Renderable = cc.internal?.Renderable2D;
|
|
527
|
+
const pickable = (node) => components(node).some(component => typeof Renderable === 'function' && component instanceof Renderable
|
|
528
|
+
|| /^(Sprite|Label|RichText|Graphics|Button|Toggle|EditBox|Slider)$/.test(componentName(component)));
|
|
444
529
|
walk((node, path) => {
|
|
445
|
-
if (node === scene || node.activeInHierarchy === false)
|
|
530
|
+
if (node === scene || node.activeInHierarchy === false || !pickable(node) || transparent(node))
|
|
446
531
|
return;
|
|
447
532
|
const bounds = nodeBounds(node);
|
|
448
533
|
const box = bounds.available ? bounds.viewport : undefined;
|
|
@@ -588,17 +673,8 @@ export function inspectCocos(request) {
|
|
|
588
673
|
truncated: components(node).length > 200,
|
|
589
674
|
};
|
|
590
675
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const matches = request.componentType ? components(node).filter(component => componentName(component) === request.componentType) : [];
|
|
594
|
-
if (matches.length > 1)
|
|
595
|
-
throw new Error('Ambiguous component type');
|
|
596
|
-
const selected = request.componentUuid
|
|
597
|
-
? components(node).find(component => component?.uuid === request.componentUuid)
|
|
598
|
-
: request.componentType ? matches[0] : node;
|
|
599
|
-
if (!selected)
|
|
600
|
-
throw new Error('Component not found');
|
|
601
|
-
const maxDepth = Math.min(Math.max(request.maxDepth ?? 3, 0), 6);
|
|
676
|
+
// Shared bounded serializer: no getters, no private or secret-like keys, references collapsed to summaries.
|
|
677
|
+
const createSerializer = (maxDepth) => {
|
|
602
678
|
let propertyCount = 0;
|
|
603
679
|
let skipped = 0;
|
|
604
680
|
let redacted = 0;
|
|
@@ -684,6 +760,24 @@ export function inspectCocos(request) {
|
|
|
684
760
|
seen.delete(value);
|
|
685
761
|
return output;
|
|
686
762
|
};
|
|
763
|
+
return { serialize, truncate, stats: () => ({ truncated, truncationReasons: [...truncationReasons], propertyCount, skipped, redacted, returned }), count: (field, by = 1) => { if (field === 'redacted')
|
|
764
|
+
redacted += by;
|
|
765
|
+
else
|
|
766
|
+
returned += by; } };
|
|
767
|
+
};
|
|
768
|
+
if (request.action === 'getProperties') {
|
|
769
|
+
const node = findByUuid(request.uuid);
|
|
770
|
+
const matches = request.componentType ? components(node).filter(component => componentName(component) === request.componentType) : [];
|
|
771
|
+
if (matches.length > 1)
|
|
772
|
+
throw new Error('Ambiguous component type');
|
|
773
|
+
const selected = request.componentUuid
|
|
774
|
+
? components(node).find(component => component?.uuid === request.componentUuid)
|
|
775
|
+
: request.componentType ? matches[0] : node;
|
|
776
|
+
if (!selected)
|
|
777
|
+
throw new Error('Component not found');
|
|
778
|
+
const maxDepth = Math.min(Math.max(request.maxDepth ?? 3, 0), 6);
|
|
779
|
+
const serializer = createSerializer(maxDepth);
|
|
780
|
+
const { serialize, truncate } = serializer;
|
|
687
781
|
// ponytail: fixed allowlist of display backing fields, read without getters; extend per component when smoke tests need more.
|
|
688
782
|
const displayFields = {};
|
|
689
783
|
const readDisplay = (type, key, backing) => {
|
|
@@ -702,6 +796,18 @@ export function inspectCocos(request) {
|
|
|
702
796
|
readDisplay('RichText', 'string', '_string');
|
|
703
797
|
readDisplay('Button', 'interactable', '_interactable');
|
|
704
798
|
readDisplay('Toggle', 'isChecked', '_isChecked');
|
|
799
|
+
// Builds that no scene EditBox pulls in leave cc.EditBox unexported, so match the registered class name.
|
|
800
|
+
if (selected !== node && componentName(selected) === 'EditBox') {
|
|
801
|
+
const text = dataProperty(selected, '_string');
|
|
802
|
+
// InputFlag.PASSWORD is 0; a password box holds the secret in plain text.
|
|
803
|
+
if (dataProperty(selected, '_inputFlag') === 0)
|
|
804
|
+
serializer.count('redacted');
|
|
805
|
+
else if (typeof text === 'string') {
|
|
806
|
+
if (text.length > 2_000)
|
|
807
|
+
truncate('STRING_LIMIT');
|
|
808
|
+
displayFields.string = text.slice(0, 2_000);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
705
811
|
if (selected === node) {
|
|
706
812
|
for (const key of ['name', 'active', 'activeInHierarchy']) {
|
|
707
813
|
const value = dataProperty(node, key) ?? dataProperty(node, `_${key}`);
|
|
@@ -717,20 +823,21 @@ export function inspectCocos(request) {
|
|
|
717
823
|
}
|
|
718
824
|
const properties = serialize(selected, 0, false);
|
|
719
825
|
Object.assign(properties, displayFields);
|
|
720
|
-
returned
|
|
826
|
+
serializer.count('returned', Object.keys(displayFields).length);
|
|
827
|
+
const stats = serializer.stats();
|
|
721
828
|
return {
|
|
722
829
|
version,
|
|
723
830
|
node: summary(node),
|
|
724
831
|
componentType: request.componentType ?? (selected === node ? undefined : componentName(selected)),
|
|
725
832
|
componentUuid: selected === node ? undefined : String(selected.uuid ?? ''),
|
|
726
833
|
properties,
|
|
727
|
-
truncated,
|
|
728
|
-
truncationReasons:
|
|
729
|
-
propertyCount,
|
|
730
|
-
inspected: propertyCount,
|
|
731
|
-
skipped,
|
|
732
|
-
redacted,
|
|
733
|
-
returned,
|
|
834
|
+
truncated: stats.truncated,
|
|
835
|
+
truncationReasons: stats.truncationReasons,
|
|
836
|
+
propertyCount: stats.propertyCount,
|
|
837
|
+
inspected: stats.propertyCount,
|
|
838
|
+
skipped: stats.skipped,
|
|
839
|
+
redacted: stats.redacted,
|
|
840
|
+
returned: stats.returned,
|
|
734
841
|
};
|
|
735
842
|
}
|
|
736
843
|
const invalidMutation = (message) => { throw new Error(`Invalid mutation: ${message}`); };
|
|
@@ -796,6 +903,190 @@ export function inspectCocos(request) {
|
|
|
796
903
|
const after = Boolean(profiler.isShowingStats());
|
|
797
904
|
return { changed: before !== after, target: {}, before: { visible: before }, after: { visible: after }, runtimeOnly: true };
|
|
798
905
|
}
|
|
906
|
+
if (request.action === 'analyzeBatches') {
|
|
907
|
+
const batcher = cc.director?.root?.batcher2D;
|
|
908
|
+
const director = cc.director;
|
|
909
|
+
if (!batcher || typeof batcher.commitComp !== 'function' || typeof director?.once !== 'function')
|
|
910
|
+
invalidMutation('2D batcher is unavailable');
|
|
911
|
+
if (root.__cocosWebInspectorBatchCapture)
|
|
912
|
+
invalidMutation('a batch capture is already running');
|
|
913
|
+
// Batches clear every frame and keep no node reference, so wrap the batcher's commit methods for one rendered frame.
|
|
914
|
+
// ponytail: reads private Batcher2D fields (_curr*, _middleware*, _emptyMaterial), verified on 3.7.4-3.8.8; recheck on new engine versions.
|
|
915
|
+
const limit = Math.min(Math.max(request.limit ?? 100, 1), 500);
|
|
916
|
+
const tintMs = request.tintMs;
|
|
917
|
+
const ENTER_LEVEL = 2;
|
|
918
|
+
const ENTER_LEVEL_INVERTED = 6;
|
|
919
|
+
const batches = [];
|
|
920
|
+
let componentCount = 0;
|
|
921
|
+
let lastReset = 'FIRST';
|
|
922
|
+
const state = () => ({
|
|
923
|
+
component: batcher._currComponent, material: batcher._currMaterial, textureHash: batcher._currTextureHash, layer: batcher._currLayer,
|
|
924
|
+
stage: batcher._currDepthStencilStateStage, middleware: batcher._currIsMiddleware, start: batcher._middlewareIndexStart, count: batcher._middlewareIndexCount,
|
|
925
|
+
});
|
|
926
|
+
const reasonFor = (before, after, stage) => stage === ENTER_LEVEL || stage === ENTER_LEVEL_INVERTED ? 'MASK'
|
|
927
|
+
: before.stage !== after.stage ? 'STENCIL'
|
|
928
|
+
: before.material === batcher._emptyMaterial || !before.component ? lastReset
|
|
929
|
+
: before.textureHash !== after.textureHash ? 'TEXTURE'
|
|
930
|
+
: before.material !== after.material ? 'MATERIAL'
|
|
931
|
+
: before.layer !== after.layer ? 'LAYER'
|
|
932
|
+
: 'BUFFER';
|
|
933
|
+
const record = (comp, reason) => {
|
|
934
|
+
batches.push({ node: comp?.node, component: componentName(comp), reason, components: 1, members: [comp?.node] });
|
|
935
|
+
lastReset = 'STATE_RESET';
|
|
936
|
+
};
|
|
937
|
+
const merge = (comp) => {
|
|
938
|
+
const batch = batches[batches.length - 1];
|
|
939
|
+
batch.components++;
|
|
940
|
+
if (batch.members.length < 200)
|
|
941
|
+
batch.members.push(comp?.node);
|
|
942
|
+
};
|
|
943
|
+
const hooks = {
|
|
944
|
+
commitComp: original => function (comp, ...rest) {
|
|
945
|
+
const before = state();
|
|
946
|
+
const stage = comp?.stencilStage;
|
|
947
|
+
const result = original.call(this, comp, ...rest);
|
|
948
|
+
try {
|
|
949
|
+
const after = state();
|
|
950
|
+
componentCount++;
|
|
951
|
+
if (after.component === comp && before.component !== comp)
|
|
952
|
+
record(comp, reasonFor(before, after, stage));
|
|
953
|
+
else if (batches.length)
|
|
954
|
+
merge(comp);
|
|
955
|
+
}
|
|
956
|
+
catch { /* bookkeeping must never break rendering */ }
|
|
957
|
+
return result;
|
|
958
|
+
},
|
|
959
|
+
commitMiddleware: original => function (comp, ...rest) {
|
|
960
|
+
const before = state();
|
|
961
|
+
const result = original.call(this, comp, ...rest);
|
|
962
|
+
try {
|
|
963
|
+
const after = state();
|
|
964
|
+
componentCount++;
|
|
965
|
+
const merged = before.middleware && after.start === before.start && after.count === before.count + Number(rest[2]);
|
|
966
|
+
if (!merged)
|
|
967
|
+
record(comp, before.middleware && before.textureHash === after.textureHash && before.material?.hash === after.material?.hash ? 'MIDDLEWARE' : reasonFor(before, after, undefined));
|
|
968
|
+
else if (batches.length)
|
|
969
|
+
merge(comp);
|
|
970
|
+
}
|
|
971
|
+
catch { /* bookkeeping must never break rendering */ }
|
|
972
|
+
return result;
|
|
973
|
+
},
|
|
974
|
+
commitModel: original => function (comp, ...rest) {
|
|
975
|
+
// Graphics-based masks commit their stencil shape as a model.
|
|
976
|
+
const stage = comp?.stencilStage;
|
|
977
|
+
const result = original.call(this, comp, ...rest);
|
|
978
|
+
componentCount++;
|
|
979
|
+
record(comp, stage === ENTER_LEVEL || stage === ENTER_LEVEL_INVERTED ? 'MASK' : 'MODEL');
|
|
980
|
+
lastReset = 'AFTER_MODEL';
|
|
981
|
+
return result;
|
|
982
|
+
},
|
|
983
|
+
commitIA: original => function (comp, ...rest) {
|
|
984
|
+
const result = original.call(this, comp, ...rest);
|
|
985
|
+
componentCount++;
|
|
986
|
+
record(comp, 'CUSTOM_IA');
|
|
987
|
+
lastReset = 'AFTER_MODEL';
|
|
988
|
+
return result;
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
const saved = Object.keys(hooks).map(name => ({ name, own: Object.getOwnPropertyDescriptor(batcher, name) }));
|
|
992
|
+
const restore = () => {
|
|
993
|
+
for (const { name, own } of saved)
|
|
994
|
+
own ? Object.defineProperty(batcher, name, own) : delete batcher[name];
|
|
995
|
+
root.__cocosWebInspectorBatchCapture = undefined;
|
|
996
|
+
};
|
|
997
|
+
const beforeDraw = cc.Director?.EVENT_BEFORE_DRAW ?? 'director_before_draw';
|
|
998
|
+
const afterDraw = cc.Director?.EVENT_AFTER_DRAW ?? 'director_after_draw';
|
|
999
|
+
root.__cocosWebInspectorBatchCapture = true;
|
|
1000
|
+
return new Promise((resolve, reject) => {
|
|
1001
|
+
const timer = setTimeout(() => {
|
|
1002
|
+
director.off(beforeDraw, start);
|
|
1003
|
+
director.off(afterDraw, finish);
|
|
1004
|
+
restore();
|
|
1005
|
+
reject(new Error('Invalid mutation: no frame rendered within 3 s; resume the game loop'));
|
|
1006
|
+
}, 3_000);
|
|
1007
|
+
function start() {
|
|
1008
|
+
for (const name of Object.keys(hooks))
|
|
1009
|
+
if (typeof batcher[name] === 'function')
|
|
1010
|
+
batcher[name] = hooks[name](batcher[name]);
|
|
1011
|
+
}
|
|
1012
|
+
function finish() {
|
|
1013
|
+
clearTimeout(timer);
|
|
1014
|
+
restore();
|
|
1015
|
+
const pathOf = (node) => {
|
|
1016
|
+
const names = [];
|
|
1017
|
+
for (let current = node, depth = 0; current && depth < 100; current = current.parent, depth++)
|
|
1018
|
+
names.unshift(String(current.name ?? '').slice(0, 200));
|
|
1019
|
+
return `/${names.join('/')}`.slice(0, 2_000);
|
|
1020
|
+
};
|
|
1021
|
+
const reasons = {};
|
|
1022
|
+
for (const batch of batches)
|
|
1023
|
+
reasons[batch.reason] = (reasons[batch.reason] ?? 0) + 1;
|
|
1024
|
+
const device = dataProperty(dataProperty(director, '_root') ?? {}, '_device');
|
|
1025
|
+
const drawCalls = device ? dataProperty(device, '_numDrawCalls') : undefined;
|
|
1026
|
+
// Optional canvas tint: one color per batch over every node it draws, in a pointer-transparent DOM overlay.
|
|
1027
|
+
const colors = [];
|
|
1028
|
+
let tinted = 0;
|
|
1029
|
+
const document = root.document;
|
|
1030
|
+
if (tintMs && document?.body) {
|
|
1031
|
+
root.__cocosWebInspectorBatchOverlay?.remove();
|
|
1032
|
+
clearTimeout(root.__cocosWebInspectorBatchTimer);
|
|
1033
|
+
const overlay = document.createElement('div');
|
|
1034
|
+
Object.assign(overlay.style, { position: 'fixed', inset: '0', pointerEvents: 'none', zIndex: '2147483647' });
|
|
1035
|
+
batches.slice(0, limit).forEach((batch, index) => {
|
|
1036
|
+
const hue = Math.round(index * 137.508) % 360;
|
|
1037
|
+
colors[index] = `hsl(${hue}, 90%, 55%)`;
|
|
1038
|
+
batch.members.forEach((member, position) => {
|
|
1039
|
+
if (tinted >= 1_000 || !member)
|
|
1040
|
+
return;
|
|
1041
|
+
const bounds = nodeBounds(member);
|
|
1042
|
+
if (!bounds.available || !bounds.visible)
|
|
1043
|
+
return;
|
|
1044
|
+
const box = document.createElement('div');
|
|
1045
|
+
Object.assign(box.style, {
|
|
1046
|
+
position: 'absolute', boxSizing: 'border-box', border: `2px solid ${colors[index]}`, background: `hsla(${hue}, 90%, 55%, 0.25)`,
|
|
1047
|
+
left: `${bounds.viewport.x}px`, top: `${bounds.viewport.y}px`, width: `${bounds.viewport.width}px`, height: `${bounds.viewport.height}px`,
|
|
1048
|
+
});
|
|
1049
|
+
if (position === 0) {
|
|
1050
|
+
const label = document.createElement('span');
|
|
1051
|
+
Object.assign(label.style, { position: 'absolute', left: '0', top: '0', font: '11px/1.3 monospace', color: '#111', background: colors[index], padding: '0 3px', whiteSpace: 'nowrap' });
|
|
1052
|
+
label.textContent = `#${index} ${batch.reason}`;
|
|
1053
|
+
box.appendChild(label);
|
|
1054
|
+
}
|
|
1055
|
+
overlay.appendChild(box);
|
|
1056
|
+
tinted++;
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
document.body.appendChild(overlay);
|
|
1060
|
+
root.__cocosWebInspectorBatchOverlay = overlay;
|
|
1061
|
+
root.__cocosWebInspectorBatchTimer = setTimeout(() => {
|
|
1062
|
+
overlay.remove();
|
|
1063
|
+
if (root.__cocosWebInspectorBatchOverlay === overlay)
|
|
1064
|
+
root.__cocosWebInspectorBatchOverlay = undefined;
|
|
1065
|
+
}, Math.min(Math.max(tintMs, 100), 30_000));
|
|
1066
|
+
}
|
|
1067
|
+
resolve({
|
|
1068
|
+
version,
|
|
1069
|
+
batchCount: batches.length,
|
|
1070
|
+
componentCount,
|
|
1071
|
+
...(typeof drawCalls === 'number' ? { drawCalls } : {}),
|
|
1072
|
+
reasons,
|
|
1073
|
+
batches: batches.slice(0, limit).map((batch, index) => ({
|
|
1074
|
+
index,
|
|
1075
|
+
node: { uuid: String(batch.node?.uuid ?? ''), name: String(batch.node?.name ?? '').slice(0, 500), path: pathOf(batch.node) },
|
|
1076
|
+
component: batch.component,
|
|
1077
|
+
reason: batch.reason,
|
|
1078
|
+
components: batch.components,
|
|
1079
|
+
...(colors[index] ? { color: colors[index] } : {}),
|
|
1080
|
+
})),
|
|
1081
|
+
...(tintMs ? { tinted } : {}),
|
|
1082
|
+
truncated: batches.length > limit,
|
|
1083
|
+
truncationReasons: batches.length > limit ? ['NODE_LIMIT'] : [],
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
director.once(beforeDraw, start);
|
|
1087
|
+
director.once(afterDraw, finish);
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
799
1090
|
if (request.action === 'setNodeActive') {
|
|
800
1091
|
const node = findByUuid(request.uuid);
|
|
801
1092
|
const before = node.active !== false;
|
|
@@ -868,6 +1159,535 @@ export function inspectCocos(request) {
|
|
|
868
1159
|
const after = safe(component[request.key]);
|
|
869
1160
|
return { changed: JSON.stringify(before) !== JSON.stringify(after), target: { nodeUuid: request.uuid, componentUuid: request.componentUuid }, before: { value: before }, after: { value: after }, runtimeOnly: true };
|
|
870
1161
|
}
|
|
1162
|
+
// Asset helpers read backing fields only; refCount, width, and texture getters stay untouched.
|
|
1163
|
+
const assetName = (asset) => String(dataProperty(asset, '_name') ?? '').slice(0, 500);
|
|
1164
|
+
const assetUuid = (asset) => String(dataProperty(asset, '_uuid') ?? '');
|
|
1165
|
+
const gfxBytes = (texture) => {
|
|
1166
|
+
const gfx = texture && typeof texture === 'object' ? dataProperty(texture, '_gfxTexture') : undefined;
|
|
1167
|
+
const size = gfx && typeof gfx === 'object' ? dataProperty(gfx, '_size') : undefined;
|
|
1168
|
+
return typeof size === 'number' && Number.isFinite(size) ? size : undefined;
|
|
1169
|
+
};
|
|
1170
|
+
// Every renderer in the scene, with the assets it points at; packed frames keep their own texture under _original.
|
|
1171
|
+
const sceneAssetUsers = () => {
|
|
1172
|
+
const users = new Map();
|
|
1173
|
+
const textureOwners = new Map();
|
|
1174
|
+
const add = (asset, path) => {
|
|
1175
|
+
const uuid = asset && typeof asset === 'object' ? assetUuid(asset) : '';
|
|
1176
|
+
if (!uuid)
|
|
1177
|
+
return;
|
|
1178
|
+
const list = users.get(uuid) ?? [];
|
|
1179
|
+
if (list.length < 5)
|
|
1180
|
+
list.push(path);
|
|
1181
|
+
users.set(uuid, list);
|
|
1182
|
+
};
|
|
1183
|
+
const addFrame = (frame, path) => {
|
|
1184
|
+
if (!frame || typeof frame !== 'object')
|
|
1185
|
+
return;
|
|
1186
|
+
add(frame, path);
|
|
1187
|
+
const texture = dataProperty(dataProperty(frame, '_original') ?? {}, '_texture') ?? dataProperty(frame, '_texture');
|
|
1188
|
+
add(texture, path);
|
|
1189
|
+
if (texture && typeof texture === 'object' && !textureOwners.has(texture))
|
|
1190
|
+
textureOwners.set(texture, path);
|
|
1191
|
+
};
|
|
1192
|
+
const traversal = walk((node, path) => {
|
|
1193
|
+
// Instantiated prefabs keep their source asset on every node's PrefabInfo.
|
|
1194
|
+
add(dataProperty(dataProperty(node, '_prefab') ?? {}, 'asset'), path);
|
|
1195
|
+
for (const component of components(node)) {
|
|
1196
|
+
addFrame(dataProperty(component, '_spriteFrame'), path);
|
|
1197
|
+
addFrame(dataProperty(component, '_ttfSpriteFrame'), path);
|
|
1198
|
+
add(dataProperty(component, '_font'), path);
|
|
1199
|
+
for (const material of [...(dataProperty(component, '_materials') ?? []), dataProperty(component, '_customMaterial')])
|
|
1200
|
+
add(material, path);
|
|
1201
|
+
add(dataProperty(component, '_skeletonData'), path);
|
|
1202
|
+
add(dataProperty(component, '_clip'), path);
|
|
1203
|
+
for (const clip of dataProperty(component, '_clips') ?? [])
|
|
1204
|
+
add(clip, path);
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
return { users, textureOwners, truncated: traversal.truncated };
|
|
1208
|
+
};
|
|
1209
|
+
if (request.action === 'callMethod') {
|
|
1210
|
+
const node = findByUuid(request.uuid);
|
|
1211
|
+
const target = request.componentUuid ? componentByUuid(node, request.componentUuid) : node;
|
|
1212
|
+
const name = request.method;
|
|
1213
|
+
// Engine and game internals, constructors, and prototype plumbing are not callable; secret-like names stay hidden like properties.
|
|
1214
|
+
if (name.startsWith('_') || isSensitiveKey(name) || ['constructor', 'destroy', '__proto__', 'prototype', 'toString', 'valueOf'].includes(name))
|
|
1215
|
+
invalidMutation(`method ${name} is not callable`);
|
|
1216
|
+
let owner = target;
|
|
1217
|
+
let descriptor;
|
|
1218
|
+
for (let depth = 0; owner && owner !== Object.prototype && depth < 20 && !descriptor; depth++, owner = Object.getPrototypeOf(owner))
|
|
1219
|
+
descriptor = Object.getOwnPropertyDescriptor(owner, name);
|
|
1220
|
+
if (!descriptor || typeof descriptor.value !== 'function')
|
|
1221
|
+
return invalidMutation(`${name} is not a method of ${target === node ? 'Node' : componentName(target)}`);
|
|
1222
|
+
const resolve = (value, depth = 0) => {
|
|
1223
|
+
if (depth > 10)
|
|
1224
|
+
invalidMutation('argument nesting is too deep');
|
|
1225
|
+
if (Array.isArray(value))
|
|
1226
|
+
return value.map(item => resolve(item, depth + 1));
|
|
1227
|
+
if (!value || typeof value !== 'object')
|
|
1228
|
+
return value;
|
|
1229
|
+
const keys = Object.keys(value);
|
|
1230
|
+
if (keys.length === 1 && typeof value.$node === 'string')
|
|
1231
|
+
return findByUuid(value.$node);
|
|
1232
|
+
if (keys.length === 1 && typeof value.$path === 'string') {
|
|
1233
|
+
const found = [];
|
|
1234
|
+
walk((candidate, path) => { if (path === value.$path)
|
|
1235
|
+
found.push(candidate); return found.length > 1 ? false : undefined; });
|
|
1236
|
+
if (found.length !== 1)
|
|
1237
|
+
invalidMutation(found.length ? `path ${value.$path} matches several nodes` : `no node at ${value.$path}`);
|
|
1238
|
+
return found[0];
|
|
1239
|
+
}
|
|
1240
|
+
if (keys.length === 1 && typeof value.$component === 'string') {
|
|
1241
|
+
let found;
|
|
1242
|
+
walk(candidate => { found = components(candidate).find(component => component?.uuid === value.$component); return found ? false : undefined; });
|
|
1243
|
+
if (!found)
|
|
1244
|
+
throw new Error('Component not found');
|
|
1245
|
+
return found;
|
|
1246
|
+
}
|
|
1247
|
+
if (keys.length === 1 && typeof value.$asset === 'string') {
|
|
1248
|
+
const asset = cc.assetManager?.assets?.get?.(value.$asset);
|
|
1249
|
+
if (!asset)
|
|
1250
|
+
invalidMutation(`asset ${value.$asset} is not loaded`);
|
|
1251
|
+
return asset;
|
|
1252
|
+
}
|
|
1253
|
+
return Object.fromEntries(keys.map(key => [key, resolve(value[key], depth + 1)]));
|
|
1254
|
+
};
|
|
1255
|
+
const args = request.args.map(arg => resolve(arg));
|
|
1256
|
+
const serializer = createSerializer(Math.min(Math.max(request.maxDepth ?? 2, 0), 6));
|
|
1257
|
+
const describe = (value) => value === undefined ? { type: 'undefined' } : { value: serializer.serialize(value, 0) ?? null };
|
|
1258
|
+
const report = (outcome) => ({ called: true, target: { nodeUuid: request.uuid, ...(request.componentUuid ? { componentUuid: request.componentUuid } : {}) }, method: name, ...outcome, serialization: serializer.stats(), runtimeOnly: true });
|
|
1259
|
+
let result;
|
|
1260
|
+
try {
|
|
1261
|
+
result = descriptor.value.apply(target, args);
|
|
1262
|
+
}
|
|
1263
|
+
catch (error) {
|
|
1264
|
+
return report({ threw: { name: String(error?.name ?? 'Error').slice(0, 200), message: String(error?.message ?? error).slice(0, 2_000) } });
|
|
1265
|
+
}
|
|
1266
|
+
if (!result || typeof result.then !== 'function' || !request.awaitMs)
|
|
1267
|
+
return report({ result: describe(result), ...(result && typeof result.then === 'function' ? { pending: true } : {}) });
|
|
1268
|
+
return Promise.race([
|
|
1269
|
+
Promise.resolve(result).then(value => report({ result: describe(value), awaited: true }), error => report({ awaited: true, threw: { name: String(error?.name ?? 'Error').slice(0, 200), message: String(error?.message ?? error).slice(0, 2_000) } })),
|
|
1270
|
+
new Promise(resolve => setTimeout(() => resolve(report({ pending: true, timedOut: true })), request.awaitMs)),
|
|
1271
|
+
]);
|
|
1272
|
+
}
|
|
1273
|
+
// CCObject._objFlags bit 0 is Destroyed (isValid reads it). A node outside the scene is only detached: pooled nodes are, legitimately.
|
|
1274
|
+
const deadState = (value) => {
|
|
1275
|
+
if (!value || typeof value !== 'object')
|
|
1276
|
+
return undefined;
|
|
1277
|
+
const flags = dataProperty(value, '_objFlags');
|
|
1278
|
+
// Destroyed = 1 << 0; ToDestroy = 1 << 2 is set by destroy() until the end of the frame.
|
|
1279
|
+
if (typeof flags === 'number' && flags & 5)
|
|
1280
|
+
return 'destroyed';
|
|
1281
|
+
const owner = dataProperty(value, 'node') ?? value;
|
|
1282
|
+
if (owner && typeof owner === 'object' && typeof cc.Node === 'function' && owner instanceof cc.Node) {
|
|
1283
|
+
const ownerFlags = dataProperty(owner, '_objFlags');
|
|
1284
|
+
if (typeof ownerFlags === 'number' && ownerFlags & 5)
|
|
1285
|
+
return 'destroyed';
|
|
1286
|
+
let top = owner;
|
|
1287
|
+
for (let depth = 0; top?.parent && depth < 200; depth++)
|
|
1288
|
+
top = top.parent;
|
|
1289
|
+
if (top !== scene && !(cc.director?.isPersistRootNode?.(top)))
|
|
1290
|
+
return 'detached';
|
|
1291
|
+
}
|
|
1292
|
+
return undefined;
|
|
1293
|
+
};
|
|
1294
|
+
const describeTarget = (value) => {
|
|
1295
|
+
if (!value || typeof value !== 'object')
|
|
1296
|
+
return { type: typeof value };
|
|
1297
|
+
const node = typeof cc.Node === 'function' && value instanceof cc.Node ? value : dataProperty(value, 'node');
|
|
1298
|
+
const flags = dataProperty(value, '_objFlags');
|
|
1299
|
+
return {
|
|
1300
|
+
type: node === value ? 'Node' : componentName(value),
|
|
1301
|
+
...(node && typeof node === 'object' ? { node: String(dataProperty(node, '_name') ?? '').slice(0, 200), nodeUuid: String(dataProperty(node, '_id') ?? '') } : {}),
|
|
1302
|
+
...(typeof flags === 'number' && flags & 5 ? { destroyed: true } : {}),
|
|
1303
|
+
};
|
|
1304
|
+
};
|
|
1305
|
+
if (request.action === 'explainClick') {
|
|
1306
|
+
// Mirrors PointerEventDispatcher: processors sorted by camera priority, then hierarchy; the first whose UITransform.hitTest
|
|
1307
|
+
// passes claims the touch, and every later one never sees it. Reads state only; dispatches nothing.
|
|
1308
|
+
const target = request.uuid ? findByUuid(request.uuid) : undefined;
|
|
1309
|
+
let point = request.x !== undefined && request.y !== undefined ? { x: request.x, y: request.y } : undefined;
|
|
1310
|
+
const reasons = [];
|
|
1311
|
+
if (target) {
|
|
1312
|
+
const bounds = nodeBounds(target);
|
|
1313
|
+
if (!bounds.available)
|
|
1314
|
+
reasons.push(bounds.reason);
|
|
1315
|
+
else if (!point)
|
|
1316
|
+
point = { x: bounds.clippedViewport.x + bounds.clippedViewport.width / 2, y: bounds.clippedViewport.y + bounds.clippedViewport.height / 2 };
|
|
1317
|
+
if (target.activeInHierarchy === false)
|
|
1318
|
+
reasons.push('INACTIVE');
|
|
1319
|
+
if (bounds.available && bounds.outsideViewport)
|
|
1320
|
+
reasons.push('OUTSIDE_VIEWPORT');
|
|
1321
|
+
const size = dataProperty(components(target).find(component => componentName(component) === 'UITransform') ?? {}, '_contentSize');
|
|
1322
|
+
if (size && (Number(size.width) === 0 || Number(size.height) === 0))
|
|
1323
|
+
reasons.push('ZERO_SIZE');
|
|
1324
|
+
const button = components(target).find(component => componentName(component) === 'Button');
|
|
1325
|
+
if (button && dataProperty(button, '_interactable') === false)
|
|
1326
|
+
reasons.push('BUTTON_NOT_INTERACTABLE');
|
|
1327
|
+
if (button && dataProperty(button, '_enabled') === false)
|
|
1328
|
+
reasons.push('BUTTON_DISABLED');
|
|
1329
|
+
const processor = dataProperty(target, '_eventProcessor');
|
|
1330
|
+
const listens = !!processor && (dataProperty(processor, 'shouldHandleEventTouch') === true || dataProperty(processor, 'shouldHandleEventMouse') === true);
|
|
1331
|
+
if (!listens && !button)
|
|
1332
|
+
reasons.push('NO_TOUCH_LISTENER');
|
|
1333
|
+
}
|
|
1334
|
+
if (!point)
|
|
1335
|
+
return { version, ...(target ? { target: { ...summary(target) } } : {}), clickable: false, reasons: reasons.length ? reasons : ['NO_POINT'] };
|
|
1336
|
+
const canvas = cc.game?.canvas ?? root.document?.querySelector('#GameCanvas');
|
|
1337
|
+
if (typeof HTMLCanvasElement === 'undefined' || !(canvas instanceof HTMLCanvasElement))
|
|
1338
|
+
return { version, clickable: false, reasons: [...reasons, 'NO_CANVAS'] };
|
|
1339
|
+
const rect = canvas.getBoundingClientRect();
|
|
1340
|
+
// Same conversion as the web touch input: canvas-relative, y up, scaled by the engine's device pixel ratio.
|
|
1341
|
+
const dpr = Number(cc.screen?.devicePixelRatio ?? root.devicePixelRatio ?? 1);
|
|
1342
|
+
const screenPoint = cc.Vec2 ? new cc.Vec2((point.x - rect.left) * dpr, (rect.top + rect.height - point.y) * dpr) : undefined;
|
|
1343
|
+
// The global cc namespace does not expose the input singleton, so rebuild the dispatcher's order from the scene:
|
|
1344
|
+
// higher camera priority first, then reverse pre-order (later siblings and descendants before what they cover).
|
|
1345
|
+
const candidates = [];
|
|
1346
|
+
let order = 0;
|
|
1347
|
+
walk(node => {
|
|
1348
|
+
order++;
|
|
1349
|
+
const processor = dataProperty(node, '_eventProcessor');
|
|
1350
|
+
if (!processor || dataProperty(processor, '_isEnabled') !== true || dataProperty(processor, 'shouldHandleEventTouch') !== true || node.activeInHierarchy === false)
|
|
1351
|
+
return;
|
|
1352
|
+
// cachedCameraPriority is refreshed only on real dispatch, so ask the batcher, as UITransform.cameraPriority does.
|
|
1353
|
+
let camera = 0;
|
|
1354
|
+
try {
|
|
1355
|
+
camera = Number(cc.director?.root?.batcher2D?.getFirstRenderCamera?.(node)?.priority ?? 0);
|
|
1356
|
+
}
|
|
1357
|
+
catch {
|
|
1358
|
+
camera = 0;
|
|
1359
|
+
}
|
|
1360
|
+
candidates.push({ node, order, camera });
|
|
1361
|
+
});
|
|
1362
|
+
const sorted = candidates.sort((a, b) => b.camera - a.camera || b.order - a.order).map(candidate => candidate.node);
|
|
1363
|
+
const hits = [];
|
|
1364
|
+
let claimer;
|
|
1365
|
+
for (const node of sorted) {
|
|
1366
|
+
const transform = components(node).find(component => componentName(component) === 'UITransform');
|
|
1367
|
+
let hit = false;
|
|
1368
|
+
// hitTest is the engine's own public query: matrix math and Mask checks, no state change.
|
|
1369
|
+
try {
|
|
1370
|
+
hit = !!screenPoint && typeof transform?.hitTest === 'function' && transform.hitTest(screenPoint, 0);
|
|
1371
|
+
}
|
|
1372
|
+
catch {
|
|
1373
|
+
hit = false;
|
|
1374
|
+
}
|
|
1375
|
+
if (!hit)
|
|
1376
|
+
continue;
|
|
1377
|
+
const types = components(node).map(componentName);
|
|
1378
|
+
hits.push({ uuid: String(node.uuid ?? ''), name: String(node.name ?? '').slice(0, 200), components: types.slice(0, 20), ...(types.includes('BlockInputEvents') ? { blocksInput: true } : {}) });
|
|
1379
|
+
if (!claimer)
|
|
1380
|
+
claimer = node;
|
|
1381
|
+
if (hits.length >= 10)
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
1384
|
+
if (target) {
|
|
1385
|
+
// A claim by an ancestor still reaches the target only if the target itself was hit first; bubbling goes child to parent.
|
|
1386
|
+
const targetHit = hits.findIndex(hit => hit.uuid === target.uuid);
|
|
1387
|
+
if (claimer && claimer !== target) {
|
|
1388
|
+
// Touch events bubble from the claiming node up to its ancestors, so the target still receives one claimed by a descendant.
|
|
1389
|
+
let reaches = false;
|
|
1390
|
+
for (let current = claimer.parent, depth = 0; current && depth < 200; current = current.parent, depth++)
|
|
1391
|
+
if (current === target)
|
|
1392
|
+
reaches = true;
|
|
1393
|
+
if (!reaches)
|
|
1394
|
+
reasons.push(hits[0]?.blocksInput ? 'BLOCKED_BY_BLOCK_INPUT_EVENTS' : 'COVERED_BY_OTHER_NODE');
|
|
1395
|
+
}
|
|
1396
|
+
if (targetHit === -1 && !claimer && reasons.length === 0)
|
|
1397
|
+
reasons.push('HIT_TEST_FAILED');
|
|
1398
|
+
// Inside a Mask whose shape excludes the point, hitTest fails though the box contains it.
|
|
1399
|
+
if (targetHit === -1 && !reasons.length)
|
|
1400
|
+
reasons.push('MASKED_OR_OUTSIDE_HIT_AREA');
|
|
1401
|
+
}
|
|
1402
|
+
return {
|
|
1403
|
+
version,
|
|
1404
|
+
point,
|
|
1405
|
+
...(target ? { target: summary(target) } : {}),
|
|
1406
|
+
claimedBy: claimer ? { uuid: String(claimer.uuid ?? ''), name: String(claimer.name ?? '').slice(0, 200), path: (() => { const names = []; for (let current = claimer, depth = 0; current && depth < 100; current = current.parent, depth++)
|
|
1407
|
+
names.unshift(String(current.name ?? '')); return `/${names.join('/')}`; })() } : null,
|
|
1408
|
+
hitStack: hits,
|
|
1409
|
+
clickable: target ? reasons.length === 0 : !!claimer,
|
|
1410
|
+
reasons,
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
if (request.action === 'listenerReport') {
|
|
1414
|
+
// ponytail: reads Scheduler._hashForTimers/_hashForUpdates, the tween system's actionMgr._hashTargets, and CallbacksInvoker._callbackTable, verified on 3.7.4-3.8.8.
|
|
1415
|
+
// The engine purges callbacks whose target is a destroyed Cocos object (listeners on next emit, tweens next frame, component timers on destroy).
|
|
1416
|
+
// It can never purge a callback whose target is not a Cocos object: an arrow function or bind(this) registered per popup open keeps
|
|
1417
|
+
// the popup alive and runs again, so those are grouped by event and name; growth between two calls is the leak signal.
|
|
1418
|
+
const limit = Math.min(Math.max(request.limit ?? 100, 1), 500);
|
|
1419
|
+
const dead = [];
|
|
1420
|
+
const unowned = new Map();
|
|
1421
|
+
const counts = {};
|
|
1422
|
+
const isCocosObject = (value) => !!value && typeof value === 'object' && typeof dataProperty(value, '_objFlags') === 'number';
|
|
1423
|
+
const note = (kind, owner, detail) => {
|
|
1424
|
+
const bucket = counts[kind] ??= { total: 0, destroyed: 0, detached: 0, unowned: 0 };
|
|
1425
|
+
bucket.total++;
|
|
1426
|
+
if (!isCocosObject(owner)) {
|
|
1427
|
+
bucket.unowned++;
|
|
1428
|
+
const targetType = owner === undefined || owner === null ? 'none' : String(dataProperty(owner, 'constructor')?.name || typeof owner).slice(0, 80);
|
|
1429
|
+
const callback = detail.callback ?? '';
|
|
1430
|
+
const key = `${kind}|${detail.event ?? ''}|${callback}|${targetType}`;
|
|
1431
|
+
const group = unowned.get(key) ?? { kind, ...(detail.event ? { event: detail.event } : {}), callback, targetType, count: 0 };
|
|
1432
|
+
group.count++;
|
|
1433
|
+
unowned.set(key, group);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
const state = deadState(owner);
|
|
1437
|
+
if (!state)
|
|
1438
|
+
return;
|
|
1439
|
+
bucket[state]++;
|
|
1440
|
+
if (dead.length < limit)
|
|
1441
|
+
dead.push({ kind, state, ...detail, target: describeTarget(owner) });
|
|
1442
|
+
};
|
|
1443
|
+
const nameOf = (callback) => String(callback?.name || '(anonymous)').replace(/^bound /, 'bound:').slice(0, 120);
|
|
1444
|
+
const scheduler = cc.director?.getScheduler?.() ?? dataProperty(cc.director ?? {}, '_scheduler');
|
|
1445
|
+
for (const entry of Object.values(dataProperty(scheduler ?? {}, '_hashForTimers') ?? {})) {
|
|
1446
|
+
for (const timer of (entry?.timers ?? []))
|
|
1447
|
+
note('schedule', entry?.target, { callback: nameOf(dataProperty(timer, '_callback')), interval: dataProperty(timer, '_interval') ?? null });
|
|
1448
|
+
}
|
|
1449
|
+
for (const entry of Object.values(dataProperty(scheduler ?? {}, '_hashForUpdates') ?? {}))
|
|
1450
|
+
note('scheduleUpdate', entry?.target, { callback: 'update' });
|
|
1451
|
+
// The tween system is not exported on cc; find it among the director's registered systems.
|
|
1452
|
+
const systems = (dataProperty(cc.director ?? {}, '_systems') ?? []);
|
|
1453
|
+
const tweens = dataProperty(dataProperty(systems.find(system => system && dataProperty(system, 'actionMgr')) ?? {}, 'actionMgr') ?? {}, '_hashTargets');
|
|
1454
|
+
if (tweens instanceof Map)
|
|
1455
|
+
for (const [target, element] of tweens)
|
|
1456
|
+
note('tween', target, { callback: `${(element?.actions ?? []).length} action(s)` });
|
|
1457
|
+
const emitters = [['director', cc.director], ['game', cc.game], ['view', cc.view], ['systemEvent', cc.systemEvent]];
|
|
1458
|
+
for (const [emitter, value] of emitters) {
|
|
1459
|
+
const table = value && typeof value === 'object' ? dataProperty(value, '_callbackTable') : undefined;
|
|
1460
|
+
for (const [event, list] of Object.entries(table ?? {})) {
|
|
1461
|
+
for (const info of (list?.callbackInfos ?? []))
|
|
1462
|
+
if (info)
|
|
1463
|
+
note(`${emitter}.on`, info.target, { event: event.slice(0, 120), callback: nameOf(info.callback) });
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const groups = [...unowned.values()].sort((a, b) => b.count - a.count);
|
|
1467
|
+
return {
|
|
1468
|
+
version,
|
|
1469
|
+
counts,
|
|
1470
|
+
destroyedCount: Object.values(counts).reduce((sum, bucket) => sum + bucket.destroyed, 0),
|
|
1471
|
+
detachedCount: Object.values(counts).reduce((sum, bucket) => sum + bucket.detached, 0),
|
|
1472
|
+
unownedCount: Object.values(counts).reduce((sum, bucket) => sum + bucket.unowned, 0),
|
|
1473
|
+
// Destroyed targets first; detached ones may be pooled nodes waiting for reuse.
|
|
1474
|
+
dead: dead.sort((a, b) => Number(b.state === 'destroyed') - Number(a.state === 'destroyed')),
|
|
1475
|
+
unowned: groups.slice(0, limit),
|
|
1476
|
+
truncated: dead.length >= limit || groups.length > limit,
|
|
1477
|
+
truncationReasons: dead.length >= limit || groups.length > limit ? ['NODE_LIMIT'] : [],
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
if (request.action === 'timeScale') {
|
|
1481
|
+
// Scales the dt every director tick passes to components, systems (scheduler, tweens, animation, physics), and rendering.
|
|
1482
|
+
const director = cc.director;
|
|
1483
|
+
if (typeof director?.tick !== 'function')
|
|
1484
|
+
invalidMutation('director.tick is unavailable');
|
|
1485
|
+
const current = root.__cocosWebInspectorTimeScale;
|
|
1486
|
+
const before = current?.scale ?? 1;
|
|
1487
|
+
if (request.scale !== undefined) {
|
|
1488
|
+
if (!finite(request.scale) || request.scale <= 0 || request.scale > 100)
|
|
1489
|
+
invalidMutation('scale must be in (0, 100]');
|
|
1490
|
+
if (request.scale === 1) {
|
|
1491
|
+
// tick normally lives on the prototype: drop the own wrapper, or put back an own method that was there before.
|
|
1492
|
+
if (current) {
|
|
1493
|
+
if (current.own)
|
|
1494
|
+
director.tick = current.original;
|
|
1495
|
+
else
|
|
1496
|
+
delete director.tick;
|
|
1497
|
+
root.__cocosWebInspectorTimeScale = undefined;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
else if (current)
|
|
1501
|
+
current.scale = request.scale;
|
|
1502
|
+
else {
|
|
1503
|
+
const original = director.tick;
|
|
1504
|
+
const state = { scale: request.scale, original, own: Object.prototype.hasOwnProperty.call(director, 'tick') };
|
|
1505
|
+
root.__cocosWebInspectorTimeScale = state;
|
|
1506
|
+
director.tick = function (dt) { return original.call(this, dt * state.scale); };
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
return { changed: before !== (root.__cocosWebInspectorTimeScale?.scale ?? 1), target: {}, before: { scale: before }, after: { scale: root.__cocosWebInspectorTimeScale?.scale ?? 1 }, runtimeOnly: true };
|
|
1510
|
+
}
|
|
1511
|
+
if (request.action === 'dynamicAtlas') {
|
|
1512
|
+
const manager = cc.internal?.dynamicAtlasManager ?? cc.dynamicAtlasManager;
|
|
1513
|
+
if (!manager || typeof manager !== 'object')
|
|
1514
|
+
return { version, available: false, reason: 'UNSUPPORTED_PUBLIC_API' };
|
|
1515
|
+
// ponytail: reads private DynamicAtlasManager/Atlas fields verified on 3.7.4-3.8.8; recheck on new engine versions.
|
|
1516
|
+
const limit = Math.min(Math.max(request.limit ?? 100, 1), 500);
|
|
1517
|
+
const atlases = (dataProperty(manager, '_atlases') ?? []);
|
|
1518
|
+
const config = {
|
|
1519
|
+
enabled: dataProperty(manager, '_enabled') === true,
|
|
1520
|
+
maxAtlasCount: dataProperty(manager, '_maxAtlasCount'),
|
|
1521
|
+
textureSize: dataProperty(manager, '_textureSize'),
|
|
1522
|
+
maxFrameSize: dataProperty(manager, '_maxFrameSize'),
|
|
1523
|
+
textureBleeding: dataProperty(manager, '_textureBleeding'),
|
|
1524
|
+
};
|
|
1525
|
+
const owners = sceneAssetUsers().textureOwners;
|
|
1526
|
+
let frameTotal = 0;
|
|
1527
|
+
const report = atlases.slice(0, 20).map((atlas, index) => {
|
|
1528
|
+
const width = Number(dataProperty(atlas, '_width') ?? 0);
|
|
1529
|
+
const height = Number(dataProperty(atlas, '_height') ?? 0);
|
|
1530
|
+
const infos = (dataProperty(atlas, '_innerTextureInfos') ?? {});
|
|
1531
|
+
const frames = (dataProperty(atlas, '_innerSpriteFrames') ?? []).filter(Boolean);
|
|
1532
|
+
frameTotal += frames.length;
|
|
1533
|
+
let usedArea = 0;
|
|
1534
|
+
const textures = Object.values(infos).map(info => {
|
|
1535
|
+
const texture = info?.texture;
|
|
1536
|
+
const w = Number(dataProperty(texture, '_width') ?? 0);
|
|
1537
|
+
const h = Number(dataProperty(texture, '_height') ?? 0);
|
|
1538
|
+
usedArea += w * h;
|
|
1539
|
+
const owner = owners.get(texture);
|
|
1540
|
+
return { name: assetName(texture), uuid: assetUuid(texture), ...(assetUuid(texture) ? {} : { runtime: true }), ...(owner ? { usedBy: owner } : {}), x: Number(info?.x ?? 0), y: Number(info?.y ?? 0), width: w, height: h };
|
|
1541
|
+
});
|
|
1542
|
+
const nextY = Number(dataProperty(atlas, '_nextY') ?? 0);
|
|
1543
|
+
return {
|
|
1544
|
+
index,
|
|
1545
|
+
width, height,
|
|
1546
|
+
textureCount: textures.length,
|
|
1547
|
+
frameCount: frames.length,
|
|
1548
|
+
fill: width * height > 0 ? Math.round(usedArea / (width * height) * 1_000) / 1_000 : 0,
|
|
1549
|
+
shelfFill: height > 0 ? Math.round(Math.min(nextY / height, 1) * 1_000) / 1_000 : 0,
|
|
1550
|
+
bytes: gfxBytes(dataProperty(atlas, '_texture')),
|
|
1551
|
+
textures: textures.slice(0, limit),
|
|
1552
|
+
truncated: textures.length > limit,
|
|
1553
|
+
};
|
|
1554
|
+
});
|
|
1555
|
+
// Why visible sprites stayed out: the same checks insertSpriteFrame and SpriteFrame._checkPackable make.
|
|
1556
|
+
const excluded = {};
|
|
1557
|
+
const examples = [];
|
|
1558
|
+
let packed = 0;
|
|
1559
|
+
walk((node, path) => {
|
|
1560
|
+
if (node.activeInHierarchy === false)
|
|
1561
|
+
return;
|
|
1562
|
+
for (const component of components(node)) {
|
|
1563
|
+
const frame = dataProperty(component, '_spriteFrame');
|
|
1564
|
+
if (!frame || typeof frame !== 'object')
|
|
1565
|
+
continue;
|
|
1566
|
+
if (dataProperty(frame, '_original')) {
|
|
1567
|
+
packed++;
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
const texture = dataProperty(frame, '_texture');
|
|
1571
|
+
const rect = dataProperty(frame, '_rect');
|
|
1572
|
+
const sampler = texture ? dataProperty(texture, '_samplerInfo') : undefined;
|
|
1573
|
+
// isCompressed is a pure format-range check on TextureBase; it reads only _format.
|
|
1574
|
+
let compressed = false;
|
|
1575
|
+
try {
|
|
1576
|
+
compressed = !!texture && texture.isCompressed === true;
|
|
1577
|
+
}
|
|
1578
|
+
catch { /* treat as uncompressed */ }
|
|
1579
|
+
const reason = !config.enabled ? 'DISABLED'
|
|
1580
|
+
: !texture || componentName(texture) === 'RenderTexture' ? 'NOT_TEXTURE2D'
|
|
1581
|
+
: compressed ? 'COMPRESSED'
|
|
1582
|
+
: rect && (Number(rect.width) > Number(config.maxFrameSize) || Number(rect.height) > Number(config.maxFrameSize)) ? 'TOO_LARGE'
|
|
1583
|
+
: dataProperty(frame, '_packable') === false ? 'NOT_PACKABLE'
|
|
1584
|
+
: sampler && (sampler.minFilter !== 2 || sampler.magFilter !== 2 || sampler.mipFilter !== 0) ? 'FILTER'
|
|
1585
|
+
: atlases.length >= Number(config.maxAtlasCount) ? 'ATLAS_FULL'
|
|
1586
|
+
: 'NOT_YET_RENDERED';
|
|
1587
|
+
excluded[reason] = (excluded[reason] ?? 0) + 1;
|
|
1588
|
+
if (examples.length < limit)
|
|
1589
|
+
examples.push({ path, frame: assetName(frame), reason });
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
return { version, available: true, config, atlasCount: atlases.length, frameTotal, packedInScene: packed, atlases: report, excluded, excludedExamples: examples };
|
|
1593
|
+
}
|
|
1594
|
+
if (request.action === 'assetReport') {
|
|
1595
|
+
const cache = cc.assetManager?.assets;
|
|
1596
|
+
const map = cache && typeof cache === 'object' ? dataProperty(cache, '_map') : undefined;
|
|
1597
|
+
if (!map || typeof map !== 'object')
|
|
1598
|
+
return { version, available: false, reason: 'UNSUPPORTED_PUBLIC_API' };
|
|
1599
|
+
// ponytail: reads Cache._map, Asset._ref, and texture backing fields; one call is a snapshot, so leaks show as growth between two calls.
|
|
1600
|
+
const limit = Math.min(Math.max(request.limit ?? 100, 1), 500);
|
|
1601
|
+
const { users, truncated: sceneTruncated } = sceneAssetUsers();
|
|
1602
|
+
// An asset is in use when a live renderer, the scene asset, or a persist-root node reaches it through the loaded dependency graph.
|
|
1603
|
+
const depends = dataProperty(dataProperty(cc.assetManager, 'dependUtil') ?? {}, '_depends');
|
|
1604
|
+
const dependMap = depends ? dataProperty(depends, '_map') ?? {} : {};
|
|
1605
|
+
const reachable = new Map();
|
|
1606
|
+
const reach = (uuid, via) => {
|
|
1607
|
+
const stack = [uuid];
|
|
1608
|
+
while (stack.length && reachable.size < 100_000) {
|
|
1609
|
+
const current = stack.pop();
|
|
1610
|
+
if (reachable.has(current))
|
|
1611
|
+
continue;
|
|
1612
|
+
reachable.set(current, via);
|
|
1613
|
+
const entry = dependMap[current];
|
|
1614
|
+
for (const dep of entry?.deps ?? [])
|
|
1615
|
+
if (typeof dep === 'string')
|
|
1616
|
+
stack.push(dep);
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
for (const [uuid, paths] of users)
|
|
1620
|
+
reach(uuid, paths[0]);
|
|
1621
|
+
// builtinResMgr keeps engine defaults (default materials, textures, physics material) for the whole session.
|
|
1622
|
+
const builtins = new Set(Object.values(dataProperty(cc.builtinResMgr ?? {}, '_resources') ?? {}));
|
|
1623
|
+
const sceneUuid = String(dataProperty(scene, '_id') ?? '');
|
|
1624
|
+
if (sceneUuid)
|
|
1625
|
+
reach(sceneUuid, 'scene');
|
|
1626
|
+
const persist = dataProperty(dataProperty(cc.assetManager, '_releaseManager') ?? cc.internal?.releaseManager ?? {}, '_persistNodeDeps');
|
|
1627
|
+
for (const deps of Object.values((persist ? dataProperty(persist, '_map') : undefined) ?? {}))
|
|
1628
|
+
for (const dep of deps)
|
|
1629
|
+
reach(dep, 'persist-root');
|
|
1630
|
+
const byType = {};
|
|
1631
|
+
const bundleOf = new Map();
|
|
1632
|
+
const bundles = dataProperty(dataProperty(cc.assetManager, 'bundles') ?? {}, '_map') ?? {};
|
|
1633
|
+
for (const [name, bundle] of Object.entries(bundles)) {
|
|
1634
|
+
const infos = dataProperty(dataProperty(dataProperty(bundle, '_config') ?? {}, 'assetInfos') ?? {}, '_map') ?? {};
|
|
1635
|
+
for (const uuid of Object.keys(infos))
|
|
1636
|
+
bundleOf.set(uuid, name);
|
|
1637
|
+
}
|
|
1638
|
+
const items = [];
|
|
1639
|
+
let total = 0;
|
|
1640
|
+
let totalBytes = 0;
|
|
1641
|
+
for (const [key, asset] of Object.entries(map)) {
|
|
1642
|
+
if (++total > 50_000)
|
|
1643
|
+
break;
|
|
1644
|
+
if (!asset || typeof asset !== 'object')
|
|
1645
|
+
continue;
|
|
1646
|
+
const type = componentName(asset);
|
|
1647
|
+
const uuid = assetUuid(asset) || key;
|
|
1648
|
+
const ref = Number(dataProperty(asset, '_ref') ?? 0);
|
|
1649
|
+
const bytes = /Texture|RenderTexture/.test(type) ? gfxBytes(asset) : undefined;
|
|
1650
|
+
const usedBy = users.get(uuid);
|
|
1651
|
+
const bundle = bundleOf.get(uuid) ?? bundleOf.get(uuid.split('@')[0]) ?? null;
|
|
1652
|
+
// Engine built-ins live in the internal bundle or builtinResMgr for the whole session.
|
|
1653
|
+
const status = usedBy ? 'used' : reachable.has(uuid) ? 'dependency' : bundle === 'internal' || builtins.has(asset) ? 'builtin' : 'unused';
|
|
1654
|
+
const unused = status === 'unused';
|
|
1655
|
+
const bucket = byType[type] ??= { count: 0, bytes: 0, unused: 0 };
|
|
1656
|
+
bucket.count++;
|
|
1657
|
+
if (bytes) {
|
|
1658
|
+
bucket.bytes += bytes;
|
|
1659
|
+
totalBytes += bytes;
|
|
1660
|
+
}
|
|
1661
|
+
if (unused)
|
|
1662
|
+
bucket.unused++;
|
|
1663
|
+
if (request.type && type !== request.type)
|
|
1664
|
+
continue;
|
|
1665
|
+
if (request.unusedOnly && !unused)
|
|
1666
|
+
continue;
|
|
1667
|
+
items.push({
|
|
1668
|
+
type, name: assetName(asset), uuid, refCount: ref,
|
|
1669
|
+
...(bytes !== undefined ? { bytes } : {}),
|
|
1670
|
+
...(/Texture/.test(type) ? { width: Number(dataProperty(asset, '_width') ?? 0), height: Number(dataProperty(asset, '_height') ?? 0) } : {}),
|
|
1671
|
+
bundle,
|
|
1672
|
+
status,
|
|
1673
|
+
...(usedBy ? { usedBy } : status === 'dependency' ? { via: reachable.get(uuid) } : {}),
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
// Unused first, then by GPU bytes and refCount: the likeliest leaks lead.
|
|
1677
|
+
items.sort((a, b) => Number(b.status === 'unused') - Number(a.status === 'unused') || (b.bytes ?? 0) - (a.bytes ?? 0) || b.refCount - a.refCount);
|
|
1678
|
+
const memory = dataProperty(dataProperty(dataProperty(cc.director, '_root') ?? {}, '_device') ?? {}, '_memoryStatus');
|
|
1679
|
+
return {
|
|
1680
|
+
version,
|
|
1681
|
+
assetCount: Math.min(total, 50_000),
|
|
1682
|
+
textureBytes: totalBytes,
|
|
1683
|
+
...(memory ? { gpuMemory: { textureBytes: Number(dataProperty(memory, 'textureSize') ?? 0), bufferBytes: Number(dataProperty(memory, 'bufferSize') ?? 0) } } : {}),
|
|
1684
|
+
byType,
|
|
1685
|
+
assets: items.slice(0, limit),
|
|
1686
|
+
matched: items.length,
|
|
1687
|
+
truncated: items.length > limit || total > 50_000 || sceneTruncated,
|
|
1688
|
+
truncationReasons: [...(items.length > limit || total > 50_000 ? ['NODE_LIMIT'] : []), ...(sceneTruncated ? ['NODE_LIMIT'] : [])].filter((value, index, all) => all.indexOf(value) === index),
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
871
1691
|
if (request.action === 'getNodeBounds')
|
|
872
1692
|
return { version, uuid: request.uuid, ...nodeBounds(findByUuid(request.uuid)) };
|
|
873
1693
|
if (request.action !== 'highlightNode')
|