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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// re_il2cpp: names out of a Unity IL2CPP global-metadata.dat — assemblies, namespaces, types, methods and string
|
|
2
|
+
// literals — so a Unity game without its C# source still yields a class map to port or rebuild from.
|
|
3
|
+
// The header is a list of (offset, size) section pairs whose order differs between metadata versions (24–31+),
|
|
4
|
+
// so sections are recognised by validating their records against the identifier string table instead of trusting
|
|
5
|
+
// fixed indices. Only names are read; no code is decompiled (the code lives in GameAssembly/libil2cpp/.wasm as
|
|
6
|
+
// native code: re_binary). Encrypted or obfuscated metadata (wrong magic, unreadable string table) is reported.
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { RPError, requireFile, limit } from './util.mjs';
|
|
10
|
+
import { workspaceDir } from './workspace.mjs';
|
|
11
|
+
|
|
12
|
+
const MAGIC = 0xfab11baf;
|
|
13
|
+
|
|
14
|
+
export function parseMetadata(buf) {
|
|
15
|
+
if (buf.length < 64) throw new RPError('BAD_FORMAT', 'File too small for IL2CPP metadata.');
|
|
16
|
+
const magic = buf.readUInt32LE(0);
|
|
17
|
+
if (magic !== MAGIC) throw new RPError('BAD_FORMAT', `Not IL2CPP metadata (magic 0x${magic.toString(16)}); the game may encrypt or obfuscate it. Reported, not decrypted.`);
|
|
18
|
+
const version = buf.readInt32LE(4);
|
|
19
|
+
// Header: pairs of (int32 offset, int32 size) until the first section starts.
|
|
20
|
+
const pairs = [];
|
|
21
|
+
let firstData = buf.length;
|
|
22
|
+
for (let p = 8; p + 8 <= Math.min(buf.length, firstData); p += 8) {
|
|
23
|
+
const off = buf.readInt32LE(p), size = buf.readInt32LE(p + 4);
|
|
24
|
+
if (off < 0 || size < 0 || off + size > buf.length) break;
|
|
25
|
+
pairs.push({ index: pairs.length, offset: off, size });
|
|
26
|
+
if (off > 0 && off < firstData) firstData = off;
|
|
27
|
+
}
|
|
28
|
+
// String table: the section of NUL-terminated identifiers. It is pair 2 in every known version; verify.
|
|
29
|
+
const isIdentTable = s => { if (!s || s.size < 4) return false; const b = buf.subarray(s.offset, s.offset + Math.min(s.size, 4096)); let printable = 0, nul = 0; for (const c of b) { if (c === 0) nul++; else if (c >= 0x20 && c < 0x7f) printable++; } return nul > 4 && printable + nul > b.length * 0.97; };
|
|
30
|
+
const strSec = isIdentTable(pairs[2]) ? pairs[2] : pairs.find(isIdentTable);
|
|
31
|
+
if (!strSec) throw new RPError('BAD_FORMAT', 'No readable identifier table: metadata is probably encrypted or obfuscated. Reported, not decrypted.');
|
|
32
|
+
const str = idx => { if (idx < 0 || idx >= strSec.size) return null; const s = strSec.offset + idx; const e = buf.indexOf(0, s); return e < 0 || e - s > 1024 ? null : buf.toString('utf8', s, e); };
|
|
33
|
+
const validName = idx => { const s = str(idx); return s != null && /^[\w<>`.$|=@-]*$/.test(s); };
|
|
34
|
+
|
|
35
|
+
// Images (40 bytes in v24.1+): nameIndex, assemblyIndex, typeStart, typeCount, … ; names end in .dll.
|
|
36
|
+
let images = null;
|
|
37
|
+
for (const s of pairs) {
|
|
38
|
+
if (s === strSec || s.size === 0 || s.size % 40) continue;
|
|
39
|
+
const n = s.size / 40;
|
|
40
|
+
const out = [];
|
|
41
|
+
let ok = true, expect = 0;
|
|
42
|
+
for (let i = 0; i < n && ok; i++) {
|
|
43
|
+
const r = s.offset + i * 40;
|
|
44
|
+
const name = str(buf.readInt32LE(r)), typeStart = buf.readInt32LE(r + 8), typeCount = buf.readUInt32LE(r + 12);
|
|
45
|
+
if (!name || !/\.dll$/i.test(name) || typeStart !== expect) ok = false; else { out.push({ name, typeStart, typeCount }); expect += typeCount; }
|
|
46
|
+
}
|
|
47
|
+
if (ok && out.length) { images = { section: s.index, list: out, typeTotal: expect }; break; }
|
|
48
|
+
}
|
|
49
|
+
if (!images) throw new RPError('BAD_FORMAT', 'Could not find the image (assembly) table; this metadata version or layout is not recognised.');
|
|
50
|
+
|
|
51
|
+
// Methods: records with nameIndex at 0 and declaringType at 4 (a type index); record size 24–40 depending on version.
|
|
52
|
+
let methods = null;
|
|
53
|
+
for (const s of pairs) {
|
|
54
|
+
if (s === strSec || s.index === images.section || s.size < 24) continue;
|
|
55
|
+
for (const rec of [32, 36, 28, 24, 40]) {
|
|
56
|
+
if (s.size % rec) continue;
|
|
57
|
+
const n = s.size / rec, sample = Math.min(n, 80);
|
|
58
|
+
let good = 0, lastDecl = -1, monotone = true;
|
|
59
|
+
for (let i = 0; i < sample; i++) { const r = s.offset + i * rec; const nm = buf.readInt32LE(r), decl = buf.readInt32LE(r + 4); if (validName(nm) && str(nm) && decl >= 0 && decl < images.typeTotal) good++; if (decl < lastDecl) monotone = false; lastDecl = decl; }
|
|
60
|
+
if (good >= sample * 0.95 && monotone) { methods = { section: s.index, rec, count: n }; break; }
|
|
61
|
+
}
|
|
62
|
+
if (methods) break;
|
|
63
|
+
}
|
|
64
|
+
// Type definitions: the section whose size divides by the type total and whose records start with name/namespace indices.
|
|
65
|
+
let types = null;
|
|
66
|
+
for (const s of pairs) {
|
|
67
|
+
if (s === strSec || s.index === methods?.section || s.index === images.section || !images.typeTotal || s.size % images.typeTotal) continue;
|
|
68
|
+
const rec = s.size / images.typeTotal;
|
|
69
|
+
if (rec < 48 || rec > 160) continue;
|
|
70
|
+
const sample = Math.min(images.typeTotal, 64);
|
|
71
|
+
let good = 0;
|
|
72
|
+
for (let i = 0; i < sample; i++) { const r = s.offset + i * rec; if (validName(buf.readInt32LE(r)) && validName(buf.readInt32LE(r + 4)) && str(buf.readInt32LE(r))) good++; }
|
|
73
|
+
if (good >= sample * 0.95) { types = { section: s.index, rec }; break; }
|
|
74
|
+
}
|
|
75
|
+
if (!types) throw new RPError('BAD_FORMAT', 'Could not find the type definition table for this metadata version.');
|
|
76
|
+
const typeList = [];
|
|
77
|
+
for (let i = 0; i < images.typeTotal; i++) {
|
|
78
|
+
const r = pairs[types.section].offset + i * types.rec;
|
|
79
|
+
typeList.push({ index: i, name: str(buf.readInt32LE(r)), namespace: str(buf.readInt32LE(r + 4)) || '', methods: [] });
|
|
80
|
+
}
|
|
81
|
+
for (const img of images.list) for (let i = img.typeStart; i < img.typeStart + img.typeCount; i++) typeList[i].assembly = img.name;
|
|
82
|
+
|
|
83
|
+
let methodCount = 0;
|
|
84
|
+
if (methods) for (let i = 0; i < methods.count; i++) { const r = pairs[methods.section].offset + i * methods.rec; const name = str(buf.readInt32LE(r)); const decl = buf.readInt32LE(r + 4); if (name && typeList[decl]) { typeList[decl].methods.push(name); methodCount++; } }
|
|
85
|
+
|
|
86
|
+
// String literals: (length, dataIndex) pairs into the literal data blob (pair 0/1 in v24–v29).
|
|
87
|
+
const literals = [];
|
|
88
|
+
const litSec = pairs[0], litData = pairs[1];
|
|
89
|
+
if (litSec && litData && litSec.size % 8 === 0 && litSec.size) {
|
|
90
|
+
for (let i = 0; i < Math.min(litSec.size / 8, 200000); i++) { const len = buf.readUInt32LE(litSec.offset + i * 8), di = buf.readInt32LE(litSec.offset + i * 8 + 4); if (di < 0 || di + len > litData.size) { literals.length = 0; break; } literals.push(buf.toString('utf8', litData.offset + di, litData.offset + di + len)); }
|
|
91
|
+
}
|
|
92
|
+
return { version, sections: pairs.length, images: images.list, types: typeList, methodCount, literals, layout: { strings: strSec.index, images: images.section, types: types.section, typeRecord: types.rec, methods: methods?.section ?? null, methodRecord: methods?.rec ?? null } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const SYSTEM_ASM = /^(mscorlib|System|Mono\.|Unity\.|UnityEngine|netstandard|Microsoft\.|DOTween|TextMeshPro|Newtonsoft)/;
|
|
96
|
+
|
|
97
|
+
/** C# skeleton per assembly: namespaces, classes and method names, for port_plan/re_blueprint to work from. */
|
|
98
|
+
export function skeleton(meta, { includeSystem = false } = {}) {
|
|
99
|
+
const byAsm = new Map();
|
|
100
|
+
for (const t of meta.types) {
|
|
101
|
+
if (!t.name || t.name === '<Module>' || t.name.startsWith('<')) continue;
|
|
102
|
+
if (!includeSystem && SYSTEM_ASM.test(t.assembly ?? '')) continue;
|
|
103
|
+
if (!byAsm.has(t.assembly)) byAsm.set(t.assembly, []);
|
|
104
|
+
byAsm.get(t.assembly).push(t);
|
|
105
|
+
}
|
|
106
|
+
const files = [];
|
|
107
|
+
for (const [asm, list] of byAsm) {
|
|
108
|
+
const lines = [`// ${asm}: names recovered by ReversePort re_il2cpp from global-metadata.dat. Bodies are not in the metadata.`, ''];
|
|
109
|
+
const byNs = new Map();
|
|
110
|
+
for (const t of list) { if (!byNs.has(t.namespace)) byNs.set(t.namespace, []); byNs.get(t.namespace).push(t); }
|
|
111
|
+
for (const [ns, ts] of [...byNs].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
112
|
+
const ind = ns ? ' ' : '';
|
|
113
|
+
if (ns) lines.push(`namespace ${ns}`, '{');
|
|
114
|
+
for (const t of ts) {
|
|
115
|
+
const cls = t.name.replace(/`\d+$/, '');
|
|
116
|
+
lines.push(`${ind}public class ${cls}`, `${ind}{`);
|
|
117
|
+
for (const m of [...new Set(t.methods)]) if (!m.startsWith('<')) lines.push(`${ind} // ${m}()`);
|
|
118
|
+
lines.push(`${ind}}`, '');
|
|
119
|
+
}
|
|
120
|
+
if (ns) lines.push('}', '');
|
|
121
|
+
}
|
|
122
|
+
files.push({ name: `${asm.replace(/\.dll$/i, '')}.cs`, text: lines.join('\n'), types: list.length });
|
|
123
|
+
}
|
|
124
|
+
return files;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function il2cppTool({ file, out, match, includeSystem = false, limit: max = 400 } = {}) {
|
|
128
|
+
if (!file) throw new RPError('INVALID_ARGUMENT', 'file is required: global-metadata.dat (in <Game>_Data/il2cpp_data/Metadata/ or Il2CppData/Metadata in WebGL).');
|
|
129
|
+
const { abs } = await requireFile(file);
|
|
130
|
+
const meta = parseMetadata(await fs.readFile(abs));
|
|
131
|
+
const q = match ? String(match).toLowerCase() : null;
|
|
132
|
+
const gameTypes = meta.types.filter(t => t.name && !t.name.startsWith('<') && (includeSystem || !SYSTEM_ASM.test(t.assembly ?? '')));
|
|
133
|
+
const shown = gameTypes.filter(t => !q || `${t.namespace}.${t.name} ${t.methods.join(' ')}`.toLowerCase().includes(q));
|
|
134
|
+
const unityCallbacks = ['Awake', 'Start', 'Update', 'FixedUpdate', 'LateUpdate', 'OnEnable', 'OnDisable', 'OnDestroy', 'OnTriggerEnter', 'OnCollisionEnter', 'OnTriggerEnter2D', 'OnCollisionEnter2D'];
|
|
135
|
+
const behaviours = gameTypes.filter(t => t.methods.some(m => unityCallbacks.includes(m))).map(t => `${t.namespace ? t.namespace + '.' : ''}${t.name}`);
|
|
136
|
+
const files = skeleton(meta, { includeSystem });
|
|
137
|
+
const dir = out ? path.resolve(out) : path.join(await workspaceDir('il2cpp'), path.basename(path.dirname(path.dirname(abs))) || 'game');
|
|
138
|
+
await fs.mkdir(dir, { recursive: true });
|
|
139
|
+
const written = [];
|
|
140
|
+
for (const f of files) { const p = path.join(dir, f.name); await fs.writeFile(p, f.text); written.push(p); }
|
|
141
|
+
const lit = meta.literals.filter(s => s.length > 1 && s.length < 200);
|
|
142
|
+
return {
|
|
143
|
+
file: abs, metadataVersion: meta.version, assemblies: meta.images.map(i => ({ name: i.name, types: i.typeCount })), types: meta.types.length, gameTypes: gameTypes.length, methods: meta.methodCount,
|
|
144
|
+
behaviours: limit(behaviours, 300), typesMatching: limit(shown.map(t => ({ name: `${t.namespace ? t.namespace + '.' : ''}${t.name}`, assembly: t.assembly, methods: [...new Set(t.methods)].slice(0, 40) })), max),
|
|
145
|
+
stringLiterals: { count: meta.literals.length, sample: lit.slice(0, 80) }, written, layout: meta.layout,
|
|
146
|
+
next: 'The skeleton lists every game class and method name; behaviours are MonoBehaviours (Unity callbacks). Port or rebuild from it with re_blueprint/port_plan; for method bodies, read the native code (GameAssembly.dll / libil2cpp.so / .wasm) with re_binary and the deep-analysis engine, matching names from here.',
|
|
147
|
+
};
|
|
148
|
+
}
|
package/port/src/jsworkspace.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export function packageCandidates({ dir, pkg }, sub) {
|
|
|
80
80
|
const pre = k.slice(0, star), post = k.slice(star + 1);
|
|
81
81
|
if (key.startsWith(pre) && key.endsWith(post) && key.length >= pre.length + post.length) {
|
|
82
82
|
const t = exportTarget(v);
|
|
83
|
-
if (t) out.push(t.
|
|
83
|
+
if (t) out.push(t.replaceAll('*', () => key.slice(pre.length, key.length - post.length))); // Node replaces every * in the target; the function keeps $&, $1… in the match literal
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
} else if (!sub && (typeof ex === 'string' || Array.isArray(ex))) { const t = exportTarget(ex); if (t) out.push(t); }
|
|
@@ -144,7 +144,7 @@ export async function tsconfigAliases(files, byRel) {
|
|
|
144
144
|
if (!spec.startsWith(pre) || !spec.endsWith(post) || spec.length < pre.length + post.length) continue;
|
|
145
145
|
wild = spec.slice(pre.length, spec.length - post.length);
|
|
146
146
|
}
|
|
147
|
-
for (const t of [targets].flat()) if (typeof t === 'string') out.push(path.posix.normalize(path.posix.join(cfg.baseDir || '.', t.
|
|
147
|
+
for (const t of [targets].flat()) if (typeof t === 'string') out.push(path.posix.normalize(path.posix.join(cfg.baseDir || '.', t.replaceAll('*', () => wild))));
|
|
148
148
|
}
|
|
149
149
|
return out;
|
|
150
150
|
}
|
package/port/src/knowledge.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { RPError } from './util.mjs';
|
|
7
7
|
import { closest } from './agent.mjs';
|
|
8
|
+
import { physicsProfile } from './physics.mjs';
|
|
8
9
|
|
|
9
10
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'knowledge');
|
|
10
11
|
export const ENGINES = JSON.parse(fs.readFileSync(path.join(ROOT, 'engines.json'), 'utf8'));
|
|
@@ -45,7 +46,8 @@ export function portMapTool({ from, to, query, topic } = {}) {
|
|
|
45
46
|
}
|
|
46
47
|
const q = query ? String(query).toLowerCase() : null;
|
|
47
48
|
const entries = map.entries.filter(e => (!topic || e.topic === topic) && (!q || `${e.id} ${e.topic} ${e.from} ${e.to} ${e.notes ?? ''}`.toLowerCase().includes(q))).map(({ re, leftoverRe, pattern, leftover, ...e }) => e);
|
|
48
|
-
|
|
49
|
+
let physics = null; try { const p = physicsProfile({ from: a, to: b }); physics = { differences: p.differences, from: p.profiles.from, to: p.profiles.to, hint: 'port_convert {from, to, physics:{gravity, fixedStep, linearDamp, friction, bounce, jumpVelocity, layers}} converts the actual numbers.' }; } catch { /* no profile for this pair */ }
|
|
50
|
+
return { title: map.title, from: a, to: b, fromEngine: ENGINES[a] ?? null, toEngine: ENGINES[b] ?? null, conventions: map.conventions, topics: [...new Set(map.entries.map(e => e.topic))], entries, ...(physics && !q && !topic ? { physics } : {}), ...(!q && !topic ? { hint: 'This is the whole map; query or topic narrows it (e.g. {query:"signal"}). port_unit already lists the entries a file uses.' } : {}) };
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/** Which mapped APIs does this source text use? */
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Physics profiles: what each engine does differently (gravity, fixed step, damping, friction/bounce combination,
|
|
2
|
+
// layers, character controllers, units) and how to carry one project's physics numbers to another engine.
|
|
3
|
+
// Numbers come from the engines' documented defaults and source (Godot: PhysicsMaterial.xml, godot_body_pair_3d.cpp;
|
|
4
|
+
// Unity: Physics/Rigidbody/PhysicMaterial manual; Unreal: UPhysicalMaterial/CharacterMovementComponent docs).
|
|
5
|
+
import { RPError } from './util.mjs';
|
|
6
|
+
|
|
7
|
+
export const PHYSICS = {
|
|
8
|
+
godot: {
|
|
9
|
+
label: 'Godot 4', units: 'm (3D) / px (2D, 100 px ≈ 1 m by default)', gravity3d: 9.8, gravity2d: 980, fixedStep: 1 / 60, fixedStepSetting: 'physics/common/physics_ticks_per_second (60)',
|
|
10
|
+
engineSetting: 'physics/3d/physics_engine: "Jolt Physics" (default since 4.4) or "GodotPhysics3D"',
|
|
11
|
+
gravitySetting: 'physics/3d/default_gravity (9.8) + physics/3d/default_gravity_vector; 2D: physics/2d/default_gravity (980)',
|
|
12
|
+
linearDamp: 0.1, angularDamp: 0.1, dampSetting: 'physics/3d/default_linear_damp (0.1) and default_angular_damp (0.1); per body linear_damp with linear_damp_mode Combine (adds to default) or Replace',
|
|
13
|
+
dampFormula: 'v *= 1 - min(damp * dt, 1) per physics tick (same shape as Unity drag and Unreal damping)',
|
|
14
|
+
friction: 1.0, bounce: 0.0, frictionCombine: 'min of both (GodotPhysics; rough=true wins), Jolt: geometric mean sqrt(f1*f2)', bounceCombine: 'sum clamped to 1 (GodotPhysics; absorbent subtracts), Jolt: max',
|
|
15
|
+
material: 'PhysicsMaterial { friction (one value, no static/dynamic split), bounce, rough, absorbent } on the body or shape owner',
|
|
16
|
+
layers: '32 layers; each body has collision_layer (what it is) and collision_mask (what it scans). A collides with B when A.mask includes B.layer — one-directional per body, no global matrix',
|
|
17
|
+
character: 'CharacterBody3D: no gravity, you integrate velocity and call move_and_slide(); floor_max_angle 45°, floor_snap_length 0.1, wall_min_slide_angle 15°',
|
|
18
|
+
ccd: 'RigidBody3D.continuous_cd (Jolt supports it; GodotPhysics uses it for shape casts)', mass: 'RigidBody3D.mass (kg, default 1); center_of_mass_mode Auto',
|
|
19
|
+
interpolation: 'physics/common/physics_interpolation (4.3+) or manual lerp in _process',
|
|
20
|
+
},
|
|
21
|
+
'godot-csharp': { alias: 'godot' },
|
|
22
|
+
unity: {
|
|
23
|
+
label: 'Unity', units: 'm (3D) / units with Pixels Per Unit (2D sprites, default 100 px per unit)', gravity3d: 9.81, gravity2d: 9.81, fixedStep: 0.02, fixedStepSetting: 'Time.fixedDeltaTime / Project Settings ▸ Time ▸ Fixed Timestep (0.02 s = 50 Hz)',
|
|
24
|
+
engineSetting: 'PhysX (3D) / Box2D (2D); Unity 6: Physics ▸ Simulation Mode',
|
|
25
|
+
gravitySetting: 'Physics.gravity (0, -9.81, 0); Physics2D.gravity (0, -9.81)',
|
|
26
|
+
linearDamp: 0, angularDamp: 0.05, dampSetting: 'Rigidbody.drag / linearDamping (0) and angularDrag / angularDamping (0.05) per body; no project default',
|
|
27
|
+
dampFormula: 'v *= clamp01(1 - drag * fixedDeltaTime) per fixed step',
|
|
28
|
+
friction: 0.6, bounce: 0.0, frictionCombine: 'Average by default (Minimum, Maximum, Multiply per PhysicMaterial)', bounceCombine: 'Average by default',
|
|
29
|
+
material: 'PhysicMaterial { dynamicFriction (0.6), staticFriction (0.6), bounciness (0), frictionCombine, bounceCombine } on the Collider',
|
|
30
|
+
layers: '32 layers with a global Layer Collision Matrix (ProjectSettings/DynamicsManager.asset m_LayerCollisionMatrix); a pair collides when the matrix allows it, symmetric',
|
|
31
|
+
character: 'CharacterController: no gravity, you add it and call Move(); slopeLimit 45°, stepOffset 0.3, skinWidth 0.08, minMoveDistance 0.001',
|
|
32
|
+
ccd: 'Rigidbody.collisionDetectionMode (Discrete, Continuous, ContinuousDynamic, ContinuousSpeculative)', mass: 'Rigidbody.mass (kg, default 1); automatic center of mass',
|
|
33
|
+
interpolation: 'Rigidbody.interpolation (None, Interpolate, Extrapolate)',
|
|
34
|
+
},
|
|
35
|
+
unreal: {
|
|
36
|
+
label: 'Unreal Engine 5', units: 'cm', gravity3d: 980, gravity2d: 980, fixedStep: null, fixedStepSetting: 'variable (frame rate) by default; Project Settings ▸ Physics ▸ Substepping or Tick Physics Async for a fixed step',
|
|
37
|
+
engineSetting: 'Chaos',
|
|
38
|
+
gravitySetting: 'Project Settings ▸ Physics ▸ Default Gravity Z (-980 cm/s²); WorldSettings GlobalGravityZ; per actor GravityScale',
|
|
39
|
+
linearDamp: 0.01, angularDamp: 0, dampSetting: 'BodyInstance LinearDamping (0.01) and AngularDamping (0) per primitive component',
|
|
40
|
+
dampFormula: 'v *= max(0, 1 - damping * dt) per substep (PhysX/Chaos convention)',
|
|
41
|
+
friction: 0.7, bounce: 0.3, frictionCombine: 'Average by default (Min, Multiply, Max; bOverrideFrictionCombineMode on the physical material)', bounceCombine: 'Average by default',
|
|
42
|
+
material: 'UPhysicalMaterial { Friction (0.7), StaticFriction (0, uses Friction unless overridden), Restitution (0.3), Density (1 g/cm³) } assigned to the mesh or collision',
|
|
43
|
+
layers: 'Object channels (WorldStatic, Pawn, PhysicsBody, custom…) with per-component responses Block/Overlap/Ignore, usually via Collision Presets; asymmetric responses possible (both must Block to collide)',
|
|
44
|
+
character: 'CharacterMovementComponent: gravity applied by the component (GravityScale 1), JumpZVelocity 420 cm/s, MaxWalkSpeed 600 cm/s, GroundFriction 8, BrakingDecelerationWalking 2048, MaxStepHeight 45 cm, WalkableFloorAngle 44.765°',
|
|
45
|
+
ccd: 'BodyInstance bUseCCD', mass: 'computed from volume × material density unless MassInKg override is set (default: a 1 m cube ≈ 1000 kg)',
|
|
46
|
+
interpolation: 'smooth by default with substepping; Chaos async tick interpolates',
|
|
47
|
+
},
|
|
48
|
+
threejs: {
|
|
49
|
+
label: 'three.js + Rapier (or cannon-es)', units: 'm', gravity3d: 9.81, gravity2d: 9.81, fixedStep: 1 / 60, fixedStepSetting: 'world.timestep (Rapier default 1/60) driven by your own accumulator',
|
|
50
|
+
engineSetting: 'no physics in three.js itself; Rapier (recommended), cannon-es, ammo.js',
|
|
51
|
+
gravitySetting: 'new RAPIER.World({ x: 0, y: -9.81, z: 0 })',
|
|
52
|
+
linearDamp: 0, angularDamp: 0, dampSetting: 'RigidBodyDesc.setLinearDamping / setAngularDamping per body',
|
|
53
|
+
dampFormula: 'v *= 1 / (1 + damping * dt) per step (Rapier)',
|
|
54
|
+
friction: 0.5, bounce: 0.0, frictionCombine: 'Average by default (ColliderDesc.setFrictionCombineRule: Average, Min, Multiply, Max)', bounceCombine: 'Average by default (setRestitutionCombineRule)',
|
|
55
|
+
material: 'ColliderDesc.setFriction / setRestitution per collider',
|
|
56
|
+
layers: 'collision groups: 16 membership bits + 16 filter bits packed in one u32 (ColliderDesc.setCollisionGroups); pair collides when each side\'s filter includes the other\'s membership',
|
|
57
|
+
character: 'KinematicCharacterController: you apply gravity to the desired translation; setMaxSlopeClimbAngle, enableAutostep, enableSnapToGround',
|
|
58
|
+
ccd: 'RigidBodyDesc.setCcdEnabled(true)', mass: 'from collider density (ColliderDesc.setDensity, default 1) or setMass; three.js has none',
|
|
59
|
+
interpolation: 'copy body translation/rotation into the mesh after each step; interpolate with the accumulator alpha',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const profile = e => { const k = String(e ?? '').toLowerCase().replace(/[^a-z-]/g, ''); const p = PHYSICS[k]; if (!p) throw new RPError('INVALID_ARGUMENT', `Unknown engine ${e}; physics profiles: ${Object.keys(PHYSICS).join(', ')}.`); return p.alias ? { ...PHYSICS[p.alias], label: `${PHYSICS[p.alias].label} (C#)` } : p; };
|
|
63
|
+
const r4 = x => Math.round(x * 1e4) / 1e4;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Carry physics numbers from one engine to another.
|
|
67
|
+
* values: { gravity, fixedStep, linearDamp, angularDamp, friction, bounce, mass, speed, jumpVelocity, jumpHeight, force, impulse, layers: [{name, index, collidesWith:[...]}], dimension: '2d'|'3d' }
|
|
68
|
+
*/
|
|
69
|
+
export function physicsProfile({ from, to, values = {} } = {}) {
|
|
70
|
+
const a = profile(from), b = profile(to);
|
|
71
|
+
const srcKey = String(from).toLowerCase().replace(/[^a-z-]/g, ''), dstKey = String(to).toLowerCase().replace(/[^a-z-]/g, '');
|
|
72
|
+
const dim = values.dimension === '2d' ? '2d' : '3d';
|
|
73
|
+
const unitFactor = (dstKey === 'unreal' ? 100 : 1) / (srcKey === 'unreal' ? 100 : 1) * (dim === '2d' ? ((dstKey.startsWith('godot') ? 100 : 1) / (srcKey.startsWith('godot') ? 100 : 1)) : 1);
|
|
74
|
+
const converted = {};
|
|
75
|
+
const settings = [];
|
|
76
|
+
const notes = [];
|
|
77
|
+
const g = v => v == null ? null : r4(Math.abs(v) * unitFactor);
|
|
78
|
+
const gravity = values.gravity != null ? g(values.gravity) : null;
|
|
79
|
+
const defaultG = dim === '2d' ? b.gravity2d : b.gravity3d;
|
|
80
|
+
if (gravity != null) {
|
|
81
|
+
converted.gravity = gravity;
|
|
82
|
+
settings.push(`${b.gravitySetting} → ${gravity}${dstKey === 'unreal' ? ' (Default Gravity Z = -' + gravity + ')' : ''}`);
|
|
83
|
+
if (Math.abs(gravity - defaultG) > 1e-6) notes.push(`Gravity ${gravity} differs from the ${b.label} default ${defaultG}: set it in the project, not per body, or jumps and falls change.`);
|
|
84
|
+
} else settings.push(`gravity: ${a.label} default ${dim === '2d' ? a.gravity2d : a.gravity3d} → ${b.label} default ${defaultG} (${b.gravitySetting})`);
|
|
85
|
+
const step = values.fixedStep ?? a.fixedStep;
|
|
86
|
+
if (step != null) {
|
|
87
|
+
converted.fixedStep = b.fixedStep ?? step;
|
|
88
|
+
if (b.fixedStep == null) { settings.push(`${b.fixedStepSetting}: enable substepping or async physics tick and set 1/${Math.round(1 / step)} s to keep a fixed ${Math.round(1 / step)} Hz step`); notes.push('Unreal physics runs at frame rate by default; per-frame forces and friction-based movement then depend on FPS unless substepping is on.'); }
|
|
89
|
+
else if (Math.abs(step - b.fixedStep) > 1e-9) { settings.push(`${b.fixedStepSetting} → ${Math.round(1 / step)} Hz to match the source (or keep ${Math.round(1 / b.fixedStep)} Hz and retune)`); notes.push(`Fixed step changes from ${Math.round(1 / step)} Hz to ${Math.round(1 / b.fixedStep)} Hz by default: anything added per physics tick (forces in FixedUpdate/_physics_process, per-tick velocity += …) scales by ${r4(step / b.fixedStep)} unless you multiply by delta.`); }
|
|
90
|
+
}
|
|
91
|
+
for (const k of ['linearDamp', 'angularDamp']) {
|
|
92
|
+
if (values[k] == null) continue;
|
|
93
|
+
converted[k] = values[k];
|
|
94
|
+
const def = b[k];
|
|
95
|
+
settings.push(`${b.dampSetting.split(';')[0]}: ${values[k]}${dstKey.startsWith('godot') && values[k] !== def ? ` with ${k === 'linearDamp' ? 'linear_damp_mode' : 'angular_damp_mode'} = Replace (Combine adds the project default ${def})` : ''}`);
|
|
96
|
+
}
|
|
97
|
+
if ((values.linearDamp == null || values.angularDamp == null) && (a.linearDamp !== b.linearDamp || a.angularDamp !== b.angularDamp)) notes.push(`Default damping differs: ${a.label} ${a.linearDamp}/${a.angularDamp} vs ${b.label} ${b.linearDamp}/${b.angularDamp} (linear/angular). Bodies that relied on defaults coast differently; set them explicitly.`);
|
|
98
|
+
if (values.friction != null || values.bounce != null) {
|
|
99
|
+
if (values.friction != null) converted.friction = values.friction;
|
|
100
|
+
if (values.bounce != null) converted.bounce = values.bounce;
|
|
101
|
+
settings.push(`${b.material}: friction ${values.friction ?? '(default ' + b.friction + ')'}, bounce ${values.bounce ?? '(default ' + b.bounce + ')'}`);
|
|
102
|
+
if (a.frictionCombine !== b.frictionCombine) notes.push(`Friction combination differs: ${a.label} = ${a.frictionCombine}; ${b.label} = ${b.frictionCombine}. Two surfaces of friction ${values.friction ?? a.friction} and ${b.friction} land at different values; put the same friction on both bodies or set the combine mode where the engine allows.`);
|
|
103
|
+
if (a.bounceCombine !== b.bounceCombine) notes.push(`Bounce combination differs: ${a.label} = ${a.bounceCombine}; ${b.label} = ${b.bounceCombine}.`);
|
|
104
|
+
} else notes.push(`Default material differs: friction ${a.friction} → ${b.friction}, bounce ${a.bounce} → ${b.bounce}; objects slide and bounce differently unless materials are set on both sides.`);
|
|
105
|
+
if (values.mass != null) { converted.mass = values.mass; settings.push(`${b.mass}: ${values.mass}`); if (dstKey === 'unreal') notes.push('Unreal derives mass from volume and density: set MassInKg (Override Mass) on each body or weights are off by orders of magnitude.'); }
|
|
106
|
+
for (const k of ['speed', 'force', 'impulse']) if (values[k] != null) converted[k] = g(values[k]);
|
|
107
|
+
if (values.jumpHeight != null) { converted.jumpHeight = g(values.jumpHeight); converted.jumpVelocity = r4(Math.sqrt(2 * (gravity ?? defaultG) * converted.jumpHeight)); settings.push(`jump velocity for ${converted.jumpHeight} ${dstKey === 'unreal' ? 'cm' : 'units'} under gravity ${gravity ?? defaultG}: ${converted.jumpVelocity}`); }
|
|
108
|
+
else if (values.jumpVelocity != null) { const srcG = values.gravity != null ? Math.abs(values.gravity) : (dim === '2d' ? a.gravity2d : a.gravity3d); const h = (values.jumpVelocity ** 2) / (2 * srcG); converted.jumpHeight = g(h); converted.jumpVelocity = r4(Math.sqrt(2 * (gravity ?? defaultG) * converted.jumpHeight)); settings.push(`jump velocity ${values.jumpVelocity} (height ${r4(h)}) → ${converted.jumpVelocity} so the height stays ${converted.jumpHeight} under gravity ${gravity ?? defaultG}`); }
|
|
109
|
+
if (values.layers?.length) {
|
|
110
|
+
converted.layers = values.layers.map((l, i) => {
|
|
111
|
+
const idx = l.index ?? i;
|
|
112
|
+
const peers = l.collidesWith ?? [];
|
|
113
|
+
const bit = n => 1 << n;
|
|
114
|
+
const indexOf = name => { const pos = values.layers.findIndex(x => x.name === name); return pos < 0 ? -1 : (values.layers[pos].index ?? pos); };
|
|
115
|
+
const mask = peers.map(p => (typeof p === 'number' ? p : indexOf(p))).filter(n => n >= 0).reduce((m, n) => m | bit(n), 0);
|
|
116
|
+
if (dstKey.startsWith('godot')) return { name: l.name, collision_layer: bit(idx), collision_mask: mask, godot: `collision_layer = ${bit(idx)}, collision_mask = ${mask} (layer ${idx + 1})` };
|
|
117
|
+
if (dstKey === 'threejs') return { name: l.name, membership: bit(idx) & 0xffff, filter: mask & 0xffff, rapier: `setCollisionGroups(${((bit(idx) & 0xffff) << 16 | (mask & 0xffff)) >>> 0})` };
|
|
118
|
+
if (dstKey === 'unreal') return { name: l.name, channel: `ECC_GameTraceChannel${idx + 1} ("${l.name}")`, responses: Object.fromEntries(values.layers.map(x => [x.name, peers.includes(x.name) || peers.includes(x.index) ? 'Block' : 'Ignore'])) };
|
|
119
|
+
return { name: l.name, layer: idx, matrixRow: Object.fromEntries(values.layers.map(x => [x.name, peers.includes(x.name) || peers.includes(x.index)])) };
|
|
120
|
+
});
|
|
121
|
+
if (srcKey === 'unity' && dstKey.startsWith('godot')) notes.push('Unity\'s layer matrix is symmetric; Godot masks are per body. For every pair the matrix allows, both bodies need the other in their mask (or only the one that must react).');
|
|
122
|
+
if (dstKey === 'unreal') notes.push('Define the channels in Project Settings ▸ Collision and build a Collision Preset per layer; Unreal needs Block on both sides to collide.');
|
|
123
|
+
}
|
|
124
|
+
if (srcKey === 'unity' && dstKey.startsWith('godot')) notes.push('Unity CharacterController → CharacterBody3D: keep your own gravity integration; Unity stepOffset has no direct equivalent (use a raycast or floor_snap_length), skinWidth has none.');
|
|
125
|
+
if (dstKey === 'unreal' && (srcKey.startsWith('godot') || srcKey === 'unity')) notes.push('Unreal CharacterMovementComponent applies gravity, friction and braking itself (GroundFriction 8, BrakingDecelerationWalking 2048): remove manual gravity/friction code or set those to 0 and keep your own.');
|
|
126
|
+
if (srcKey.startsWith('godot') && dstKey === 'unity') notes.push('Godot default linear/angular damp 0.1 is added to every body (Combine mode); Unity bodies default to drag 0: set drag 0.1 on ported rigidbodies that relied on the default.');
|
|
127
|
+
if (dim === '2d') notes.push(`2D units: ${a.units} → ${b.units}; speeds and gravity were scaled ×${unitFactor}.`);
|
|
128
|
+
return {
|
|
129
|
+
from: a.label, to: b.label, dimension: dim, unitFactor,
|
|
130
|
+
profiles: { from: pick(a), to: pick(b) },
|
|
131
|
+
converted, settings, differences: notes,
|
|
132
|
+
next: 'Apply the settings, then check in play: fall time from a fixed height, a jump\'s peak, a sliding box\'s stop distance and a dropped ball\'s bounce count should match the original within tolerance (re_blueprint parity.json style).',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const pick = p => ({ units: p.units, gravity3d: p.gravity3d, gravity2d: p.gravity2d, fixedStep: p.fixedStep, fixedStepSetting: p.fixedStepSetting, engine: p.engineSetting, damping: `${p.linearDamp}/${p.angularDamp} — ${p.dampFormula}`, friction: p.friction, bounce: p.bounce, frictionCombine: p.frictionCombine, bounceCombine: p.bounceCombine, material: p.material, layers: p.layers, character: p.character, ccd: p.ccd, mass: p.mass, interpolation: p.interpolation });
|
package/port/src/plan.mjs
CHANGED
|
@@ -15,6 +15,9 @@ import { ENGINES, normEngine, mapFor, apiHits, leftovers, LIFECYCLE_EQUIV, portM
|
|
|
15
15
|
import { readScene, parseGodotScene, parseUnityYaml } from './scene.mjs';
|
|
16
16
|
import { convertTree } from './convert.mjs';
|
|
17
17
|
import { writeTscn, unitySpec, threeModule, unrealSpec, scaffold, sceneFunctionName } from './porters.mjs';
|
|
18
|
+
import { dormantFindings, resumePack } from './dormant.mjs';
|
|
19
|
+
import { parityFindings } from './trace.mjs';
|
|
20
|
+
import { readUnityAnimation, clipToTres, controllerToGd, snake as animSnake } from './unity-anim.mjs';
|
|
18
21
|
|
|
19
22
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
23
|
const TARGETS = ['godot', 'godot-csharp', 'unity', 'unreal', 'threejs'];
|
|
@@ -24,7 +27,7 @@ const MAX_UNIT_SOURCE = 200 * 1024;
|
|
|
24
27
|
const plansDir = () => workspaceDir('ports');
|
|
25
28
|
const planFile = async id => path.join(await plansDir(), id, 'plan.json');
|
|
26
29
|
|
|
27
|
-
async function loadPlan(id) {
|
|
30
|
+
export async function loadPlan(id) {
|
|
28
31
|
if (!id || !/^[a-z0-9-]{3,80}$/.test(id)) throw new RPError('INVALID_ARGUMENT', 'Give a planId (port_status lists them).');
|
|
29
32
|
const plan = await readJson(await planFile(id), null);
|
|
30
33
|
if (!plan) throw new RPError('UNKNOWN_PLAN', `No port plan "${id}". port_status without planId lists the plans.`);
|
|
@@ -45,7 +48,7 @@ const callText = c => `${c.tool} ${JSON.stringify(c.arguments)}`;
|
|
|
45
48
|
function nextCallObj(plan) {
|
|
46
49
|
const u = nextUnit(plan);
|
|
47
50
|
if (!u) return call('port_verify', { planId: plan.id });
|
|
48
|
-
if (u.kind === 'scene') return call('port_scene', { planId: plan.id, unit: u.id });
|
|
51
|
+
if (u.kind === 'scene' || (u.kind === 'animation' && u.action === 'convert-scene')) return call('port_scene', { planId: plan.id, unit: u.id });
|
|
49
52
|
if (u.kind === 'asset') return call('port_copy_assets', { planId: plan.id });
|
|
50
53
|
return call('port_unit', { planId: plan.id });
|
|
51
54
|
}
|
|
@@ -97,6 +100,11 @@ function targetPath(rel, kind, ctx) {
|
|
|
97
100
|
const tExt = LANGUAGE_TARGETS[to] ?? ext;
|
|
98
101
|
return j(dir, `${base}${tExt}`);
|
|
99
102
|
}
|
|
103
|
+
if (kind === 'animation') {
|
|
104
|
+
const isCtrl = ext === '.controller';
|
|
105
|
+
if (to === 'godot' || to === 'godot-csharp') return j('animations', dir.replace(/^(Animations?|Anim)(\/|$)/, ''), isCtrl ? `${snakeCase(base)}_animator.gd` : `${snakeCase(base)}.tres`);
|
|
106
|
+
return j('ReversePort', 'Notes', `${p}.md`);
|
|
107
|
+
}
|
|
100
108
|
if (kind === 'scene') {
|
|
101
109
|
if (to === 'godot') return j('scenes', dir.replace(/^(Scenes|Prefabs)(\/|$)/, ''), `${snakeCase(base)}.tscn`);
|
|
102
110
|
if (to === 'godot-csharp') return rel;
|
|
@@ -121,6 +129,7 @@ function kindOf(f) {
|
|
|
121
129
|
if (/\.(import|meta|uid)$/i.test(f.rel)) return null;
|
|
122
130
|
if (lang === 'godot-scene' || lang === 'unity-scene' || lang === 'unity-prefab') return 'scene';
|
|
123
131
|
if (lang === 'godot-resource') return 'resource';
|
|
132
|
+
if (/\.(anim|controller)$/i.test(f.rel) && /(^|\/)Assets\//.test(f.rel)) return 'animation';
|
|
124
133
|
if (lang === 'shader') return 'shader';
|
|
125
134
|
if (cat === 'code') return 'script';
|
|
126
135
|
if (['image', 'audio', 'model', 'font', 'video'].includes(cat)) return 'asset';
|
|
@@ -163,7 +172,7 @@ export async function planPort({ source, to, output, name, include, exclude, unr
|
|
|
163
172
|
const kind = kindOf(f);
|
|
164
173
|
if (!kind) continue;
|
|
165
174
|
const node = graph.nodes.get(f.rel);
|
|
166
|
-
let action = kind === 'asset' ? 'copy' : kind === 'scene' ? (['godot', 'unity', 'threejs', 'unreal', 'godot-csharp'].includes(target) ? 'convert-scene' : 'rebuild') : kind === 'config' ? 'rebuild' : 'translate';
|
|
175
|
+
let action = kind === 'asset' ? 'copy' : kind === 'scene' ? (['godot', 'unity', 'threejs', 'unreal', 'godot-csharp'].includes(target) ? 'convert-scene' : 'rebuild') : kind === 'animation' ? (target === 'godot' ? 'convert-scene' : 'rebuild') : kind === 'config' ? 'rebuild' : 'translate';
|
|
167
176
|
if (target === 'godot-csharp' && ctx.from === 'godot' && (kind === 'resource' || (kind === 'config' && /project\.godot$/.test(f.rel)))) action = 'convert-scene';
|
|
168
177
|
const unit = { source: f.rel, kind, language: languageOf(f.rel), size: f.size, lines: node?.lines ?? null, target: targetPath(f.rel, kind, ctx), action, status: 'pending' };
|
|
169
178
|
if (target === 'unreal' && kind === 'script') unit.extraTargets = [unit.target.replace(/\.h$/, '.cpp')];
|
|
@@ -265,7 +274,7 @@ const CHECKLIST = {
|
|
|
265
274
|
],
|
|
266
275
|
scene: ['Run port_scene {planId, unit}: it writes the converted scene/spec and marks the unit. Then review its reverseport TODO notes.'],
|
|
267
276
|
resource: ['Port the data with the field names of its ported script/class. Keep values; convert units if they are distances.'],
|
|
268
|
-
shader: ['
|
|
277
|
+
shader: ['Start from port_shader {file, to}: it maps uniforms, intrinsics and built-ins and marks the rest with PORT: comments. Resolve every leftover it lists; uniforms keep their names.'],
|
|
269
278
|
asset: ['Copy as-is: port_write {planId, unit, copy:true}. The target engine imports it itself.'],
|
|
270
279
|
config: ['Recreate these project settings in the target (input actions, tags/groups, autoloads/singletons, main scene). Write what you did into the notes file.'],
|
|
271
280
|
};
|
|
@@ -280,6 +289,10 @@ export async function portUnit({ planId, unit, full = false } = {}) {
|
|
|
280
289
|
if (u.status === 'pending') { u.status = 'in_progress'; u.started = new Date().toISOString(); await savePlan(plan); }
|
|
281
290
|
result.checklist = repeat ? 'Same checklist as the earlier units (port_unit {planId, unit, full:true} repeats it).' : CHECKLIST[u.kind] ?? CHECKLIST.script;
|
|
282
291
|
if (u.kind === 'asset') { const c = call('port_write', { planId, unit: u.id, copy: true }); return { ...result, next: callText(c), nextCall: c }; }
|
|
292
|
+
if (u.kind === 'animation' && u.action === 'convert-scene') {
|
|
293
|
+
const c = call('port_scene', { planId, unit: u.id });
|
|
294
|
+
return { ...result, next: callText(c), nextCall: c };
|
|
295
|
+
}
|
|
283
296
|
if (u.kind === 'scene') {
|
|
284
297
|
const scene = await readScene(abs).catch(e => ({ error: e.message }));
|
|
285
298
|
const c = call('port_scene', { planId, unit: u.id });
|
|
@@ -433,19 +446,19 @@ export async function portStatus({ planId } = {}) {
|
|
|
433
446
|
}
|
|
434
447
|
const plan = await loadPlan(planId);
|
|
435
448
|
await savePlan(plan);
|
|
436
|
-
return { ...summary(plan), progress: progress(plan), recent: plan.log.slice(-10), partial: plan.units.filter(u => u.status === 'partial').map(u => ({ unit: u.id, source: u.source, notes: u.notes })).slice(0, 30), planFile: path.join(plan.output, 'PORT-PLAN.md') };
|
|
449
|
+
return { ...summary(plan), progress: progress(plan), recent: plan.log.slice(-10), partial: plan.units.filter(u => u.status === 'partial').map(u => ({ unit: u.id, source: u.source, notes: u.notes })).slice(0, 30), planFile: path.join(plan.output, 'PORT-PLAN.md'), resume: resumePack(plan, { verify: plan.lastVerify ?? null }) };
|
|
437
450
|
}
|
|
438
451
|
|
|
439
452
|
export async function portVerify({ planId } = {}) {
|
|
440
453
|
const plan = await loadPlan(planId);
|
|
441
454
|
const errors = [], warnings = [], notPorted = [];
|
|
442
|
-
let markers = 0, left = 0, missingTotal = 0;
|
|
455
|
+
let markers = 0, left = 0, missingTotal = 0, dormantTotal = 0, behaviourTotal = 0;
|
|
443
456
|
for (const u of plan.units) {
|
|
444
457
|
if (u.status === 'skipped') continue;
|
|
445
458
|
if (['pending', 'in_progress'].includes(u.status)) { notPorted.push(u.id); continue; }
|
|
446
459
|
const files = u.written ?? [];
|
|
447
460
|
for (const rel of files) if (!(await statOrNull(safeJoin(plan.output, rel)))) errors.push({ unit: u.id, issue: `written file is missing: ${rel}` });
|
|
448
|
-
if (u.kind === 'asset' || u.kind === 'scene' || u.kind === 'config') continue;
|
|
461
|
+
if (u.kind === 'asset' || u.kind === 'scene' || u.kind === 'config' || u.kind === 'animation') continue;
|
|
449
462
|
const texts = [];
|
|
450
463
|
for (const rel of files) { const b = await fs.readFile(safeJoin(plan.output, rel)).catch(() => null); if (b && isText(b)) texts.push(b.toString('utf8')); }
|
|
451
464
|
if (!texts.length) continue;
|
|
@@ -457,18 +470,27 @@ export async function portVerify({ planId } = {}) {
|
|
|
457
470
|
const parity = parityFor(check.sym, src, texts.join('\n'));
|
|
458
471
|
if (parity.missing.length) { missingTotal += parity.missing.length; warnings.push({ unit: u.id, source: u.source, issue: 'functions/signals not found in the port', missing: parity.missing.slice(0, 40) }); }
|
|
459
472
|
if (parity.lifecycle.length) warnings.push({ unit: u.id, source: u.source, issue: 'lifecycle callbacks without a counterpart (fine if their work moved elsewhere)', lifecycle: parity.lifecycle });
|
|
473
|
+
// Dormant logic: names exist but do nothing, or nothing calls them, or errors were silenced.
|
|
474
|
+
const srcLang = languageOf(u.source);
|
|
475
|
+
const srcText = srcLang && CODE_LANGUAGES.has(srcLang) ? await fs.readFile(path.join(plan.source, u.source), 'utf8').catch(() => null) : null;
|
|
476
|
+
const findings = dormantFindings({ sourceText: srcText, sourceLang: srcLang, targetText: texts.join('\n'), targetLang: languageOf(u.target), lifecycleGroups: LIFECYCLE_EQUIV });
|
|
477
|
+
if (findings.length) { dormantTotal += findings.length; warnings.push({ unit: u.id, source: u.source, issue: 'dormant logic: empty or thin bodies, functions the source called but the port never does, added suppressions', findings: findings.slice(0, 40) }); }
|
|
478
|
+
// Behaviour parity: tick placement, delta use, randomness and seeding, execution order, time scale.
|
|
479
|
+
const behaviour = parityFindings({ sourceText: srcText, sourceLang: srcLang, targetText: texts.join('\n'), targetLang: languageOf(u.target) });
|
|
480
|
+
if (behaviour.length) { behaviourTotal += behaviour.length; warnings.push({ unit: u.id, source: u.source, issue: 'behaviour parity: timing, randomness or order differs from the source', findings: behaviour }); }
|
|
460
481
|
}
|
|
461
482
|
if (u.status === 'partial') warnings.push({ unit: u.id, source: u.source, issue: 'marked partial', notes: u.notes });
|
|
462
483
|
}
|
|
463
484
|
const p = progress(plan);
|
|
464
|
-
const passed = !errors.length && p.pending === 0 && p.partial === 0 && left === 0 && missingTotal === 0;
|
|
485
|
+
const passed = !errors.length && p.pending === 0 && p.partial === 0 && left === 0 && missingTotal === 0 && dormantTotal === 0 && behaviourTotal === 0;
|
|
465
486
|
if (notPorted.length) warnings.unshift({ issue: 'not ported yet', count: notPorted.length, units: notPorted.slice(0, 10) });
|
|
466
487
|
const onlyPending = !errors.length && warnings.length === (notPorted.length ? 1 : 0);
|
|
467
|
-
const report = { planId, passed, progress: p, errors, warnings: warnings.slice(0, 200), portMarkers: markers, leftovers: left, missingSymbols: missingTotal, output: plan.output,
|
|
468
|
-
next: passed ? 'Open the output in the target engine, build, run, and compare behaviour with the original.' : onlyPending ? `Nothing is wrong so far; ${notPorted.length} unit(s) are not ported yet. Continue: ${nextCall(plan)}` : 'Fix the errors and the warnings above (leftovers, missing functions, partial units), then run port_verify again.',
|
|
488
|
+
const report = { planId, passed, progress: p, errors, warnings: warnings.slice(0, 200), portMarkers: markers, leftovers: left, missingSymbols: missingTotal, dormant: dormantTotal, behaviour: behaviourTotal, output: plan.output,
|
|
489
|
+
next: passed ? 'Open the output in the target engine, build, run, and compare behaviour with the original.' : onlyPending ? `Nothing is wrong so far; ${notPorted.length} unit(s) are not ported yet. Continue: ${nextCall(plan)}` : 'Fix the errors and the warnings above (leftovers, missing functions, dormant logic, behaviour parity, partial units), then run port_verify again. To prove behaviour, log both with port_trace harnesses and compare.',
|
|
469
490
|
...(passed ? {} : { nextCall: onlyPending ? nextCallObj(plan) : call('port_verify', { planId }) }) };
|
|
470
491
|
plan.lastVerify = { at: new Date().toISOString(), passed, errors: errors.length, warnings: warnings.length };
|
|
471
492
|
await savePlan(plan);
|
|
493
|
+
report.resume = resumePack(plan, { verify: report });
|
|
472
494
|
return report;
|
|
473
495
|
}
|
|
474
496
|
|
|
@@ -495,11 +517,71 @@ async function sceneTree(abs) {
|
|
|
495
517
|
throw new RPError('UNSUPPORTED_SCENE', 'Scene conversion needs a Godot .tscn or a Unity .unity/.prefab saved as text.');
|
|
496
518
|
}
|
|
497
519
|
|
|
520
|
+
/** For every Animator controller used in a Unity tree: its ported script and the clips already ported (by plan). */
|
|
521
|
+
async function animatorInfos(plan, root, resolveFromPlan) {
|
|
522
|
+
const out = new Map();
|
|
523
|
+
const visit = async n => {
|
|
524
|
+
const a = (n.components ?? []).find(c => c.type === 'Animator');
|
|
525
|
+
if (a && !out.has(a.controller ?? null)) {
|
|
526
|
+
const info = { clips: [], missing: [], script: null };
|
|
527
|
+
if (a.controller && plan && !a.controller.startsWith('guid:')) {
|
|
528
|
+
const t = resolveFromPlan(a.controller);
|
|
529
|
+
info.script = t ? `res://${t}` : null;
|
|
530
|
+
try {
|
|
531
|
+
const { controller } = await readUnityAnimation(path.join(plan.source, plan.sourceRoot ? `${plan.sourceRoot}/${a.controller}` : a.controller));
|
|
532
|
+
for (const st of controller.layers[0]?.states ?? []) {
|
|
533
|
+
if (!st.motion) continue;
|
|
534
|
+
const res = resolveFromPlan(st.motion);
|
|
535
|
+
const name = animSnake(path.basename(st.motion).replace(/\.anim$/i, ''));
|
|
536
|
+
if (res && !info.clips.some(c => c.name === name)) info.clips.push({ name, res: `res://${res}` }); else if (!res) info.missing.push(st.motion);
|
|
537
|
+
}
|
|
538
|
+
} catch { /* controller unreadable: the node gets a todo */ }
|
|
539
|
+
}
|
|
540
|
+
out.set(a.controller ?? null, info);
|
|
541
|
+
}
|
|
542
|
+
for (const c of n.children ?? []) await visit(c);
|
|
543
|
+
};
|
|
544
|
+
await visit(root);
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function portUnityAnimation({ plan, u, abs, target, out }) {
|
|
549
|
+
if (target !== 'godot') throw new RPError('INVALID_ARGUMENT', 'Unity .anim/.controller conversion targets Godot; other targets get a rebuild note from port_unit.');
|
|
550
|
+
const parsed = await readUnityAnimation(abs);
|
|
551
|
+
const resolveFromPlan = ref => { if (!plan || !ref) return null; const rel = plan.sourceRoot ? `${plan.sourceRoot}/${ref}` : ref; return plan.units.find(x => x.source === rel || x.source.toLowerCase() === rel.toLowerCase())?.target ?? null; };
|
|
552
|
+
let content, notes = [], summary;
|
|
553
|
+
if (parsed.kind === 'unity-animation') {
|
|
554
|
+
const r = clipToTres(parsed.clip, { coords: 'unity-3d', resolve: ref => { const t = resolveFromPlan(ref); return t ? `res://${t}` : null; } });
|
|
555
|
+
content = r.text; summary = { tracks: r.tracks, length: parsed.clip.length, loop: parsed.clip.loop, todo: r.todo };
|
|
556
|
+
notes.push(`${r.tracks} track(s); positions mirrored on Z. Add this Animation to the AnimationPlayer library of the node that had the Animator (port_scene does that for prefabs ported after this clip).`);
|
|
557
|
+
if (r.todo.length) notes.push(...r.todo);
|
|
558
|
+
} else {
|
|
559
|
+
const ctrl = parsed.controller;
|
|
560
|
+
const r = controllerToGd(ctrl, { className: `${pascalCase(path.basename(abs, '.controller'))}Animator`, resolveClip: ref => (ref && resolveFromPlan(ref) ? animSnake(path.basename(ref).replace(/\.anim$/i, '')) : null) });
|
|
561
|
+
content = r.text; summary = { states: r.states, transitions: r.transitions, parameters: ctrl.parameters.length, todo: r.todo };
|
|
562
|
+
notes.push('A GDScript state machine with the same states, parameters and conditions, driving an AnimationPlayer; port_scene puts it on an "Animator" child of every node that used this controller. Blend trees and extra layers need an AnimationTree by hand.');
|
|
563
|
+
if (r.todo.length) notes.push(...r.todo);
|
|
564
|
+
}
|
|
565
|
+
if (plan) {
|
|
566
|
+
const written = await writeFiles(plan, u, [{ path: u.target, content }]);
|
|
567
|
+
u.status = 'done'; u.written = written; u.finished = new Date().toISOString(); u.notes = notes.join(' ');
|
|
568
|
+
plan.log.push({ at: u.finished, event: 'scene', unit: u.id, files: written });
|
|
569
|
+
await savePlan(plan);
|
|
570
|
+
return withNext(plan, { planId: plan.id, unit: u.id, written: written.map(r => path.join(plan.output, r)), ...summary, notes, progress: progress(plan) });
|
|
571
|
+
}
|
|
572
|
+
const name = path.basename(abs).replace(/\.[^.]+$/, '');
|
|
573
|
+
const dest = out ? path.resolve(out) : path.join(await workspaceDir('scenes'), parsed.kind === 'unity-animation' ? `${snakeCase(name)}.tres` : `${snakeCase(name)}_animator.gd`);
|
|
574
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
575
|
+
await fs.writeFile(dest, content);
|
|
576
|
+
return { file: dest, target, ...summary, notes, preview: content.slice(0, 4000) };
|
|
577
|
+
}
|
|
578
|
+
|
|
498
579
|
export async function portScene({ planId, unit, file, to, out } = {}) {
|
|
499
580
|
let plan = null, u = null, abs, target;
|
|
500
581
|
if (planId) { plan = await loadPlan(planId); u = unit ? findUnit(plan, unit) : plan.units.find(x => x.action === 'convert-scene' && x.status !== 'done'); if (!u) return { planId, message: 'No scene units left.' }; if (u.action !== 'convert-scene') throw new RPError('INVALID_ARGUMENT', `Unit ${u.id} (${u.kind}) is not a scene conversion; use port_unit/port_write.`); abs = path.join(plan.source, u.source); target = plan.to; }
|
|
501
582
|
else { if (!file || !to) throw new RPError('INVALID_ARGUMENT', 'Give {planId, unit} or {file, to}.'); abs = path.resolve(file); target = normEngine(to); }
|
|
502
583
|
if (target === 'godot-csharp') return swapToCsharp(plan, u, abs, resolveRef => resolveRef);
|
|
584
|
+
if (/\.(anim|controller)$/i.test(abs)) return portUnityAnimation({ plan, u, abs, target, out });
|
|
503
585
|
const { engine, parsed } = await sceneTree(abs);
|
|
504
586
|
const resolveFromPlan = (srcRef, kindWanted) => {
|
|
505
587
|
if (!plan || !srcRef) return null;
|
|
@@ -516,7 +598,8 @@ export async function portScene({ planId, unit, file, to, out } = {}) {
|
|
|
516
598
|
if (target === 'godot') {
|
|
517
599
|
if (engine !== 'unity') throw new RPError('INVALID_ARGUMENT', 'Scene → Godot conversion reads Unity scenes/prefabs.');
|
|
518
600
|
const tree = convertTree(parsed.root, 'unity', 'godot');
|
|
519
|
-
|
|
601
|
+
const animators = await animatorInfos(plan, parsed.root, resolveFromPlan);
|
|
602
|
+
content = writeTscn(tree, { engine: 'unity', name, resolve: ref => { const t = resolveFromPlan(ref); return t ? `res://${t}` : null; }, animator: ref => animators.get(ref ?? null) ?? null });
|
|
520
603
|
notes.push('Meshes from model files are placeholders (metadata/reverseport_todo); import the models in Godot and assign them.');
|
|
521
604
|
} else if (target === 'unity') {
|
|
522
605
|
if (engine !== 'godot') throw new RPError('INVALID_ARGUMENT', 'Scene → Unity conversion reads Godot .tscn scenes.');
|