cocos-web-inspector-mcp 2.3.0 → 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 +24 -4
- package/dist/src/bridge.d.ts +27 -2
- package/dist/src/bridge.js +507 -15
- package/dist/src/browser.js +6 -3
- package/dist/src/native.d.ts +3 -2
- package/dist/src/native.js +15 -4
- package/dist/src/server.js +194 -60
- package/package.json +1 -1
package/dist/src/bridge.js
CHANGED
|
@@ -19,12 +19,12 @@ export async function visibleClip(page, uuid) {
|
|
|
19
19
|
return clip;
|
|
20
20
|
}
|
|
21
21
|
/** Whether base64 image data stays under the response ceiling. */
|
|
22
|
-
export const fitsImage = (data) => Buffer.byteLength(JSON.stringify({ data }), 'utf8') <= MAX_BYTES - 4_096;
|
|
23
|
-
export async function captureNode(page, uuid) {
|
|
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
24
|
const clip = await visibleClip(page, uuid);
|
|
25
25
|
if ('reason' in clip)
|
|
26
26
|
return { captured: false, reason: clip.reason };
|
|
27
|
-
const fits = fitsImage;
|
|
27
|
+
const fits = (data) => fitsImage(data, maxBytes);
|
|
28
28
|
const size = { width: Math.round(clip.width), height: Math.round(clip.height) };
|
|
29
29
|
// Device-pixel PNG, then CSS-pixel JPEG quality steps.
|
|
30
30
|
const attempts = [{ type: 'png', scale: 'device' }, ...[80, 60, 40].map(quality => ({ type: 'jpeg', quality, scale: 'css' }))];
|
|
@@ -114,6 +114,26 @@ export async function typeText(page, uuid, text, submit) {
|
|
|
114
114
|
const after = typeof properties?.string === 'string' ? { string: properties.string } : { redacted: true };
|
|
115
115
|
return { typed: true, target: { nodeUuid: uuid }, length: text.length, submitted: submit, after, runtimeOnly: true };
|
|
116
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
|
+
}
|
|
117
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';
|
|
118
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';
|
|
119
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`;
|
|
@@ -242,7 +262,7 @@ export function inspectCocos(request) {
|
|
|
242
262
|
throw new Error('Cocos Creator 3.x runtime not found');
|
|
243
263
|
const scene = cc.director?.getScene?.();
|
|
244
264
|
if (!scene)
|
|
245
|
-
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`);
|
|
246
266
|
const sensitiveParts = ['token', 'cookie', 'authorization', 'password', 'secret', 'credential', 'storage', 'jwt', 'apikey', 'privatekey', 'accesskey', 'signingkey', 'authheader'];
|
|
247
267
|
const isSensitiveKey = (key) => {
|
|
248
268
|
const normalized = key.toLowerCase().replace(/[^a-z]/g, '');
|
|
@@ -282,8 +302,26 @@ export function inspectCocos(request) {
|
|
|
282
302
|
activeInHierarchy: node?.activeInHierarchy !== false,
|
|
283
303
|
componentTypes: components(node).slice(0, 100).map(componentName),
|
|
284
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
|
+
};
|
|
285
323
|
const walk = (visit, limit = 5_000) => {
|
|
286
|
-
const stack = [{ node: scene, path: `/${String(scene.name ?? '')}
|
|
324
|
+
const stack = [{ node: scene, path: truncatePath(`/${String(scene.name ?? '')}`), depth: 0 }];
|
|
287
325
|
const seen = new Set();
|
|
288
326
|
let count = 0;
|
|
289
327
|
let truncated = false;
|
|
@@ -303,7 +341,7 @@ export function inspectCocos(request) {
|
|
|
303
341
|
const child = next[index];
|
|
304
342
|
const childName = String(child?.name ?? '').slice(0, 500);
|
|
305
343
|
const childPath = `${current.path}/${childName}`;
|
|
306
|
-
stack.push({ node: child, path: childPath
|
|
344
|
+
stack.push({ node: child, path: truncatePath(childPath), depth: current.depth + 1 });
|
|
307
345
|
}
|
|
308
346
|
}
|
|
309
347
|
return { truncated: truncated || stack.length > 0 };
|
|
@@ -317,7 +355,7 @@ export function inspectCocos(request) {
|
|
|
317
355
|
}
|
|
318
356
|
});
|
|
319
357
|
if (!match)
|
|
320
|
-
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' : ''}`);
|
|
321
359
|
return match;
|
|
322
360
|
};
|
|
323
361
|
const nodeBounds = (node) => {
|
|
@@ -631,6 +669,73 @@ export function inspectCocos(request) {
|
|
|
631
669
|
...(selection ? {} : { hint: 'Alt+click a node on the game canvas, then call again' }),
|
|
632
670
|
};
|
|
633
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
|
+
}
|
|
634
739
|
if (request.action === 'snapshotSubtree') {
|
|
635
740
|
const rootNode = findByUuid(request.uuid);
|
|
636
741
|
const maxDepth = Math.min(Math.max(request.maxDepth ?? 6, 0), 20);
|
|
@@ -648,7 +753,7 @@ export function inspectCocos(request) {
|
|
|
648
753
|
...summary(node),
|
|
649
754
|
components: components(node).slice(0, 200).map(component => ({ type: componentName(component), uuid: String(component.uuid ?? ''), enabled: component.enabled !== false })),
|
|
650
755
|
};
|
|
651
|
-
bytes += JSON.stringify(item)
|
|
756
|
+
bytes += utf8Bytes(JSON.stringify(item));
|
|
652
757
|
if (bytes > 70_000) {
|
|
653
758
|
reasons.add('RESPONSE_LIMIT');
|
|
654
759
|
return undefined;
|
|
@@ -776,12 +881,12 @@ export function inspectCocos(request) {
|
|
|
776
881
|
const node = findByUuid(request.uuid);
|
|
777
882
|
const matches = request.componentType ? components(node).filter(component => componentName(component) === request.componentType) : [];
|
|
778
883
|
if (matches.length > 1)
|
|
779
|
-
throw new Error(
|
|
884
|
+
throw new Error(`Ambiguous component type ${request.componentType}; pass componentUuid: ${matches.map(component => component?.uuid).join(', ')}`);
|
|
780
885
|
const selected = request.componentUuid
|
|
781
886
|
? components(node).find(component => component?.uuid === request.componentUuid)
|
|
782
887
|
: request.componentType ? matches[0] : node;
|
|
783
888
|
if (!selected)
|
|
784
|
-
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)}`);
|
|
785
890
|
const maxDepth = Math.min(Math.max(request.maxDepth ?? 3, 0), 6);
|
|
786
891
|
const serializer = createSerializer(maxDepth);
|
|
787
892
|
const { serialize, truncate } = serializer;
|
|
@@ -856,7 +961,7 @@ export function inspectCocos(request) {
|
|
|
856
961
|
const componentByUuid = (node, uuid) => {
|
|
857
962
|
const component = components(node).find(candidate => candidate?.uuid === uuid);
|
|
858
963
|
if (!component)
|
|
859
|
-
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)}`);
|
|
860
965
|
return component;
|
|
861
966
|
};
|
|
862
967
|
const directorState = () => {
|
|
@@ -1021,7 +1126,7 @@ export function inspectCocos(request) {
|
|
|
1021
1126
|
const names = [];
|
|
1022
1127
|
for (let current = node, depth = 0; current && depth < 100; current = current.parent, depth++)
|
|
1023
1128
|
names.unshift(String(current.name ?? '').slice(0, 200));
|
|
1024
|
-
return `/${names.join('/')}
|
|
1129
|
+
return truncatePath(`/${names.join('/')}`);
|
|
1025
1130
|
};
|
|
1026
1131
|
const reasons = {};
|
|
1027
1132
|
for (const batch of batches)
|
|
@@ -1144,7 +1249,7 @@ export function inspectCocos(request) {
|
|
|
1144
1249
|
for (let depth = 0; owner && depth < 20 && !descriptor; depth++, owner = Object.getPrototypeOf(owner))
|
|
1145
1250
|
descriptor = Object.getOwnPropertyDescriptor(owner, request.key);
|
|
1146
1251
|
if (!descriptor || !('value' in descriptor))
|
|
1147
|
-
invalidMutation('property is not a data property');
|
|
1252
|
+
invalidMutation('property is not a data property (accessor or missing); only plain data properties are writable');
|
|
1148
1253
|
const current = descriptor.value;
|
|
1149
1254
|
const keys = current && typeof current === 'object'
|
|
1150
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'] : [])]
|
|
@@ -1236,8 +1341,13 @@ export function inspectCocos(request) {
|
|
|
1236
1341
|
let descriptor;
|
|
1237
1342
|
for (let depth = 0; owner && owner !== Object.prototype && depth < 20 && !descriptor; depth++, owner = Object.getPrototypeOf(owner))
|
|
1238
1343
|
descriptor = Object.getOwnPropertyDescriptor(owner, name);
|
|
1239
|
-
if (!descriptor || typeof descriptor.value !== 'function')
|
|
1240
|
-
|
|
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
|
+
}
|
|
1241
1351
|
const resolve = (value, depth = 0) => {
|
|
1242
1352
|
if (depth > 10)
|
|
1243
1353
|
invalidMutation('argument nesting is too deep');
|
|
@@ -1714,6 +1824,388 @@ export function inspectCocos(request) {
|
|
|
1714
1824
|
truncationReasons: [...(items.length > limit || total > 50_000 ? ['NODE_LIMIT'] : []), ...(sceneTruncated ? ['NODE_LIMIT'] : [])].filter((value, index, all) => all.indexOf(value) === index),
|
|
1715
1825
|
};
|
|
1716
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
|
+
}
|
|
1717
2209
|
if (request.action === 'getNodeBounds')
|
|
1718
2210
|
return { version, uuid: request.uuid, ...nodeBounds(findByUuid(request.uuid)) };
|
|
1719
2211
|
if (request.action !== 'highlightNode')
|