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