reverseport 1.6.0 → 1.7.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 +71 -26
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/bin/reverseport.mjs +13 -1
- package/dist/generatedPackageMetadata.js +1 -1
- package/licenses/README.md +1 -0
- package/licenses/godot-engine-MIT.txt +20 -0
- package/native/windows/build/manifest.json +1 -1
- package/package.json +1 -1
- package/port/cli.mjs +6 -4
- package/port/knowledge/apis/godot-3-to-4-renames.json +1 -0
- package/port/knowledge/apis/godot-3.json +1 -0
- package/port/knowledge/apis/godot-4.3.json +1 -0
- package/port/knowledge/apis/godot-4.7.json +1 -0
- package/port/src/agent.mjs +1 -1
- package/port/src/agentdisk.mjs +83 -0
- package/port/src/apicheck.mjs +280 -0
- package/port/src/archive.mjs +34 -4
- package/port/src/convert.mjs +3 -1
- package/port/src/dormant.mjs +159 -0
- package/port/src/formats.mjs +1 -0
- package/port/src/gamearchives.mjs +476 -0
- package/port/src/godotbin.mjs +544 -0
- package/port/src/il2cpp.mjs +148 -0
- package/port/src/jsworkspace.mjs +2 -2
- package/port/src/knowledge.mjs +3 -1
- package/port/src/physics.mjs +135 -0
- package/port/src/plan.mjs +94 -11
- package/port/src/porters.mjs +87 -5
- package/port/src/refs.mjs +120 -0
- package/port/src/rpgmaker.mjs +416 -0
- package/port/src/save.mjs +326 -0
- package/port/src/scene.mjs +37 -3
- package/port/src/shader.mjs +385 -0
- package/port/src/strings.mjs +327 -0
- package/port/src/tools.mjs +48 -8
- package/port/src/trace.mjs +302 -0
- package/port/src/unity-anim.mjs +229 -0
- package/port/src/upgrade.mjs +219 -0
- package/skills/build-inspector/SKILL.md +8 -0
- package/skills/godot-port/SKILL.md +2 -0
- package/skills/port-verify/SKILL.md +20 -9
- package/skills/reverse-engineer-anything/SKILL.md +18 -2
- package/skills/reverse-engineer-anything/references/porting-and-rebuilds.md +35 -4
- package/skills/unity-to-godot/SKILL.md +14 -2
package/port/src/porters.mjs
CHANGED
|
@@ -67,16 +67,81 @@ function unityRoles(node) {
|
|
|
67
67
|
const cam = has('Camera'); if (cam) roles.camera = { fov: cam.fieldOfView ?? 60 };
|
|
68
68
|
const light = has('Light'); if (light) roles.light = { Directional: 'directional', Point: 'point', Spot: 'spot' }[light.lightType] ?? 'point';
|
|
69
69
|
const audio = has('AudioSource'); if (audio) roles.audio = { spatial: true, clip: audio.clip };
|
|
70
|
-
roles.scripts = (node.components ?? []).filter(c => c.type === 'MonoBehaviour' && c.script).map(c => c.script);
|
|
70
|
+
roles.scripts = (node.components ?? []).filter(c => c.type === 'MonoBehaviour' && c.script && !c.ui).map(c => c.script);
|
|
71
|
+
const anim = has('Animator'); if (anim) roles.animator = anim.controller ?? null;
|
|
72
|
+
const widgets = (node.components ?? []).filter(c => c.ui).map(c => c.ui);
|
|
73
|
+
if (node.ui || widgets.length || has('Canvas')) roles.ui = { canvas: !!has('Canvas'), widgets, group: has('CanvasGroup') ?? null };
|
|
71
74
|
return roles;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
// ---------------------------------------------------------------- uGUI → Control
|
|
78
|
+
const UI_ALIGN_H = { 0: 0, 1: 1, 2: 2, 3: 0, 4: 1, 5: 2, 6: 0, 7: 1, 8: 2 }; // Unity TextAnchor → HORIZONTAL_ALIGNMENT
|
|
79
|
+
const UI_ALIGN_V = { 0: 0, 1: 0, 2: 0, 3: 1, 4: 1, 5: 1, 6: 2, 7: 2, 8: 2 };
|
|
80
|
+
const TMP_ALIGN = a => ({ h: [1, 2, 4, 8].findIndex(b => a & b) === -1 ? 0 : { 1: 0, 2: 1, 4: 2, 8: 3 }[[1, 2, 4, 8].find(b => a & b)], v: { 256: 0, 512: 1, 1024: 2 }[[256, 512, 1024].find(b => a & b)] ?? 0 });
|
|
81
|
+
const color4 = c => `Color(${c.map(f).join(', ')})`;
|
|
82
|
+
/** Godot anchors/offsets from a Unity RectTransform (Y flipped), plus widget properties. */
|
|
83
|
+
function uiProps(node, roles, todo, addExt, resolve) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const r = node.rect;
|
|
86
|
+
const canvas = roles.ui.canvas;
|
|
87
|
+
if (canvas) {
|
|
88
|
+
out.push('anchors_preset = 15', 'anchor_right = 1.0', 'anchor_bottom = 1.0', 'grow_horizontal = 2', 'grow_vertical = 2', 'mouse_filter = 2');
|
|
89
|
+
const scaler = roles.ui.widgets.find(w => w.widget === 'CanvasScaler');
|
|
90
|
+
if (scaler?.referenceResolution?.x) todo.push(`Canvas scaler reference ${scaler.referenceResolution.x}x${scaler.referenceResolution.y}: set display/window/size to it and stretch mode canvas_items in Project Settings`);
|
|
91
|
+
} else if (r) {
|
|
92
|
+
const aMin = r.anchorMin, aMax = r.anchorMax, ap = r.anchoredPosition, sd = r.sizeDelta, pv = r.pivot;
|
|
93
|
+
const offMin = { x: (ap.x ?? 0) - (sd.x ?? 0) * (pv.x ?? 0.5), y: (ap.y ?? 0) - (sd.y ?? 0) * (pv.y ?? 0.5) };
|
|
94
|
+
const offMax = { x: (ap.x ?? 0) + (sd.x ?? 0) * (1 - (pv.x ?? 0.5)), y: (ap.y ?? 0) + (sd.y ?? 0) * (1 - (pv.y ?? 0.5)) };
|
|
95
|
+
const vals = { anchor_left: aMin.x ?? 0, anchor_top: 1 - (aMax.y ?? 0), anchor_right: aMax.x ?? 0, anchor_bottom: 1 - (aMin.y ?? 0), offset_left: offMin.x, offset_top: -offMax.y, offset_right: offMax.x, offset_bottom: -offMin.y };
|
|
96
|
+
for (const [k, v] of Object.entries(vals)) if (Math.abs(v) > 1e-9) out.push(`${k} = ${f(v)}`);
|
|
97
|
+
if (Math.abs((aMin.x ?? 0) - 0) < 1e-9 && Math.abs((aMax.x ?? 0) - 1) < 1e-9) out.push('grow_horizontal = 2');
|
|
98
|
+
if (Math.abs((aMin.y ?? 0) - 0) < 1e-9 && Math.abs((aMax.y ?? 0) - 1) < 1e-9) out.push('grow_vertical = 2');
|
|
99
|
+
if (pv.x !== undefined && (Math.abs(pv.x - 0.5) > 1e-6 || Math.abs((pv.y ?? 0.5) - 0.5) > 1e-6)) out.push(`pivot_offset = Vector2(${f((pv.x ?? 0.5) * (sd.x ?? 0))}, ${f((1 - (pv.y ?? 0.5)) * (sd.y ?? 0))})`);
|
|
100
|
+
const t = node.transform;
|
|
101
|
+
if (t?.scale && (Math.abs(t.scale[0] - 1) > 1e-6 || Math.abs(t.scale[1] - 1) > 1e-6)) out.push(`scale = Vector2(${f(t.scale[0])}, ${f(t.scale[1])})`);
|
|
102
|
+
if (t?.rotation) { const [x, y, z, w] = t.rotation; const ang = Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)); if (Math.abs(ang) > 1e-6) out.push(`rotation = ${f(-ang)}`); }
|
|
103
|
+
}
|
|
104
|
+
for (const w of roles.ui.widgets) {
|
|
105
|
+
if (w.widget === 'Image' || w.widget === 'RawImage') {
|
|
106
|
+
const src = w.sprite ?? w.texture; const res = src ? resolve(src) : null;
|
|
107
|
+
if (res) out.push(`texture = ExtResource("${addExt('Texture2D', res)}")`); else if (src) todo.push(`texture ${src} not ported yet`);
|
|
108
|
+
out.push('expand_mode = 1', `stretch_mode = ${w.preserveAspect ? 5 : w.imageType === 'tiled' ? 1 : 1}`);
|
|
109
|
+
if (w.color && w.color.some((c, i) => Math.abs(c - 1) > 1e-6)) out.push(`modulate = ${color4(w.color)}`);
|
|
110
|
+
if (w.imageType === 'filled') todo.push('Unity filled Image: use a TextureProgressBar (fill_mode) or a shader for the fill amount');
|
|
111
|
+
if (w.imageType === 'sliced') todo.push('Unity sliced Image: use NinePatchRect with the sprite borders');
|
|
112
|
+
}
|
|
113
|
+
if (w.widget === 'Text' || w.widget === 'TextMeshPro') {
|
|
114
|
+
out.push(`text = ${q(w.text ?? '')}`);
|
|
115
|
+
if (w.fontSize) out.push(`theme_override_font_sizes/font_size = ${Math.round(w.fontSize)}`);
|
|
116
|
+
if (w.color && w.color.some((c, i) => Math.abs(c - 1) > 1e-6)) out.push(`theme_override_colors/font_color = ${color4(w.color)}`);
|
|
117
|
+
const al = w.widget === 'Text' ? { h: UI_ALIGN_H[w.alignment] ?? 0, v: UI_ALIGN_V[w.alignment] ?? 0 } : TMP_ALIGN(w.alignment ?? 257);
|
|
118
|
+
if (al.h) out.push(`horizontal_alignment = ${al.h}`);
|
|
119
|
+
if (al.v) out.push(`vertical_alignment = ${al.v}`);
|
|
120
|
+
out.push('autowrap_mode = 3');
|
|
121
|
+
}
|
|
122
|
+
if (w.widget === 'Button') {
|
|
123
|
+
const label = (node.children ?? []).flatMap(c => (c.components ?? []).map(x => x.ui).filter(Boolean)).find(u => u.widget === 'Text' || u.widget === 'TextMeshPro');
|
|
124
|
+
if (label?.text) out.push(`text = ${q(label.text)}`);
|
|
125
|
+
if (w.onClick?.length) todo.push(`OnClick → ${w.onClick.map(c => c.method).join(', ')}: connect the pressed signal to the ported method`);
|
|
126
|
+
}
|
|
127
|
+
if (w.widget === 'Slider') { out.push(`min_value = ${f(w.min ?? 0)}`, `max_value = ${f(w.max ?? 1)}`, `value = ${f(w.value ?? 0)}`, `step = ${w.wholeNumbers ? 1 : 0.001}`); }
|
|
128
|
+
if (w.widget === 'Toggle' && w.isOn) out.push('button_pressed = true');
|
|
129
|
+
if (w.widget === 'InputField') out.push(`text = ${q(w.text ?? '')}`);
|
|
130
|
+
if (w.widget === 'GridLayoutGroup') { out.push(`columns = ${Math.max(1, w.constraintCount ?? 2)}`); if (w.spacing) out.push(`theme_override_constants/h_separation = ${f(w.spacing)}`, `theme_override_constants/v_separation = ${f(w.spacing)}`); }
|
|
131
|
+
if (w.widget === 'VerticalLayoutGroup' || w.widget === 'LayoutGroup') { if (w.spacing) out.push(`theme_override_constants/separation = ${f(w.spacing)}`); }
|
|
132
|
+
if (w.widget === 'Mask') out.push('clip_contents = true');
|
|
133
|
+
}
|
|
134
|
+
if (roles.ui.group && roles.ui.group.alpha !== undefined && Math.abs(roles.ui.group.alpha - 1) > 1e-6) out.push(`modulate = Color(1, 1, 1, ${f(roles.ui.group.alpha)})`);
|
|
135
|
+
if (node.active === false) out.push('visible = false');
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
74
139
|
// ---------------------------------------------------------------- → Godot .tscn
|
|
75
140
|
/**
|
|
76
141
|
* Write a Godot 4 text scene from a neutral tree that is already in Godot coordinates.
|
|
77
142
|
* resolve(scriptOrPrefabSourcePath) → res:// path of the ported file (or null).
|
|
78
143
|
*/
|
|
79
|
-
export function writeTscn(root, { engine = 'unity', resolve = () => null, name } = {}) {
|
|
144
|
+
export function writeTscn(root, { engine = 'unity', resolve = () => null, animator = () => null, name } = {}) {
|
|
80
145
|
const ext = [], subs = [], nodes = [];
|
|
81
146
|
const extId = new Map();
|
|
82
147
|
const addExt = (type, p) => { const k = `${type}:${p}`; if (!extId.has(k)) { const id = `${ext.length + 1}_rp`; extId.set(k, id); ext.push(`[ext_resource type="${type}" path=${q(p)} id="${id}"]`); } return extId.get(k); };
|
|
@@ -100,8 +165,14 @@ export function writeTscn(root, { engine = 'unity', resolve = () => null, name }
|
|
|
100
165
|
if (c.shape === 'capsule') return addSub('CapsuleShape3D', { radius: f(c.radius ?? 0.5), height: f(c.height ?? 2) });
|
|
101
166
|
return null;
|
|
102
167
|
};
|
|
168
|
+
const UI_TYPES = { Image: 'TextureRect', RawImage: 'TextureRect', Text: 'Label', TextMeshPro: 'Label', Button: 'Button', Slider: 'HSlider', Toggle: 'CheckBox', ScrollRect: 'ScrollContainer', InputField: 'LineEdit', GridLayoutGroup: 'GridContainer', VerticalLayoutGroup: 'VBoxContainer', LayoutGroup: 'HBoxContainer', Mask: 'Control', CanvasScaler: null };
|
|
103
169
|
const godotType = (node, roles) => {
|
|
104
170
|
if (node.type === 'PrefabInstance') return null;
|
|
171
|
+
if (roles.ui) {
|
|
172
|
+
const w = roles.ui.widgets.find(x => UI_TYPES[x.widget]);
|
|
173
|
+
if (w?.widget === 'Slider' && w.vertical) return 'VSlider';
|
|
174
|
+
return (w && UI_TYPES[w.widget]) || 'Control';
|
|
175
|
+
}
|
|
105
176
|
if (roles.body === 'character') return 'CharacterBody3D';
|
|
106
177
|
if (roles.body === 'rigid') return 'RigidBody3D';
|
|
107
178
|
if (roles.body === 'kinematic') return 'AnimatableBody3D';
|
|
@@ -127,20 +198,31 @@ export function writeTscn(root, { engine = 'unity', resolve = () => null, name }
|
|
|
127
198
|
}
|
|
128
199
|
if (node.tag) attrs.push(`groups=[${q(node.tag)}]`);
|
|
129
200
|
const lines = [`[node ${attrs.join(' ')}]`];
|
|
130
|
-
|
|
201
|
+
const todo = [];
|
|
202
|
+
if (roles.ui) lines.push(...uiProps(node, roles, todo, addExt, resolve));
|
|
203
|
+
else if (!isIdentity(node.transform)) lines.push(`transform = ${tf(node.transform)}`);
|
|
131
204
|
const scripts = (roles.scripts ?? []).map(s => ({ src: s, res: resolve(s) }));
|
|
132
205
|
if (scripts[0]?.res) lines.push(`script = ExtResource("${addExt('Script', scripts[0].res)}")`);
|
|
133
|
-
const todo = [];
|
|
134
206
|
if (node.type === 'PrefabInstance' && node.prefab && !resolve(node.prefab)) todo.push(`instance of ${node.prefab} (not ported yet)`);
|
|
207
|
+
for (const ov of node.overrides ?? []) {
|
|
208
|
+
if (ov.property === 'm_IsActive') { if (ov.value === '0') lines.push('visible = false'); continue; }
|
|
209
|
+
todo.push(`override ${ov.property} = ${ov.reference ?? ov.value} (set it on the ported node)`);
|
|
210
|
+
}
|
|
135
211
|
if (scripts.length > 1) todo.push(`more scripts on this GameObject: ${scripts.slice(1).map(s => s.src).join(', ')} — add them as child nodes`);
|
|
136
212
|
if (scripts[0] && !scripts[0].res) todo.push(`script ${scripts[0].src} not ported yet`);
|
|
137
213
|
if (roles.camera) lines.push(`fov = ${f(roles.camera.fov)}`);
|
|
138
|
-
if (node.active === false) lines.push('visible = false');
|
|
214
|
+
if (node.active === false && !roles.ui) lines.push('visible = false');
|
|
139
215
|
if (roles.mesh?.asset) todo.push(`mesh ${roles.mesh.asset}: import the model and assign it`);
|
|
140
216
|
if (roles.audio?.clip) todo.push(`audio clip ${roles.audio.clip}`);
|
|
141
217
|
if (todo.length) lines.push(`metadata/reverseport_todo = ${q(todo.join('; '))}`);
|
|
142
218
|
nodes.push(lines.join('\n'));
|
|
143
219
|
const myPath = isRoot ? '.' : parentPath === '.' ? nodeName : `${parentPath}/${nodeName}`;
|
|
220
|
+
if (roles.animator !== undefined) {
|
|
221
|
+
const info = animator(roles.animator) ?? { clips: [], script: null };
|
|
222
|
+
const lib = info.clips.length ? addSub('AnimationLibrary', { _data: `{\n${info.clips.map(c => `${q(c.name)}: ExtResource("${addExt('Animation', c.res)}")`).join(',\n')}\n}` }) : null;
|
|
223
|
+
nodes.push(`[node name="AnimationPlayer" type="AnimationPlayer" parent=${q(myPath)}]${lib ? `\nlibraries = {\n"": SubResource("${lib}")\n}` : ''}${info.missing?.length ? `\nmetadata/reverseport_todo = ${q(`clips not ported yet: ${info.missing.join(', ')}`)}` : ''}`);
|
|
224
|
+
nodes.push(`[node name="Animator" type="Node" parent=${q(myPath)}]${info.script ? `\nscript = ExtResource("${addExt('Script', info.script)}")\nplayer = NodePath("../AnimationPlayer")` : `\nmetadata/reverseport_todo = ${q(`animator controller ${roles.animator ?? '(none)'} not ported yet: port it with port_scene, then set script here`)}`}`);
|
|
225
|
+
}
|
|
144
226
|
if (roles.mesh?.primitive && roles.body) {
|
|
145
227
|
const id = meshSub(roles.mesh);
|
|
146
228
|
nodes.push(`[node name="Mesh" type="MeshInstance3D" parent=${q(myPath)}]\nmesh = SubResource("${id}")`);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// re_refs: audit Unity GUID references. Finds references to GUIDs that no .meta file declares (dangling), the
|
|
2
|
+
// same GUID declared by two .meta files (duplicates after a copy), assets without a .meta file, and .meta files
|
|
3
|
+
// whose asset is gone. Suggests remaps for dangling script references by matching the MonoBehaviour class name
|
|
4
|
+
// to a script with the same name. Reports only: nothing is written.
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { RPError, requireDir, walk, limit } from './util.mjs';
|
|
8
|
+
import { findUnityRoot } from './scene.mjs';
|
|
9
|
+
|
|
10
|
+
const BUILTIN = new Map([
|
|
11
|
+
['0000000000000000e000000000000000', 'Unity built-in resources'],
|
|
12
|
+
['0000000000000000f000000000000000', 'Unity built-in extra resources'],
|
|
13
|
+
]);
|
|
14
|
+
const REF_EXT = new Set(['.unity', '.prefab', '.asset', '.mat', '.controller', '.anim', '.playable', '.overridecontroller', '.mask', '.physicmaterial', '.physicsmaterial2d', '.flare', '.renderTexture', '.cubemap', '.spriteatlas', '.preset', '.shadervariants', '.signal', '.guiskin', '.fontsettings', '.terrainlayer', '.brush', '.mixer', '.lighting', '.giparams']);
|
|
15
|
+
|
|
16
|
+
async function readText(file, max = 32 * 1024 * 1024) {
|
|
17
|
+
const st = await fs.stat(file);
|
|
18
|
+
if (st.size > max) return null;
|
|
19
|
+
return fs.readFile(file, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Audit the GUID references of a Unity project. `path` may be the project root or any file/folder inside it. */
|
|
23
|
+
export async function refsTool({ path: target, limit: max = 200 }) {
|
|
24
|
+
const abs = await requireDir(target).catch(async () => path.dirname(path.resolve(target)));
|
|
25
|
+
const root = await findUnityRoot(path.join(abs, 'x')) ?? (await isUnityRoot(abs) ? abs : null);
|
|
26
|
+
if (!root) throw new RPError('NOT_UNITY', `No Unity project found at or above ${abs} (needs Assets/ and ProjectSettings/).`);
|
|
27
|
+
|
|
28
|
+
const declared = new Map(); // guid → [asset paths]
|
|
29
|
+
const metaFor = new Set(); // asset paths that have a .meta
|
|
30
|
+
const assets = []; // asset paths (not .meta)
|
|
31
|
+
const metaMissingAsset = [];
|
|
32
|
+
const scriptsByClass = new Map(); // class name → [script paths]
|
|
33
|
+
const refFiles = [];
|
|
34
|
+
|
|
35
|
+
for (const sub of ['Assets', 'Packages']) {
|
|
36
|
+
const base = path.join(root, sub);
|
|
37
|
+
const { files } = await walk(base, { maxFiles: 300000 }).catch(() => ({ files: [] }));
|
|
38
|
+
for (const f of files) {
|
|
39
|
+
const rel = `${sub}/${f.rel}`;
|
|
40
|
+
if (f.ext === '.meta') {
|
|
41
|
+
const head = (await readText(f.abs, 64 * 1024)) ?? '';
|
|
42
|
+
const m = head.match(/guid:\s*([0-9a-f]{32})/);
|
|
43
|
+
const asset = rel.replace(/\.meta$/, '');
|
|
44
|
+
metaFor.add(asset);
|
|
45
|
+
if (m) (declared.get(m[1]) ?? declared.set(m[1], []).get(m[1])).push(asset);
|
|
46
|
+
const assetAbs = f.abs.replace(/\.meta$/, '');
|
|
47
|
+
const st = await fs.stat(assetAbs).catch(() => null);
|
|
48
|
+
if (!st) metaMissingAsset.push({ meta: rel, expected: asset });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
assets.push(rel);
|
|
52
|
+
if (f.ext === '.cs') scriptsByClass.set(path.basename(f.rel, '.cs'), [...(scriptsByClass.get(path.basename(f.rel, '.cs')) ?? []), rel]);
|
|
53
|
+
if (REF_EXT.has(f.ext)) refFiles.push({ rel, abs: f.abs, ext: f.ext });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Assets without a .meta (Unity ignores them, or re-creates the .meta with a NEW guid → references break).
|
|
58
|
+
const missingMeta = assets.filter(a => !metaFor.has(a) && !/(^|\/)\.[^/]+$/.test(a) && !/^Packages\/(manifest|packages-lock)\.json$/.test(a));
|
|
59
|
+
|
|
60
|
+
// References.
|
|
61
|
+
const dangling = new Map(); // guid → { files: Set, scriptClass? }
|
|
62
|
+
const used = new Set();
|
|
63
|
+
let refCount = 0;
|
|
64
|
+
for (const f of refFiles) {
|
|
65
|
+
const text = await readText(f.abs);
|
|
66
|
+
if (text === null || !text.startsWith('%YAML')) continue;
|
|
67
|
+
// Pair each guid reference with the nearest preceding "m_Name:"/class hint so script remaps can be suggested.
|
|
68
|
+
for (const m of text.matchAll(/fileID:\s*(-?\d+),\s*guid:\s*([0-9a-f]{32})/g)) {
|
|
69
|
+
const guid = m[2];
|
|
70
|
+
refCount++;
|
|
71
|
+
used.add(guid);
|
|
72
|
+
if (declared.has(guid) || BUILTIN.has(guid)) continue;
|
|
73
|
+
const entry = dangling.get(guid) ?? dangling.set(guid, { guid, files: new Set(), fileIDs: new Set(), kinds: new Set() }).get(guid);
|
|
74
|
+
entry.files.add(f.rel);
|
|
75
|
+
entry.fileIDs.add(m[1]);
|
|
76
|
+
const before = text.slice(Math.max(0, m.index - 400), m.index);
|
|
77
|
+
const key = before.match(/(\w+):\s*\{?\s*$/)?.[1] ?? '';
|
|
78
|
+
if (key === 'm_Script') entry.kinds.add('script');
|
|
79
|
+
else if (key === 'm_Shader') entry.kinds.add('shader');
|
|
80
|
+
else if (key === 'm_Mesh') entry.kinds.add('mesh');
|
|
81
|
+
else if (key === 'm_Texture') entry.kinds.add('texture');
|
|
82
|
+
else if (key === 'm_SourcePrefab') entry.kinds.add('prefab');
|
|
83
|
+
else if (/m_Materials:\s*\n\s*-\s*\{?\s*$/.test(before)) entry.kinds.add('material');
|
|
84
|
+
else entry.kinds.add('other');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Suggest remaps for dangling script references: a MonoBehaviour's class name usually equals the file name, and
|
|
89
|
+
// Unity writes the class name next to the component in many files; when absent we list all unreferenced scripts.
|
|
90
|
+
const referencedScripts = new Set([...used].flatMap(g => declared.get(g) ?? []).filter(p => p.endsWith('.cs')));
|
|
91
|
+
const unreferencedScripts = assets.filter(a => a.endsWith('.cs') && !referencedScripts.has(a) && !/\/Editor\//.test(a));
|
|
92
|
+
const danglingList = [...dangling.values()].map(d => {
|
|
93
|
+
const suggestions = [];
|
|
94
|
+
if (d.kinds.has('script') && unreferencedScripts.length && unreferencedScripts.length <= 20) suggestions.push(...unreferencedScripts.map(s => ({ asset: s, guid: guidOf(declared, s), why: 'script never referenced by any other asset' })));
|
|
95
|
+
return { guid: d.guid, kinds: [...d.kinds], files: [...d.files].sort(), fileIDs: [...d.fileIDs].slice(0, 10), suggestions: suggestions.slice(0, 5) };
|
|
96
|
+
}).sort((a, b) => b.files.length - a.files.length);
|
|
97
|
+
|
|
98
|
+
const duplicates = [...declared.entries()].filter(([, paths]) => paths.length > 1).map(([guid, paths]) => ({ guid, assets: paths.sort(), referenced: used.has(guid), note: 'Two .meta files declare the same guid (copied outside Unity); references resolve to whichever Unity imports first.' }));
|
|
99
|
+
const orphans = [...declared.entries()].filter(([guid, paths]) => !used.has(guid) && paths.every(p => !/\.(cs|unity|asmdef|dll)$/.test(p) && !/^Packages\//.test(p))).map(([guid, paths]) => ({ guid, asset: paths[0] }));
|
|
100
|
+
|
|
101
|
+
const problems = danglingList.length + duplicates.length + missingMeta.length + metaMissingAsset.length;
|
|
102
|
+
return {
|
|
103
|
+
unityProject: root,
|
|
104
|
+
summary: { metaFiles: metaFor.size, guidsDeclared: declared.size, references: refCount, dangling: danglingList.length, duplicateGuids: duplicates.length, assetsWithoutMeta: missingMeta.length, metaWithoutAsset: metaMissingAsset.length, unreferencedAssets: orphans.length },
|
|
105
|
+
dangling: limit(danglingList, max),
|
|
106
|
+
duplicateGuids: limit(duplicates, max),
|
|
107
|
+
assetsWithoutMeta: limit(missingMeta.sort(), max),
|
|
108
|
+
metaWithoutAsset: limit(metaMissingAsset, max),
|
|
109
|
+
unreferencedAssets: limit(orphans, max),
|
|
110
|
+
builtin: [...BUILTIN].map(([guid, what]) => ({ guid, what })),
|
|
111
|
+
passed: problems === 0,
|
|
112
|
+
next: problems ? 'Fix in Unity (reimport or re-link in the Inspector) or write the suggested guid into the .meta of the right asset; keep the asset path, never edit references in scenes by hand.' : 'All GUID references resolve.',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const guidOf = (declared, asset) => { for (const [g, paths] of declared) if (paths.includes(asset)) return g; return null; };
|
|
117
|
+
|
|
118
|
+
async function isUnityRoot(dir) {
|
|
119
|
+
try { await fs.access(path.join(dir, 'Assets')); await fs.access(path.join(dir, 'ProjectSettings')); return true; } catch { return false; }
|
|
120
|
+
}
|