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,327 @@
1
+ // re_strings: extract user-facing text for localisation, and put translations back into a copy.
2
+ // Sources: Godot .tscn/.tres (text, tooltip_text, placeholder_text, title, dialog_text, item lists), Godot code
3
+ // (tr("…"), tr_n, .text = "…"), Unity YAML (m_Text, m_text for TextMeshPro, m_Name of UI), Unity C#
4
+ // (.text = "…"), RPG Maker MV/MZ (Show Text 401, choices 102, names), Ren'Py (say and menu statements) and
5
+ // web/JSON i18n files. Every string gets a stable id: file + node path/line + property.
6
+ // Output: a catalog (JSON) and optionally .po or CSV. `apply` writes translations into a COPY of the project.
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { RPError, requireDir, walk, limit, toPosix } from './util.mjs';
10
+ import { workspaceDir, safeJoin } from './workspace.mjs';
11
+
12
+ const GODOT_PROPS = new Set(['text', 'tooltip_text', 'placeholder_text', 'title', 'dialog_text', 'ok_button_text', 'cancel_button_text', 'hint_tooltip', 'placeholder', 'window_title', 'name_text', 'popup/item_0/text']);
13
+ const UNITY_KEYS = new Set(['m_Text', 'm_text', 'm_Label', 'm_PlaceholderText']);
14
+ const MAX_LEN = 4000;
15
+
16
+ const unquote = s => s.replace(/^"(.*)"$/s, '$1').replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
17
+ const skip = s => !s.trim() || s.length > MAX_LEN || /^(res|uid|user):\/\//.test(s) || /^[\d\s.,:;+\-*/%=()[\]{}<>#$&|^~`!?@]*$/.test(s) || /^[a-z_][a-z0-9_]*$/.test(s) && !s.includes(' ');
18
+
19
+ function entry(file, where, prop, text, kind, line) {
20
+ return { id: `${file}#${where}#${prop}`, file, where, prop, text, kind, line, chars: text.length, placeholders: [...text.matchAll(/%[sdif]|\{[^}]+\}|%\d+\$[sd]/g)].map(m => m[0]) };
21
+ }
22
+
23
+ function fromGodotScene(file, text) {
24
+ const out = [];
25
+ let node = '', nodeType = '';
26
+ const lines = text.split('\n');
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const l = lines[i];
29
+ const n = l.match(/^\[(node|sub_resource|resource)\s*(.*)\]$/);
30
+ if (n) { node = n[1] === 'node' ? `${(n[2].match(/parent="([^"]*)"/)?.[1] ?? '').replace(/^\.$/, '')}/${n[2].match(/name="([^"]*)"/)?.[1] ?? ''}`.replace(/^\/+/, '') || '.' : (n[2].match(/id="?([^" ]+)"?/)?.[1] ?? n[1]); nodeType = n[2].match(/type="([^"]*)"/)?.[1] ?? ''; continue; }
31
+ const kv = l.match(/^([A-Za-z_][\w/]*)\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/);
32
+ if (kv && (GODOT_PROPS.has(kv[1]) || /\/text$/.test(kv[1]))) { const v = unquote(`"${kv[2]}"`); if (!skip(v)) out.push(entry(file, node, kv[1], v, nodeType || 'resource', i + 1)); continue; }
33
+ const arr = l.match(/^(items|options|popup_items)\s*=\s*\[(.*)\]\s*$/);
34
+ if (arr) for (const m of arr[2].matchAll(/"((?:[^"\\]|\\.)*)"/g)) { const v = unquote(`"${m[1]}"`); if (!skip(v)) out.push(entry(file, node, arr[1], v, nodeType, i + 1)); }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function fromCode(file, text, lang) {
40
+ const out = [];
41
+ const lines = text.split('\n');
42
+ const patterns = lang === 'gd'
43
+ ? [/\btr\(\s*"((?:[^"\\]|\\.)*)"/g, /\btr_n\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*"((?:[^"\\]|\\.)*)"/g, /\.(?:text|tooltip_text|placeholder_text|title)\s*=\s*"((?:[^"\\]|\\.)*)"/g, /\b(?:add_item|add_icon_item|set_item_text|add_check_item|set_text|push_warning|push_error|print(?:_rich)?)\(\s*"((?:[^"\\]|\\.)*)"/g, /\$"((?:[^"\\]|\\.)*)"/g]
44
+ : lang === 'cs'
45
+ ? [/\.(?:text|Text|tooltip|Tooltip)\s*=\s*\$?"((?:[^"\\]|\\.)*)"/g, /\bSetText\(\s*"((?:[^"\\]|\\.)*)"/g, /\b(?:Localize|Translate|GetString|L|T)\(\s*"((?:[^"\\]|\\.)*)"/g, /\bDebug\.Log(?:Warning|Error)?\(\s*"((?:[^"\\]|\\.)*)"/g]
46
+ : [/\b(?:t|i18n\.t|\$t|gettext|_)\(\s*["']((?:[^"'\\]|\\.)*)["']/g, /\.(?:textContent|innerText|title|placeholder)\s*=\s*["']((?:[^"'\\]|\\.)*)["']/g];
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const l = lines[i];
49
+ if (/^\s*(#|\/\/)/.test(l)) continue;
50
+ for (const re of patterns) for (const m of l.matchAll(re)) {
51
+ const v = unquote(`"${m[1]}"`);
52
+ if (skip(v) || /^[\w.]+$/.test(v)) continue;
53
+ const prop = re.source.startsWith('\\btr_n') ? 'tr_n' : re.source.startsWith('\\btr\\(') ? 'tr' : re.source.includes('Log') || re.source.includes('push_') || re.source.includes('print') ? 'log' : 'code';
54
+ out.push(entry(file, `L${i + 1}`, prop, v, 'code', i + 1));
55
+ if (m[2]) out.push(entry(file, `L${i + 1}`, 'tr_n.plural', unquote(`"${m[2]}"`), 'code', i + 1));
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function fromUnityYaml(file, text) {
62
+ const out = [];
63
+ if (!text.startsWith('%YAML')) return out;
64
+ const lines = text.split('\n');
65
+ let objId = '', objType = '', objName = '';
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const l = lines[i];
68
+ const h = l.match(/^--- !u!(\d+) &(-?\d+)/);
69
+ if (h) { objId = h[2]; objType = ''; objName = ''; continue; }
70
+ if (!objType && /^[A-Za-z]\w*:\s*$/.test(l)) { objType = l.slice(0, -1); continue; }
71
+ const name = l.match(/^\s{2}m_Name:\s*(.+)$/);
72
+ if (name && objType === 'GameObject') { objName = name[1].trim(); continue; }
73
+ const kv = l.match(/^\s{2}(m_\w+):\s*(.+)$/);
74
+ if (!kv || !UNITY_KEYS.has(kv[1])) continue;
75
+ let v = kv[2].trim();
76
+ if (/^['"]/.test(v)) v = v.slice(1, -1).replace(/''/g, "'").replace(/\\n/g, '\n');
77
+ else if (v === '|' || v === '>') { const block = []; let j = i + 1; while (j < lines.length && /^\s{4,}/.test(lines[j])) block.push(lines[j].replace(/^\s{4}/, '')), j++; v = block.join('\n'); }
78
+ if (!skip(v)) out.push(entry(file, `${objType}&${objId}${objName ? `(${objName})` : ''}`, kv[1], v, objType, i + 1));
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function fromRpgMaker(file, text) {
84
+ const out = [];
85
+ let data;
86
+ try { data = JSON.parse(text); } catch { return out; }
87
+ const base = path.basename(file);
88
+ const walkCommands = (list, where) => {
89
+ let buf = [], start = 0;
90
+ const flush = () => { if (buf.length) { const v = buf.join('\n'); if (!skip(v)) out.push(entry(file, `${where}/cmd${start}`, 'ShowText', v, 'rpgmaker-event', null)); buf = []; } };
91
+ (list ?? []).forEach((c, i) => {
92
+ if (c.code === 401) { if (!buf.length) start = i; buf.push(String(c.parameters?.[0] ?? '')); return; }
93
+ flush();
94
+ if (c.code === 102) (c.parameters?.[0] ?? []).forEach((choice, k) => { if (!skip(choice)) out.push(entry(file, `${where}/cmd${i}/choice${k}`, 'Choice', choice, 'rpgmaker-event', null)); });
95
+ if (c.code === 105 || c.code === 405) { const v = String(c.parameters?.[0] ?? ''); if (!skip(v)) out.push(entry(file, `${where}/cmd${i}`, 'Scroll', v, 'rpgmaker-event', null)); }
96
+ if (c.code === 108 || c.code === 408) return; // comments
97
+ });
98
+ flush();
99
+ };
100
+ if (/^Map\d+\.json$/.test(base)) {
101
+ if (data.displayName && !skip(data.displayName)) out.push(entry(file, 'map', 'displayName', data.displayName, 'rpgmaker-map', null));
102
+ for (const ev of data.events ?? []) if (ev) ev.pages?.forEach((p, pi) => walkCommands(p.list, `event${ev.id}/page${pi}`));
103
+ } else if (base === 'CommonEvents.json') {
104
+ for (const ev of data) if (ev) walkCommands(ev.list, `common${ev.id}`);
105
+ } else if (/^(Actors|Items|Weapons|Armors|Skills|Enemies|States|Classes)\.json$/.test(base)) {
106
+ for (const it of data) if (it) for (const k of ['name', 'nickname', 'profile', 'description', 'message1', 'message2', 'message3', 'message4']) if (typeof it[k] === 'string' && !skip(it[k])) out.push(entry(file, `${base.replace('.json', '').toLowerCase()}${it.id}`, k, it[k], 'rpgmaker-db', null));
107
+ } else if (base === 'System.json') {
108
+ if (data.gameTitle && !skip(data.gameTitle)) out.push(entry(file, 'system', 'gameTitle', data.gameTitle, 'rpgmaker-db', null));
109
+ for (const [group, list] of Object.entries(data.terms ?? {})) {
110
+ if (Array.isArray(list)) list.forEach((v, i) => { if (typeof v === 'string' && !skip(v)) out.push(entry(file, `terms/${group}/${i}`, 'term', v, 'rpgmaker-db', null)); });
111
+ else if (list && typeof list === 'object') for (const [k, v] of Object.entries(list)) if (typeof v === 'string' && !skip(v)) out.push(entry(file, `terms/${group}/${k}`, 'term', v, 'rpgmaker-db', null));
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function fromRenpy(file, text) {
118
+ const out = [];
119
+ const lines = text.split('\n');
120
+ let label = '';
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const l = lines[i];
123
+ const lb = l.match(/^label\s+([\w.]+)/);
124
+ if (lb) { label = lb[1]; continue; }
125
+ if (/^\s*(#|\$|python|init|define|default|image|transform|screen|style)\b/.test(l)) continue;
126
+ const say = l.match(/^\s*(?:(\w+)\s+)?"((?:[^"\\]|\\.)*)"(?:\s+(?:with|nointeract).*)?\s*$/);
127
+ if (say) { const v = unquote(`"${say[2]}"`); if (!skip(v)) out.push(entry(file, `${label || 'top'}/L${i + 1}`, say[1] ? `say:${say[1]}` : 'narrator', v, 'renpy', i + 1)); continue; }
128
+ const choice = l.match(/^\s+"((?:[^"\\]|\\.)*)"\s*(?:if\s+.+)?:\s*$/);
129
+ if (choice) { const v = unquote(`"${choice[1]}"`); if (!skip(v)) out.push(entry(file, `${label || 'top'}/L${i + 1}`, 'menu', v, 'renpy', i + 1)); }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ function fromJsonI18n(file, text) {
135
+ const out = [];
136
+ let data;
137
+ try { data = JSON.parse(text); } catch { return out; }
138
+ const visit = (v, p) => {
139
+ if (typeof v === 'string') { if (!skip(v)) out.push(entry(file, p, 'value', v, 'i18n-json', null)); }
140
+ else if (Array.isArray(v)) v.forEach((x, i) => visit(x, `${p}[${i}]`));
141
+ else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) visit(x, p ? `${p}.${k}` : k);
142
+ };
143
+ visit(data, '');
144
+ return out;
145
+ }
146
+
147
+ function fromGodotCsv(file, text) {
148
+ // Godot translation CSV: keys,en,nl,...
149
+ const out = [];
150
+ const rows = text.split('\n').filter(Boolean).map(r => r.split(','));
151
+ if (rows.length < 2 || rows[0][0]?.toLowerCase() !== 'keys') return out;
152
+ const langs = rows[0].slice(1);
153
+ for (const r of rows.slice(1)) langs.forEach((lang, i) => { const v = (r[i + 1] ?? '').replace(/^"|"$/g, ''); if (v && !skip(v)) out.push(entry(file, r[0], lang, v, 'godot-csv', null)); });
154
+ return out;
155
+ }
156
+
157
+ const EXTRACTORS = [
158
+ { test: f => /\.(tscn|tres)$/.test(f), fn: fromGodotScene },
159
+ { test: f => f.endsWith('.gd'), fn: (f, t) => fromCode(f, t, 'gd') },
160
+ { test: f => f.endsWith('.cs'), fn: (f, t) => fromCode(f, t, 'cs') },
161
+ { test: f => /\.(js|ts|jsx|tsx|mjs|vue|svelte)$/.test(f) && !/\.(min|bundle)\./.test(f), fn: (f, t) => fromCode(f, t, 'js') },
162
+ { test: f => /\.(unity|prefab|asset)$/.test(f), fn: fromUnityYaml },
163
+ { test: f => /(^|\/)data\/(Map\d+|CommonEvents|Actors|Items|Weapons|Armors|Skills|Enemies|States|Classes|System)\.json$/.test(f), fn: fromRpgMaker },
164
+ { test: f => f.endsWith('.rpy'), fn: fromRenpy },
165
+ { test: f => /(^|\/)(i18n|locales?|lang|translations?)\/[^/]+\.json$/i.test(f), fn: fromJsonI18n },
166
+ { test: f => f.endsWith('.csv'), fn: fromGodotCsv },
167
+ ];
168
+
169
+ export async function extractStrings(root, { maxFiles = 50000, match } = {}) {
170
+ const { files, truncated } = await walk(root, { maxFiles });
171
+ const entries = [];
172
+ const perKind = {};
173
+ const seen = new Map();
174
+ for (const f of files) {
175
+ if (match && !f.rel.includes(match)) continue;
176
+ const ex = EXTRACTORS.find(e => e.test(f.rel));
177
+ if (!ex || f.size > 32 * 1024 * 1024) continue;
178
+ const text = await fs.readFile(f.abs, 'utf8').catch(() => null);
179
+ if (text === null) continue;
180
+ for (const e of ex.fn(f.rel, text)) {
181
+ entries.push(e);
182
+ perKind[e.kind] = (perKind[e.kind] ?? 0) + 1;
183
+ seen.set(e.text, (seen.get(e.text) ?? 0) + 1);
184
+ }
185
+ }
186
+ const duplicates = [...seen.entries()].filter(([, n]) => n > 1).sort((a, b) => b[1] - a[1]).map(([text, n]) => ({ text, occurrences: n }));
187
+ const words = entries.reduce((s, e) => s + e.text.split(/\s+/).filter(Boolean).length, 0);
188
+ return { entries, perKind, duplicates, words, files: files.length, truncated };
189
+ }
190
+
191
+ export function toPo(entries, { lang = '', project = 'reverseport' } = {}) {
192
+ const esc = s => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
193
+ const header = `msgid ""\nmsgstr ""\n"Project-Id-Version: ${project}\\n"\n"Language: ${lang}\\n"\n"MIME-Version: 1.0\\n"\n"Content-Type: text/plain; charset=UTF-8\\n"\n"Content-Transfer-Encoding: 8bit\\n"\n"X-Generator: ReversePort re_strings\\n"\n`;
194
+ const byText = new Map();
195
+ for (const e of entries) (byText.get(e.text) ?? byText.set(e.text, []).get(e.text)).push(e);
196
+ const blocks = [...byText.entries()].map(([text, es]) => {
197
+ const refs = es.map(e => e.line ? `${e.file}:${e.line}` : e.file).join(' ');
198
+ const ctx = es.map(e => `${e.where} ${e.prop}`).slice(0, 3).join('; ');
199
+ return `#: ${refs}\n#. ${ctx}${es[0].placeholders.length ? `\n#, c-format` : ''}\nmsgid "${esc(text)}"\nmsgstr ""\n`;
200
+ });
201
+ return header + '\n' + blocks.join('\n');
202
+ }
203
+
204
+ export function toCsv(entries) {
205
+ const q = s => `"${String(s).replace(/"/g, '""')}"`;
206
+ return ['id,file,where,prop,kind,chars,source,translation', ...entries.map(e => [e.id, e.file, e.where, e.prop, e.kind, e.chars, e.text, ''].map(q).join(','))].join('\n') + '\n';
207
+ }
208
+
209
+ /** Parse translations from .po (msgid→msgstr), CSV (id or source → translation) or JSON ({id|source: translation}). */
210
+ export async function readTranslations(file) {
211
+ const text = await fs.readFile(file, 'utf8');
212
+ const map = new Map();
213
+ if (file.endsWith('.po')) {
214
+ const un = s => s.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
215
+ for (const m of text.matchAll(/msgid\s+"((?:[^"\\]|\\.)*)"((?:\s*\n\s*"(?:[^"\\]|\\.)*")*)\s*\nmsgstr\s+"((?:[^"\\]|\\.)*)"((?:\s*\n\s*"(?:[^"\\]|\\.)*")*)/g)) {
216
+ const id = un(m[1] + m[2].replace(/\s*\n\s*"/g, '').replace(/"$/g, '').replace(/"(?=\n|$)/g, ''));
217
+ const str = un(m[3] + m[4].replace(/\s*\n\s*"/g, '').replace(/"$/g, '').replace(/"(?=\n|$)/g, ''));
218
+ if (id && str) map.set(id, str);
219
+ }
220
+ } else if (file.endsWith('.csv')) {
221
+ const rows = parseCsv(text);
222
+ const head = rows[0].map(h => h.toLowerCase());
223
+ const iId = head.indexOf('id'), iSrc = head.indexOf('source'), iTr = head.indexOf('translation');
224
+ if (iTr < 0) throw new RPError('INVALID_ARGUMENT', 'CSV needs a "translation" column (and "id" or "source").');
225
+ for (const r of rows.slice(1)) { const t = r[iTr]; if (!t) continue; if (iId >= 0 && r[iId]) map.set(r[iId], t); if (iSrc >= 0 && r[iSrc]) map.set(r[iSrc], t); }
226
+ } else {
227
+ const data = JSON.parse(text);
228
+ for (const [k, v] of Object.entries(data)) if (typeof v === 'string' && v) map.set(k, v);
229
+ }
230
+ return map;
231
+ }
232
+
233
+ function parseCsv(text) {
234
+ const rows = [];
235
+ let row = [], cell = '', q = false;
236
+ for (let i = 0; i < text.length; i++) {
237
+ const c = text[i];
238
+ if (q) { if (c === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else q = false; } else cell += c; }
239
+ else if (c === '"') q = true;
240
+ else if (c === ',') { row.push(cell); cell = ''; }
241
+ else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(cell); rows.push(row); row = []; cell = ''; }
242
+ else cell += c;
243
+ }
244
+ if (cell || row.length) { row.push(cell); rows.push(row); }
245
+ return rows.filter(r => r.length > 1 || r[0]);
246
+ }
247
+
248
+ /** Write translations into a copy of the project at `out`. Returns what changed and what did not fit. */
249
+ export async function applyTranslations(root, translations, out, entries) {
250
+ const { files } = await walk(root, { maxFiles: 200000 });
251
+ const byFile = new Map();
252
+ for (const e of entries) (byFile.get(e.file) ?? byFile.set(e.file, []).get(e.file)).push(e);
253
+ let changed = 0, copied = 0;
254
+ const problems = [];
255
+ const applied = [];
256
+ await fs.mkdir(out, { recursive: true });
257
+ for (const f of files) {
258
+ const dest = safeJoin(out, f.rel);
259
+ await fs.mkdir(path.dirname(dest), { recursive: true });
260
+ const es = byFile.get(f.rel);
261
+ if (!es) { await fs.copyFile(f.abs, dest); copied++; continue; }
262
+ let text = await fs.readFile(f.abs, 'utf8');
263
+ const lines = text.split('\n');
264
+ let n = 0;
265
+ for (const e of es) {
266
+ const t = translations.get(e.id) ?? translations.get(e.text);
267
+ if (!t || t === e.text) continue;
268
+ const srcPh = e.placeholders.join(','), dstPh = [...t.matchAll(/%[sdif]|\{[^}]+\}|%\d+\$[sd]/g)].map(m => m[0]).join(',');
269
+ if (srcPh !== dstPh) { problems.push({ id: e.id, problem: `placeholders differ: "${srcPh}" vs "${dstPh}"` }); continue; }
270
+ if (t.length > Math.max(40, e.text.length * 2.5)) problems.push({ id: e.id, problem: `translation is ${t.length} chars for a ${e.text.length}-char source; check the layout` });
271
+ if (e.line && e.kind !== 'rpgmaker-event') {
272
+ const idx = e.line - 1;
273
+ const esc = v => v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
274
+ const before = lines[idx];
275
+ const after = before.includes(`"${esc(e.text)}"`) ? before.replace(`"${esc(e.text)}"`, `"${esc(t)}"`) : before.replace(e.text, t);
276
+ if (after === before) { problems.push({ id: e.id, problem: 'source text not found at its line (file changed since extraction?)' }); continue; }
277
+ lines[idx] = after; n++;
278
+ } else if (f.rel.endsWith('.json')) {
279
+ // JSON-based (RPG Maker, i18n): replace the exact string value.
280
+ const json = JSON.stringify(e.text).slice(1, -1), rep = JSON.stringify(t).slice(1, -1);
281
+ const joined = lines.join('\n');
282
+ if (!joined.includes(`"${json}"`)) { problems.push({ id: e.id, problem: 'source text not found' }); continue; }
283
+ const replaced = joined.replace(`"${json}"`, `"${rep}"`);
284
+ lines.length = 0; lines.push(...replaced.split('\n')); n++;
285
+ } else problems.push({ id: e.id, problem: 'cannot write this kind back yet' });
286
+ applied.push(e.id);
287
+ }
288
+ text = lines.join('\n');
289
+ await fs.writeFile(dest, text);
290
+ changed += n ? 1 : 0;
291
+ if (!n) copied++;
292
+ }
293
+ return { out, filesChanged: changed, filesCopied: copied, applied: applied.length, problems };
294
+ }
295
+
296
+ export async function stringsTool({ path: target, action = 'extract', format = 'json', out, translations, lang, match, limit: max = 500 }) {
297
+ const root = await requireDir(target, 'project folder');
298
+ const result = await extractStrings(root, { match });
299
+ if (action === 'extract') {
300
+ const outDir = out ? path.resolve(out) : await workspaceDir('strings');
301
+ const name = path.basename(root).replace(/[^A-Za-z0-9._-]+/g, '_') || 'project';
302
+ const written = [];
303
+ if (format === 'po' || format === 'all') { const f = path.join(outDir, `${name}.pot`); await fs.mkdir(outDir, { recursive: true }); await fs.writeFile(f, toPo(result.entries, { project: name })); written.push(f); }
304
+ if (format === 'csv' || format === 'all') { const f = path.join(outDir, `${name}.csv`); await fs.mkdir(outDir, { recursive: true }); await fs.writeFile(f, toCsv(result.entries)); written.push(f); }
305
+ const catalog = path.join(outDir, `${name}.strings.json`);
306
+ await fs.mkdir(outDir, { recursive: true });
307
+ await fs.writeFile(catalog, JSON.stringify({ project: toPosix(root), extracted: new Date().toISOString(), entries: result.entries }, null, 2));
308
+ written.push(catalog);
309
+ return {
310
+ project: root, strings: result.entries.length, words: result.words, perKind: result.perKind, duplicates: limit(result.duplicates, 50), entries: limit(result.entries, max),
311
+ written, truncated: result.truncated,
312
+ next: `Translate ${written.find(w => w.endsWith('.pot')) ?? written.find(w => w.endsWith('.csv')) ?? catalog} (fill msgstr / the translation column, keep placeholders like %d and {name}), then re_strings action:"apply" with translations:<that file> and out:<new folder>. The catalog ${catalog} holds the ids.`,
313
+ };
314
+ }
315
+ if (action === 'apply') {
316
+ if (!translations) throw new RPError('INVALID_ARGUMENT', 'apply needs translations: a .po, .csv (id|source,translation) or .json file.');
317
+ if (!out) throw new RPError('INVALID_ARGUMENT', 'apply needs out: a new or empty folder for the translated copy (the source is never modified).');
318
+ const outAbs = path.resolve(out);
319
+ if (outAbs === root || outAbs.startsWith(root + path.sep) || root.startsWith(outAbs + path.sep)) throw new RPError('INVALID_ARGUMENT', 'out must be outside the source project.');
320
+ const existing = await fs.readdir(outAbs).catch(() => []);
321
+ if (existing.length) throw new RPError('OUTPUT_NOT_EMPTY', `out is not empty: ${outAbs}`);
322
+ const map = await readTranslations(path.resolve(translations));
323
+ const r = await applyTranslations(root, map, outAbs, result.entries);
324
+ return { ...r, lang: lang ?? null, strings: result.entries.length, translated: r.applied, untranslated: result.entries.length - r.applied, next: r.problems.length ? 'Fix the listed problems in the translation file and run apply again into an empty folder.' : `Open ${outAbs} in the engine and check layouts for long strings.` };
325
+ }
326
+ throw new RPError('INVALID_ARGUMENT', 'action must be extract or apply.');
327
+ }
@@ -14,6 +14,16 @@ import { lookupTool, SOURCES } from './lookup.mjs';
14
14
  import { blueprintTool, ARCHETYPE_IDS } from './blueprint.mjs';
15
15
  import { caseTool, ACTIONS as CASE_ACTIONS, INTENTS, KINDS, CONFIDENCE, PHASES, APPROVALS } from './proofcase.mjs';
16
16
  import { annotationsFor, closest, formatTools, shareFeedback } from './agent.mjs';
17
+ import { refsTool } from './refs.mjs';
18
+ import { stringsTool } from './strings.mjs';
19
+ import { apicheckTool, GODOT_VERSIONS } from './apicheck.mjs';
20
+ import { upgradeTool } from './upgrade.mjs';
21
+ import { saveTool } from './save.mjs';
22
+ import { portShaderTool } from './shader.mjs';
23
+ import { portTraceTool } from './trace.mjs';
24
+ import { decodeTool } from './godotbin.mjs';
25
+ import { il2cppTool } from './il2cpp.mjs';
26
+ import { portRpgMakerTool } from './rpgmaker.mjs';
17
27
 
18
28
  /** One version for the whole package: the root package.json. */
19
29
  export const VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
@@ -46,8 +56,8 @@ export const tools = [
46
56
  { name: 're_binary', brief: 'Inspect an executable or bytecode file: format, sections, imports, .NET types, strings, hints.', title: 'Inspect an executable or bytecode',
47
57
  description: 'Inspects PE/EXE/DLL (incl. .NET assemblies: runtime, types and methods), ELF, Mach-O (incl. universal), WebAssembly, Java .class, Android .dex, Lua/LuaJIT and Python bytecode: architecture, sections with entropy, imports/exports, PDB path, signing, overlay data (e.g. an embedded game pack), toolchain/engine hints and strings. Read-only: it does not unpack, decrypt or patch.',
48
58
  inputSchema: obj({ file: str('Binary file.'), strings: obj({ min: int('Minimum string length (default 6).', { minimum: 3, maximum: 64 }), limit: int('Strings to return (default 120).', { minimum: 1, maximum: 5000 }), match: str('Filter strings: substring, glob (*.dll) or /regex/i.'), maxMB: int('Scan this many MiB for strings (default 64).', { minimum: 1, maximum: 256 }), raw: bool('Include strings that look like byte noise (default false).') }, [], 'String options: min, limit, match, maxMB, raw.'), names: obj({ match: str('Filter import/export names: substring, glob or /regex/i.'), limit: int('Import and export names to return each (default 100).', { minimum: 1, maximum: 5000 }) }, [], 'Import/export name options: match, limit.'), maxTypes: int('.NET types to list (default 300).', { minimum: 1, maximum: 5000 }) }, ['file']) },
49
- { name: 're_archive', brief: 'List or extract a pack/archive: Godot .pck (also inside .exe), zip/apk/jar, tar.gz, asar.', title: 'List or extract a pack/archive',
50
- description: 'Lists or extracts Godot .pck packs (also packs embedded in an exported .exe/.x86_64), ZIP family (apk, jar, ipa, nupkg, docx), tar and tar.gz (.unitypackage), and Electron .asar. Identifies Unity bundles and Unreal paks (header only). Godot entries are MD5-checked on extraction. Encrypted packs/entries are reported, never decrypted. Extraction goes to the ReversePort workspace unless out is given.',
59
+ { name: 're_archive', brief: 'List or extract a pack/archive: Godot .pck (also inside .exe), zip/apk/jar, tar.gz, asar, Ren\'Py .rpa, GameMaker data.win, FMOD/Wwise banks, Unreal .pak, Flash .swf.', title: 'List or extract a pack/archive',
60
+ description: 'Lists or extracts Godot .pck packs (also packs embedded in an exported .exe/.x86_64), ZIP family (apk, jar, ipa, nupkg, docx), tar and tar.gz (.unitypackage), Electron .asar, Ren\'Py .rpa (2.0/3.0, index XOR key, .rpyc noted as compiled AST), GameMaker data.win (chunks, game name/version, sprite/object/room/script names, textures as PNG/QOI, audio as WAV/OGG), FMOD Studio .bank (event/bus paths, embedded FSB5 samples with codec/rate), Wwise .bnk (DIDX media as .wem, HIRC object counts, names from SoundbanksInfo.xml), Unreal .pak v1–v11 (mount point, entries with compression; stored and Zlib entries extract; Oodle/encrypted listed only) and Flash .swf (stage, tags, AS2/AS3, document and symbol classes, frame labels; JPEG/sound/ABC/binary assets extract; CWS inflates to an FWS copy), Unity WebGL Build/*.data (also .br/.gz/.unityweb; flags IL2CPP metadata) and Emscripten .data packages (file list read from the loader .js next to it). Identifies Unity bundles (header only). Godot entries are MD5-checked on extraction. Encrypted packs/entries are reported, never decrypted. Extraction goes to the ReversePort workspace unless out is given.',
51
61
  inputSchema: obj({ file: str('Archive/pack/executable.'), action: str('list (default) or extract.', { enum: ['list', 'extract'] }), match: str('Only entries matching: substring, glob (*.gd, scenes/*.tscn) or /regex/.'), limit: int('Max entries listed/extracted (default 500).', { minimum: 1, maximum: 20000 }), out: str('Extraction folder (default: workspace/extracted/<file>).'), maxBytes: int('Extraction budget in bytes (default 4 GiB).', { minimum: 1 }) }, ['file']) },
52
62
  { name: 're_probe', brief: 'Identify unknown data: hex, entropy map, embedded files and record strides.', title: 'Probe unknown data',
53
63
  description: 'For unknown or custom formats: identifies the format at an offset, hex view, entropy map (text/code/compressed regions), embedded files (PNG, OGG, WAV, ZIP, GDPC, glTF, DDS, ELF/PE…) with offsets and sizes, and likely record strides for tables. Works on multi-GB files.',
@@ -58,6 +68,27 @@ export const tools = [
58
68
  { name: 're_compare', brief: 'What changed between two folders, packs, binaries or files.', title: 'Diff two builds, packs, binaries or folders',
59
69
  description: 'Compares two folders (added/removed/changed files), two packs/archives (entries, MD5 for Godot packs), two executables (imports, exports, sections) or any two files (first difference, changed regions). Use it to see what changed between builds or versions.',
60
70
  inputSchema: obj({ a: str('First folder/file (old).'), b: str('Second folder/file (new).'), limit: int('Max items per list (default 300).', { minimum: 10, maximum: 5000 }) }, ['a', 'b']) },
71
+ { name: 're_refs', brief: 'Unity: find broken GUID references (missing scripts, pink materials), duplicate and missing .meta files.', title: 'Audit Unity GUID references',
72
+ description: 'Audits a Unity project\'s GUID references: references to GUIDs no .meta file declares (dangling: missing scripts, pink materials), the same GUID in two .meta files (copies made outside Unity), assets without a .meta, .meta files whose asset is gone, and declared assets nothing references. For dangling script references it suggests scripts that nothing references. Reports only; nothing is written. Fix by re-linking in Unity or by setting the suggested guid in the right .meta.',
73
+ inputSchema: obj({ path: str('Unity project root, or any folder/file inside it.'), limit: int('Max items per list (default 200).', { minimum: 10, maximum: 5000 }) }, ['path']) },
74
+ { name: 're_strings', brief: 'Extract user-facing text for translation (Godot, Unity, RPG Maker, Ren\'Py, i18n JSON) and write translations back into a copy.', title: 'Extract and apply translatable text',
75
+ description: 'Localisation: extracts user-facing text with stable ids from Godot scenes/resources and GDScript (text, tooltips, tr()), Unity YAML (m_Text, TextMeshPro) and C#, RPG Maker MV/MZ data (Show Text, choices, database names, terms), Ren\'Py scripts (say, menu), web code and i18n JSON files. Writes a catalog plus .pot (gettext) and/or CSV with file:line references and placeholder hints. action:"apply" takes a translated .po/.csv/.json and writes a translated COPY of the project into out, checking placeholders and flagging strings that grew a lot. The source is never modified.',
76
+ inputSchema: obj({ path: str('Project folder.'), action: str('extract (default) or apply.', { enum: ['extract', 'apply'] }), format: str('extract: json (default), po, csv or all.', { enum: ['json', 'po', 'csv', 'all'] }), out: str('extract: folder for the catalog/.pot/.csv (default workspace/strings). apply: new or empty folder for the translated copy.'), translations: str('apply: translated .po, .csv (id|source + translation columns) or .json ({id|source: translation}).'), lang: str('Language code for the output (e.g. nl).'), match: str('Only files whose path contains this.'), limit: int('Entries to return inline (default 500; the files hold all).', { minimum: 1, maximum: 20000 }) }, ['path']) },
77
+ { name: 're_apicheck', brief: 'Does this GDScript/C# use APIs that exist in the Godot version it targets? Flags Godot 3 names in Godot 4 code (with replacements), invented members and too-new classes.', title: 'Check code against a real engine API',
78
+ description: 'Checks GDScript (and class names in C#, names in shaders) against name-only snapshots of the real Godot API (' + GODOT_VERSIONS.map(v => 'godot@' + v).join(', ') + ') plus the official 3→4 rename table. Reports: renamed-class/function/member/pattern (Godot 3 names in Godot 4 code with the Godot 4 replacement: KinematicBody2D, instance(), yield, deg2rad, string connect, export/onready without @), unknown-class/method/member (not in that version: a model-invented or misspelled API, with the closest real name), too-new (exists only in a newer version, or Godot 4 names in a Godot 3 project). Members are checked only where the receiver type is known (extends, typed vars, Class.new(), singletons, parameters with type hints); untyped receivers are skipped, not guessed. The Godot version comes from engine, else from project.godot, else the newest snapshot. Run it on every file an agent wrote before opening Godot.',
79
+ inputSchema: obj({ path: str('A .gd/.cs/.gdshader file or a project folder.'), content: str('Code to check instead of path.'), engine: str('Target: godot@4.7, godot@4.3, godot@3 (or just 4 / 3). Default: project.godot, else newest.'), limit: int('Max files in the result (default 300).', { minimum: 1, maximum: 5000 }) }, []) },
80
+ { name: 'port_upgrade', brief: 'Godot 3 → Godot 4: converted copy of a whole project (renames, annotations, await, signals, scenes, project.godot, shaders) plus the list of what still needs a human.', title: 'Upgrade a Godot 3 project to Godot 4',
81
+ description: 'Converts a Godot 3.x project to Godot 4 as a COPY (the source is never modified): the official rename table for classes, builtin types, functions, properties, signals, enums, colors, theme overrides, project settings and shader names; export/onready/tool → @annotations with typed hints; yield → await; instance → instantiate; string connect/disconnect/emit_signal → signal.connect(callable)/emit; Pool*Array → Packed*Array; OS time functions → Time; .shader → .gdshader (references updated); scenes to format 3 with renamed node types and properties; project.godot to config_version 5 with the input map keys. What cannot be converted mechanically is listed with file and line and how to do it: setget, funcref, move_and_slide with arguments, var velocity in CharacterBody scripts, Tween nodes, binds, RPC keywords, networking, SCREEN_TEXTURE, window functions. action:"check" only reports. Follow with re_apicheck on the copy and open it in Godot 4.',
82
+ inputSchema: obj({ path: str('Godot 3 project folder (has project.godot with config_version=4).'), action: str('check (default): report what would change. apply: write the converted copy.', { enum: ['check', 'apply'] }), out: str('apply: new or empty folder for the copy (default: workspace/upgrades/<name>-godot4).'), limit: int('Max items per list (default 300).', { minimum: 10, maximum: 5000 }) }, ['path']) },
83
+ { name: 're_save', brief: 'Save files: container layers, fields with roles (health, currency, position…), verified checksums, what changed between two saves, and a schema + SaveData.gd/.cs to port the format.', title: 'Analyse a save file',
84
+ description: 'Peels the open layers of a save file (gzip/zlib, base64, LZ-String as RPG Maker MV/MZ uses, JSON, XML, INI/ConfigFile, Godot store_var) and lists every field with type, sample and a guessed role (health, currency, score, position, progress, collection, time, version, identity, integrity, rng, settings). Checksum fields are verified by recomputing md5/sha1/sha256/crc32 over the save without that field; binary saves are checked for CRC32 trailers/headers and get record strides and strings. compare:<second save> lists the fields that changed between two saves, the fastest way to learn which field is which. action:"schema" writes a JSON schema plus SaveData.gd and SaveData.cs with the same field names, to port the save format. Encrypted, XOR-obfuscated, .NET BinaryFormatter and pickle content is reported, never decoded.',
85
+ inputSchema: obj({ file: str('The save file.'), compare: str('A second save of the same game to diff against.'), action: str('inspect (default) or schema (write schema + data classes).', { enum: ['inspect', 'schema'] }), out: str('schema: folder for the files (default workspace/saves).'), limit: int('Max fields/changes (default 400).', { minimum: 10, maximum: 10000 }) }, ['file']) },
86
+ { name: 're_decode', brief: 'Godot export back to editable files: .gdc → .gd, binary .scn/.res → .tscn/.tres, project.binary → project.godot.', title: 'Decode Godot binary exports',
87
+ description: 'Turns what a Godot export leaves into editable text: compiled GDScript (.gdc: Godot 3 bytecode v10–13 and Godot 4.3+ binary tokens v100/v101, zstd-compressed or not) back to .gd with indentation, binary resources and scenes (RSRC/RSCC .scn/.res, also under .godot/exported with their .remap targets) to .tscn/.tres with ext/sub resources, node tree, groups, instances and signal connections, and project.binary to project.godot. Give the folder re_archive extracted from the .pck. Names, constants and structure are exact; comments and the original formatting are not in the export. Encrypted scripts (.gde) are reported, never decrypted.',
88
+ inputSchema: obj({ path: str('A .gdc/.scn/.res/project.binary file or an extracted pack folder.'), out: str('Output folder (default workspace/decoded/<name>).'), limit: int('Max files (default 2000).', { minimum: 1, maximum: 100000 }) }, ['path']) },
89
+ { name: 're_il2cpp', brief: 'Unity IL2CPP game without source: assemblies, namespaces, classes, method names and string literals from global-metadata.dat, plus a C# skeleton.', title: 'Read Unity IL2CPP metadata',
90
+ description: 'Reads a Unity IL2CPP global-metadata.dat (metadata versions 24–31; sections are recognised by validating their records, not by fixed offsets): assemblies with type counts, every type with namespace and method names, MonoBehaviours (types with Unity callbacks) and string literals. Writes one C# skeleton per game assembly (namespaces, classes, method names as comments) to rebuild or port from. Method bodies are native code in GameAssembly.dll / libil2cpp.so / the .wasm (re_binary). Encrypted or obfuscated metadata is reported, never decrypted.',
91
+ inputSchema: obj({ file: str('global-metadata.dat (<Game>_Data/il2cpp_data/Metadata/ or Il2CppData/Metadata in WebGL builds).'), match: str('Only types whose name or methods contain this.'), includeSystem: bool('Also list Unity/System/mscorlib types (default false).'), out: str('Folder for the C# skeletons (default workspace/il2cpp/<game>).'), limit: int('Max types listed (default 400).', { minimum: 1, maximum: 100000 }) }, ['file']) },
61
92
  { name: 're_report', brief: 'Write ARCHITECTURE.md for a project folder.', title: 'Write an architecture report',
62
93
  description: 'Writes ARCHITECTURE.md for a project: stack, languages, entry points, mermaid module graph, cycles, most-used files, scene trees of the entry scenes, class table, possibly unused files and external dependencies.',
63
94
  inputSchema: obj({ path: str('Project folder.'), out: str('Output .md path (default: workspace/reports).') }, ['path']) },
@@ -73,8 +104,8 @@ export const tools = [
73
104
  description: `Curated porting knowledge: engine conventions (axes, units, handedness, Euler order, FOV, physics tick, lifecycle) and API maps with notes and pitfalls. Without arguments: lists engines and maps. Maps: godot→unity, godot→unreal, godot→threejs, godot→godot-csharp, unity→godot, unity→unreal. query/topic filter entries.`,
74
105
  inputSchema: obj({ from: str('Source engine (godot, godot-csharp, unity, unreal, threejs).'), to: str('Target engine.'), query: str('Filter entries (e.g. "signal", "input", "physics").'), topic: str('Exact topic (lifecycle, input, physics, events, scene tree, rendering…).') }) },
75
106
  { name: 'port_convert', brief: 'Convert positions, rotations, scale and FOV between engines.', title: 'Convert coordinates between engines',
76
- description: `Converts positions, rotations (quaternion [x,y,z,w] or Euler degrees in the source engine's convention), scales and camera FOV between ${Object.keys(SYSTEMS).join(', ')}. Returns target quaternion plus Euler in the target's convention (Godot rotation_degrees, Unity eulerAngles, three.js), the axis matrix and unit factor (Unreal ×100).`,
77
- inputSchema: obj({ from: str('Source system.', { enum: Object.keys(SYSTEMS) }), to: str('Target system.', { enum: Object.keys(SYSTEMS) }), position: vec(3, '[x, y, z] in source units.'), rotation: vec(4, 'Quaternion [x, y, z, w].'), euler: vec(3, 'Euler degrees in the source convention (godot, unity, threejs).'), scale: vec(3, '[x, y, z].'), fov: num('Camera FOV in degrees (vertical; horizontal for unreal).'), aspect: num('Aspect ratio for FOV conversion (default 16/9).') }, ['from', 'to']) },
107
+ description: `Converts positions, rotations (quaternion [x,y,z,w] or Euler degrees in the source engine's convention), scales and camera FOV between ${Object.keys(SYSTEMS).join(', ')}. Returns target quaternion plus Euler in the target's convention (Godot rotation_degrees, Unity eulerAngles, three.js), the axis matrix and unit factor (Unreal ×100). physics:{…} adds a physics profile: gravity, fixed step, damping, friction/bounce (with each engine's combination rule), mass, speeds, jump velocity recomputed for the target gravity, and collision layers turned into Godot layer/mask bits, Rapier groups, Unreal channels or a Unity matrix — plus the settings to change and the behavioural differences to retune.`,
108
+ inputSchema: obj({ from: str('Source system.', { enum: Object.keys(SYSTEMS) }), to: str('Target system.', { enum: Object.keys(SYSTEMS) }), position: vec(3, '[x, y, z] in source units.'), rotation: vec(4, 'Quaternion [x, y, z, w].'), euler: vec(3, 'Euler degrees in the source convention (godot, unity, threejs).'), scale: vec(3, '[x, y, z].'), fov: num('Camera FOV in degrees (vertical; horizontal for unreal).'), aspect: num('Aspect ratio for FOV conversion (default 16/9).'), physics: obj({ gravity: num('Gravity magnitude in source units (sign ignored).'), fixedStep: num('Physics step in seconds (e.g. 0.02).'), linearDamp: num('Linear damping/drag.'), angularDamp: num('Angular damping/drag.'), friction: num('Material friction.'), bounce: num('Material bounciness/restitution.'), mass: num('Body mass (kg).'), speed: num('A speed to convert (units/s).'), force: num('A force to convert.'), impulse: num('An impulse to convert.'), jumpVelocity: num('Jump velocity in the source; recomputed for the target gravity.'), jumpHeight: num('Jump height instead of velocity.'), dimension: str('2d or 3d (default 3d).', { enum: ['2d', '3d'] }), layers: { type: 'array', description: 'Collision layers: [{ name, index, collidesWith: [names] }].', items: obj({ name: str('Layer name.'), index: int('Layer index 0–31.'), collidesWith: { type: 'array', items: { type: 'string' }, description: 'Names of layers this one collides with.' } }, ['name']) } }, [], 'Physics numbers to carry over; pass {} for the profile and differences only.') }, ['from', 'to']) },
78
109
  { name: 'port_plan', brief: 'Plan a port to another engine or language and create the output project.', title: 'Plan a port',
79
110
  description: `Plans a port of a project to another engine or language and creates the output project (scaffold). Every source file becomes a unit with a target path, action (translate, convert-scene, copy, rebuild), dependencies, API-usage count and risk, ordered dependencies-first. Engines: ${engines.join(', ')}; or a language (typescript, python, csharp, cpp, rust, go…). The source is never modified; output must be a new or empty folder.`,
80
111
  inputSchema: obj({ source: str('Source project folder.'), to: str(`Target: ${engines.join(', ')} or a language.`), output: str('Output folder (default: workspace/ports/<id>/output).'), name: str('Project name for the target.'), include: str('Regex: only source paths matching.'), exclude: str('Regex: skip source paths matching.'), unrealVersion: str('EngineAssociation for Unreal targets (default 5.5).') }, ['source', 'to']) },
@@ -85,16 +116,25 @@ export const tools = [
85
116
  description: 'Writes the ported file(s) for a unit into the output project (never outside it) and records progress. Returns leftovers (source-engine APIs still present), PORT/TODO markers, source functions/signals missing in the port, and the next unit. copy:true copies an asset unchanged. Give exactly one of content, files or copy. status partial/skipped with notes when something cannot be ported yet.',
86
117
  inputSchema: obj({ planId, unit: str('Unit id or path.'), content: str('File content for the unit target.'), files: { type: 'array', maxItems: 20, items: obj({ path: str('Path inside the output project (default: unit target).'), content: str('File content.') }, ['content']), description: 'Several files (e.g. Unreal .h and .cpp).' }, copy: bool('Copy the source file unchanged (assets).'), status: str('done (default), partial or skipped.', { enum: ['done', 'partial', 'skipped'] }), notes: str('What is left or why it was skipped.') }, ['planId', 'unit']) },
87
118
  { name: 'port_scene', brief: 'Convert a scene/prefab automatically.', title: 'Convert a scene/prefab',
88
- description: 'Converts a scene deterministically: Godot .tscn → Unity prefab/scene spec (+ editor builder menu), three.js TypeScript builder module, Unreal scene spec (cm, Z-up) or Godot C# (script paths swapped); Unity .unity/.prefab → Godot .tscn (bodies, colliders, primitive meshes, cameras, lights, scripts, prefab instances). Transforms are converted between coordinate systems. With planId it writes the unit; with file+to it writes into the workspace.',
119
+ description: 'Converts a scene deterministically: Godot .tscn → Unity prefab/scene spec (+ editor builder menu), three.js TypeScript builder module, Unreal scene spec (cm, Z-up) or Godot C# (script paths swapped); Unity .unity/.prefab → Godot .tscn (bodies, colliders, primitive meshes, cameras, lights, scripts, prefab instances and variants with their overrides, uGUI Canvas/Image/Text/TextMeshPro/Button/Slider/Toggle/ScrollRect/layout groups → Control nodes with Godot anchors and offsets, Animator → AnimationPlayer + state-machine script); Unity .anim → Godot Animation .tres (position/rotation/scale/property/sprite/event tracks) and .controller → GDScript state machine with the same states, parameters and transition conditions. Transforms are converted between coordinate systems. With planId it writes the unit; with file+to it writes into the workspace.',
89
120
  inputSchema: obj({ planId, unit, file: str('Scene file (without a plan).'), to: str('Target engine (without a plan).', { enum: engines }), out: str('Output file (without a plan).') }) },
121
+ { name: 'port_shader', brief: 'First draft of a shader in another engine: Unity ShaderLab/HLSL → Godot, Shadertoy/GLSL → Godot or three.js, Godot → three.js or Unity; every untranslatable piece marked PORT:.', title: 'Port a shader',
122
+ description: 'Translates shader code between engines as a faithful first draft: Unity ShaderLab (surface shaders → fragment() with ALBEDO/NORMAL_MAP/METALLIC/ROUGHNESS/EMISSION/ALPHA; vertex/fragment shaders → vertex()/fragment() with varyings) → Godot .gdshader with Properties as uniforms (hints, ranges, _ST tiling), HLSL types/intrinsics/built-ins mapped (float4→vec4, lerp→mix, tex2D→texture, saturate→clamp, _Time.y→TIME, UnityObjectToClipPos→VERTEX, unity_ObjectToWorld→MODEL_MATRIX…); Shadertoy or plain GLSL → Godot canvas_item (iTime/iResolution/iChannelN/fragCoord mapped, Y flipped) or a three.js ShaderMaterial module; Godot .gdshader → three.js ShaderMaterial (uniforms object included) or Unity ShaderLab (canvas_item → sprite shader, spatial → Standard surface shader). Lighting models and coordinate spaces differ per engine, so the result lists leftovers (identifiers with no equivalent) and PORT: markers; compile it in the target engine before trusting it.',
123
+ inputSchema: obj({ file: str('Shader file (.shader, .gdshader, .glsl, .hlsl, .cginc).'), content: str('Shader code instead of file.'), to: str('Target: godot, threejs or unity.', { enum: ['godot', 'threejs', 'unity'] }), out: str('Output file (default workspace/shaders/<name>.<ext>).'), name: str('Shader name for the output.') }, ['to']) },
124
+ { name: 'port_trace', brief: 'Prove behaviour parity: a per-tick trace logger for Godot/Unity/three.js/Unreal, and a comparer that finds where two traces diverge.', title: 'Trace and compare behaviour',
125
+ description: 'action:"harness" returns (and with out writes) a small logger for the engine (ReversePortTrace.gd/.cs/.js/.h) that records the chosen properties of one node per physics tick to JSON with a fixed seed and tick rate. Run it in the original and in the port from the same start, then action:"compare" {a, b, tolerance} resamples both by time (different tick rates are fine), reports per property the first divergence, max and mean error and whether the error grows (integration differs: delta, damping, gravity, friction), differs from tick 0 (units, axes, spawn) or jumps at one moment (an event happened differently). port_verify already flags timing/randomness/order differences statically; this is the runtime proof.',
126
+ inputSchema: obj({ action: str('harness (default) or compare.', { enum: ['harness', 'compare'] }), engine: str('harness: godot, unity, threejs or unreal.', { enum: ['godot', 'unity', 'threejs', 'unreal'] }), node: str('harness: node path to watch (default the harness\'s target).'), properties: { type: 'array', items: { type: 'string' }, description: 'harness: properties to log (default ["position"]; velocity, rotation, custom fields).' }, ticks: int('harness: ticks to record (default 300).', { minimum: 10, maximum: 100000 }), out: str('harness: write the logger to this file.'), a: str('compare: trace JSON from the original.'), b: str('compare: trace JSON from the port.'), tolerance: num('compare: allowed error per sample (default 0.01).'), axisMap: { type: 'array', items: { type: 'string' }, description: 'compare: reorder/negate b\'s vector components, e.g. ["0","2","-1"] maps b=[x,y,z] to [x,z,-y].' } }, []) },
127
+ { name: 'port_rpgmaker', brief: 'RPG Maker MV/MZ project → runnable Godot 4 project: maps drawn from the tilesets, events with an interpreter for the common commands, database autoload, list of what is left.', title: 'Port an RPG Maker MV/MZ game to Godot',
128
+ description: 'Ports an RPG Maker MV/MZ project (data/*.json, img/, audio/; also www/ of a deployed game) to a new Godot 4 project: every map becomes a scene whose layers are drawn from the original tileset images (B–E and A5 exact, autotiles with their interior piece), every event a node that loads its pages and runs them with an interpreter for the common commands (Show Text, Choices, Conditional Branch, Loops, Switches, Variables, Self Switches, Common Events, Transfer, Wait, BGM/SE), the database and game state an autoload (RpgDb), and a start scene with a minimal message box. Returns a census of every event command with which ones run; PORT-NOTES.md lists the rest (movement, menus, battles, plugins). Encrypted images/audio are copied, never decrypted.',
129
+ inputSchema: obj({ path: str('RPG Maker MV/MZ project folder (or the www/ folder of a deployed game).'), out: str('New or empty folder for the Godot project (default workspace/ports/<name>-godot).'), limit: int('Max maps listed (default 300).', { minimum: 1, maximum: 10000 }) }, ['path']) },
90
130
  { name: 'port_copy_assets', brief: 'Copy all asset units of a plan.', title: 'Copy all asset units',
91
131
  description: 'Copies every pending asset unit (textures, audio, models, fonts, video) of a plan into the output project. match limits it with a regex.',
92
132
  inputSchema: obj({ planId, match: str('Regex over source paths.') }, ['planId']) },
93
133
  { name: 'port_status', brief: 'Port progress, or the list of plans.', title: 'Port progress',
94
- description: 'Without planId: lists all port plans in the workspace with progress. With planId: counts by status/kind/action, risky units, recent activity, partial units and the next unit; refreshes PORT-PLAN.md in the output folder.',
134
+ description: 'Without planId: lists all port plans in the workspace with progress. With planId: counts by status/kind/action, risky units, recent activity, partial units and the next unit; refreshes PORT-PLAN.md in the output folder. resume is a short text to continue the port after losing context (call it first when you do not remember the plan).',
95
135
  inputSchema: obj({ planId }) },
96
136
  { name: 'port_verify', brief: 'Check a port: missing files, leftovers, missing functions, markers.', title: 'Verify a port',
97
- description: 'Checks a port: missing output files, units not ported yet, source-engine API leftovers, functions and signals from the source that do not exist in the port (lifecycle renames understood), PORT/TODO markers, partial units. passed is true only when everything is ported and clean.',
137
+ description: 'Checks a port: missing output files, units not ported yet, source-engine API leftovers, functions and signals from the source that do not exist in the port (lifecycle renames understood), PORT/TODO markers, partial units, and dormant logic (empty or stub bodies, functions the source called that nothing in the port calls, signals only declared, suppressions like `as any` or @warning_ignore added by the port). passed is true only when everything is ported and clean. Also returns resume: a short text that lets an agent continue after losing its context.',
98
138
  inputSchema: obj({ planId }, ['planId']) },
99
139
  // ------------------------------------------------------------ proof (evidence ledger)
100
140
  { name: 're_case', brief: 'Evidence ledger: save what you inspected, cite it in claims, record decisions and tests, check the gates.', title: 'Keep a proof case for an investigation or port',
@@ -110,7 +150,7 @@ for (const t of tools) t.annotations = annotationsFor(t.name);
110
150
 
111
151
  const handlers = {
112
152
  re_scan: scanProject, re_graph: graphTool, re_symbols: symbolsTool, re_scene: a => readScene(a.file), re_binary: inspectBinary,
113
- re_archive: archiveTool, re_probe: probe, re_extract: carve, re_compare: compare, re_report: report, re_lookup: lookupTool, re_blueprint: blueprintTool,
153
+ re_archive: archiveTool, re_probe: probe, re_extract: carve, re_compare: compare, re_refs: refsTool, re_strings: stringsTool, re_apicheck: apicheckTool, re_decode: decodeTool, re_il2cpp: il2cppTool, port_upgrade: upgradeTool, re_save: saveTool, port_shader: portShaderTool, port_trace: portTraceTool, port_rpgmaker: portRpgMakerTool, re_report: report, re_lookup: lookupTool, re_blueprint: blueprintTool,
114
154
  port_map: portMapTool, port_convert: convert, port_plan: planPort, port_unit: portUnit, port_write: portWrite, port_scene: portScene,
115
155
  port_copy_assets: portCopyAssets, port_status: portStatus, port_verify: portVerify, re_case: caseTool, share_feedback: shareFeedback,
116
156
  };