reverseport 1.6.0 → 1.8.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.
Files changed (44) hide show
  1. package/README.md +71 -26
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/bin/reverseport.mjs +13 -1
  4. package/dist/generatedPackageMetadata.js +1 -1
  5. package/licenses/README.md +1 -0
  6. package/licenses/godot-engine-MIT.txt +20 -0
  7. package/native/windows/build/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/port/cli.mjs +6 -4
  10. package/port/knowledge/apis/godot-3-to-4-renames.json +1 -0
  11. package/port/knowledge/apis/godot-3.json +1 -0
  12. package/port/knowledge/apis/godot-4.3.json +1 -0
  13. package/port/knowledge/apis/godot-4.7.json +1 -0
  14. package/port/src/agent.mjs +1 -1
  15. package/port/src/agentdisk.mjs +83 -0
  16. package/port/src/apicheck.mjs +280 -0
  17. package/port/src/archive.mjs +34 -4
  18. package/port/src/convert.mjs +3 -1
  19. package/port/src/dormant.mjs +159 -0
  20. package/port/src/formats.mjs +1 -0
  21. package/port/src/gamearchives.mjs +476 -0
  22. package/port/src/godotbin.mjs +544 -0
  23. package/port/src/il2cpp.mjs +148 -0
  24. package/port/src/jsworkspace.mjs +2 -2
  25. package/port/src/knowledge.mjs +3 -1
  26. package/port/src/physics.mjs +135 -0
  27. package/port/src/plan.mjs +94 -11
  28. package/port/src/porters.mjs +87 -5
  29. package/port/src/refs.mjs +120 -0
  30. package/port/src/rpgmaker.mjs +416 -0
  31. package/port/src/save.mjs +326 -0
  32. package/port/src/scene.mjs +37 -3
  33. package/port/src/shader.mjs +385 -0
  34. package/port/src/strings.mjs +327 -0
  35. package/port/src/tools.mjs +48 -8
  36. package/port/src/trace.mjs +302 -0
  37. package/port/src/unity-anim.mjs +229 -0
  38. package/port/src/upgrade.mjs +219 -0
  39. package/skills/build-inspector/SKILL.md +8 -0
  40. package/skills/godot-port/SKILL.md +2 -0
  41. package/skills/port-verify/SKILL.md +20 -9
  42. package/skills/reverse-engineer-anything/SKILL.md +18 -2
  43. package/skills/reverse-engineer-anything/references/porting-and-rebuilds.md +35 -4
  44. 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
+ }
@@ -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.replace('*', key.slice(pre.length, key.length - post.length)));
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.replace('*', wild))));
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
  }
@@ -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
- 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, ...(!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
+ 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: ['Port the shader to the target shading system; uniforms keep their names. Mark untranslatable parts with PORT: comments.'],
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
- content = writeTscn(tree, { engine: 'unity', name, resolve: ref => { const t = resolveFromPlan(ref); return t ? `res://${t}` : null; } });
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.');