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.
@@ -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('Active Cocos scene not found');
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 ?? '')}`, depth: 0 }];
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.length <= 10_000 ? childPath : `${childPath.slice(0, 9_999)}…`, depth: current.depth + 1 });
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' : 'Node not found');
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).length;
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('Ambiguous component type');
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('Component not found');
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('Component not found');
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('/')}`.slice(0, 2_000);
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
- return invalidMutation(`${name} is not a method of ${target === node ? 'Node' : componentName(target)}`);
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')