cocos-web-inspector-mcp 2.0.0 → 2.1.0

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