cocos-web-inspector-mcp 2.2.1 → 2.4.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 +31 -8
- package/dist/src/bridge.d.ts +40 -1
- package/dist/src/bridge.js +526 -28
- package/dist/src/browser-data.d.ts +17 -2
- package/dist/src/browser-data.js +15 -3
- package/dist/src/browser.js +6 -3
- package/dist/src/cli.js +32 -2
- package/dist/src/native.d.ts +15 -0
- package/dist/src/native.js +271 -7
- package/dist/src/server.js +209 -79
- package/package.json +1 -1
package/dist/src/bridge.js
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
const MAX_BYTES = 200_000;
|
|
2
|
+
/** Whether a tool result, sent as both text and structuredContent, stays under the response ceiling. */
|
|
3
|
+
export function fitsResponse(result) {
|
|
4
|
+
const encoded = JSON.stringify({ content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result });
|
|
5
|
+
return Buffer.byteLength(encoded, 'utf8') <= MAX_BYTES - 4_096;
|
|
6
|
+
}
|
|
2
7
|
export async function runBridge(page, request) {
|
|
3
8
|
const result = await page.evaluate(inspectCocos, request);
|
|
4
|
-
|
|
5
|
-
const encoded = JSON.stringify({ content: [{ type: 'text', text }], structuredContent: result });
|
|
6
|
-
if (Buffer.byteLength(encoded, 'utf8') > MAX_BYTES - 4_096) {
|
|
7
|
-
return { truncated: true, truncationReasons: ['RESPONSE_LIMIT'] };
|
|
8
|
-
}
|
|
9
|
-
return result;
|
|
9
|
+
return fitsResponse(result) ? result : { truncated: true, truncationReasons: ['RESPONSE_LIMIT'] };
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/** The node's visible viewport rectangle, or why it has none. */
|
|
12
|
+
export async function visibleClip(page, uuid) {
|
|
12
13
|
const result = await page.evaluate(inspectCocos, { action: 'getNodeBounds', uuid });
|
|
13
14
|
if (!result.available || !result.visible)
|
|
14
|
-
return {
|
|
15
|
+
return { reason: result.reason ?? 'OUTSIDE_VIEWPORT' };
|
|
15
16
|
const clip = result.clippedViewport;
|
|
16
|
-
if (!clip || !
|
|
17
|
-
return {
|
|
18
|
-
|
|
17
|
+
if (!clip || ![clip.x, clip.y, clip.width, clip.height].every(Number.isFinite) || clip.width <= 0 || clip.height <= 0)
|
|
18
|
+
return { reason: 'INVALID_GEOMETRY' };
|
|
19
|
+
return clip;
|
|
20
|
+
}
|
|
21
|
+
/** Whether base64 image data stays under the response ceiling. */
|
|
22
|
+
export const fitsImage = (data, limit = MAX_BYTES - 4_096) => Buffer.byteLength(JSON.stringify({ data }), 'utf8') <= Math.min(limit, MAX_BYTES - 4_096);
|
|
23
|
+
export async function captureNode(page, uuid, maxBytes) {
|
|
24
|
+
const clip = await visibleClip(page, uuid);
|
|
25
|
+
if ('reason' in clip)
|
|
26
|
+
return { captured: false, reason: clip.reason };
|
|
27
|
+
const fits = (data) => fitsImage(data, maxBytes);
|
|
19
28
|
const size = { width: Math.round(clip.width), height: Math.round(clip.height) };
|
|
20
29
|
// Device-pixel PNG, then CSS-pixel JPEG quality steps.
|
|
21
30
|
const attempts = [{ type: 'png', scale: 'device' }, ...[80, 60, 40].map(quality => ({ type: 'jpeg', quality, scale: 'css' }))];
|
|
@@ -40,11 +49,8 @@ export async function captureNode(page, uuid) {
|
|
|
40
49
|
return { captured: false, reason: 'RESPONSE_LIMIT' };
|
|
41
50
|
}
|
|
42
51
|
export async function nodeCenter(page, uuid) {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
return { reason: result.reason ?? 'OUTSIDE_VIEWPORT' };
|
|
46
|
-
const clip = result.clippedViewport;
|
|
47
|
-
return { x: clip.x + clip.width / 2, y: clip.y + clip.height / 2 };
|
|
52
|
+
const clip = await visibleClip(page, uuid);
|
|
53
|
+
return 'reason' in clip ? clip : { x: clip.x + clip.width / 2, y: clip.y + clip.height / 2 };
|
|
48
54
|
}
|
|
49
55
|
// Under touch emulation Chrome turns mouse input into touch and never acknowledges Playwright's mouse calls, so send touch directly.
|
|
50
56
|
function pointer(page) {
|
|
@@ -108,6 +114,26 @@ export async function typeText(page, uuid, text, submit) {
|
|
|
108
114
|
const after = typeof properties?.string === 'string' ? { string: properties.string } : { redacted: true };
|
|
109
115
|
return { typed: true, target: { nodeUuid: uuid }, length: text.length, submitted: submit, after, runtimeOnly: true };
|
|
110
116
|
}
|
|
117
|
+
// Keys the press tools accept: Playwright names, which native maps to Android key codes.
|
|
118
|
+
export const namedKeys = ['Enter', 'Escape', 'Tab', 'Backspace', 'Delete', 'Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'];
|
|
119
|
+
export const keyPattern = new RegExp(`^(?:${namedKeys.join('|')}|[a-zA-Z0-9])$`);
|
|
120
|
+
// Real keydown/keyup for game keyboard handlers (cc.Input KEY_DOWN/KEY_UP); cocos_type_text is the path for EditBox text.
|
|
121
|
+
// The engine listens for keys on the GameCanvas element, so focus it first unless a DOM input (an open EditBox) already has focus.
|
|
122
|
+
export async function pressKey(page, key) {
|
|
123
|
+
await page.evaluate(() => {
|
|
124
|
+
const active = document.activeElement;
|
|
125
|
+
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement)
|
|
126
|
+
return;
|
|
127
|
+
const canvas = document.getElementById('GameCanvas');
|
|
128
|
+
if (canvas) {
|
|
129
|
+
if (!canvas.hasAttribute('tabindex'))
|
|
130
|
+
canvas.tabIndex = -1;
|
|
131
|
+
canvas.focus();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
await page.keyboard.press(key === 'Space' ? ' ' : key);
|
|
135
|
+
return { pressed: true, key, runtimeOnly: true };
|
|
136
|
+
}
|
|
111
137
|
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';
|
|
112
138
|
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';
|
|
113
139
|
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`;
|
|
@@ -236,7 +262,7 @@ export function inspectCocos(request) {
|
|
|
236
262
|
throw new Error('Cocos Creator 3.x runtime not found');
|
|
237
263
|
const scene = cc.director?.getScene?.();
|
|
238
264
|
if (!scene)
|
|
239
|
-
throw new Error(
|
|
265
|
+
throw new Error(`Active Cocos scene not found (${cc.director ? 'director has no scene yet' : 'no director'}); the game may still be booting or loading a scene, retry shortly`);
|
|
240
266
|
const sensitiveParts = ['token', 'cookie', 'authorization', 'password', 'secret', 'credential', 'storage', 'jwt', 'apikey', 'privatekey', 'accesskey', 'signingkey', 'authheader'];
|
|
241
267
|
const isSensitiveKey = (key) => {
|
|
242
268
|
const normalized = key.toLowerCase().replace(/[^a-z]/g, '');
|
|
@@ -276,8 +302,26 @@ export function inspectCocos(request) {
|
|
|
276
302
|
activeInHierarchy: node?.activeInHierarchy !== false,
|
|
277
303
|
componentTypes: components(node).slice(0, 100).map(componentName),
|
|
278
304
|
});
|
|
305
|
+
const truncatePath = (path) => path.length <= 10_000 ? path : `${path.slice(0, 9_999)}…`;
|
|
306
|
+
const utf8Bytes = (text) => {
|
|
307
|
+
let bytes = 0;
|
|
308
|
+
for (let index = 0; index < text.length; index++) {
|
|
309
|
+
const code = text.charCodeAt(index);
|
|
310
|
+
if (code < 0x80)
|
|
311
|
+
bytes++;
|
|
312
|
+
else if (code < 0x800)
|
|
313
|
+
bytes += 2;
|
|
314
|
+
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length && (text.charCodeAt(index + 1) & 0xfc00) === 0xdc00) {
|
|
315
|
+
bytes += 4;
|
|
316
|
+
index++;
|
|
317
|
+
}
|
|
318
|
+
else
|
|
319
|
+
bytes += 3;
|
|
320
|
+
}
|
|
321
|
+
return bytes;
|
|
322
|
+
};
|
|
279
323
|
const walk = (visit, limit = 5_000) => {
|
|
280
|
-
const stack = [{ node: scene, path: `/${String(scene.name ?? '')}
|
|
324
|
+
const stack = [{ node: scene, path: truncatePath(`/${String(scene.name ?? '')}`), depth: 0 }];
|
|
281
325
|
const seen = new Set();
|
|
282
326
|
let count = 0;
|
|
283
327
|
let truncated = false;
|
|
@@ -297,7 +341,7 @@ export function inspectCocos(request) {
|
|
|
297
341
|
const child = next[index];
|
|
298
342
|
const childName = String(child?.name ?? '').slice(0, 500);
|
|
299
343
|
const childPath = `${current.path}/${childName}`;
|
|
300
|
-
stack.push({ node: child, path: childPath
|
|
344
|
+
stack.push({ node: child, path: truncatePath(childPath), depth: current.depth + 1 });
|
|
301
345
|
}
|
|
302
346
|
}
|
|
303
347
|
return { truncated: truncated || stack.length > 0 };
|
|
@@ -311,7 +355,7 @@ export function inspectCocos(request) {
|
|
|
311
355
|
}
|
|
312
356
|
});
|
|
313
357
|
if (!match)
|
|
314
|
-
throw new Error(traversal.truncated ? 'Node not found within traversal limit' :
|
|
358
|
+
throw new Error(traversal.truncated ? 'Node not found within traversal limit' : `Node not found${/^(Node|Comp)\.\d+$/.test(uuid) ? '; Node.N ids are runtime-assigned and change on scene switch or reload, look the node up by path' : ''}`);
|
|
315
359
|
return match;
|
|
316
360
|
};
|
|
317
361
|
const nodeBounds = (node) => {
|
|
@@ -625,6 +669,73 @@ export function inspectCocos(request) {
|
|
|
625
669
|
...(selection ? {} : { hint: 'Alt+click a node on the game canvas, then call again' }),
|
|
626
670
|
};
|
|
627
671
|
}
|
|
672
|
+
if (request.action === 'semanticSnapshot') {
|
|
673
|
+
const rootNode = request.uuid ? findByUuid(request.uuid) : scene;
|
|
674
|
+
const maxDepth = Math.min(Math.max(request.maxDepth ?? 6, 0), 20);
|
|
675
|
+
const maxNodes = Math.min(Math.max(request.maxNodes ?? 500, 1), 5_000);
|
|
676
|
+
// ponytail: normalized snapshots stop near 65 KB; increase only with a response-size-aware transport.
|
|
677
|
+
const budget = 65_000;
|
|
678
|
+
const reasons = new Set();
|
|
679
|
+
const nodes = [];
|
|
680
|
+
let bytes = 0;
|
|
681
|
+
const vector = (value, keys) => Object.fromEntries(keys.map(key => [key, Number(dataProperty(value ?? {}, key) ?? 0)]));
|
|
682
|
+
const collect = (node, path, depth) => {
|
|
683
|
+
if (nodes.length >= maxNodes) {
|
|
684
|
+
reasons.add('NODE_LIMIT');
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const item = {
|
|
688
|
+
uuid: String(dataProperty(node, 'uuid') ?? dataProperty(node, '_id') ?? ''),
|
|
689
|
+
name: String(dataProperty(node, 'name') ?? dataProperty(node, '_name') ?? '').slice(0, 500),
|
|
690
|
+
path,
|
|
691
|
+
active: (dataProperty(node, '_active') ?? dataProperty(node, 'active')) !== false,
|
|
692
|
+
position: vector(dataProperty(node, '_lpos') ?? dataProperty(node, '_position') ?? dataProperty(node, 'position'), ['x', 'y', 'z']),
|
|
693
|
+
rotation: vector(dataProperty(node, '_lrot') ?? dataProperty(node, '_rotation') ?? dataProperty(node, 'rotation'), ['x', 'y', 'z', 'w']),
|
|
694
|
+
scale: vector(dataProperty(node, '_lscale') ?? dataProperty(node, '_scale') ?? dataProperty(node, 'scale'), ['x', 'y', 'z']),
|
|
695
|
+
};
|
|
696
|
+
const transform = components(node).find(component => componentName(component) === 'UITransform');
|
|
697
|
+
const size = transform && dataProperty(transform, '_contentSize');
|
|
698
|
+
const anchor = transform && dataProperty(transform, '_anchorPoint');
|
|
699
|
+
if (size && anchor)
|
|
700
|
+
item.uiBounds = { x: -Number(dataProperty(size, 'width') ?? 0) * Number(dataProperty(anchor, 'x') ?? 0), y: -Number(dataProperty(size, 'height') ?? 0) * Number(dataProperty(anchor, 'y') ?? 0), width: Number(dataProperty(size, 'width') ?? 0), height: Number(dataProperty(size, 'height') ?? 0) };
|
|
701
|
+
const label = components(node).find(component => componentName(component) === 'Label');
|
|
702
|
+
if (label && typeof dataProperty(label, '_string') === 'string')
|
|
703
|
+
item.labelText = String(dataProperty(label, '_string')).slice(0, 2_000);
|
|
704
|
+
const button = components(node).find(component => componentName(component) === 'Button');
|
|
705
|
+
if (button && typeof dataProperty(button, '_interactable') === 'boolean')
|
|
706
|
+
item.buttonInteractable = dataProperty(button, '_interactable');
|
|
707
|
+
const encoded = utf8Bytes(JSON.stringify(item));
|
|
708
|
+
if (bytes + encoded > budget) {
|
|
709
|
+
reasons.add('RESPONSE_LIMIT');
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
bytes += encoded;
|
|
713
|
+
nodes.push(item);
|
|
714
|
+
const next = children(node);
|
|
715
|
+
if (depth >= maxDepth) {
|
|
716
|
+
if (next.length)
|
|
717
|
+
reasons.add('MAX_DEPTH');
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
for (const child of next) {
|
|
721
|
+
if (nodes.length >= maxNodes) {
|
|
722
|
+
reasons.add('NODE_LIMIT');
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
collect(child, truncatePath(`${path}/${String(dataProperty(child, 'name') ?? dataProperty(child, '_name') ?? '').slice(0, 500)}`), depth + 1);
|
|
726
|
+
if (reasons.has('RESPONSE_LIMIT'))
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
let rootPath = truncatePath(`/${String(dataProperty(scene, 'name') ?? dataProperty(scene, '_name') ?? '').slice(0, 500)}`);
|
|
731
|
+
if (request.uuid)
|
|
732
|
+
walk((node, path) => { if (node === rootNode) {
|
|
733
|
+
rootPath = path;
|
|
734
|
+
return false;
|
|
735
|
+
} });
|
|
736
|
+
collect(rootNode, rootPath, 0);
|
|
737
|
+
return { version, ...(typeof performance !== 'undefined' && Number.isFinite(performance.timeOrigin) ? { documentId: performance.timeOrigin } : {}), rootUuid: String(dataProperty(rootNode, 'uuid') ?? dataProperty(rootNode, '_id') ?? ''), nodes, nodeCount: nodes.length, truncated: reasons.size > 0, truncationReasons: [...reasons] };
|
|
738
|
+
}
|
|
628
739
|
if (request.action === 'snapshotSubtree') {
|
|
629
740
|
const rootNode = findByUuid(request.uuid);
|
|
630
741
|
const maxDepth = Math.min(Math.max(request.maxDepth ?? 6, 0), 20);
|
|
@@ -642,7 +753,7 @@ export function inspectCocos(request) {
|
|
|
642
753
|
...summary(node),
|
|
643
754
|
components: components(node).slice(0, 200).map(component => ({ type: componentName(component), uuid: String(component.uuid ?? ''), enabled: component.enabled !== false })),
|
|
644
755
|
};
|
|
645
|
-
bytes += JSON.stringify(item)
|
|
756
|
+
bytes += utf8Bytes(JSON.stringify(item));
|
|
646
757
|
if (bytes > 70_000) {
|
|
647
758
|
reasons.add('RESPONSE_LIMIT');
|
|
648
759
|
return undefined;
|
|
@@ -770,12 +881,12 @@ export function inspectCocos(request) {
|
|
|
770
881
|
const node = findByUuid(request.uuid);
|
|
771
882
|
const matches = request.componentType ? components(node).filter(component => componentName(component) === request.componentType) : [];
|
|
772
883
|
if (matches.length > 1)
|
|
773
|
-
throw new Error(
|
|
884
|
+
throw new Error(`Ambiguous component type ${request.componentType}; pass componentUuid: ${matches.map(component => component?.uuid).join(', ')}`);
|
|
774
885
|
const selected = request.componentUuid
|
|
775
886
|
? components(node).find(component => component?.uuid === request.componentUuid)
|
|
776
887
|
: request.componentType ? matches[0] : node;
|
|
777
888
|
if (!selected)
|
|
778
|
-
throw new Error(
|
|
889
|
+
throw new Error(`Component not found: ${request.componentUuid ?? request.componentType} on ${node.name}; components: ${components(node).map(component => `${componentName(component)} ${component?.uuid}`).join(', ').slice(0, 500)}`);
|
|
779
890
|
const maxDepth = Math.min(Math.max(request.maxDepth ?? 3, 0), 6);
|
|
780
891
|
const serializer = createSerializer(maxDepth);
|
|
781
892
|
const { serialize, truncate } = serializer;
|
|
@@ -850,7 +961,7 @@ export function inspectCocos(request) {
|
|
|
850
961
|
const componentByUuid = (node, uuid) => {
|
|
851
962
|
const component = components(node).find(candidate => candidate?.uuid === uuid);
|
|
852
963
|
if (!component)
|
|
853
|
-
throw new Error(
|
|
964
|
+
throw new Error(`Component not found: ${uuid} on ${node?.name}; components: ${components(node).map(candidate => `${componentName(candidate)} ${candidate?.uuid}`).join(', ').slice(0, 500)}`);
|
|
854
965
|
return component;
|
|
855
966
|
};
|
|
856
967
|
const directorState = () => {
|
|
@@ -1015,7 +1126,7 @@ export function inspectCocos(request) {
|
|
|
1015
1126
|
const names = [];
|
|
1016
1127
|
for (let current = node, depth = 0; current && depth < 100; current = current.parent, depth++)
|
|
1017
1128
|
names.unshift(String(current.name ?? '').slice(0, 200));
|
|
1018
|
-
return `/${names.join('/')}
|
|
1129
|
+
return truncatePath(`/${names.join('/')}`);
|
|
1019
1130
|
};
|
|
1020
1131
|
const reasons = {};
|
|
1021
1132
|
for (const batch of batches)
|
|
@@ -1138,7 +1249,7 @@ export function inspectCocos(request) {
|
|
|
1138
1249
|
for (let depth = 0; owner && depth < 20 && !descriptor; depth++, owner = Object.getPrototypeOf(owner))
|
|
1139
1250
|
descriptor = Object.getOwnPropertyDescriptor(owner, request.key);
|
|
1140
1251
|
if (!descriptor || !('value' in descriptor))
|
|
1141
|
-
invalidMutation('property is not a data property');
|
|
1252
|
+
invalidMutation('property is not a data property (accessor or missing); only plain data properties are writable');
|
|
1142
1253
|
const current = descriptor.value;
|
|
1143
1254
|
const keys = current && typeof current === 'object'
|
|
1144
1255
|
? 'width' in current && 'height' in current ? ['width', 'height'] : 'r' in current && 'g' in current && 'b' in current ? ['r', 'g', 'b', 'a'] : ['x', 'y', ...('z' in current ? ['z'] : []), ...('w' in current ? ['w'] : [])]
|
|
@@ -1230,8 +1341,13 @@ export function inspectCocos(request) {
|
|
|
1230
1341
|
let descriptor;
|
|
1231
1342
|
for (let depth = 0; owner && owner !== Object.prototype && depth < 20 && !descriptor; depth++, owner = Object.getPrototypeOf(owner))
|
|
1232
1343
|
descriptor = Object.getOwnPropertyDescriptor(owner, name);
|
|
1233
|
-
if (!descriptor || typeof descriptor.value !== 'function')
|
|
1234
|
-
|
|
1344
|
+
if (!descriptor || typeof descriptor.value !== 'function') {
|
|
1345
|
+
const hasMethod = (object) => { for (let owner = object, depth = 0; owner && owner !== Object.prototype && depth < 20; depth++, owner = Object.getPrototypeOf(owner))
|
|
1346
|
+
if (typeof Object.getOwnPropertyDescriptor(owner, name)?.value === 'function')
|
|
1347
|
+
return true; return false; };
|
|
1348
|
+
const owners = target === node ? components(node).filter(hasMethod).map(component => `${componentName(component)} ${component?.uuid}`) : [];
|
|
1349
|
+
return invalidMutation(`${name} is not a method of ${target === node ? 'Node' : componentName(target)}${owners.length ? `; components with ${name}: ${owners.join(', ')} (pass componentUuid)` : ''}`);
|
|
1350
|
+
}
|
|
1235
1351
|
const resolve = (value, depth = 0) => {
|
|
1236
1352
|
if (depth > 10)
|
|
1237
1353
|
invalidMutation('argument nesting is too deep');
|
|
@@ -1708,6 +1824,388 @@ export function inspectCocos(request) {
|
|
|
1708
1824
|
truncationReasons: [...(items.length > limit || total > 50_000 ? ['NODE_LIMIT'] : []), ...(sceneTruncated ? ['NODE_LIMIT'] : [])].filter((value, index, all) => all.indexOf(value) === index),
|
|
1709
1825
|
};
|
|
1710
1826
|
}
|
|
1827
|
+
const pathOf = (node) => {
|
|
1828
|
+
const names = [];
|
|
1829
|
+
for (let current = node, depth = 0; current && depth < 100; current = current.parent, depth++)
|
|
1830
|
+
names.unshift(String(current.name ?? '').slice(0, 200));
|
|
1831
|
+
return `/${names.join('/')}`;
|
|
1832
|
+
};
|
|
1833
|
+
const chainOf = (node) => {
|
|
1834
|
+
const chain = [];
|
|
1835
|
+
for (let current = node, depth = 0; current && depth < 100; current = current.parent, depth++)
|
|
1836
|
+
chain.push(current);
|
|
1837
|
+
return chain;
|
|
1838
|
+
};
|
|
1839
|
+
const finiteOr = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback;
|
|
1840
|
+
const sizeOf = (node) => {
|
|
1841
|
+
const transform = components(node).find(component => componentName(component) === 'UITransform');
|
|
1842
|
+
const size = transform && dataProperty(transform, '_contentSize');
|
|
1843
|
+
const anchor = transform && dataProperty(transform, '_anchorPoint');
|
|
1844
|
+
return size ? { width: finiteOr(size.width), height: finiteOr(size.height), ...(anchor ? { anchor: { x: finiteOr(anchor.x), y: finiteOr(anchor.y) } } : {}) } : undefined;
|
|
1845
|
+
};
|
|
1846
|
+
if (request.action === 'explainVisibility') {
|
|
1847
|
+
// Reads backing fields (_active, _layer, _color, _opacity, _visibility) so no getter runs; mirrors what the renderer checks, without rendering.
|
|
1848
|
+
const target = findByUuid(request.uuid);
|
|
1849
|
+
const chain = chainOf(target);
|
|
1850
|
+
const reasons = [];
|
|
1851
|
+
const warnings = [];
|
|
1852
|
+
const add = (list, code, node, detail) => list.push({ code, ...(node ? { node: String(node.name ?? '').slice(0, 200), nodeUuid: String(node.uuid ?? '') } : {}), ...(detail ? { detail } : {}) });
|
|
1853
|
+
if (target.activeInHierarchy === false) {
|
|
1854
|
+
const off = chain.find(node => dataProperty(node, '_active') === false) ?? target;
|
|
1855
|
+
add(reasons, 'INACTIVE', off, off === target ? 'the node itself is inactive' : 'an ancestor is inactive');
|
|
1856
|
+
}
|
|
1857
|
+
const rendererTypes = ['Sprite', 'Label', 'RichText', 'Graphics', 'Skeleton', 'ArmatureDisplay', 'MeshRenderer', 'SkinnedMeshRenderer', 'ParticleSystem2D', 'ParticleSystem', 'TiledMap', 'MotionStreak', 'UIMeshRenderer', 'VideoPlayer', 'WebView'];
|
|
1858
|
+
const renderers = components(target).filter(component => rendererTypes.includes(componentName(component)));
|
|
1859
|
+
if (!renderers.length)
|
|
1860
|
+
add(warnings, 'NO_RENDERER', target, 'no Sprite, Label, Graphics, or other renderer component on this node; it draws nothing itself');
|
|
1861
|
+
for (const renderer of renderers) {
|
|
1862
|
+
const type = componentName(renderer);
|
|
1863
|
+
if (dataProperty(renderer, '_enabled') === false)
|
|
1864
|
+
add(reasons, 'RENDERER_DISABLED', target, type);
|
|
1865
|
+
if (type === 'Sprite' && !dataProperty(renderer, '_spriteFrame'))
|
|
1866
|
+
add(reasons, 'NO_SPRITE_FRAME', target, 'Sprite has no spriteFrame');
|
|
1867
|
+
if (type === 'Label' && dataProperty(renderer, '_string') === '')
|
|
1868
|
+
add(reasons, 'EMPTY_TEXT', target, 'Label string is empty');
|
|
1869
|
+
const color = dataProperty(renderer, '_color');
|
|
1870
|
+
if (color && finiteOr(color.a, 255) === 0)
|
|
1871
|
+
add(reasons, 'TRANSPARENT_COLOR', target, `${type} color alpha is 0`);
|
|
1872
|
+
}
|
|
1873
|
+
let opacity = 1;
|
|
1874
|
+
for (const node of chain) {
|
|
1875
|
+
const ui = components(node).find(component => componentName(component) === 'UIOpacity');
|
|
1876
|
+
const value = ui ? dataProperty(ui, '_opacity') : undefined;
|
|
1877
|
+
if (typeof value === 'number') {
|
|
1878
|
+
opacity *= value / 255;
|
|
1879
|
+
if (value === 0)
|
|
1880
|
+
add(reasons, 'ZERO_OPACITY', node, 'UIOpacity is 0');
|
|
1881
|
+
}
|
|
1882
|
+
const scale = dataProperty(node, '_lscale');
|
|
1883
|
+
if (scale && (finiteOr(scale.x, 1) === 0 || finiteOr(scale.y, 1) === 0))
|
|
1884
|
+
add(reasons, 'ZERO_SCALE', node, 'local scale x or y is 0');
|
|
1885
|
+
}
|
|
1886
|
+
const size = sizeOf(target);
|
|
1887
|
+
if (size && (size.width === 0 || size.height === 0))
|
|
1888
|
+
add(reasons, 'ZERO_SIZE', target, `${size.width}x${size.height}`);
|
|
1889
|
+
// Native (JSB) nodes keep _layer in C++ behind an accessor that data reads skip; fall back to the public layer getter, which returns a plain bitmask.
|
|
1890
|
+
const layerData = dataProperty(target, '_layer');
|
|
1891
|
+
const layer = Number(typeof layerData === 'number' ? layerData : target.layer);
|
|
1892
|
+
const cameras = [];
|
|
1893
|
+
walk(node => {
|
|
1894
|
+
for (const component of components(node)) {
|
|
1895
|
+
if (componentName(component) === 'Camera' && dataProperty(component, '_enabled') !== false && node.activeInHierarchy !== false)
|
|
1896
|
+
cameras.push({ node: String(node.name ?? '').slice(0, 200), visibility: finiteOr(dataProperty(component, '_visibility')) });
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1899
|
+
if (Number.isFinite(layer) && cameras.length) {
|
|
1900
|
+
if (!cameras.some(camera => (camera.visibility & layer) !== 0))
|
|
1901
|
+
add(reasons, 'LAYER_NOT_RENDERED', target, `node layer ${layer} is outside every active camera's visibility (${cameras.slice(0, 5).map(camera => `${camera.node}: ${camera.visibility}`).join(', ')})`);
|
|
1902
|
+
}
|
|
1903
|
+
else if (!cameras.length)
|
|
1904
|
+
add(warnings, 'NO_ACTIVE_CAMERA', undefined, 'no active Camera found; cannot check layer visibility');
|
|
1905
|
+
const bounds = nodeBounds(target);
|
|
1906
|
+
if (bounds.available && bounds.outsideViewport)
|
|
1907
|
+
add(reasons, 'OUTSIDE_VIEWPORT', target);
|
|
1908
|
+
if (bounds.available) {
|
|
1909
|
+
for (const ancestor of chain.slice(1)) {
|
|
1910
|
+
const mask = components(ancestor).find(component => componentName(component) === 'Mask' && dataProperty(component, '_enabled') !== false);
|
|
1911
|
+
if (!mask)
|
|
1912
|
+
continue;
|
|
1913
|
+
const maskBounds = nodeBounds(ancestor);
|
|
1914
|
+
if (!maskBounds.available)
|
|
1915
|
+
continue;
|
|
1916
|
+
const a = bounds.viewport;
|
|
1917
|
+
const b = maskBounds.viewport;
|
|
1918
|
+
if (a.x >= b.x + b.width || b.x >= a.x + a.width || a.y >= b.y + b.height || b.y >= a.y + a.height)
|
|
1919
|
+
add(reasons, 'CLIPPED_BY_MASK', ancestor, 'the node lies entirely outside this Mask\'s box');
|
|
1920
|
+
}
|
|
1921
|
+
// ponytail: covering is judged by an opaque Sprite's box only; texture transparency and camera priority are ignored. Upgrade: sample the framebuffer.
|
|
1922
|
+
if (!bounds.outsideViewport && target.activeInHierarchy !== false) {
|
|
1923
|
+
const clip = bounds.clippedViewport;
|
|
1924
|
+
let after = false;
|
|
1925
|
+
let checked = 0;
|
|
1926
|
+
const covering = [];
|
|
1927
|
+
walk(node => {
|
|
1928
|
+
if (node === target) {
|
|
1929
|
+
after = true;
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
if (!after || checked >= 500 || covering.length >= 3 || node.activeInHierarchy === false || chainOf(node).includes(target))
|
|
1933
|
+
return;
|
|
1934
|
+
const sprite = components(node).find(component => componentName(component) === 'Sprite' && dataProperty(component, '_enabled') !== false && dataProperty(component, '_spriteFrame'));
|
|
1935
|
+
const color = sprite && dataProperty(sprite, '_color');
|
|
1936
|
+
if (!sprite || !color || finiteOr(color.a, 255) < 255)
|
|
1937
|
+
return;
|
|
1938
|
+
checked++;
|
|
1939
|
+
const other = nodeBounds(node);
|
|
1940
|
+
if (other.available && other.viewport.x <= clip.x && other.viewport.y <= clip.y && other.viewport.x + other.viewport.width >= clip.x + clip.width && other.viewport.y + other.viewport.height >= clip.y + clip.height)
|
|
1941
|
+
covering.push({ uuid: String(node.uuid ?? ''), name: String(node.name ?? '').slice(0, 200), path: pathOf(node) });
|
|
1942
|
+
});
|
|
1943
|
+
if (covering.length)
|
|
1944
|
+
warnings.push({ code: 'POSSIBLY_COVERED', detail: 'an opaque Sprite drawn later fully contains this node\'s box', coveredBy: covering });
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
return {
|
|
1948
|
+
version,
|
|
1949
|
+
target: summary(target),
|
|
1950
|
+
visible: reasons.length === 0,
|
|
1951
|
+
reasons,
|
|
1952
|
+
warnings,
|
|
1953
|
+
effectiveOpacity: Math.round(opacity * 1000) / 1000,
|
|
1954
|
+
...(Number.isFinite(layer) ? { layer } : {}),
|
|
1955
|
+
bounds: bounds.available ? { viewport: bounds.viewport, clippedViewport: bounds.clippedViewport } : { reason: bounds.reason },
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
if (request.action === 'explainLayout') {
|
|
1959
|
+
// Reads Widget, Layout, Label, and Sprite backing fields to say which of them drives this node's size and position.
|
|
1960
|
+
const target = findByUuid(request.uuid);
|
|
1961
|
+
const parent = target.parent;
|
|
1962
|
+
const explanations = [];
|
|
1963
|
+
const note = (code, detail, extra = {}) => explanations.push({ code, detail, ...extra });
|
|
1964
|
+
const own = components(target);
|
|
1965
|
+
const find = (node, type) => components(node).find(component => componentName(component) === type);
|
|
1966
|
+
const alignModes = ['ONCE', 'ON_WINDOW_RESIZE', 'ALWAYS'];
|
|
1967
|
+
const widget = find(target, 'Widget');
|
|
1968
|
+
let widgetInfo;
|
|
1969
|
+
if (widget) {
|
|
1970
|
+
const flags = finiteOr(dataProperty(widget, '_alignFlags'));
|
|
1971
|
+
const edge = (flag, field, abs) => flags & flag ? { value: finiteOr(dataProperty(widget, field)), unit: dataProperty(widget, abs) === false ? 'percent' : 'px' } : undefined;
|
|
1972
|
+
const edges = { top: edge(1, '_top', '_isAbsTop'), bottom: edge(4, '_bottom', '_isAbsBottom'), left: edge(8, '_left', '_isAbsLeft'), right: edge(32, '_right', '_isAbsRight'), horizontalCenter: edge(16, '_horizontalCenter', '_isAbsHorizontalCenter'), verticalCenter: edge(2, '_verticalCenter', '_isAbsVerticalCenter') };
|
|
1973
|
+
const alignMode = alignModes[finiteOr(dataProperty(widget, '_alignMode'), -1)] ?? 'UNKNOWN';
|
|
1974
|
+
const reference = dataProperty(widget, '_target');
|
|
1975
|
+
widgetInfo = { enabled: dataProperty(widget, '_enabled') !== false, alignMode, ...(reference ? { target: { uuid: String(reference.uuid ?? ''), name: String(reference.name ?? '').slice(0, 200) } } : {}), edges: Object.fromEntries(Object.entries(edges).filter(([, value]) => value)) };
|
|
1976
|
+
if (dataProperty(widget, '_enabled') === false)
|
|
1977
|
+
note('WIDGET_DISABLED', 'Widget is disabled, so it does not constrain this node');
|
|
1978
|
+
else {
|
|
1979
|
+
if (flags & 8 && flags & 32)
|
|
1980
|
+
note('WIDGET_STRETCHES_WIDTH', 'left and right edges are both aligned, so the width is the reference width minus both offsets');
|
|
1981
|
+
if (flags & 1 && flags & 4)
|
|
1982
|
+
note('WIDGET_STRETCHES_HEIGHT', 'top and bottom edges are both aligned, so the height is the reference height minus both offsets');
|
|
1983
|
+
if (flags & 15 && !(flags & 8 && flags & 32) || flags & 63)
|
|
1984
|
+
note('WIDGET_SETS_POSITION', 'Widget positions the node against ' + (reference ? 'its target' : 'its parent') + ' edges');
|
|
1985
|
+
if (alignMode === 'ONCE')
|
|
1986
|
+
note('WIDGET_ALIGN_ONCE', 'alignMode ONCE aligns when enabled and not again, so a later resize or parent change leaves the node where it was');
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
const parentLayout = parent ? find(parent, 'Layout') : undefined;
|
|
1990
|
+
const layoutTypes = ['NONE', 'HORIZONTAL', 'VERTICAL', 'GRID'];
|
|
1991
|
+
const resizeModes = ['NONE', 'CONTAINER', 'CHILDREN'];
|
|
1992
|
+
const describeLayout = (layout) => ({ type: layoutTypes[finiteOr(dataProperty(layout, '_layoutType'), -1)] ?? 'UNKNOWN', resizeMode: resizeModes[finiteOr(dataProperty(layout, '_resizeMode'), -1)] ?? 'UNKNOWN', spacingX: finiteOr(dataProperty(layout, '_spacingX')), spacingY: finiteOr(dataProperty(layout, '_spacingY')), padding: { left: finiteOr(dataProperty(layout, '_paddingLeft')), right: finiteOr(dataProperty(layout, '_paddingRight')), top: finiteOr(dataProperty(layout, '_paddingTop')), bottom: finiteOr(dataProperty(layout, '_paddingBottom')) }, affectedByScale: dataProperty(layout, '_affectedByScale') === true, enabled: dataProperty(layout, '_enabled') !== false });
|
|
1993
|
+
let parentLayoutInfo;
|
|
1994
|
+
if (parentLayout) {
|
|
1995
|
+
parentLayoutInfo = describeLayout(parentLayout);
|
|
1996
|
+
if (parentLayoutInfo.enabled && parentLayoutInfo.type !== 'NONE')
|
|
1997
|
+
note('LAYOUT_CONTROLS_POSITION', `the parent's ${parentLayoutInfo.type} Layout places its children; a manual position is overwritten the next time it updates`);
|
|
1998
|
+
if (parentLayoutInfo.enabled && parentLayoutInfo.resizeMode === 'CHILDREN')
|
|
1999
|
+
note('LAYOUT_CONTROLS_SIZE', 'the parent Layout resizes its children');
|
|
2000
|
+
}
|
|
2001
|
+
const ownLayout = find(target, 'Layout');
|
|
2002
|
+
let ownLayoutInfo;
|
|
2003
|
+
if (ownLayout) {
|
|
2004
|
+
ownLayoutInfo = describeLayout(ownLayout);
|
|
2005
|
+
if (ownLayoutInfo.enabled && ownLayoutInfo.resizeMode === 'CONTAINER')
|
|
2006
|
+
note('LAYOUT_FITS_CONTAINER_TO_CHILDREN', 'resizeMode CONTAINER sizes this node to fit its children');
|
|
2007
|
+
}
|
|
2008
|
+
const label = find(target, 'Label');
|
|
2009
|
+
if (label && [0, 3].includes(finiteOr(dataProperty(label, '_overflow'), -1)))
|
|
2010
|
+
note('LABEL_CONTROLS_SIZE', `Label overflow ${finiteOr(dataProperty(label, '_overflow')) === 0 ? 'NONE' : 'RESIZE_HEIGHT'} sets the size from its text`);
|
|
2011
|
+
const sprite = find(target, 'Sprite');
|
|
2012
|
+
if (sprite && finiteOr(dataProperty(sprite, '_sizeMode'), 0) !== 0)
|
|
2013
|
+
note('SPRITE_CONTROLS_SIZE', `Sprite sizeMode ${finiteOr(dataProperty(sprite, '_sizeMode')) === 1 ? 'TRIMMED' : 'RAW'} takes the size from its spriteFrame`);
|
|
2014
|
+
let ancestorScale = { x: 1, y: 1 };
|
|
2015
|
+
for (const node of chainOf(target).slice(1)) {
|
|
2016
|
+
const scale = dataProperty(node, '_lscale');
|
|
2017
|
+
if (scale)
|
|
2018
|
+
ancestorScale = { x: ancestorScale.x * finiteOr(scale.x, 1), y: ancestorScale.y * finiteOr(scale.y, 1) };
|
|
2019
|
+
}
|
|
2020
|
+
if (ancestorScale.x !== 1 || ancestorScale.y !== 1)
|
|
2021
|
+
note('SCALED_BY_ANCESTOR', `ancestors scale this node by ${Math.round(ancestorScale.x * 1000) / 1000} x ${Math.round(ancestorScale.y * 1000) / 1000}`);
|
|
2022
|
+
if (!find(target, 'UITransform'))
|
|
2023
|
+
note('NO_UI_TRANSFORM', 'the node has no UITransform, so it has no size, anchor, or Widget/Layout participation');
|
|
2024
|
+
const position = dataProperty(target, '_lpos');
|
|
2025
|
+
const bounds = nodeBounds(target);
|
|
2026
|
+
return {
|
|
2027
|
+
version,
|
|
2028
|
+
target: summary(target),
|
|
2029
|
+
size: sizeOf(target) ?? null,
|
|
2030
|
+
localPosition: position ? { x: finiteOr(position.x), y: finiteOr(position.y), z: finiteOr(position.z) } : null,
|
|
2031
|
+
parent: parent ? { uuid: String(parent.uuid ?? ''), name: String(parent.name ?? '').slice(0, 200), size: sizeOf(parent) ?? null } : null,
|
|
2032
|
+
...(widgetInfo ? { widget: widgetInfo } : {}),
|
|
2033
|
+
...(parentLayoutInfo ? { parentLayout: parentLayoutInfo } : {}),
|
|
2034
|
+
...(ownLayoutInfo ? { layout: ownLayoutInfo } : {}),
|
|
2035
|
+
ancestorScale,
|
|
2036
|
+
explanations,
|
|
2037
|
+
viewport: bounds.available ? bounds.viewport : { reason: bounds.reason },
|
|
2038
|
+
componentTypes: own.slice(0, 50).map(componentName),
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
if (request.action === 'animationReport') {
|
|
2042
|
+
// Reads Animation states, Spine/DragonBones fields, and the tween manager's hash by data property; nothing is played or stopped.
|
|
2043
|
+
const limit = Math.min(Math.max(request.limit ?? 100, 1), 500);
|
|
2044
|
+
const animations = [];
|
|
2045
|
+
let playing = 0;
|
|
2046
|
+
let total = 0;
|
|
2047
|
+
const finiteJson = (value) => typeof value === 'number' ? (Number.isFinite(value) ? value : String(value)) : value;
|
|
2048
|
+
const traversal = walk((node, path) => {
|
|
2049
|
+
for (const component of components(node)) {
|
|
2050
|
+
const type = componentName(component);
|
|
2051
|
+
if (!['Animation', 'Skeleton', 'ArmatureDisplay'].includes(type))
|
|
2052
|
+
continue;
|
|
2053
|
+
total++;
|
|
2054
|
+
const base = { uuid: String(node.uuid ?? ''), node: String(node.name ?? '').slice(0, 200), path, type, enabled: dataProperty(component, '_enabled') !== false, activeInHierarchy: node.activeInHierarchy !== false };
|
|
2055
|
+
if (type === 'Animation') {
|
|
2056
|
+
const states = Object.values(dataProperty(component, '_nameToState') ?? {});
|
|
2057
|
+
const active = states.filter(state => dataProperty(state, '_isPlaying') === true);
|
|
2058
|
+
playing += active.length;
|
|
2059
|
+
if (animations.length < limit)
|
|
2060
|
+
animations.push({ ...base, stateCount: states.length, playing: active.length, states: states.filter(state => dataProperty(state, '_isPlaying') === true).slice(0, 20).map(state => ({ name: String(dataProperty(state, '_name') ?? '').slice(0, 120), paused: dataProperty(state, '_isPaused') === true, speed: finiteJson(dataProperty(state, '_speed')), wrapMode: dataProperty(state, '_wrapMode'), repeatCount: finiteJson(dataProperty(state, '_repeatCount')), time: finiteJson(dataProperty(state, 'time')) })) });
|
|
2061
|
+
}
|
|
2062
|
+
else {
|
|
2063
|
+
const animation = dataProperty(component, '_animationName');
|
|
2064
|
+
if (animation)
|
|
2065
|
+
playing++;
|
|
2066
|
+
if (animations.length < limit)
|
|
2067
|
+
animations.push({ ...base, animation: animation ? String(animation).slice(0, 120) : null, loop: finiteJson(dataProperty(component, 'loop') ?? dataProperty(component, '_loop')), timeScale: finiteJson(dataProperty(component, '_timeScale')), paused: dataProperty(component, '_paused') === true ? true : undefined });
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
2071
|
+
const systems = (dataProperty(cc.director ?? {}, '_systems') ?? []);
|
|
2072
|
+
const hash = dataProperty(dataProperty(systems.find(system => system && dataProperty(system, 'actionMgr')) ?? {}, 'actionMgr') ?? {}, '_hashTargets');
|
|
2073
|
+
const tweens = [];
|
|
2074
|
+
let tweenTotal = 0;
|
|
2075
|
+
let tweenActions = 0;
|
|
2076
|
+
if (hash instanceof Map) {
|
|
2077
|
+
for (const [owner, element] of hash) {
|
|
2078
|
+
const actions = Array.isArray(element?.actions) ? element.actions.length : 0;
|
|
2079
|
+
tweenTotal++;
|
|
2080
|
+
tweenActions += actions;
|
|
2081
|
+
if (tweens.length >= limit)
|
|
2082
|
+
continue;
|
|
2083
|
+
const isNode = !!owner && typeof owner === 'object' && typeof dataProperty(owner, '_id') === 'string';
|
|
2084
|
+
tweens.push({ target: isNode ? { uuid: String(owner.uuid ?? ''), name: String(owner.name ?? '').slice(0, 200), path: pathOf(owner) } : { type: String(dataProperty(owner ?? {}, 'constructor')?.name ?? typeof owner).slice(0, 80) }, actions, paused: element?.paused === true });
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
return {
|
|
2088
|
+
version,
|
|
2089
|
+
counts: { animationComponents: total, playingStates: playing, tweenTargets: tweenTotal, tweenActions },
|
|
2090
|
+
animations,
|
|
2091
|
+
tweens,
|
|
2092
|
+
...(hash instanceof Map ? {} : { unavailableMetrics: { tweens: 'UNSUPPORTED_PUBLIC_API' } }),
|
|
2093
|
+
truncated: traversal.truncated || animations.length >= limit || tweens.length >= limit,
|
|
2094
|
+
truncationReasons: traversal.truncated || animations.length >= limit || tweens.length >= limit ? ['NODE_LIMIT'] : [],
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
if (request.action === 'frameProfile') {
|
|
2098
|
+
// Measures wall-clock time between consecutive AFTER_DRAW events, so GC pauses and long frames show; reads draw stats the same frame.
|
|
2099
|
+
const wanted = Math.min(Math.max(Math.floor(request.frames ?? 120), 2), 600);
|
|
2100
|
+
const timeoutMs = Math.min(Math.max(request.timeoutMs ?? 10_000, 100), 30_000);
|
|
2101
|
+
const director = cc.director;
|
|
2102
|
+
if (typeof director?.on !== 'function' || typeof director?.off !== 'function' || !cc.Director?.EVENT_AFTER_DRAW)
|
|
2103
|
+
invalidMutation('director events are unavailable');
|
|
2104
|
+
if (typeof director.isPaused === 'function' && director.isPaused())
|
|
2105
|
+
return { version, sampled: 0, reason: 'DIRECTOR_PAUSED' };
|
|
2106
|
+
const now = () => typeof root.performance?.now === 'function' ? root.performance.now() : Date.now();
|
|
2107
|
+
const renderRoot = dataProperty(director, '_root');
|
|
2108
|
+
const device = renderRoot && typeof renderRoot === 'object' ? dataProperty(renderRoot, '_device') : undefined;
|
|
2109
|
+
const read = (owner, key) => { const value = owner && typeof owner === 'object' ? dataProperty(owner, key) : undefined; return typeof value === 'number' && Number.isFinite(value) ? value : undefined; };
|
|
2110
|
+
const samples = [];
|
|
2111
|
+
return new Promise(resolve => {
|
|
2112
|
+
let last;
|
|
2113
|
+
let finished = false;
|
|
2114
|
+
let timer;
|
|
2115
|
+
const finish = (timedOut) => {
|
|
2116
|
+
if (finished)
|
|
2117
|
+
return;
|
|
2118
|
+
finished = true;
|
|
2119
|
+
director.off(cc.Director.EVENT_AFTER_DRAW, onFrame);
|
|
2120
|
+
if (timer !== undefined)
|
|
2121
|
+
clearTimeout(timer);
|
|
2122
|
+
const sorted = samples.map(sample => sample.ms).sort((a, b) => a - b);
|
|
2123
|
+
const at = (fraction) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(fraction * sorted.length))] : 0;
|
|
2124
|
+
const round = (value) => Math.round(value * 100) / 100;
|
|
2125
|
+
const median = at(0.5);
|
|
2126
|
+
const stat = (key) => { const values = samples.map(sample => sample[key]).filter((value) => value !== undefined); return values.length ? { min: Math.min(...values), avg: Math.round(values.reduce((sum, value) => sum + value, 0) / values.length), max: Math.max(...values) } : undefined; };
|
|
2127
|
+
resolve({
|
|
2128
|
+
version,
|
|
2129
|
+
sampled: samples.length,
|
|
2130
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
2131
|
+
frameMs: samples.length ? { avg: round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length), p50: round(median), p95: round(at(0.95)), max: round(sorted[sorted.length - 1]) } : undefined,
|
|
2132
|
+
fps: samples.length && median > 0 ? round(1_000 / median) : undefined,
|
|
2133
|
+
// A spike is a frame over twice the median and over 33 ms: slow enough to be felt, not ordinary jitter.
|
|
2134
|
+
spikes: samples.filter(sample => sample.ms > median * 2 && sample.ms > 33).length,
|
|
2135
|
+
worstFrames: samples.map((sample, index) => ({ index, ms: round(sample.ms) })).sort((a, b) => b.ms - a.ms).slice(0, 5),
|
|
2136
|
+
drawCalls: stat('drawCalls'),
|
|
2137
|
+
triangles: stat('triangles'),
|
|
2138
|
+
});
|
|
2139
|
+
};
|
|
2140
|
+
const onFrame = () => {
|
|
2141
|
+
const at = now();
|
|
2142
|
+
if (last !== undefined)
|
|
2143
|
+
samples.push({ ms: at - last, drawCalls: read(device, '_numDrawCalls'), triangles: read(device, '_numTris') });
|
|
2144
|
+
last = at;
|
|
2145
|
+
if (samples.length >= wanted)
|
|
2146
|
+
finish(false);
|
|
2147
|
+
};
|
|
2148
|
+
director.on(cc.Director.EVENT_AFTER_DRAW, onFrame);
|
|
2149
|
+
timer = setTimeout(() => finish(true), timeoutMs);
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
if (request.action === 'eventTrace') {
|
|
2153
|
+
// Wraps Node.prototype.emit for a bounded window and counts calls per event and node. Arguments are never recorded; the original runs unchanged.
|
|
2154
|
+
const durationMs = Math.min(Math.max(request.durationMs ?? 2_000, 100), 10_000);
|
|
2155
|
+
const limit = Math.min(Math.max(request.limit ?? 50, 1), 200);
|
|
2156
|
+
if (root.__cocosWebInspectorEventTrace)
|
|
2157
|
+
invalidMutation('an event trace is already running');
|
|
2158
|
+
const proto = cc.Node?.prototype;
|
|
2159
|
+
if (!proto || typeof proto.emit !== 'function')
|
|
2160
|
+
invalidMutation('Node.prototype.emit is unavailable');
|
|
2161
|
+
const original = proto.emit;
|
|
2162
|
+
const hadOwn = Object.prototype.hasOwnProperty.call(proto, 'emit');
|
|
2163
|
+
const groups = new Map();
|
|
2164
|
+
let total = 0;
|
|
2165
|
+
let droppedGroups = 0;
|
|
2166
|
+
const startedAt = Date.now();
|
|
2167
|
+
root.__cocosWebInspectorEventTrace = true;
|
|
2168
|
+
proto.emit = function (type, ...rest) {
|
|
2169
|
+
try {
|
|
2170
|
+
total++;
|
|
2171
|
+
const event = String(typeof type === 'string' || typeof type === 'number' ? type : 'unknown').slice(0, 120);
|
|
2172
|
+
const name = String(dataProperty(this, '_name') ?? '').slice(0, 200);
|
|
2173
|
+
const uuid = String(dataProperty(this, '_id') ?? '');
|
|
2174
|
+
const key = `${event}|${uuid}`;
|
|
2175
|
+
const group = groups.get(key);
|
|
2176
|
+
if (group)
|
|
2177
|
+
group.count++;
|
|
2178
|
+
else if (groups.size < 1_000)
|
|
2179
|
+
groups.set(key, { event, node: name, nodeUuid: uuid, count: 1 });
|
|
2180
|
+
else
|
|
2181
|
+
droppedGroups++;
|
|
2182
|
+
}
|
|
2183
|
+
catch { /* tracing must never break the game */ }
|
|
2184
|
+
return original.call(this, type, ...rest);
|
|
2185
|
+
};
|
|
2186
|
+
return new Promise(resolve => setTimeout(() => {
|
|
2187
|
+
if (hadOwn)
|
|
2188
|
+
proto.emit = original;
|
|
2189
|
+
else
|
|
2190
|
+
delete proto.emit;
|
|
2191
|
+
root.__cocosWebInspectorEventTrace = undefined;
|
|
2192
|
+
const seconds = Math.max((Date.now() - startedAt) / 1_000, 0.001);
|
|
2193
|
+
const top = [...groups.values()].sort((a, b) => b.count - a.count);
|
|
2194
|
+
const perEvent = new Map();
|
|
2195
|
+
for (const group of top)
|
|
2196
|
+
perEvent.set(group.event, (perEvent.get(group.event) ?? 0) + group.count);
|
|
2197
|
+
resolve({
|
|
2198
|
+
version,
|
|
2199
|
+
durationMs: Date.now() - startedAt,
|
|
2200
|
+
totalEmits: total,
|
|
2201
|
+
emitsPerSecond: Math.round(total / seconds),
|
|
2202
|
+
byEvent: [...perEvent.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit).map(([event, count]) => ({ event, count })),
|
|
2203
|
+
top: top.slice(0, limit).map(group => ({ ...group, perSecond: Math.round(group.count / seconds) })),
|
|
2204
|
+
truncated: top.length > limit || droppedGroups > 0,
|
|
2205
|
+
truncationReasons: top.length > limit || droppedGroups > 0 ? ['NODE_LIMIT'] : [],
|
|
2206
|
+
});
|
|
2207
|
+
}, durationMs));
|
|
2208
|
+
}
|
|
1711
2209
|
if (request.action === 'getNodeBounds')
|
|
1712
2210
|
return { version, uuid: request.uuid, ...nodeBounds(findByUuid(request.uuid)) };
|
|
1713
2211
|
if (request.action !== 'highlightNode')
|