reverseport 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -78,7 +78,7 @@ function distance(a, b) {
78
78
 
79
79
  // ---------------------------------------------------------------------------------------------------- annotations
80
80
  // MCP hints: clients use them to skip approval prompts for reads and to run reads in parallel.
81
- const READ_ONLY = new Set(['re_scan', 're_graph', 're_symbols', 're_scene', 're_binary', 're_probe', 're_compare', 'port_map', 'port_convert', 'port_status', 'port_verify']);
81
+ const READ_ONLY = new Set(['re_scan', 're_graph', 're_symbols', 're_scene', 're_binary', 're_probe', 're_compare', 're_refs', 're_strings', 're_apicheck', 're_save', 're_decode', 're_il2cpp', 'port_map', 'port_trace', 'port_convert', 'port_status', 'port_verify']);
82
82
  const OPEN_WORLD = new Set(['re_lookup']);
83
83
 
84
84
  export function annotationsFor(name) {
@@ -0,0 +1,83 @@
1
+ // Optional link to agentdisk (github.com/laaielichter/agentdisk): the disk report for coding agents, with a cleanup
2
+ // you can undo. ReversePort never needs it. When it is there, `port doctor` says how much of the workspace would
3
+ // really come back (hardlinks counted once) and points at agentdisk's own plan → quarantine → purge route, which only
4
+ // a person can apply. ReversePort never asks agentdisk to move or delete anything.
5
+ //
6
+ // Found through AGENTDISK_BIN, else `agentdisk` on PATH. REVERSEPORT_AGENTDISK=off turns the link off.
7
+ import { execFile } from 'node:child_process';
8
+ import { existsSync } from 'node:fs';
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+
12
+ const TIMEOUT_MS = 30000;
13
+ const OFF = 'Turned off with REVERSEPORT_AGENTDISK=off.';
14
+ const MISSING = 'agentdisk is not installed (optional). It reports what agents and tools left on disk and cleans up with undo: https://github.com/laaielichter/agentdisk';
15
+
16
+ function locate(env = process.env) {
17
+ if (/^(off|0|false|no)$/i.test(env.REVERSEPORT_AGENTDISK ?? '')) return { off: true };
18
+ const bin = env.AGENTDISK_BIN?.trim();
19
+ if (bin) return existsSync(bin) ? { command: /\.m?js$/i.test(bin) ? [process.execPath, bin] : [bin] } : { missing: `AGENTDISK_BIN points to ${bin}, which does not exist.` };
20
+ for (const dir of (env.PATH ?? '').split(path.delimiter).filter(Boolean)) {
21
+ for (const name of process.platform === 'win32' ? ['agentdisk.exe', 'agentdisk.cmd'] : ['agentdisk']) {
22
+ const full = path.join(dir, name);
23
+ if (existsSync(full)) return { command: [full] };
24
+ }
25
+ }
26
+ return { missing: MISSING };
27
+ }
28
+
29
+ function run(command, args) {
30
+ return new Promise(resolve => {
31
+ execFile(command[0], [...command.slice(1), ...args], { timeout: TIMEOUT_MS, maxBuffer: 1024 * 1024, windowsHide: true, shell: process.platform === 'win32' && /\.cmd$/i.test(command[0]) }, (err, stdout, stderr) => {
32
+ resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout: String(stdout), stderr: String(stderr).trim() });
33
+ });
34
+ });
35
+ }
36
+
37
+ /** Bytes per top-level folder of the workspace, largest first. Never throws. */
38
+ export async function workspaceUsage(root) {
39
+ const folders = [];
40
+ let entries = [];
41
+ try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { return { bytes: 0, folders }; }
42
+ const sizeOf = async dir => {
43
+ let total = 0;
44
+ const stack = [dir];
45
+ while (stack.length) {
46
+ const d = stack.pop();
47
+ let list = [];
48
+ try { list = await fs.readdir(d, { withFileTypes: true }); } catch { continue; }
49
+ for (const e of list) {
50
+ const abs = path.join(d, e.name);
51
+ if (e.isSymbolicLink()) continue;
52
+ if (e.isDirectory()) stack.push(abs);
53
+ else if (e.isFile()) { try { total += (await fs.stat(abs)).size; } catch { /* gone meanwhile */ } }
54
+ }
55
+ }
56
+ return total;
57
+ };
58
+ for (const e of entries) if (e.isDirectory() && !e.isSymbolicLink()) folders.push({ folder: e.name, bytes: await sizeOf(path.join(root, e.name)) });
59
+ folders.sort((a, b) => b.bytes - a.bytes);
60
+ return { bytes: folders.reduce((s, f) => s + f.bytes, 0), folders };
61
+ }
62
+
63
+ /**
64
+ * The workspace's disk use, plus agentdisk's view when it is installed. Never throws: disk advice is a bonus.
65
+ * { bytes, folders, agentdisk: { available, reason? | version, reclaimableBytes, next } }
66
+ */
67
+ export async function diskReport(root, { env = process.env } = {}) {
68
+ const usage = await workspaceUsage(root);
69
+ const found = locate(env);
70
+ if (!found.command) return { ...usage, agentdisk: { available: false, reason: found.off ? OFF : found.missing } };
71
+ const [version, size] = await Promise.all([run(found.command, ['--version']), run(found.command, ['size', root, '--json'])]);
72
+ let measured = null;
73
+ try { measured = size.code === 0 ? JSON.parse(size.stdout) : null; } catch { measured = null; }
74
+ return {
75
+ ...usage,
76
+ agentdisk: {
77
+ available: true,
78
+ version: version.stdout.trim().replace(/^agentdisk\s+/, '') || null,
79
+ ...(measured ? { reclaimableBytes: measured.reclaimable_bytes ?? null, onDiskBytes: measured.allocated_bytes ?? null } : { error: size.stderr || 'agentdisk size failed.' }),
80
+ next: 'agentdisk explain <folder> says what a folder is and whether it may go; agentdisk plan makes a cleanup plan (proof cases and port plans are never in it). Only a person applies a plan; items go to quarantine first and can be restored.',
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,280 @@
1
+ // re_apicheck: does this code use APIs that exist in the engine version it targets?
2
+ // AI agents mix Godot 3 and 4 and invent members. This checks GDScript (and, for class names and renames, C#)
3
+ // against name-only snapshots of the real engine API (knowledge/apis/godot-*.json) and the official 3→4 rename
4
+ // table. It reports unknown classes, unknown members on a known receiver type, Godot 3 names used in Godot 4
5
+ // code (with the replacement), and Godot 4 names used in Godot 3 code. Static analysis only: when the receiver
6
+ // type cannot be inferred, the member is not checked.
7
+ import fs from 'node:fs';
8
+ import fsp from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { RPError, requireDir, walk, limit, statOrNull } from './util.mjs';
12
+ import { stripComments } from './symbols.mjs';
13
+ import { closest } from './agent.mjs';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'knowledge', 'apis');
16
+ const cache = new Map();
17
+ export const GODOT_VERSIONS = fs.readdirSync(ROOT).filter(f => /^godot-\d+(?:\.\d+)*\.json$/.test(f)).map(f => f.replace(/^godot-|\.json$/g, '')).sort();
18
+
19
+ function snapshot(version) {
20
+ const key = `godot-${version}`;
21
+ if (!cache.has(key)) {
22
+ const file = path.join(ROOT, `${key}.json`);
23
+ if (!fs.existsSync(file)) throw new RPError('INVALID_ARGUMENT', `No API snapshot for Godot ${version}. Available: ${GODOT_VERSIONS.join(', ')}.`);
24
+ const api = JSON.parse(fs.readFileSync(file, 'utf8'));
25
+ api.classSet = new Set(Object.keys(api.classes));
26
+ api.memberCache = new Map();
27
+ cache.set(key, api);
28
+ }
29
+ return cache.get(key);
30
+ }
31
+
32
+ let renames = null;
33
+ export function renameTables() {
34
+ if (!renames) {
35
+ const r = JSON.parse(fs.readFileSync(path.join(ROOT, 'godot-3-to-4-renames.json'), 'utf8')).tables;
36
+ const toMap = pairs => new Map(pairs);
37
+ // Godot's editor converter handles these outside its rename table; agents hit them constantly.
38
+ const extraFunctions = [['instance', 'instantiate'], ['empty', 'is_empty'], ['invert', 'reverse'], ['funcref', 'Callable(obj, "method")'], ['set_cellv', 'set_cell'], ['get_cellv', 'get_cell_source_id'],
39
+ ['yield', 'await'], ['get_tree().change_scene', 'get_tree().change_scene_to_file'], ['is_a_parent_of', 'is_ancestor_of'], ['get_closest_point_to_segment_2d', 'get_closest_point_to_segment'], ['validate_json', 'JSON.parse_string'], ['parse_json', 'JSON.parse_string'], ['to_json', 'JSON.stringify']];
40
+ renames = {
41
+ classes: toMap(r.class_renames), builtins: toMap(r.builtin_types_renames), functions: toMap([...r.gdscript_function_renames, ...extraFunctions]), csFunctions: toMap(r.csharp_function_renames),
42
+ properties: toMap(r.gdscript_properties_renames), signals: toMap(r.gdscript_signals_renames), enums: toMap(r.enum_renames), shaders: toMap(r.shaders_renames),
43
+ reverseClasses: toMap(r.class_renames.map(([a, b]) => [b, a])), reverseFunctions: toMap([...r.gdscript_function_renames, ...extraFunctions].filter(([, b]) => /^\w+$/.test(b)).map(([a, b]) => [b, a])),
44
+ };
45
+ }
46
+ return renames;
47
+ }
48
+
49
+ /** Pick the snapshot for a version string: "4", "4.3", "4.7", "3", "3.5", "godot@4.3"… */
50
+ export function resolveVersion(v) {
51
+ const s = String(v ?? '').replace(/^godot[@ -]?/i, '').trim();
52
+ if (!s) return GODOT_VERSIONS.filter(x => x.startsWith('4')).at(-1);
53
+ if (GODOT_VERSIONS.includes(s)) return s;
54
+ const major = s.split('.')[0];
55
+ const same = GODOT_VERSIONS.filter(x => x.startsWith(major));
56
+ if (!same.length) throw new RPError('INVALID_ARGUMENT', `No API snapshot for Godot ${s}. Available: ${GODOT_VERSIONS.join(', ')}.`);
57
+ const minor = Number(s.split('.')[1] ?? 99);
58
+ const notNewer = same.filter(x => Number(x.split('.')[1] ?? 0) <= minor);
59
+ return notNewer.at(-1) ?? same[0];
60
+ }
61
+
62
+ /** All members of a class including inherited ones. */
63
+ function members(api, cls) {
64
+ if (api.memberCache.has(cls)) return api.memberCache.get(cls);
65
+ const out = { methods: new Set(), properties: new Set(), signals: new Set(), constants: new Set() };
66
+ let c = cls;
67
+ const seen = new Set();
68
+ while (c && api.classes[c] && !seen.has(c)) {
69
+ seen.add(c);
70
+ const k = api.classes[c];
71
+ for (const key of Object.keys(out)) for (const n of k[key] ?? []) out[key].add(n);
72
+ c = k.inherits;
73
+ }
74
+ api.memberCache.set(cls, out);
75
+ return out;
76
+ }
77
+
78
+ const MOVED_3_TO_4 = {
79
+ 'OS.get_ticks_msec': 'Time.get_ticks_msec()', 'OS.get_ticks_usec': 'Time.get_ticks_usec()', 'OS.get_unix_time': 'Time.get_unix_time_from_system()',
80
+ 'OS.get_datetime': 'Time.get_datetime_dict_from_system()', 'OS.get_date': 'Time.get_date_dict_from_system()', 'OS.get_time': 'Time.get_time_dict_from_system()',
81
+ 'OS.get_system_time_msecs': 'Time.get_ticks_msec()', 'OS.window_size': 'get_window().size', 'OS.set_window_size': 'get_window().size =', 'OS.window_fullscreen': 'get_window().mode == Window.MODE_FULLSCREEN',
82
+ 'OS.get_screen_size': 'DisplayServer.screen_get_size()', 'Engine.editor_hint': 'Engine.is_editor_hint()', 'Engine.target_fps': 'Engine.max_fps',
83
+ 'Input.set_mouse_mode': 'Input.mouse_mode =', 'Node.instance': 'instantiate()', 'PackedScene.instance': 'instantiate()',
84
+ };
85
+ function allMethods(api) {
86
+ if (!api.allMethods) { api.allMethods = new Set(); for (const c of Object.values(api.classes)) for (const n of c.methods ?? []) api.allMethods.add(n); }
87
+ return api.allMethods;
88
+ }
89
+
90
+ const GD_KEYWORDS = new Set('if elif else for while match break continue pass return class class_name extends is in as self super signal func static const enum var await yield preload load assert void null true false and or not setget onready export tool breakpoint remote master puppet remotesync mastersync puppetsync'.split(' '));
91
+ const BUILTIN_TYPES_3 = new Set('int float bool String Vector2 Vector3 Rect2 Transform2D Plane Quat AABB Basis Transform Color NodePath RID Object Dictionary Array PoolByteArray PoolIntArray PoolRealArray PoolStringArray PoolVector2Array PoolVector3Array PoolColorArray Variant'.split(' '));
92
+
93
+ /** Static type of common receivers: `self`, typed vars, `ClassName.`, `$Node as Type`, `Singleton`. */
94
+ function typeEnv(code, api) {
95
+ const env = new Map();
96
+ const ext = code.match(/^extends\s+([A-Z]\w*)/m);
97
+ if (ext) env.set('self', ext[1]);
98
+ for (const m of code.matchAll(/\b(?:var|const|@onready\s+var|onready\s+var|export(?:\([^)]*\))?\s+var)\s+(\w+)\s*:\s*([A-Z]\w*)/g)) env.set(m[1], m[2]);
99
+ for (const m of code.matchAll(/\b(?:var|const)\s+(\w+)\s*:?=\s*([A-Z]\w*)\.new\(/g)) env.set(m[1], m[2]);
100
+ for (const m of code.matchAll(/\b(?:var|const)\s+(\w+)\s*:?=\s*\$[\w/%"]+\s+as\s+([A-Z]\w*)/g)) env.set(m[1], m[2]);
101
+ for (const m of code.matchAll(/func\s+\w+\s*\(([^)]*)\)/g)) for (const p of m[1].split(',')) { const t = p.match(/(\w+)\s*:\s*([A-Z]\w*)/); if (t) env.set(t[1], t[2]); }
102
+ for (const [name, type] of Object.entries(api.singletons ?? {})) env.set(name, type);
103
+ return env;
104
+ }
105
+
106
+ export function checkGdscript(text, version) {
107
+ const api = snapshot(version);
108
+ const major = version.split('.')[0];
109
+ const R = renameTables();
110
+ const code = stripComments(text, 'gdscript');
111
+ const lines = code.split('\n');
112
+ const findings = [];
113
+ const seen = new Set();
114
+ const add = f => { const k = `${f.kind}|${f.name}|${f.line}`; if (!seen.has(k)) { seen.add(k); findings.push(f); } };
115
+ const env = typeEnv(code, api);
116
+ const localFuncs = new Set([...code.matchAll(/^\s*(?:static\s+)?func\s+(\w+)/gm)].map(m => m[1]));
117
+ const localVars = new Set([...code.matchAll(/\b(?:var|const|signal|enum)\s+(\w+)/g)].map(m => m[1]));
118
+ const localClasses = new Set([...code.matchAll(/^\s*class(?:_name)?\s+(\w+)/gm)].map(m => m[1]));
119
+ const utility = new Set(api.utility_functions ?? []);
120
+ const globalEnums = new Set(api.global_enums ?? []);
121
+
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const l = lines[i];
124
+ const line = i + 1;
125
+ // Class names used as types or receivers.
126
+ for (const m of l.matchAll(/(?<![\w.$"'/])([A-Z][A-Za-z0-9]+)\b(?!\s*(?::\s*$|"))/g)) {
127
+ const name = m[1];
128
+ if (api.classSet.has(name) || localClasses.has(name) || localVars.has(name) || globalEnums.has(name) || /^[A-Z_0-9]+$/.test(name)) continue;
129
+ if (major === '4' && (R.classes.has(name) || R.builtins.has(name))) { add({ kind: 'renamed-class', name, line, replacement: R.classes.get(name) ?? R.builtins.get(name), detail: `Godot 3 name; in Godot 4 it is ${R.classes.get(name) ?? R.builtins.get(name)}` }); continue; }
130
+ if (major === '3' && R.reverseClasses.has(name)) { add({ kind: 'too-new', name, line, replacement: R.reverseClasses.get(name), detail: `Godot 4 name; in Godot 3 it is ${R.reverseClasses.get(name)}` }); continue; }
131
+ if (major === '3' && BUILTIN_TYPES_3.has(name)) continue;
132
+ // Only flag names that look like engine classes (used with .new(, as a type, or as a static receiver).
133
+ const after = l.slice(m.index + name.length);
134
+ const before = l.slice(0, m.index);
135
+ if (/^\s*\.\s*\w+\s*\(|^\s*\.new\(|^\s*\)|^\s*,|^\s*$/.test(after) && /(:\s*|\bextends\s+|\bis\s+|\bas\s+|\(\s*|,\s*|=\s*|->\s*)$/.test(before) || /^\s*\.new\(/.test(after) || /^\s*\.[A-Z_]+\b/.test(after)) {
136
+ const newer = GODOT_VERSIONS.filter(v => v.startsWith(major) && v > version).find(v => snapshot(v).classSet.has(name));
137
+ if (newer) { add({ kind: 'too-new', name, line, detail: `${name} exists from Godot ${newer}, not in ${api.version}` }); continue; }
138
+ const near = closest(name, [...api.classSet], 2);
139
+ add({ kind: 'unknown-class', name, line, ...(near ? { replacement: near } : {}), detail: near ? `not in Godot ${api.version}; did you mean ${near}?` : `not in Godot ${api.version}` });
140
+ }
141
+ }
142
+ // Member access on a known receiver: recv.member( or recv.member
143
+ for (const m of l.matchAll(/(?<![\w.])(\w+)\.(\w+)\s*(\()?/g)) {
144
+ const [, recv, member, call] = m;
145
+ if (member === 'new' && call) continue;
146
+ let type = env.get(recv) ?? (api.classSet.has(recv) ? recv : null);
147
+ if (!type || !api.classes[type]) {
148
+ // Untyped receiver: only Godot 3 method names that no Godot 4 class has are safe to flag.
149
+ if (call && major === '4' && R.functions.has(member) && !allMethods(api).has(member)) add({ kind: 'renamed-member', name: member, line, replacement: R.functions.get(member), detail: `Godot 3 name; in Godot 4 use ${R.functions.get(member)}` });
150
+ continue;
151
+ }
152
+ const moved = major === '4' ? MOVED_3_TO_4[`${type}.${member}`] : null;
153
+ if (moved) { add({ kind: 'renamed-member', name: `${type}.${member}`, line, replacement: moved, detail: `moved in Godot 4: use ${moved}` }); continue; }
154
+ const mem = members(api, type);
155
+ const isMethod = !!call;
156
+ if (isMethod ? mem.methods.has(member) : (mem.properties.has(member) || mem.constants.has(member) || mem.signals.has(member) || mem.methods.has(member))) continue;
157
+ if (recv === 'self' && (localFuncs.has(member) || localVars.has(member))) continue;
158
+ const rn = (isMethod ? R.functions : R.properties).get(member) ?? R.signals.get(member) ?? R.enums.get(member);
159
+ if (major === '4' && rn) { add({ kind: 'renamed-member', name: `${type}.${member}`, line, replacement: rn, detail: `Godot 3 name; in Godot 4 use ${rn}` }); continue; }
160
+ if (major === '3' && R.reverseFunctions.has(member)) { add({ kind: 'too-new', name: `${type}.${member}`, line, replacement: R.reverseFunctions.get(member), detail: `Godot 4 name; in Godot 3 it is ${R.reverseFunctions.get(member)}` }); continue; }
161
+ const pool = isMethod ? [...mem.methods] : [...mem.properties, ...mem.methods, ...mem.constants, ...mem.signals];
162
+ const near = closest(member, pool, 2);
163
+ add({ kind: isMethod ? 'unknown-method' : 'unknown-member', name: `${type}.${member}`, line, ...(near ? { replacement: near } : {}), detail: `${type} has no ${isMethod ? 'method' : 'member'} ${member} in Godot ${api.version}${near ? `; did you mean ${near}?` : ''}` });
164
+ }
165
+ // Method calls on call results or indexes: `preload(...).instance()`, `a[0].foo()`.
166
+ if (major === '4') for (const m of l.matchAll(/[)\]]\.(\w+)\s*\(/g)) {
167
+ const member = m[1];
168
+ if (R.functions.has(member) && !allMethods(api).has(member)) add({ kind: 'renamed-member', name: member, line, replacement: R.functions.get(member), detail: `Godot 3 name; in Godot 4 use ${R.functions.get(member)}` });
169
+ }
170
+ // Bare calls: local func, utility function, inherited method of `self`, or renamed.
171
+ for (const m of l.matchAll(/(?<![\w.@])([a-z_]\w*)\s*\(/g)) {
172
+ const name = m[1];
173
+ if (major === '4' && name === 'yield') { add({ kind: 'renamed-function', name, line, replacement: 'await', detail: 'yield() is gone in Godot 4: use await signal_or_coroutine' }); continue; }
174
+ if (GD_KEYWORDS.has(name) || localFuncs.has(name) || localVars.has(name) || utility.has(name)) continue;
175
+ const selfType = env.get('self');
176
+ if (selfType && members(api, selfType).methods.has(name)) continue;
177
+ if (major === '4' && R.functions.has(name)) { add({ kind: 'renamed-function', name, line, replacement: R.functions.get(name), detail: `Godot 3 name; in Godot 4 use ${R.functions.get(name)}` }); continue; }
178
+ if (major === '3' && R.reverseFunctions.has(name)) { add({ kind: 'too-new', name, line, replacement: R.reverseFunctions.get(name), detail: `Godot 4 name; in Godot 3 it is ${R.reverseFunctions.get(name)}` }); continue; }
179
+ if (selfType && api.classes[selfType]) {
180
+ const near = closest(name, [...members(api, selfType).methods, ...utility], 2);
181
+ add({ kind: 'unknown-function', name, line, ...(near ? { replacement: near } : {}), detail: `neither defined here, a Godot ${api.version} utility function, nor a method of ${selfType}${near ? `; did you mean ${near}?` : ''}` });
182
+ }
183
+ }
184
+ if (major === '4') {
185
+ const c3 = l.match(/\bconnect\(\s*"(\w+)"\s*,\s*\w+\s*,\s*"(\w+)"/);
186
+ if (c3) add({ kind: 'renamed-pattern', name: 'connect("signal", obj, "method")', line, replacement: `${c3[1]}.connect(${c3[2]})`, detail: 'Godot 3 string connect; in Godot 4 connect the signal to a Callable' });
187
+ if (/\b(?:export|onready|tool)\b(?!\s*\()/.test(l) && /^\s*(?:export|onready|tool)\b/.test(l)) add({ kind: 'renamed-pattern', name: l.trim().split(/\s/)[0], line, replacement: '@' + l.trim().split(/\s/)[0], detail: 'Godot 4 annotations start with @' });
188
+ if (/\bsetget\b/.test(l)) add({ kind: 'renamed-pattern', name: 'setget', line, replacement: 'var x: T:\n\tset(value): …\n\tget: …', detail: 'Godot 4 uses property get/set blocks' });
189
+ }
190
+ if (major === '3' && /^\s*@(export|onready|tool)/.test(l)) add({ kind: 'too-new', name: '@' + l.match(/^\s*@(\w+)/)[1], line, replacement: l.match(/^\s*@(\w+)/)[1], detail: 'annotations are Godot 4; Godot 3 uses the bare keyword' });
191
+ }
192
+ return findings;
193
+ }
194
+
195
+ export function checkCsharp(text, version) {
196
+ // Only class renames and well-known method renames (the C# API mirrors GDScript in PascalCase).
197
+ const major = version.split('.')[0];
198
+ const R = renameTables();
199
+ const api = snapshot(version);
200
+ const code = stripComments(text, 'csharp');
201
+ const findings = [];
202
+ code.split('\n').forEach((l, i) => {
203
+ for (const m of l.matchAll(/\b([A-Z][A-Za-z0-9]+)\b/g)) {
204
+ const name = m[1];
205
+ if (major === '4' && R.classes.has(name) && !api.classSet.has(name)) findings.push({ kind: 'renamed-class', name, line: i + 1, replacement: R.classes.get(name), detail: `Godot 3 name; in Godot 4 it is ${R.classes.get(name)}` });
206
+ }
207
+ for (const m of l.matchAll(/\.(\w+)\s*\(/g)) {
208
+ if (major === '4' && R.csFunctions.has(m[1])) findings.push({ kind: 'renamed-member', name: m[1], line: i + 1, replacement: R.csFunctions.get(m[1]), detail: `Godot 3 name; in Godot 4 use ${R.csFunctions.get(m[1])}` });
209
+ }
210
+ });
211
+ return findings;
212
+ }
213
+
214
+ export function checkShader(text, version) {
215
+ const major = version.split('.')[0];
216
+ const R = renameTables();
217
+ const findings = [];
218
+ if (major !== '4') return findings;
219
+ text.split('\n').forEach((l, i) => { for (const [old, nu] of R.shaders) if (new RegExp(`\\b${old.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(l)) findings.push({ kind: 'renamed-shader', name: old, line: i + 1, replacement: nu, detail: `Godot 3 shader name; in Godot 4 use ${nu}` }); });
220
+ return findings;
221
+ }
222
+
223
+ function detectVersion(root) {
224
+ try {
225
+ const pg = fs.readFileSync(path.join(root, 'project.godot'), 'utf8');
226
+ const f = pg.match(/config\/features=PackedStringArray\(([^)]*)\)/);
227
+ const v = f?.[1].match(/"(\d+\.\d+)"/)?.[1];
228
+ if (v) return { version: v, from: 'project.godot config/features' };
229
+ if (/config_version=5/.test(pg)) return { version: '4', from: 'project.godot config_version=5' };
230
+ if (/config_version=4/.test(pg)) return { version: '3', from: 'project.godot config_version=4' };
231
+ } catch { /* no project.godot */ }
232
+ return null;
233
+ }
234
+
235
+ export async function apicheckTool({ path: target, content, engine, limit: max = 300 } = {}) {
236
+ if (!target && !content) throw new RPError('INVALID_ARGUMENT', 'Give path (file or folder) or content.');
237
+ let root = null, files = [];
238
+ if (content) files.push({ rel: '(content)', text: content, lang: /^\s*(?:using\s+Godot|namespace|public\s+(?:partial\s+)?class)/m.test(content) ? 'csharp' : /shader_type/.test(content) ? 'shader' : 'gdscript' });
239
+ else {
240
+ const abs = path.resolve(target);
241
+ const st = await statOrNull(abs);
242
+ if (!st) throw new RPError('NOT_FOUND', `not found: ${abs}`);
243
+ if (st.isDirectory()) {
244
+ root = await requireDir(abs);
245
+ const { files: all } = await walk(root, { maxFiles: 20000 });
246
+ for (const f of all) if (['.gd', '.cs', '.gdshader', '.shader'].includes(f.ext)) files.push({ rel: f.rel, abs: f.abs, lang: f.ext === '.gd' ? 'gdscript' : f.ext === '.cs' ? 'csharp' : 'shader' });
247
+ } else {
248
+ root = path.dirname(abs);
249
+ for (let d = root; ; d = path.dirname(d)) { if (fs.existsSync(path.join(d, 'project.godot'))) { root = d; break; } if (path.dirname(d) === d) break; }
250
+ const ext = path.extname(abs).toLowerCase();
251
+ files.push({ rel: path.relative(root, abs), abs, lang: ext === '.gd' ? 'gdscript' : ext === '.cs' ? 'csharp' : 'shader' });
252
+ }
253
+ }
254
+ const eng = String(engine ?? 'godot').toLowerCase().replace(/^(\d)/, 'godot@$1');
255
+ if (!eng.startsWith('godot')) throw new RPError('INVALID_ARGUMENT', `re_apicheck knows Godot (${GODOT_VERSIONS.map(v => `godot@${v}`).join(', ')}) for now. Unity/Unreal: see port_verify leftovers and port_map.`);
256
+ const detected = !/\d/.test(eng) && root ? detectVersion(root) : null;
257
+ const version = resolveVersion(eng.includes('@') || /\d/.test(eng) ? eng.replace(/^godot[@ -]?/, '') : detected?.version);
258
+ const api = snapshot(version);
259
+
260
+ const perFile = [];
261
+ const counts = {};
262
+ let total = 0;
263
+ for (const f of files) {
264
+ const text = f.text ?? await fsp.readFile(f.abs, 'utf8').catch(() => null);
265
+ if (text === null || text.length > 4 * 1024 * 1024) continue;
266
+ const findings = f.lang === 'gdscript' ? checkGdscript(text, version) : f.lang === 'csharp' ? checkCsharp(text, version) : checkShader(text, version);
267
+ if (!findings.length) continue;
268
+ total += findings.length;
269
+ for (const x of findings) counts[x.kind] = (counts[x.kind] ?? 0) + 1;
270
+ perFile.push({ file: f.rel, findings: findings.slice(0, 60) });
271
+ }
272
+ perFile.sort((a, b) => b.findings.length - a.findings.length);
273
+ const wrongMajor = (counts['renamed-class'] ?? 0) + (counts['renamed-function'] ?? 0) + (counts['renamed-member'] ?? 0) + (counts['renamed-pattern'] ?? 0) >= 3 || (counts['too-new'] ?? 0) >= 3;
274
+ return {
275
+ engine: `godot@${api.version}`, versionSource: detected ? detected.from : engine ? 'argument' : 'default (latest snapshot)', filesChecked: files.length, findings: total, byKind: counts,
276
+ files: limit(perFile, max), passed: total === 0,
277
+ ...(wrongMajor ? { suspicion: version.startsWith('4') ? 'Several Godot 3 names: this code was written for Godot 3, or the model mixed versions. Port it with the renames below, or check it against godot@3.' : 'Several Godot 4 names in a Godot 3 project: check it against godot@4.' } : {}),
278
+ next: total ? 'Replace each finding with its replacement (renames) or a real API (unknown-*), then run re_apicheck again. unknown-* on a receiver whose type is not declared is not checked: add type hints (var x: Node3D) to check more.' : `All class names and checked members exist in Godot ${api.version}.`,
279
+ };
280
+ }
@@ -13,6 +13,7 @@ import { RPError, readAt, requireFile, compileMatcher, sha256, MiB } from './uti
13
13
  import { identify } from './formats.mjs';
14
14
  import { parsePE, parseELF } from './binary.mjs';
15
15
  import { safeJoin, workspaceDir, extractionName } from './workspace.mjs';
16
+ import { readRpa, readGameMaker, readFmodBank, readWwiseBank, readUnrealPak, readSwf, readUnityWebData, readEmscriptenData } from './gamearchives.mjs';
16
17
 
17
18
  const MAX_ENTRIES = 2_000_000;
18
19
 
@@ -252,9 +253,17 @@ export async function openArchive(file) {
252
253
  await walkTar(abs, gz, e => { if (entries.length < MAX_ENTRIES) entries.push({ path: e.path, size: e.size }); return null; });
253
254
  return { abs, size, format: gz ? 'tar.gz' : 'tar', gz, entries };
254
255
  }
256
+ if (kind === 'renpy-rpa') return { abs, size, ...(await readRpa(abs, size)) };
257
+ if (kind === 'gamemaker-data') return { abs, size, ...(await readGameMaker(abs, size)) };
258
+ if (kind === 'fmod-bank') return { abs, size, ...(await readFmodBank(abs, size)) };
259
+ if (kind === 'wwise-bank') return { abs, size, ...(await readWwiseBank(abs, size)) };
260
+ if (kind === 'swf') return { abs, size, ...(await readSwf(abs, size)) };
261
+ if (head.subarray(0, 16).toString('latin1').startsWith('UnityWebData') || /\.(data\.(gz|br)|unityweb)$/i.test(abs) || (/\.data$/i.test(abs) && /Build[\\/]/.test(abs))) { try { return { abs, size, ...(await readUnityWebData(abs)) }; } catch (e) { if (e.code !== 'BAD_FORMAT') throw e; } }
262
+ if (/\.data$/i.test(abs)) { try { return { abs, size, ...(await readEmscriptenData(abs)) }; } catch (e) { if (e.code !== 'BAD_FORMAT') throw e; } }
263
+ if (/\.pak$/i.test(abs) && kind !== 'zip') { try { return { abs, size, ...(await readUnrealPak(abs, size)) }; } catch (e) { if (e.code !== 'BAD_FORMAT') throw e; } }
255
264
  if (kind === 'unityfs' || /\.pak$/i.test(abs)) { const d = await describeOnly(abs, size, kind); if (d) return { abs, size, ...d }; }
256
265
  try { return { abs, size, ...(await readPck(abs, size)) }; } catch (e) { if (e.code !== 'NOT_A_PCK') throw e; }
257
- throw new RPError('BAD_FORMAT', `Not a supported archive (${identify(head).name}). Supported: Godot .pck (also embedded in a game .exe/binary), zip/apk/jar/ipa, tar, tar.gz/.unitypackage, Electron .asar.`);
266
+ throw new RPError('BAD_FORMAT', `Not a supported archive (${identify(head).name}). Supported: Godot .pck (also embedded in a game .exe/binary), zip/apk/jar/ipa, tar, tar.gz/.unitypackage, Electron .asar, Ren'Py .rpa, GameMaker data.win, FMOD .bank, Wwise .bnk, Unreal .pak, Flash .swf.`);
258
267
  }
259
268
 
260
269
  class Md5Pass extends Transform {
@@ -267,7 +276,7 @@ export async function archiveTool({ file, action = 'list', match, limit = 500, o
267
276
  const want = compileMatcher(match);
268
277
  const selected = arc.entries.filter(e => want(e.path));
269
278
  const summary = { format: arc.format, file: arc.abs, size: arc.size };
270
- for (const k of ['packVersion', 'godotVersion', 'embedded', 'embeddedVia', 'start', 'encryptedDirectory', 'unityVersion', 'unityRevision', 'compression', 'pakVersion', 'bundleFormat', 'note']) if (arc[k] !== undefined) summary[k] = arc[k];
279
+ for (const k of ['packVersion', 'godotVersion', 'embedded', 'embeddedVia', 'start', 'encryptedDirectory', 'unityVersion', 'unityRevision', 'compression', 'pakVersion', 'bundleFormat', 'note', 'rpaVersion', 'keyed', 'renpy', 'chunks', 'game', 'counts', 'names', 'fsb', 'events', 'buses', 'vcas', 'bankVersion', 'bankId', 'objects', 'objectCount', 'namedIds', 'mountPoint', 'encryptedIndex', 'encryptionKeyGuid', 'compressionMethods', 'unreal', 'swfVersion', 'stage', 'tags', 'actionScript', 'documentClass', 'symbolClasses', 'frameLabels', 'classes', 'identifierStrings', 'loader', 'packageSize', 'engine', 'unity']) if (arc[k] !== undefined) summary[k] = arc[k];
271
280
  const byExt = {};
272
281
  for (const e of arc.entries) { const ext = path.extname(e.path).toLowerCase() || '(none)'; byExt[ext] = byExt[ext] ?? { files: 0, bytes: 0 }; byExt[ext].files++; byExt[ext].bytes += e.size ?? 0; }
273
282
  const topTypes = Object.entries(byExt).sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 25).map(([ext, v]) => ({ ext, ...v }));
@@ -276,7 +285,7 @@ export async function archiveTool({ file, action = 'list', match, limit = 500, o
276
285
  return { ...summary, totalEntries: arc.entries.length, matching: selected.length, totalBytes: arc.entries.reduce((s, e) => s + (e.size ?? 0), 0), types: topTypes, entries: selected.slice(0, max).map(({ localHeader, method, ...e }) => e), truncated: selected.length > max };
277
286
  }
278
287
  if (action !== 'extract') throw new RPError('INVALID_ARGUMENT', 'action must be "list" or "extract".');
279
- if (['unityfs', 'unreal-pak'].includes(arc.format)) throw new RPError('UNSUPPORTED_ARCHIVE', summary.note);
288
+ if (arc.format === 'unityfs' || (arc.format === 'unreal-pak' && !arc.entries.length)) throw new RPError('UNSUPPORTED_ARCHIVE', summary.note ?? 'nothing to extract');
280
289
  const budget = Math.min(maxBytes ?? 4096 * MiB, 64 * 1024 * MiB);
281
290
  const root = out ? path.resolve(out) : await workspaceDir('extracted', extractionName(arc.abs, sha256(`${arc.abs}:${arc.size}`)));
282
291
  await fs.mkdir(root, { recursive: true });
@@ -286,11 +295,23 @@ export async function archiveTool({ file, action = 'list', match, limit = 500, o
286
295
  const allow = new Set(plan.map(e => e.path));
287
296
  const canWrite = e => {
288
297
  if (e.encrypted) { skipped.push({ path: e.path, reason: 'encrypted — not decrypted' }); return false; }
298
+ if (e.extractable === false) { skipped.push({ path: e.path, reason: e.compressionMethod ? `${e.compressionMethod}-compressed entry: only stored and Zlib entries are extracted` : 'not extractable' }); return false; }
299
+ if (e.size == null) { skipped.push({ path: e.path, reason: 'size unknown (not in the directory index)' }); return false; }
300
+ if (e.inflated) { skipped.push({ path: e.path, reason: 'inside a compressed SWF body: extract from the inflated FWS copy (written as swf-inflated.swf)' }); return false; }
289
301
  if (e.unpacked) { skipped.push({ path: e.path, reason: 'stored outside the asar (app.asar.unpacked)' }); return false; }
290
302
  if (e.link) { skipped.push({ path: e.path, reason: 'symbolic link' }); return false; }
291
303
  if (written + (e.size ?? 0) > budget) { skipped.push({ path: e.path, reason: `over the ${Math.round(budget / MiB)} MiB extraction budget (raise maxBytes)` }); return false; }
292
304
  return true;
293
305
  };
306
+ if (arc.format === 'swf' && arc.compression === 'zlib') {
307
+ const whole = await fs.readFile(arc.abs);
308
+ const body = zlib.inflateSync(whole.subarray(8));
309
+ const header = Buffer.from(whole.subarray(0, 8)); header.write('FWS', 0, 'latin1');
310
+ const dest = path.join(root, 'swf-inflated.swf');
311
+ await fs.writeFile(dest, Buffer.concat([header, body]));
312
+ files.push({ path: 'swf-inflated.swf', file: dest, size: 8 + body.length });
313
+ written += 8 + body.length;
314
+ }
294
315
  if (arc.format === 'tar' || arc.format === 'tar.gz') {
295
316
  await walkTar(arc.abs, arc.gz, async e => {
296
317
  if (!allow.has(e.path) || !canWrite(e)) return null;
@@ -310,8 +331,17 @@ export async function archiveTool({ file, action = 'list', match, limit = 500, o
310
331
  const start = await zipDataStart(arc.abs, e);
311
332
  const src = e.compressedSize ? createReadStream(arc.abs, { start, end: start + e.compressedSize - 1 }) : Readable.from([]);
312
333
  await (e.method === 8 ? pipeline(src, zlib.createInflateRaw(), createWriteStream(dest)) : pipeline(src, createWriteStream(dest)));
334
+ } else if (e.inMemory && arc.data) {
335
+ await fs.writeFile(dest, arc.data.subarray(e.offset, e.offset + e.size));
336
+ } else if (e.zlibBlocks) {
337
+ const ws = createWriteStream(dest);
338
+ for (const b of e.zlibBlocks) { const chunk = await readAt(arc.abs, b.start, b.end - b.start); await new Promise((res, rej) => ws.write(zlib.inflateSync(chunk), err => (err ? rej(err) : res()))); }
339
+ await new Promise((res, rej) => { ws.once('error', rej); ws.end(res); });
313
340
  } else {
314
- const src = e.size ? createReadStream(arc.abs, { start: e.offset, end: e.offset + e.size - 1 }) : Readable.from([]);
341
+ const parts = [];
342
+ if (e.prefix?.length) parts.push(Readable.from([Buffer.from(e.prefix)]));
343
+ const body = e.size ? createReadStream(arc.abs, { start: e.offset, end: e.offset + Math.max(0, e.size - (e.prefix?.length ?? 0)) - 1 }) : Readable.from([]);
344
+ const src = parts.length ? Readable.from((async function* () { for (const s of parts) yield* s; yield* body; })()) : body;
315
345
  const md5 = new Md5Pass();
316
346
  await pipeline(src, md5, createWriteStream(dest));
317
347
  if (arc.format === 'godot-pck' && e.md5 && !/^0+$/.test(e.md5) && md5.hash.digest('hex') !== e.md5) mismatches.push(e.path);
@@ -3,6 +3,7 @@
3
3
  // Godot's frame (right-handed, Y up, -Z forward, meters) plus a unit scale.
4
4
  import { RPError } from './util.mjs';
5
5
  import { matToQuat } from './scene.mjs';
6
+ import { physicsProfile } from './physics.mjs';
6
7
 
7
8
  const I = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
8
9
  /** Rows map Godot coordinates into the system: p_sys = M · p_godot × scale. */
@@ -77,9 +78,10 @@ function eulerFor(sys, R) {
77
78
  * Convert transforms between engines. Inputs in the source system: position [x,y,z], rotation as a
78
79
  * quaternion [x,y,z,w] or euler degrees in the source engine's convention, scale [x,y,z], fov in degrees.
79
80
  */
80
- export function convert({ from, to, position, rotation, euler, scale, fov, aspect = 16 / 9 } = {}) {
81
+ export function convert({ from, to, position, rotation, euler, scale, fov, aspect = 16 / 9, physics } = {}) {
81
82
  const { T: M, factor } = transformBetween(from, to);
82
83
  const out = { from: system(from).label, to: system(to).label, axisMatrix: M.map(r => r.map(clean)), unitFactor: factor };
84
+ if (physics) out.physics = physicsProfile({ from, to, values: typeof physics === 'object' ? physics : {} });
83
85
  const vec3 = (v, what) => { if (!Array.isArray(v) || v.length !== 3 || v.some(n => typeof n !== 'number' || !Number.isFinite(n))) throw new RPError('INVALID_ARGUMENT', `${what} must be [x, y, z] numbers.`); return v; };
84
86
  if (position) out.position = mulVec(M, vec3(position, 'position')).map(v => clean(v * factor));
85
87
  let R = null;