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,302 @@
1
+ // Behaviour parity beyond names: timing (frame vs physics tick, delta use, fixed step), randomness (seeding, RNG
2
+ // calls) and execution order, compared between a source unit and its port; plus trace harnesses that log the
3
+ // same properties per physics tick in both engines and a comparer that finds the first divergence.
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { RPError, requireFile } from './util.mjs';
7
+ import { functionBodies } from './dormant.mjs';
8
+
9
+ const FRAME = ['_process', 'Update', 'LateUpdate', 'Tick', 'update', '_Process', 'onUpdate', 'draw'];
10
+ const PHYSICS = ['_physics_process', 'FixedUpdate', '_PhysicsProcess', 'fixedUpdate', 'physicsUpdate', '_integrate_forces', '_IntegrateForces', 'TickPhysics', 'step'];
11
+ const DELTA_RE = /\b(delta|deltaTime|DeltaTime|fixedDeltaTime|DeltaSeconds|dt|elapsed|get_process_delta_time\(\)|get_physics_process_delta_time\(\))\b/;
12
+ const MOTION_RE = /\b(position|global_position|velocity|linear_velocity|angular_velocity|transform\.position|transform\.Translate|Translate|Rotate|rotation|rotate|move_and_slide|move_and_collide|MovePosition|AddForce|apply_force|apply_impulse|apply_central_impulse|AddTorque|SetActorLocation|AddActorLocalOffset|AddMovementInput|LaunchCharacter|\.x\s*[+-]=|\.y\s*[+-]=|\.z\s*[+-]=)\b/;
13
+ const RNG_CALL_RE = /\b(randf|randi|randf_range|randi_range|randfn|rand_range|randomize|RandomNumberGenerator|GD\.Randf|GD\.Randi|GD\.RandRange|Random\.(Range|value|insideUnitCircle|insideUnitSphere|onUnitSphere|rotation|InitState)|UnityEngine\.Random|System\.Random|new Random|Math\.random|FMath::(Rand\w*|FRand\w*|SRand|RandRange|VRand)|FRandomStream|rand\(\)|random\(\)|randint|uniform|choice|shuffle|RandomRange|RandomFloat|GetRandom\w*)\b/g;
14
+ const SEED_RE = /\b(seed\s*\(|randomize\s*\(|\.seed\s*=|set_seed|Random\.InitState|new Random\s*\(\s*\d|new System\.Random\s*\(\s*\w|FMath::SRandInit|FMath::RandInit|FRandomStream\s*\(|srand\s*\(|random\.seed|seedrandom|Mulberry|xorshift|RandomNumberGenerator\s*\.new\s*\(\s*\)\s*[\s\S]{0,80}?\.seed)/;
15
+ const ORDER_RE = /\b(DefaultExecutionOrder|process_priority|process_physics_priority|ExecutionOrder|TickGroup|PrimaryActorTick\.TickGroup|AddTickPrerequisiteActor|call_deferred|CallDeferred|Invoke\s*\(|InvokeRepeating|StartCoroutine|coroutine|yield return|await\s+ToSignal|await\s+\w+\.\w+|\.timeout\b|SetTimer|setTimeout|requestAnimationFrame)\b/g;
16
+ const TIMESCALE_RE = /\b(?:(?:Time\.timeScale|Engine\.time_scale|Engine\.TimeScale|CustomTimeDilation|Application\.targetFrameRate|Engine\.max_fps|Engine\.MaxFps|QualitySettings\.vSyncCount|Time\.fixedDeltaTime|Engine\.physics_ticks_per_second|Engine\.PhysicsTicksPerSecond|Time\.maximumDeltaTime|Engine\.max_physics_steps_per_frame)(?=\s*=[^=])|SetGlobalTimeDilation|t\.MaxFPS|ProjectSettings\.set_setting\("physics\/common\/physics_ticks_per_second")/g;
17
+
18
+ function bodiesByName(text, lang) {
19
+ const out = new Map();
20
+ for (const b of functionBodies(text, lang).functions) out.set(b.name, (out.get(b.name) ?? '') + '\n' + b.body);
21
+ return out;
22
+ }
23
+ const cat = name => (FRAME.includes(name) ? 'frame' : PHYSICS.includes(name) ? 'physics' : null);
24
+
25
+ /** Timing/randomness/order findings for one unit. Returns [{kind, detail, fix}]. */
26
+ export function parityFindings({ sourceText, sourceLang, targetText, targetLang }) {
27
+ const out = [];
28
+ if (!sourceText || !targetText) return out;
29
+ const src = bodiesByName(sourceText, sourceLang), dst = bodiesByName(targetText, targetLang);
30
+ const srcCat = { frame: '', physics: '' }, dstCat = { frame: '', physics: '' };
31
+ for (const [n, b] of src) { const c = cat(n); if (c) srcCat[c] += b; }
32
+ for (const [n, b] of dst) { const c = cat(n); if (c) dstCat[c] += b; }
33
+ // 1. Motion moved between frame and physics callbacks.
34
+ const srcMotion = { frame: MOTION_RE.test(srcCat.frame), physics: MOTION_RE.test(srcCat.physics) };
35
+ const dstMotion = { frame: MOTION_RE.test(dstCat.frame), physics: MOTION_RE.test(dstCat.physics) };
36
+ if (srcMotion.physics && !dstMotion.physics && dstMotion.frame) out.push({ kind: 'tick-moved', detail: 'movement/physics code ran in the physics tick in the source but in the frame callback in the port', fix: 'move it to the physics callback (FixedUpdate / _physics_process / fixed-step accumulator) or results depend on frame rate' });
37
+ if (srcMotion.frame && !dstMotion.frame && dstMotion.physics && !srcMotion.physics) out.push({ kind: 'tick-moved', detail: 'movement ran per frame in the source but per physics tick in the port', fix: 'fine for physics bodies; for visual-only motion keep it per frame or interpolate' });
38
+ // 2. Delta usage changed where motion happens.
39
+ const sAnyDelta = DELTA_RE.test(srcCat.frame + srcCat.physics) && MOTION_RE.test(srcCat.frame + srcCat.physics);
40
+ const dAnyDelta = DELTA_RE.test(dstCat.frame + dstCat.physics), dAnyMotion = MOTION_RE.test(dstCat.frame + dstCat.physics);
41
+ if (sAnyDelta && dAnyMotion && !dAnyDelta) out.push({ kind: 'delta-dropped', detail: 'the source scaled its motion by delta; the port moves by fixed amounts per callback', fix: 'multiply speeds by delta (or by the fixed step) in the port' });
42
+ for (const c of ['frame', 'physics']) {
43
+ if (sAnyDelta && !dAnyDelta) break;
44
+ const sHas = DELTA_RE.test(srcCat[c]), dHas = DELTA_RE.test(dstCat[c]);
45
+ if (MOTION_RE.test(srcCat[c]) && sHas && !dHas && (dstCat[c].trim() || c === 'frame')) out.push({ kind: 'delta-dropped', detail: `the source scaled ${c}-callback motion by delta; the port does not`, fix: 'multiply speeds by delta (or by the fixed step) in the port' });
46
+ if (MOTION_RE.test(dstCat[c]) && !sHas && dHas && srcCat[c].trim()) out.push({ kind: 'delta-added', detail: `the port scales ${c}-callback motion by delta; the source used fixed per-tick amounts`, fix: 'either keep per-tick amounts at the same tick rate, or convert amounts to per-second values (amount × tick rate) when multiplying by delta' });
47
+ }
48
+ // 3. Randomness.
49
+ const sRng = (sourceText.match(RNG_CALL_RE) ?? []).length, dRng = (targetText.match(RNG_CALL_RE) ?? []).length;
50
+ if (sRng && !dRng) out.push({ kind: 'rng-missing', detail: `source uses randomness (${sRng} call(s)); the port has none`, fix: 'port the random choices; replays and difficulty depend on them' });
51
+ if (sRng && dRng) {
52
+ const sSeed = SEED_RE.test(sourceText), dSeed = SEED_RE.test(targetText);
53
+ if (sSeed && !dSeed) out.push({ kind: 'seed-missing', detail: 'the source seeds its RNG (deterministic runs); the port does not', fix: 'seed the same way (seed(n) / Random.InitState(n) / FMath::SRandInit(n) / a seeded PRNG in JS) and draw in the same order' });
54
+ if (!sSeed && dSeed) out.push({ kind: 'seed-added', detail: 'the port seeds its RNG; the source did not (runs varied)', fix: 'drop the fixed seed unless replays are wanted' });
55
+ if (Math.abs(sRng - dRng) > Math.max(2, sRng * 0.5)) out.push({ kind: 'rng-count', detail: `random calls: ${sRng} in the source, ${dRng} in the port`, fix: 'same number of draws in the same order keeps seeded runs identical' });
56
+ }
57
+ // 4. Ordering and deferred execution.
58
+ const sOrder = new Set((sourceText.match(ORDER_RE) ?? []).map(x => x.trim())), dOrder = new Set((targetText.match(ORDER_RE) ?? []).map(x => x.trim()));
59
+ if (sOrder.size && !dOrder.size) out.push({ kind: 'order-dropped', detail: `the source controls execution order/deferral (${[...sOrder].slice(0, 5).join(', ')}); the port has nothing equivalent`, fix: 'map to process_priority / [DefaultExecutionOrder] / TickGroup, and deferred calls to call_deferred / coroutines / timers' });
60
+ // 5. Time scale and frame-rate settings.
61
+ const sTime = new Set((sourceText.match(TIMESCALE_RE) ?? [])), dTime = new Set((targetText.match(TIMESCALE_RE) ?? []));
62
+ if (sTime.size && !dTime.size) out.push({ kind: 'timescale-dropped', detail: `the source sets time scale / frame rate / fixed step (${[...sTime].join(', ')}); the port does not`, fix: 'set the equivalent (Engine.time_scale, Time.timeScale, CustomTimeDilation; Engine.max_fps, Application.targetFrameRate; physics_ticks_per_second, Time.fixedDeltaTime)' });
63
+ return out;
64
+ }
65
+
66
+ const TICK_RATE = { godot: 60, 'godot-csharp': 60, unity: 50, unreal: 60, threejs: 60 };
67
+
68
+ /** A logger that records properties of one node per physics tick to JSON, in the engine's own language. */
69
+ export function traceHarness({ engine, node = '.', properties = ['position'], ticks = 300, file = 'reverseport-trace.json', label = engine } = {}) {
70
+ const e = String(engine).toLowerCase();
71
+ const props = properties.map(p => String(p));
72
+ const common = { ticks, properties: props, format: '{ "engine", "tickRate", "ticks": [{ "t": tick, "<property>": number | [numbers] }] } — compare two with port_trace action:"compare"' };
73
+ if (e === 'godot' || e === 'godot-csharp') {
74
+ const text = `extends Node
75
+ ## ReversePort trace harness: add this node to the scene, set target to the node to watch, run ${ticks} physics ticks.
76
+ ## Writes user://${file}. Compare with the original engine's trace: port_trace {action:"compare", a, b}.
77
+ @export var target_path: NodePath = ^"${node}"
78
+ @export var ticks: int = ${ticks}
79
+ var _rows: Array = []
80
+ var _tick := 0
81
+
82
+ func _ready() -> void:
83
+ \tseed(12345) # same seed in both engines
84
+ \tEngine.physics_ticks_per_second = ${TICK_RATE[e]}
85
+
86
+ func _physics_process(_delta: float) -> void:
87
+ \tvar target := get_node_or_null(target_path)
88
+ \tif target == null:
89
+ \t\treturn
90
+ \tvar row := {"t": _tick}
91
+ ${props.map(p => `\trow["${p}"] = _num(target.get("${p}"))`).join('\n')}
92
+ \t_rows.append(row)
93
+ \t_tick += 1
94
+ \tif _tick >= ticks:
95
+ \t\t_flush()
96
+ \t\tset_physics_process(false)
97
+
98
+ func _num(v):
99
+ \tif v is Vector2: return [v.x, v.y]
100
+ \tif v is Vector3: return [v.x, v.y, v.z]
101
+ \tif v is Quaternion: return [v.x, v.y, v.z, v.w]
102
+ \tif v is bool: return 1.0 if v else 0.0
103
+ \treturn v
104
+
105
+ func _flush() -> void:
106
+ \tvar f := FileAccess.open("user://${file}", FileAccess.WRITE)
107
+ \tf.store_string(JSON.stringify({"engine": "${label}", "tickRate": ${TICK_RATE[e]}, "ticks": _rows}))
108
+ \tf.close()
109
+ \tprint("ReversePort trace written: ", ProjectSettings.globalize_path("user://${file}"))
110
+ `;
111
+ return { engine: e, fileName: 'ReversePortTrace.gd', text, where: 'user:// folder (printed at the end)', ...common };
112
+ }
113
+ if (e === 'unity') {
114
+ const text = `using System.Collections.Generic;
115
+ using System.IO;
116
+ using System.Text;
117
+ using UnityEngine;
118
+
119
+ // ReversePort trace harness: add to any GameObject, assign target, play ${ticks} fixed updates.
120
+ // Writes ${file} next to persistentDataPath. Compare with the port's trace: port_trace {action:"compare", a, b}.
121
+ public class ReversePortTrace : MonoBehaviour
122
+ {
123
+ public Transform target;
124
+ public int ticks = ${ticks};
125
+ readonly List<string> rows = new List<string>();
126
+ int tick;
127
+
128
+ void Awake() { Random.InitState(12345); Time.fixedDeltaTime = 1f / ${TICK_RATE[e]}f; }
129
+
130
+ void FixedUpdate()
131
+ {
132
+ if (target == null || tick >= ticks) return;
133
+ var sb = new StringBuilder();
134
+ sb.Append("{\\"t\\":").Append(tick);
135
+ ${props.map(p => ` sb.Append(",\\"${p}\\":").Append(Num(Get("${p}")));`).join('\n')}
136
+ sb.Append("}");
137
+ rows.Add(sb.ToString());
138
+ if (++tick >= ticks) Flush();
139
+ }
140
+
141
+ object Get(string prop)
142
+ {
143
+ var rb = target.GetComponent<Rigidbody>();
144
+ var rb2 = target.GetComponent<Rigidbody2D>();
145
+ switch (prop)
146
+ {
147
+ case "position": case "global_position": return target.position;
148
+ case "rotation": return target.rotation;
149
+ case "rotation_degrees": return target.eulerAngles;
150
+ case "scale": return target.localScale;
151
+ case "velocity": case "linear_velocity": return rb != null ? (object)rb.linearVelocity : rb2 != null ? (object)rb2.linearVelocity : null;
152
+ case "angular_velocity": return rb != null ? (object)rb.angularVelocity : rb2 != null ? (object)rb2.angularVelocity : null;
153
+ }
154
+ foreach (var c in target.GetComponents<MonoBehaviour>())
155
+ {
156
+ var f = c.GetType().GetField(prop); if (f != null) return f.GetValue(c);
157
+ var pr = c.GetType().GetProperty(prop); if (pr != null) return pr.GetValue(c);
158
+ }
159
+ return null;
160
+ }
161
+
162
+ static string Num(object v)
163
+ {
164
+ var ci = System.Globalization.CultureInfo.InvariantCulture;
165
+ switch (v)
166
+ {
167
+ case null: return "null";
168
+ case Vector2 a: return "[" + a.x.ToString("R", ci) + "," + a.y.ToString("R", ci) + "]";
169
+ case Vector3 a: return "[" + a.x.ToString("R", ci) + "," + a.y.ToString("R", ci) + "," + a.z.ToString("R", ci) + "]";
170
+ case Quaternion q: return "[" + q.x.ToString("R", ci) + "," + q.y.ToString("R", ci) + "," + q.z.ToString("R", ci) + "," + q.w.ToString("R", ci) + "]";
171
+ case bool b: return b ? "1" : "0";
172
+ case float f: return f.ToString("R", ci);
173
+ case double d: return d.ToString("R", ci);
174
+ case int i: return i.ToString(ci);
175
+ }
176
+ return "\\"" + v + "\\"";
177
+ }
178
+
179
+ void Flush()
180
+ {
181
+ var p = Path.Combine(Application.persistentDataPath, "${file}");
182
+ File.WriteAllText(p, "{\\"engine\\":\\"${label}\\",\\"tickRate\\":${TICK_RATE[e]},\\"ticks\\":[" + string.Join(",", rows) + "]}");
183
+ Debug.Log("ReversePort trace written: " + p);
184
+ }
185
+ }
186
+ `;
187
+ return { engine: e, fileName: 'ReversePortTrace.cs', text, where: 'Application.persistentDataPath (logged)', ...common };
188
+ }
189
+ if (e === 'threejs') {
190
+ const text = `// ReversePort trace harness (three.js + your physics step). Call trace.tick(object) once per fixed step
191
+ // and trace.download() (browser) or trace.toJSON() after ${ticks} ticks. Compare: port_trace {action:"compare", a, b}.
192
+ export function createTrace({ ticks = ${ticks}, properties = ${JSON.stringify(props)}, label = ${JSON.stringify(label)} } = {}) {
193
+ const rows = [];
194
+ let tick = 0;
195
+ const num = v => (v == null ? null : typeof v === 'number' ? v : typeof v === 'boolean' ? (v ? 1 : 0) : 'w' in v ? [v.x, v.y, v.z, v.w] : 'z' in v ? [v.x, v.y, v.z] : [v.x, v.y]);
196
+ const get = (obj, p) => { const map = { position: obj.position, global_position: obj.getWorldPosition?.(new obj.position.constructor()) ?? obj.position, rotation: obj.quaternion, rotation_degrees: obj.rotation, scale: obj.scale, velocity: obj.userData?.velocity ?? obj.body?.linvel?.(), linear_velocity: obj.userData?.velocity ?? obj.body?.linvel?.(), angular_velocity: obj.body?.angvel?.() }; return p in map ? map[p] : obj.userData?.[p] ?? obj[p]; };
197
+ return {
198
+ tick(obj) { if (tick >= ticks) return; const row = { t: tick }; for (const p of properties) row[p] = num(get(obj, p)); rows.push(row); tick++; },
199
+ done() { return tick >= ticks; },
200
+ toJSON() { return { engine: label, tickRate: ${TICK_RATE[e]}, ticks: rows }; },
201
+ download(name = ${JSON.stringify(file)}) { const blob = new Blob([JSON.stringify(this.toJSON())], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); },
202
+ };
203
+ }
204
+ // Seed your PRNG the same way in both engines (e.g. a mulberry32(12345)) and use a fixed-step accumulator at 1/${TICK_RATE[e]} s.
205
+ `;
206
+ return { engine: e, fileName: 'reverseport-trace.js', text, where: 'browser download or your own fs write', ...common };
207
+ }
208
+ if (e === 'unreal') {
209
+ const text = `// ReversePort trace harness (Unreal, C++). Add this component to the actor to watch; it logs ${ticks} physics ticks
210
+ // to Saved/${file} on the TickGroup TG_PostPhysics. Compare: port_trace {action:"compare", a, b}.
211
+ #pragma once
212
+ #include "CoreMinimal.h"
213
+ #include "Components/ActorComponent.h"
214
+ #include "Misc/FileHelper.h"
215
+ #include "ReversePortTrace.generated.h"
216
+
217
+ UCLASS(ClassGroup=(ReversePort), meta=(BlueprintSpawnableComponent))
218
+ class UReversePortTrace : public UActorComponent
219
+ {
220
+ GENERATED_BODY()
221
+ public:
222
+ UPROPERTY(EditAnywhere) int32 Ticks = ${ticks};
223
+ UReversePortTrace() { PrimaryComponentTick.bCanEverTick = true; PrimaryComponentTick.TickGroup = TG_PostPhysics; }
224
+ virtual void BeginPlay() override { Super::BeginPlay(); FMath::SRandInit(12345); }
225
+ virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override
226
+ {
227
+ Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
228
+ if (Tick >= Ticks) return;
229
+ AActor* A = GetOwner();
230
+ const FVector P = A->GetActorLocation() / 100.0f; // cm → m, Unreal X-forward/Z-up: convert axes with port_convert before comparing
231
+ const FQuat Q = A->GetActorQuat();
232
+ const FVector V = A->GetVelocity() / 100.0f;
233
+ Rows.Add(FString::Printf(TEXT("{\\"t\\":%d,\\"position\\":[%g,%g,%g],\\"rotation\\":[%g,%g,%g,%g],\\"velocity\\":[%g,%g,%g]}"), Tick, P.X, P.Y, P.Z, Q.X, Q.Y, Q.Z, Q.W, V.X, V.Y, V.Z));
234
+ if (++Tick >= Ticks)
235
+ {
236
+ const FString Out = FString::Printf(TEXT("{\\"engine\\":\\"${label}\\",\\"tickRate\\":${TICK_RATE[e]},\\"ticks\\":[%s]}"), *FString::Join(Rows, TEXT(",")));
237
+ FFileHelper::SaveStringToFile(Out, *(FPaths::ProjectSavedDir() / TEXT("${file}")));
238
+ UE_LOG(LogTemp, Log, TEXT("ReversePort trace written: %s"), *(FPaths::ProjectSavedDir() / TEXT("${file}")));
239
+ }
240
+ }
241
+ private:
242
+ int32 Tick = 0;
243
+ TArray<FString> Rows;
244
+ };
245
+ `;
246
+ return { engine: e, fileName: 'ReversePortTrace.h', text, where: 'Saved/ folder of the project', ...common, note: 'Unreal logs position/rotation/velocity in meters, Unreal axes; convert the other trace with port_convert or compare per axis with mapping.' };
247
+ }
248
+ throw new RPError('INVALID_ARGUMENT', `No trace harness for ${engine}; available: godot, unity, threejs, unreal.`);
249
+ }
250
+
251
+ /** Compare two traces: first divergence per property, max error, drift (error growth per tick). */
252
+ export async function compareTraces({ a, b, tolerance = 0.01, resample = true, axisMap } = {}) {
253
+ const read = async f => { const { abs } = await requireFile(f); const j = JSON.parse(await fs.readFile(abs, 'utf8')); if (!Array.isArray(j.ticks)) throw new RPError('BAD_FORMAT', `${abs} is not a ReversePort trace (no ticks array).`); return { file: abs, ...j }; };
254
+ const A = await read(a), B = await read(b);
255
+ const rateA = A.tickRate ?? 60, rateB = B.tickRate ?? 60;
256
+ const props = [...new Set([...Object.keys(A.ticks[0] ?? {}), ...Object.keys(B.ticks[0] ?? {})])].filter(k => k !== 't');
257
+ const mapB = v => (Array.isArray(v) && axisMap && v.length >= 3 ? axisMap.map(i => (typeof i === 'string' ? (i.startsWith('-') ? -v[Number(i.slice(1))] : v[Number(i)]) : v[i])) : v);
258
+ const at = (trace, rate, tSeconds) => {
259
+ const idx = tSeconds * rate;
260
+ const i0 = Math.floor(idx), i1 = Math.min(trace.ticks.length - 1, i0 + 1), f = idx - i0;
261
+ const r0 = trace.ticks[i0], r1 = trace.ticks[i1];
262
+ if (!r0) return null;
263
+ if (!resample || !r1 || f === 0) return r0;
264
+ const row = { t: idx };
265
+ for (const k of Object.keys(r0)) { const x = r0[k], y = r1[k]; row[k] = Array.isArray(x) && Array.isArray(y) ? x.map((v, j) => v + (y[j] - v) * f) : typeof x === 'number' && typeof y === 'number' ? x + (y - x) * f : x; }
266
+ return row;
267
+ };
268
+ const seconds = Math.min((A.ticks.length - 1) / rateA, (B.ticks.length - 1) / rateB);
269
+ const steps = Math.max(1, Math.floor(seconds * Math.min(rateA, rateB)));
270
+ const result = {};
271
+ for (const p of props) {
272
+ let first = null, maxErr = 0, maxAt = 0, sum = 0, n = 0, early = 0, late = 0;
273
+ for (let s = 0; s <= steps; s++) {
274
+ const t = s / Math.min(rateA, rateB);
275
+ const ra = at(A, rateA, t), rb = at(B, rateB, t);
276
+ if (!ra || !rb || ra[p] == null || rb[p] == null) continue;
277
+ const va = ra[p], vb = mapB(rb[p]);
278
+ const err = Array.isArray(va) ? Math.sqrt(va.reduce((acc, v, i) => acc + (v - (vb[i] ?? 0)) ** 2, 0)) : Math.abs(va - vb);
279
+ if (!Number.isFinite(err)) continue;
280
+ sum += err; n++;
281
+ if (s < steps / 2) early += err; else late += err;
282
+ if (err > maxErr) { maxErr = err; maxAt = t; }
283
+ if (first === null && err > tolerance) first = { seconds: Math.round(t * 1000) / 1000, a: va, b: vb, error: Math.round(err * 1e6) / 1e6 };
284
+ }
285
+ result[p] = { matches: first === null, firstDivergence: first, maxError: Math.round(maxErr * 1e6) / 1e6, maxErrorAt: Math.round(maxAt * 1000) / 1000, meanError: n ? Math.round((sum / n) * 1e6) / 1e6 : null, drift: n ? (late > early * 1.5 ? 'growing' : late < early * 0.67 ? 'shrinking' : 'steady') : null };
286
+ }
287
+ const failing = Object.entries(result).filter(([, r]) => !r.matches).map(([p]) => p);
288
+ const hints = [];
289
+ if (rateA !== rateB) hints.push(`tick rates differ (${rateA} vs ${rateB} Hz): traces were resampled by time; per-tick amounts without delta scale by ${Math.round((rateB / rateA) * 1000) / 1000}`);
290
+ for (const p of failing) { const r = result[p]; if (r.drift === 'growing') hints.push(`${p}: error grows over time → integration differs (delta vs per-tick, damping, gravity or friction); check the physics profile (port_convert physics)`); else if (r.firstDivergence && r.firstDivergence.seconds === 0) hints.push(`${p}: differs from the first tick → initial state, units or axes differ (axisMap, unit factor, spawn position)`); else hints.push(`${p}: diverges at ${r.firstDivergence?.seconds}s → an event (input, collision, random draw) happened differently there`); }
291
+ return { a: { file: A.file, engine: A.engine, tickRate: rateA, ticks: A.ticks.length }, b: { file: B.file, engine: B.engine, tickRate: rateB, ticks: B.ticks.length }, comparedSeconds: Math.round(seconds * 1000) / 1000, tolerance, properties: result, passed: failing.length === 0, failing, hints };
292
+ }
293
+
294
+ export async function portTraceTool({ action = 'harness', engine, node, properties, ticks, out, a, b, tolerance, axisMap } = {}) {
295
+ if (action === 'compare') { if (!a || !b) throw new RPError('INVALID_ARGUMENT', 'compare needs a and b: two trace JSON files.'); return compareTraces({ a, b, tolerance, axisMap }); }
296
+ if (action !== 'harness') throw new RPError('INVALID_ARGUMENT', 'action must be harness or compare.');
297
+ if (!engine) throw new RPError('INVALID_ARGUMENT', 'harness needs engine: godot, unity, threejs or unreal.');
298
+ const h = traceHarness({ engine, node, properties, ticks });
299
+ let file = null;
300
+ if (out) { file = path.resolve(out); await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, h.text); }
301
+ return { ...h, ...(file ? { file } : {}), next: `Add ${h.fileName} to both projects (original and port) watching the same node and properties, run each for ${h.ticks} ticks from the same start, then port_trace {action:"compare", a:<original trace>, b:<port trace>, tolerance}.` };
302
+ }
@@ -0,0 +1,229 @@
1
+ // Unity animation clips (.anim) and animator controllers (.controller) → Godot Animation resources and a
2
+ // state-machine script. Clips become .tres Animation resources with value tracks (position/rotation/scale/
3
+ // float properties/sprite swaps); controllers become an AnimationPlayer-driven GDScript state machine with the
4
+ // same states, parameters and transition conditions, so the behaviour is readable and finishable by hand.
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { RPError } from './util.mjs';
8
+ import { findUnityRoot, unityGuidMap } from './scene.mjs';
9
+
10
+ const f = n => { const v = Number(n); if (!Number.isFinite(v)) return '0'; const s = v.toFixed(6).replace(/\.?0+$/, ''); return s === '-0' ? '0' : s; };
11
+ const ref = v => { const m = String(v ?? '').match(/fileID:\s*(-?\d+)(?:,\s*guid:\s*([0-9a-f]{32}))?/); return m ? { fileID: m[1], guid: m[2] ?? null } : null; };
12
+ export const snake = s => String(s).replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'anim';
13
+
14
+ /** Split a Unity YAML file into documents keyed by fileID with the raw body (the generic parser in scene.mjs is flat; curves need depth). */
15
+ function docs(text) {
16
+ const parts = text.split(/^--- !u!(\d+) &(-?\d+)[^\n]*$/m);
17
+ const out = new Map();
18
+ for (let i = 1; i + 2 < parts.length; i += 3) {
19
+ const body = parts[i + 2] ?? '';
20
+ const type = (body.match(/^(\w+):/m) ?? [])[1] ?? '';
21
+ out.set(parts[i + 1], { classId: Number(parts[i]), fileID: parts[i + 1], type, body });
22
+ }
23
+ return out;
24
+ }
25
+
26
+ /** Parse the `m_Curve:` key list under a block: returns [{time, value}] where value is number | {x,y,z,w}. */
27
+ function keys(block) {
28
+ const out = [];
29
+ for (const m of block.matchAll(/- serializedVersion: \d+\s*\n\s*time: (-?[\d.e+-]+)\s*\n\s*value: ([^\n]*)/g)) {
30
+ const raw = m[2].trim();
31
+ const v = raw.startsWith('{') ? Object.fromEntries([...raw.matchAll(/([xyzw]):\s*(-?[\d.e+-]+)/g)].map(x => [x[1], Number(x[2])])) : raw.startsWith('{fileID') ? ref(raw) : Number(raw);
32
+ out.push({ time: Number(m[1]), value: v });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /** Top-level list blocks of a clip: `m_PositionCurves:` … up to the next ` m_` key at indent 2. */
38
+ function listBlock(body, key) {
39
+ const m = body.match(new RegExp(`^ ${key}:\\s*\\n((?:(?! {2}\\w).*\\n?)*)`, 'm'));
40
+ if (!m) return [];
41
+ // Each curve starts with " - " at indent 2.
42
+ return m[1].split(/^ - /m).slice(1);
43
+ }
44
+
45
+ export function parseAnimClip(text, { asset = r => (r?.guid ? `guid:${r.guid}` : null) } = {}) {
46
+ const d = [...docs(text).values()].find(x => x.type === 'AnimationClip');
47
+ if (!d) throw new RPError('BAD_FORMAT', 'Not a Unity AnimationClip (.anim saved as text).');
48
+ const b = d.body;
49
+ const name = b.match(/^ m_Name: (.*)$/m)?.[1]?.trim() ?? 'clip';
50
+ const sampleRate = Number(b.match(/m_SampleRate: ([\d.]+)/)?.[1] ?? 60);
51
+ const loop = /m_LoopTime: 1/.test(b);
52
+ const tracks = [];
53
+ const curvePath = block => block.match(/^\s*path: (.*)$/m)?.[1]?.trim() ?? '';
54
+ for (const c of listBlock(b, 'm_PositionCurves')) tracks.push({ kind: 'position', path: curvePath(c), keys: keys(c) });
55
+ for (const c of listBlock(b, 'm_RotationCurves')) tracks.push({ kind: 'rotation', path: curvePath(c), keys: keys(c) });
56
+ for (const c of listBlock(b, 'm_EulerCurves')) tracks.push({ kind: 'euler', path: curvePath(c), keys: keys(c) });
57
+ for (const c of listBlock(b, 'm_ScaleCurves')) tracks.push({ kind: 'scale', path: curvePath(c), keys: keys(c) });
58
+ for (const c of listBlock(b, 'm_FloatCurves')) {
59
+ const attr = c.match(/^\s*attribute: (.*)$/m)?.[1]?.trim() ?? '';
60
+ const type = c.match(/classID: (\d+)/)?.[1];
61
+ tracks.push({ kind: 'float', path: curvePath(c), attribute: attr, classID: Number(type ?? 0), keys: keys(c) });
62
+ }
63
+ for (const c of listBlock(b, 'm_PPtrCurves')) {
64
+ const attr = c.match(/^\s*attribute: (.*)$/m)?.[1]?.trim() ?? '';
65
+ const ks = [...c.matchAll(/- time: (-?[\d.e+-]+)\s*\n\s*value: ([^\n]*)/g)].map(m => ({ time: Number(m[1]), value: asset(ref(m[2])) }));
66
+ tracks.push({ kind: 'object', path: curvePath(c), attribute: attr, keys: ks });
67
+ }
68
+ const events = [...b.matchAll(/- time: (-?[\d.e+-]+)\s*\n\s*functionName: (\w+)/g)].map(m => ({ time: Number(m[1]), function: m[2] }));
69
+ let length = 0;
70
+ for (const t of tracks) for (const k of t.keys) length = Math.max(length, k.time);
71
+ const stopTime = Number(b.match(/m_StopTime: ([\d.e+-]+)/)?.[1] ?? length);
72
+ return { name, sampleRate, loop, length: Math.max(length, stopTime), tracks, events };
73
+ }
74
+
75
+ const PROPERTY_MAP = {
76
+ 'm_IsActive': 'visible', 'm_Enabled': 'visible', 'm_LocalPosition.x': 'position:x', 'm_LocalPosition.y': 'position:y', 'm_LocalPosition.z': 'position:z',
77
+ 'm_LocalScale.x': 'scale:x', 'm_LocalScale.y': 'scale:y', 'm_LocalScale.z': 'scale:z', 'localEulerAnglesRaw.x': 'rotation_degrees:x', 'localEulerAnglesRaw.y': 'rotation_degrees:y', 'localEulerAnglesRaw.z': 'rotation_degrees:z',
78
+ 'm_Color.r': 'modulate:r', 'm_Color.g': 'modulate:g', 'm_Color.b': 'modulate:b', 'm_Color.a': 'modulate:a', 'm_Sprite': 'texture', 'm_Intensity': 'light_energy', 'm_Volume': 'volume_db', 'm_Pitch': 'pitch_scale', 'm_Size': 'size', 'm_FieldOfView': 'fov',
79
+ 'm_FlipX': 'flip_h', 'm_FlipY': 'flip_v', 'm_AnchoredPosition.x': 'position:x', 'm_AnchoredPosition.y': 'position:y', 'm_SizeDelta.x': 'size:x', 'm_SizeDelta.y': 'size:y',
80
+ };
81
+
82
+ /** Godot node path for a Unity transform path ("Body/Arm" → "Body/Arm", "" → "."), with the Godot property. */
83
+ function nodePath(p, prop) { return `NodePath("${p ? p.split('/').map(s => s.replace(/[":]/g, '_')).join('/') : '.'}:${prop}")`; }
84
+
85
+ /**
86
+ * Write a Godot 4 Animation .tres from a parsed clip.
87
+ * coords: 'unity-3d' mirrors Z (Unity left-handed → Godot), 'unity-2d' flips Y for sprites, 'none' keeps values.
88
+ */
89
+ export function clipToTres(clip, { coords = 'unity-3d', resolve = () => null } = {}) {
90
+ const ext = [], extId = new Map();
91
+ const addExt = (type, p) => { const k = `${type}:${p}`; if (!extId.has(k)) { const id = `${ext.length + 1}_rp`; extId.set(k, id); ext.push(`[ext_resource type="${type}" path="${p}" id="${id}"]`); } return extId.get(k); };
92
+ const lines = [];
93
+ const todo = [];
94
+ let n = 0;
95
+ const mirror = v => (coords === 'unity-3d' ? { ...v, z: -(v.z ?? 0) } : coords === 'unity-2d' ? { ...v, y: -(v.y ?? 0) } : v);
96
+ const track = (pathStr, { times, values, update = 0, interp = 1 }) => {
97
+ lines.push(`tracks/${n}/type = "value"`, `tracks/${n}/imported = false`, `tracks/${n}/enabled = true`, `tracks/${n}/path = ${pathStr}`, `tracks/${n}/interp = ${interp}`, `tracks/${n}/loop_wrap = true`,
98
+ `tracks/${n}/keys = {\n"times": PackedFloat32Array(${times.map(f).join(', ')}),\n"transitions": PackedFloat32Array(${times.map(() => '1').join(', ')}),\n"update": ${update},\n"values": [${values.join(', ')}]\n}`);
99
+ n++;
100
+ };
101
+ const floatGroups = new Map(); // path+base → {x,y,z,w} float curves merged into one vector track when they animate the same property.
102
+ for (const t of clip.tracks) {
103
+ if (!t.keys.length) continue;
104
+ if (t.kind === 'position') track(nodePath(t.path, 'position'), { times: t.keys.map(k => k.time), values: t.keys.map(k => { const v = mirror(k.value); return coords === 'unity-2d' ? `Vector2(${f(v.x)}, ${f(v.y)})` : `Vector3(${f(v.x)}, ${f(v.y)}, ${f(v.z)})`; }) });
105
+ else if (t.kind === 'scale') track(nodePath(t.path, 'scale'), { times: t.keys.map(k => k.time), values: t.keys.map(k => coords === 'unity-2d' ? `Vector2(${f(k.value.x)}, ${f(k.value.y)})` : `Vector3(${f(k.value.x)}, ${f(k.value.y)}, ${f(k.value.z)})`) });
106
+ else if (t.kind === 'rotation') {
107
+ // Unity quaternion (x,y,z,w) in a left-handed frame → mirror Z: negate x and y of the quaternion.
108
+ track(nodePath(t.path, 'quaternion'), { times: t.keys.map(k => k.time), values: t.keys.map(k => { const q = k.value; const m = coords === 'unity-3d' ? { x: -q.x, y: -q.y, z: q.z, w: q.w } : q; return `Quaternion(${f(m.x)}, ${f(m.y)}, ${f(m.z)}, ${f(m.w)})`; }) });
109
+ } else if (t.kind === 'euler') {
110
+ track(nodePath(t.path, coords === 'unity-2d' ? 'rotation_degrees' : 'rotation_degrees'), { times: t.keys.map(k => k.time), values: t.keys.map(k => { const e = k.value; return coords === 'unity-2d' ? f(-(e.z ?? 0)) : coords === 'unity-3d' ? `Vector3(${f(-(e.x ?? 0))}, ${f(-(e.y ?? 0))}, ${f(e.z ?? 0)})` : `Vector3(${f(e.x)}, ${f(e.y)}, ${f(e.z)})`; }) });
111
+ } else if (t.kind === 'float') {
112
+ const prop = PROPERTY_MAP[t.attribute];
113
+ if (!prop) { todo.push(`${t.path || '.'}: ${t.attribute} (${t.keys.length} keys) has no Godot property mapping; add a value track by hand`); continue; }
114
+ const isBool = prop === 'visible' || prop.startsWith('flip_');
115
+ const vals = t.keys.map(k => isBool ? (k.value >= 0.5 ? 'true' : 'false') : f(coords === 'unity-2d' && /^(position:y|rotation_degrees)$/.test(prop) ? -k.value : coords === 'unity-3d' && prop === 'position:z' ? -k.value : k.value));
116
+ track(nodePath(t.path, prop), { times: t.keys.map(k => k.time), values: vals, update: isBool ? 1 : 0, interp: isBool ? 0 : 1 });
117
+ void floatGroups;
118
+ } else if (t.kind === 'object') {
119
+ const prop = PROPERTY_MAP[t.attribute] ?? (t.attribute === 'm_Sprite' ? 'texture' : null);
120
+ if (!prop) { todo.push(`${t.path || '.'}: object curve ${t.attribute} not mapped`); continue; }
121
+ const vals = t.keys.map(k => { const target = k.value ? resolve(k.value) : null; if (!target) { todo.push(`${t.path || '.'}: sprite ${k.value ?? '?'} at ${k.time}s not resolved; assign the imported texture`); return 'null'; } return `ExtResource("${addExt('Texture2D', target)}")`; });
122
+ track(nodePath(t.path, prop), { times: t.keys.map(k => k.time), values: vals, update: 1, interp: 0 });
123
+ }
124
+ }
125
+ if (clip.events.length) {
126
+ track('NodePath(".")', { times: clip.events.map(e => e.time), values: clip.events.map(e => `{\n"args": [],\n"method": &"${e.function}"\n}`), update: 1, interp: 0 });
127
+ // Godot method tracks have their own type; rewrite the last emitted track header.
128
+ const i = lines.findIndex((l, k) => k === lines.length - 7);
129
+ if (i >= 0) lines[i] = `tracks/${n - 1}/type = "method"`;
130
+ }
131
+ const head = `[gd_resource type="Animation" load_steps=${ext.length + 1} format=3]`;
132
+ const body = ['[resource]', `resource_name = "${snake(clip.name)}"`, `length = ${f(clip.length)}`, `loop_mode = ${clip.loop ? 1 : 0}`, `step = ${f(1 / (clip.sampleRate || 60))}`, ...lines];
133
+ if (todo.length) body.push(`metadata/reverseport_todo = "${todo.join('; ').replace(/"/g, "'")}"`);
134
+ return { text: [head, ext.join('\n'), body.join('\n')].filter(Boolean).join('\n\n') + '\n', tracks: n, todo };
135
+ }
136
+
137
+ const PARAM_TYPES = { 1: 'float', 3: 'int', 4: 'bool', 9: 'trigger' };
138
+ const MODES = { 1: '>', 2: '<', 3: '>', 4: '<', 6: '==', 7: '!=' }; // 1 If (bool true), 2 IfNot, 3 Greater, 4 Less, 6 Equals, 7 NotEqual
139
+
140
+ export function parseAnimatorController(text, { asset = r => (r?.guid ? `guid:${r.guid}` : null) } = {}) {
141
+ const all = docs(text);
142
+ const ctrl = [...all.values()].find(d => d.type === 'AnimatorController');
143
+ if (!ctrl) throw new RPError('BAD_FORMAT', 'Not a Unity AnimatorController (.controller saved as text).');
144
+ const name = ctrl.body.match(/^ m_Name: (.*)$/m)?.[1]?.trim() ?? 'Animator';
145
+ const parameters = [...ctrl.body.matchAll(/- m_Name: (.*)\n\s*m_Type: (\d+)\n\s*m_DefaultFloat: ([\d.e+-]+)\n\s*m_DefaultInt: (-?\d+)\n\s*m_DefaultBool: (\d)/g)].map(m => ({ name: m[1].trim(), type: PARAM_TYPES[m[2]] ?? m[2], default: m[2] === '1' ? Number(m[3]) : m[2] === '3' ? Number(m[4]) : m[5] === '1' }));
146
+ const layers = [...ctrl.body.matchAll(/- serializedVersion: \d+\s*\n\s*m_Name: (.*)\n\s*m_StateMachine: ([^\n]*)/g)].map(m => ({ name: m[1].trim(), stateMachine: ref(m[2])?.fileID }));
147
+ const stateOf = id => all.get(id);
148
+ const transitionsOf = ids => ids.map(id => all.get(id)).filter(t => t?.type === 'AnimatorStateTransition').map(t => {
149
+ const b = t.body;
150
+ const dst = ref(b.match(/m_DstState: ([^\n]*)/)?.[1]);
151
+ const conditions = [...b.matchAll(/m_ConditionMode: (\d+)\s*\n\s*m_ConditionEvent: (.*)\n\s*m_EventTreshold: ([\d.e+-]+)/g)].map(c => ({ parameter: c[2].trim(), mode: Number(c[1]), op: MODES[c[1]] ?? '?', threshold: Number(c[3]) }));
152
+ return { to: dst ? stateOf(dst.fileID)?.body.match(/^ m_Name: (.*)$/m)?.[1]?.trim() ?? null : null, exit: /m_IsExit: 1/.test(b), hasExitTime: /m_HasExitTime: 1/.test(b), exitTime: Number(b.match(/m_ExitTime: ([\d.e+-]+)/)?.[1] ?? 1), duration: Number(b.match(/m_TransitionDuration: ([\d.e+-]+)/)?.[1] ?? 0), conditions };
153
+ });
154
+ const machines = layers.map(l => {
155
+ const sm = all.get(l.stateMachine);
156
+ if (!sm) return { layer: l.name, states: [], anyState: [], defaultState: null };
157
+ const stateIds = [...sm.body.matchAll(/- serializedVersion: \d+\s*\n\s*m_State: ([^\n]*)/g)].map(m => ref(m[1])?.fileID).filter(Boolean);
158
+ const states = stateIds.map(id => stateOf(id)).filter(Boolean).map(s => {
159
+ const b = s.body;
160
+ const trIds = [...b.matchAll(/- \{fileID: (-?\d+)\}/g)].map(m => m[1]);
161
+ return { name: b.match(/^ m_Name: (.*)$/m)?.[1]?.trim() ?? 'State', speed: Number(b.match(/m_Speed: ([\d.e+-]+)/)?.[1] ?? 1), motion: asset(ref(b.match(/m_Motion: ([^\n]*)/)?.[1])), transitions: transitionsOf(trIds), tag: b.match(/m_Tag: (.*)/)?.[1]?.trim() || null };
162
+ });
163
+ const anyIds = (sm.body.match(/m_AnyStateTransitions:\s*\n((?:\s*- \{fileID: -?\d+\}\n?)*)/)?.[1] ?? '').match(/-?\d+/g) ?? [];
164
+ const defaultState = stateOf(ref(sm.body.match(/m_DefaultState: ([^\n]*)/)?.[1])?.fileID)?.body.match(/^ m_Name: (.*)$/m)?.[1]?.trim() ?? states[0]?.name ?? null;
165
+ return { layer: l.name, states, anyState: transitionsOf(anyIds), defaultState };
166
+ });
167
+ return { name, parameters, layers: machines };
168
+ }
169
+
170
+ /** GDScript state machine driving an AnimationPlayer with the controller's states, parameters and conditions. */
171
+ export function controllerToGd(ctrl, { className, resolveClip = () => null } = {}) {
172
+ const L = ctrl.layers[0] ?? { states: [], anyState: [], defaultState: null };
173
+ const cls = className ?? `${ctrl.name.replace(/[^A-Za-z0-9]/g, '')}Animator`;
174
+ const gdName = s => snake(s);
175
+ const cond = c => {
176
+ const p = ctrl.parameters.find(x => x.name === c.parameter);
177
+ const v = gdName(c.parameter);
178
+ if (!p) return `false # unknown parameter ${c.parameter}`;
179
+ if (p.type === 'trigger') return `_triggers.has(&"${c.parameter}")`;
180
+ if (p.type === 'bool') return c.mode === 1 ? v : `not ${v}`;
181
+ return `${v} ${c.op} ${f(c.threshold)}`;
182
+ };
183
+ const lines = [
184
+ `class_name ${cls}`, 'extends Node', '## Ported by ReversePort from the Unity AnimatorController "' + ctrl.name + '".', '## Same states, parameters and transition conditions; call update_state() every frame (or on parameter change),',
185
+ '## set_trigger("Name") for triggers. Blend trees and layers beyond the first are listed in metadata/reverseport_todo.', '',
186
+ '@export var player: AnimationPlayer', '',
187
+ ...ctrl.parameters.filter(p => p.type !== 'trigger').map(p => `var ${gdName(p.name)}: ${p.type === 'int' ? 'int' : p.type === 'bool' ? 'bool' : 'float'} = ${p.type === 'bool' ? (p.default ? 'true' : 'false') : f(p.default)}`),
188
+ '', 'var state: StringName = &"' + (L.defaultState ?? '') + '"', 'var _triggers: Array[StringName] = []', 'var _time_in_state := 0.0', '',
189
+ 'const CLIPS := {', ...L.states.map(s => `\t&"${s.name}": "${resolveClip(s.motion) ?? (s.motion ? `PORT: ${s.motion}` : '')}",`), '}', '',
190
+ 'func _ready() -> void:', '\tif player == null:', '\t\tplayer = get_parent().get_node_or_null("AnimationPlayer")', '\t_enter(state)', '',
191
+ 'func _process(delta: float) -> void:', '\t_time_in_state += delta', '\tupdate_state()', '',
192
+ 'func set_trigger(name: StringName) -> void:', '\tif not _triggers.has(name):', '\t\t_triggers.append(name)', '',
193
+ 'func _enter(next: StringName) -> void:', '\tstate = next', '\t_time_in_state = 0.0', '\tif player and CLIPS.has(next) and player.has_animation(CLIPS[next]):', '\t\tplayer.play(CLIPS[next])', '',
194
+ 'func _exit_time_reached(exit_time: float) -> bool:', '\tif player == null or not player.is_playing():', '\t\treturn true', '\treturn _time_in_state >= exit_time * player.current_animation_length', '',
195
+ 'func update_state() -> void:',
196
+ ];
197
+ const trLines = (t, indent) => {
198
+ const parts = [...t.conditions.map(cond), ...(t.hasExitTime ? [`_exit_time_reached(${f(t.exitTime)})`] : [])];
199
+ const target = t.exit ? (L.defaultState ?? '') : t.to;
200
+ if (!target) return [];
201
+ const out = [`${indent}if ${parts.length ? parts.join(' and ') : 'true'}:`, ...t.conditions.filter(c => ctrl.parameters.find(p => p.name === c.parameter)?.type === 'trigger').map(c => `${indent}\t_triggers.erase(&"${c.parameter}")`), `${indent}\t_enter(&"${target}")`, `${indent}\treturn`];
202
+ return out;
203
+ };
204
+ for (const t of L.anyState) lines.push(...trLines(t, '\t'));
205
+ lines.push('\tmatch state:');
206
+ for (const s of L.states) {
207
+ lines.push(`\t\t&"${s.name}":`);
208
+ const body = s.transitions.flatMap(t => trLines(t, '\t\t\t'));
209
+ lines.push(...(body.length ? body : ['\t\t\tpass']));
210
+ }
211
+ if (!L.states.length) lines.push('\t\t_:', '\t\t\tpass');
212
+ const todo = [];
213
+ if (ctrl.layers.length > 1) todo.push(`${ctrl.layers.length - 1} more layer(s): ${ctrl.layers.slice(1).map(l => l.layer).join(', ')} (Godot: AnimationTree with blend/add nodes, or a second player)`);
214
+ for (const s of L.states) if (!s.motion) todo.push(`state ${s.name} has no clip (blend tree or empty): build it in an AnimationTree`);
215
+ if (todo.length) lines.push('', `# PORT: ${todo.join('; ')}`);
216
+ return { text: lines.join('\n') + '\n', todo, states: L.states.length, transitions: L.states.reduce((n, s) => n + s.transitions.length, L.anyState.length) };
217
+ }
218
+
219
+ /** Read a .anim or .controller from disk with GUIDs resolved through the Unity project. */
220
+ export async function readUnityAnimation(file) {
221
+ const text = await fs.readFile(file, 'utf8');
222
+ const root = await findUnityRoot(file);
223
+ const guids = await unityGuidMap(root);
224
+ const asset = r => (r?.guid ? guids.get(r.guid) ?? `guid:${r.guid}` : null);
225
+ const ext = path.extname(file).toLowerCase();
226
+ if (ext === '.anim') return { kind: 'unity-animation', clip: parseAnimClip(text, { asset }) };
227
+ if (ext === '.controller') return { kind: 'unity-animator', controller: parseAnimatorController(text, { asset }) };
228
+ throw new RPError('UNSUPPORTED_SCENE', 'Expected a .anim or .controller file.');
229
+ }