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
@@ -0,0 +1,326 @@
1
+ // re_save: what is in a save file, how is it protected, and what would a port need to keep?
2
+ // Layers are peeled only where the format is open (gzip/zlib, base64, LZ-String as RPG Maker uses it, JSON, XML,
3
+ // INI/ConfigFile, Godot store_var). Encrypted or obfuscated content is reported, never decoded (project rule).
4
+ // Checksums are recognised by recomputing them over the obvious payloads. With two saves of the same game the
5
+ // tool lists the fields that changed, which is how an agent finds "the gold" or "the position" without guessing.
6
+ // action:"schema" writes a JSON schema plus GDScript and C# data classes to port the save format with.
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import zlib from 'node:zlib';
10
+ import crypto from 'node:crypto';
11
+ import { RPError, requireFile, limit, entropy, isText, toPosix } from './util.mjs';
12
+ import { workspaceDir } from './workspace.mjs';
13
+ import { identify, strideGuess } from './formats.mjs';
14
+ import { extractStrings } from './binary.mjs';
15
+
16
+ // ------------------------------------------------------------------------------------------------ small codecs
17
+ const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
18
+ export function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }
19
+
20
+ /** LZ-String decompressFromBase64 (RPG Maker MV/MZ .rpgsave). Port of the public-domain-licensed (WTFPL/MIT) algorithm, written from the spec. */
21
+ export function lzStringFromBase64(input) {
22
+ const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
23
+ const rev = new Map([...ALPHA].map((c, i) => [c, i]));
24
+ const getNextValue = i => rev.get(input.charAt(i)) ?? 0;
25
+ const length = input.length, resetValue = 32;
26
+ const dictionary = [];
27
+ let enlargeIn = 4, dictSize = 4, numBits = 3, entry = '', result = [], w, c;
28
+ const data = { val: getNextValue(0), position: resetValue, index: 1 };
29
+ const readBits = n => { let bits = 0, maxpower = 1 << n, power = 1; while (power !== maxpower) { const resb = data.val & data.position; data.position >>= 1; if (data.position === 0) { data.position = resetValue; data.val = getNextValue(data.index++); } bits |= (resb > 0 ? 1 : 0) * power; power <<= 1; } return bits; };
30
+ for (let i = 0; i < 3; i++) dictionary[i] = i;
31
+ switch (readBits(2)) {
32
+ case 0: c = String.fromCharCode(readBits(8)); break;
33
+ case 1: c = String.fromCharCode(readBits(16)); break;
34
+ case 2: return '';
35
+ }
36
+ dictionary[3] = c; w = c; result.push(c);
37
+ for (;;) {
38
+ if (data.index > length) return '';
39
+ let cc = readBits(numBits);
40
+ switch (cc) {
41
+ case 0: dictionary[dictSize++] = String.fromCharCode(readBits(8)); cc = dictSize - 1; enlargeIn--; break;
42
+ case 1: dictionary[dictSize++] = String.fromCharCode(readBits(16)); cc = dictSize - 1; enlargeIn--; break;
43
+ case 2: return result.join('');
44
+ }
45
+ if (enlargeIn === 0) { enlargeIn = 1 << numBits; numBits++; }
46
+ if (dictionary[cc] !== undefined) entry = dictionary[cc];
47
+ else if (cc === dictSize) entry = w + w.charAt(0);
48
+ else return null;
49
+ result.push(entry);
50
+ dictionary[dictSize++] = w + entry.charAt(0);
51
+ enlargeIn--;
52
+ w = entry;
53
+ if (enlargeIn === 0) { enlargeIn = 1 << numBits; numBits++; }
54
+ if (result.length > 4e6) return null;
55
+ }
56
+ }
57
+
58
+ /** Godot store_var / var_to_bytes: enough of the Variant binary encoding to read dictionaries of plain values. */
59
+ export function godotVariant(buf, off = 0, depth = 0) {
60
+ if (depth > 32 || off + 4 > buf.length) return null;
61
+ const type = buf.readUInt32LE(off) & 0xffff, flags = buf.readUInt32LE(off) >>> 16;
62
+ let p = off + 4;
63
+ const str = () => { const n = buf.readUInt32LE(p); const s = buf.toString('utf8', p + 4, p + 4 + n); p += 4 + ((n + 3) & ~3); return s; };
64
+ const f = () => { if (flags & 1) { const v = buf.readDoubleLE(p); p += 8; return v; } const v = buf.readFloatLE(p); p += 4; return v; };
65
+ switch (type) {
66
+ case 0: return { value: null, end: p };
67
+ case 1: { const v = buf.readUInt32LE(p) !== 0; return { value: v, end: p + 4 }; }
68
+ case 2: { if (flags & 1) { const v = Number(buf.readBigInt64LE(p)); return { value: v, end: p + 8 }; } return { value: buf.readInt32LE(p), end: p + 4 }; }
69
+ case 3: { const v = f(); return { value: v, end: p }; }
70
+ case 4: { const v = str(); return { value: v, end: p }; }
71
+ case 5: { const v = [f(), f()]; return { value: v, end: p }; }
72
+ case 9: { const v = [f(), f(), f()]; return { value: v, end: p }; }
73
+ case 20: { const v = [f(), f(), f(), f()]; return { value: v, end: p }; }
74
+ case 22: { const v = str(); return { value: v, end: p }; }
75
+ case 27: { const n = buf.readUInt32LE(p) & 0x7fffffff; p += 4; const out = {}; for (let i = 0; i < n; i++) { const k = godotVariant(buf, p, depth + 1); if (!k) return null; const v = godotVariant(buf, k.end, depth + 1); if (!v) return null; out[String(k.value)] = v.value; p = v.end; } return { value: out, end: p }; }
76
+ case 28: { const n = buf.readUInt32LE(p) & 0x7fffffff; p += 4; const out = []; for (let i = 0; i < n; i++) { const v = godotVariant(buf, p, depth + 1); if (!v) return null; out.push(v.value); p = v.end; } return { value: out, end: p }; }
77
+ case 30: case 32: { const n = buf.readUInt32LE(p); p += 4; const out = []; for (let i = 0; i < n; i++) { out.push(type === 30 ? buf.readInt32LE(p) : buf.readFloatLE(p)); p += 4; } return { value: out, end: p }; }
78
+ case 34: { const n = buf.readUInt32LE(p); p += 4; const out = []; for (let i = 0; i < n; i++) out.push(str()); return { value: out, end: p }; }
79
+ default: return null;
80
+ }
81
+ }
82
+
83
+ // ------------------------------------------------------------------------------------------------ peeling layers
84
+ function tryJson(text) { try { const v = JSON.parse(text); return typeof v === 'object' && v !== null ? v : undefined; } catch { return undefined; } }
85
+
86
+ function parseIni(text) {
87
+ const out = {}; let section = out;
88
+ for (const raw of text.split(/\r?\n/)) {
89
+ const l = raw.trim();
90
+ if (!l || l.startsWith(';') || l.startsWith('#')) continue;
91
+ const sec = l.match(/^\[(.+)\]$/);
92
+ if (sec) { section = out[sec[1]] = {}; continue; }
93
+ const kv = l.match(/^([^=]+?)\s*=\s*(.*)$/);
94
+ if (!kv) continue;
95
+ let v = kv[2];
96
+ const j = tryJson(v); if (j !== undefined) v = j; else if (/^-?\d+(\.\d+)?$/.test(v)) v = Number(v); else if (v === 'true' || v === 'false') v = v === 'true'; else v = v.replace(/^"(.*)"$/, '$1');
97
+ section[kv[1]] = v;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function parseXml(text) {
103
+ // Shallow: elements become keys, attributes and text become values. Enough to list fields.
104
+ const out = {};
105
+ for (const m of text.matchAll(/<([A-Za-z_][\w.-]*)((?:\s+[\w:.-]+="[^"]*")*)\s*(?:\/>|>([^<]*)<\/\1>)/g)) {
106
+ const node = {};
107
+ for (const a of m[2].matchAll(/([\w:.-]+)="([^"]*)"/g)) node[a[1]] = /^-?\d+(\.\d+)?$/.test(a[2]) ? Number(a[2]) : a[2];
108
+ const txt = m[3]?.trim();
109
+ const val = Object.keys(node).length ? (txt ? { ...node, _text: txt } : node) : (txt && /^-?\d+(\.\d+)?$/.test(txt) ? Number(txt) : txt ?? '');
110
+ if (out[m[1]] === undefined) out[m[1]] = val; else if (Array.isArray(out[m[1]])) out[m[1]].push(val); else out[m[1]] = [out[m[1]], val];
111
+ }
112
+ return Object.keys(out).length ? out : undefined;
113
+ }
114
+
115
+ const XOR_NOTE = 'looks like single-byte XOR obfuscation (one byte dominates where JSON/text would have spaces, and the text has no structure). Reported, not decoded.';
116
+ /** Single-byte XOR leaves the shape of text (one dominant byte = key ^ space) but no readable structure. */
117
+ function looksXored(bytes) {
118
+ const n = Math.min(bytes.length, 65536);
119
+ if (n < 64) return false;
120
+ const hist = new Uint32Array(256); for (let i = 0; i < n; i++) hist[bytes[i]]++;
121
+ let topB = 0; for (let b = 1; b < 256; b++) if (hist[b] > hist[topB]) topB = b;
122
+ if (topB === 0x20 || topB === 0x0a || topB === 0 || hist[topB] < n * 0.08) return false;
123
+ const key = topB ^ 0x20;
124
+ let printable = 0; for (let i = 0; i < n; i++) { const c = bytes[i] ^ key; if ((c >= 0x20 && c < 0x7f) || c === 0x0a || c === 0x0d || c === 0x09) printable++; }
125
+ return printable > n * 0.97;
126
+ }
127
+
128
+ /** Peel container layers; returns { layers, data?, text?, bytes, stop } where stop explains why decoding ended. */
129
+ export function peel(buf, name = '') {
130
+ const layers = [];
131
+ let bytes = buf, text = null, data, stop = null;
132
+ for (let round = 0; round < 6; round++) {
133
+ const head = bytes.subarray(0, 4096);
134
+ const kind = identify(head);
135
+ if (kind.id === 'gzip' || (bytes[0] === 0x78 && [0x01, 0x5e, 0x9c, 0xda].includes(bytes[1]))) {
136
+ try { bytes = kind.id === 'gzip' ? zlib.gunzipSync(bytes) : zlib.inflateSync(bytes); layers.push(kind.id === 'gzip' ? 'gzip' : 'zlib'); continue; } catch { stop = 'compressed data did not inflate'; break; }
137
+ }
138
+ if (kind.id === 'zip') { layers.push('zip'); stop = 'zip container: use re_archive to list and extract the entries, then re_save on each'; break; }
139
+ if (!isText(head)) {
140
+ const gv = (() => { try { return godotVariant(bytes, 0); } catch { return null; } })();
141
+ if (gv && gv.end === bytes.length && typeof gv.value === 'object') { layers.push('godot-store_var'); data = gv.value; break; }
142
+ if (bytes.length > 8 && bytes.readUInt32LE(0) + 4 === bytes.length) { const inner = (() => { try { return godotVariant(bytes, 4); } catch { return null; } })(); if (inner && inner.end === bytes.length && typeof inner.value === 'object') { layers.push('godot-store_var (length-prefixed)'); data = inner.value; break; } }
143
+ if (bytes[0] === 0 && bytes[1] === 1 && bytes[2] === 0 && bytes[3] === 0 && bytes[4] === 0 && bytes[5] === 0xff) { layers.push('dotnet-binaryformatter'); stop = '.NET BinaryFormatter stream: the class names and field names are readable in strings; values follow the class layout. Unity games using BinaryFormatter: port by re-serializing from the game, not by parsing.'; break; }
144
+ if (bytes[0] === 0x80 && bytes[1] <= 5 && /\.(save|pickle|pkl|dat)$/i.test(name)) { layers.push('python-pickle'); stop = 'Python pickle (Ren\'Py saves keep one in the zip entry "log"): field names are readable in strings; load it with Python to migrate.'; break; }
145
+ const e = entropy(bytes.subarray(0, Math.min(bytes.length, 65536)));
146
+ if (e > 7.5) { layers.push('encrypted-or-compressed'); stop = `high entropy (${e.toFixed(2)} bits/byte) without a known header: encrypted or an unknown compression. Reported, not decoded.`; break; }
147
+ if (looksXored(bytes)) { layers.push('possibly-xor-obfuscated'); stop = XOR_NOTE; break; }
148
+ stop = 'binary layout without a known header: see strides and strings below';
149
+ break;
150
+ }
151
+ text = bytes.toString('utf8').replace(/^/, '');
152
+ const trimmed = text.trim();
153
+ if (/^[A-Za-z0-9+/=\r\n]+$/.test(trimmed) && trimmed.length >= 16 && !/^\d+$/.test(trimmed)) {
154
+ const dec = trimmed.replace(/\s+/g, '').length % 4 === 0 ? Buffer.from(trimmed.replace(/\s+/g, ''), 'base64') : Buffer.alloc(0);
155
+ if (dec.length > 0) {
156
+ if (isText(dec.subarray(0, 512)) && (tryJson(dec.toString('utf8')) !== undefined || /^[\[{<]/.test(dec.toString('utf8', 0, 1)))) { layers.push('base64'); bytes = dec; continue; }
157
+ if (dec[0] === 0x1f && dec[1] === 0x8b || dec[0] === 0x78) { layers.push('base64'); bytes = dec; continue; }
158
+ }
159
+ const lzText = (() => { try { return lzStringFromBase64(trimmed.replace(/\s+/g, '')); } catch { return null; } })();
160
+ if (lzText && tryJson(lzText) !== undefined) { layers.push('lz-string'); bytes = Buffer.from(lzText, 'utf8'); continue; }
161
+ }
162
+ const j = tryJson(trimmed);
163
+ if (j !== undefined) { layers.push('json'); data = j; break; }
164
+ if (/^\s*<\?xml|^\s*<[A-Za-z]/.test(trimmed)) { const x = parseXml(trimmed); if (x) { layers.push('xml'); data = x; break; } }
165
+ if (/^\s*\[[^\]]+\]\s*$/m.test(trimmed) && /^[^=\n]+=/m.test(trimmed)) { layers.push('ini'); data = parseIni(trimmed); break; }
166
+ if (/^[^=\n]+=[^\n]*$/m.test(trimmed) && trimmed.split('\n').filter(l => l.includes('=')).length >= 2) { layers.push('key=value'); data = parseIni(trimmed); break; }
167
+ if (looksXored(bytes)) { layers.push('possibly-xor-obfuscated'); stop = XOR_NOTE; break; }
168
+ stop = 'text without a recognised structure';
169
+ break;
170
+ }
171
+ return { layers, data, text, bytes, stop };
172
+ }
173
+
174
+ // ------------------------------------------------------------------------------------------------ fields
175
+ const ROLE_RULES = [
176
+ [/(^|[._])(hp|health|lives?|hearts?)$/i, 'health'], [/(gold|coins?|money|cash|currency|gems?|credits?)$/i, 'currency'], [/(score|points|high ?score|best)$/i, 'score'],
177
+ [/(^|[._])(x|y|z|pos|position|location|coords?)$/i, 'position'], [/(level|stage|chapter|area|map|scene|room|world)$/i, 'progress'], [/(inventory|items|bag|equipment|weapons?|unlock(ed|s)?|achievements?|flags?|quests?)$/i, 'collection'],
178
+ [/(time|timestamp|date|played|playtime|saved_?at|created)$/i, 'time'], [/(version|format|schema|build)$/i, 'version'], [/(name|player|profile|slot)$/i, 'identity'], [/(checksum|hash|crc|signature|digest|hmac|sig)$/i, 'integrity'],
179
+ [/(seed|rng|random)$/i, 'rng'], [/(settings?|options?|volume|resolution|fullscreen|language|locale)$/i, 'settings'],
180
+ ];
181
+ function roleOf(key) { for (const [re, role] of ROLE_RULES) if (re.test(key)) return role; return null; }
182
+
183
+ export function flatten(value, prefix = '', out = [], depth = 0) {
184
+ if (depth > 12 || out.length > 5000) return out;
185
+ if (Array.isArray(value)) {
186
+ out.push({ path: prefix || '$', type: 'array', length: value.length, ...(value.length && typeof value[0] !== 'object' ? { sample: value.slice(0, 5) } : {}) });
187
+ if (value.length && typeof value[0] === 'object' && value[0] !== null) flatten(value[0], `${prefix}[0]`, out, depth + 1);
188
+ return out;
189
+ }
190
+ if (value && typeof value === 'object') { for (const [k, v] of Object.entries(value)) flatten(v, prefix ? `${prefix}.${k}` : k, out, depth + 1); return out; }
191
+ const t = value === null ? 'null' : typeof value;
192
+ const entry = { path: prefix || '$', type: t };
193
+ if (t === 'string') { entry.sample = value.length > 60 ? value.slice(0, 57) + '…' : value; if (/^[A-Za-z0-9+/=]{24,}$/.test(value)) entry.looksLike = 'base64'; else if (/^[0-9a-f]{32}$/i.test(value)) entry.looksLike = 'md5/hex128'; else if (/^[0-9a-f]{40}$/i.test(value)) entry.looksLike = 'sha1'; else if (/^[0-9a-f]{64}$/i.test(value)) entry.looksLike = 'sha256'; else if (/^\d{4}-\d{2}-\d{2}T/.test(value)) entry.looksLike = 'iso-date'; }
194
+ else if (t === 'number') { entry.sample = value; if (value > 1e9 && value < 2e9 && Number.isInteger(value)) entry.looksLike = 'unix-seconds'; else if (value > 1e12 && value < 2e12) entry.looksLike = 'unix-millis'; else if (value > 6e17 && value < 7e17) entry.looksLike = 'dotnet-ticks'; }
195
+ else entry.sample = value;
196
+ const role = roleOf(prefix.split(/[.[]/).filter(Boolean).at(-1) ?? '');
197
+ if (role) entry.role = role;
198
+ out.push(entry);
199
+ return out;
200
+ }
201
+
202
+ /** Recognise checksums: a field that equals a hash of the payload with that field removed (or of other fields). */
203
+ export function findChecksums(data, text, bytes) {
204
+ const out = [];
205
+ const integrity = flatten(data).filter(f => f.role === 'integrity' && (f.type === 'string' || f.type === 'number'));
206
+ const hashes = payload => ({ md5: crypto.createHash('md5').update(payload).digest('hex'), sha1: crypto.createHash('sha1').update(payload).digest('hex'), sha256: crypto.createHash('sha256').update(payload).digest('hex'), crc32: crc32(Buffer.from(payload)) });
207
+ const without = (obj, pathStr) => { const copy = JSON.parse(JSON.stringify(obj)); const parts = pathStr.split('.'); let o = copy; for (const p of parts.slice(0, -1)) o = o?.[p]; if (o) delete o[parts.at(-1)]; return copy; };
208
+ for (const f of integrity) {
209
+ const rest = without(data, f.path);
210
+ const candidates = [['json-compact', JSON.stringify(rest)], ['json-pretty-2', JSON.stringify(rest, null, 2)], ['json-pretty-4', JSON.stringify(rest, null, 4)]];
211
+ if (rest && typeof rest === 'object' && !Array.isArray(rest)) candidates.push(['values-concat', Object.values(rest).filter(v => typeof v !== 'object').join('')], ['values-concat-|', Object.values(rest).filter(v => typeof v !== 'object').join('|')]);
212
+ const sample = String(f.sample);
213
+ let matched = null;
214
+ for (const [how, payload] of candidates) {
215
+ const h = hashes(payload);
216
+ for (const [algo, val] of Object.entries(h)) if (String(val).toLowerCase() === sample.toLowerCase() || (algo === 'crc32' && (val === Number(sample) || val.toString(16) === sample.toLowerCase()))) matched = { algo, over: how };
217
+ if (matched) break;
218
+ }
219
+ out.push({ field: f.path, value: f.sample, ...(matched ? { verified: true, ...matched, how: `${matched.algo} of the ${matched.over} serialisation of the save without this field` } : { verified: false, note: 'no plain hash of the rest of the file matched; it may use a secret (HMAC), a different serialisation or cover only some fields. Change one field and compare two saves to see whether the game rejects it.' }) });
220
+ }
221
+ if (bytes && bytes.length > 8 && !text) {
222
+ const body = bytes.subarray(0, bytes.length - 4), tail = bytes.readUInt32LE(bytes.length - 4);
223
+ if (crc32(body) === tail) out.push({ field: 'last 4 bytes', verified: true, algo: 'crc32', over: 'all bytes before it', how: 'CRC32 (LE) of everything before the trailer' });
224
+ const headV = bytes.readUInt32LE(0);
225
+ if (crc32(bytes.subarray(4)) === headV) out.push({ field: 'first 4 bytes', verified: true, algo: 'crc32', over: 'all bytes after it', how: 'CRC32 (LE) of everything after the header word' });
226
+ }
227
+ return out;
228
+ }
229
+
230
+ function diffData(a, b, prefix = '', out = []) {
231
+ if (out.length > 2000) return out;
232
+ if (Array.isArray(a) && Array.isArray(b)) {
233
+ if (a.length !== b.length) out.push({ path: prefix || '$', before: `array[${a.length}]`, after: `array[${b.length}]` });
234
+ for (let i = 0; i < Math.min(a.length, b.length, 200); i++) diffData(a[i], b[i], `${prefix}[${i}]`, out);
235
+ return out;
236
+ }
237
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
238
+ for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
239
+ const p = prefix ? `${prefix}.${k}` : k;
240
+ if (!(k in a)) out.push({ path: p, before: undefined, after: b[k] }); else if (!(k in b)) out.push({ path: p, before: a[k], after: undefined }); else diffData(a[k], b[k], p, out);
241
+ }
242
+ return out;
243
+ }
244
+ if (a !== b) out.push({ path: prefix || '$', before: a, after: b });
245
+ return out;
246
+ }
247
+
248
+ // ------------------------------------------------------------------------------------------------ schema + classes
249
+ function schemaOf(value) {
250
+ if (Array.isArray(value)) return { type: 'array', items: value.length ? schemaOf(value[0]) : {} };
251
+ if (value && typeof value === 'object') { const props = {}; for (const [k, v] of Object.entries(value)) props[k] = schemaOf(v); return { type: 'object', properties: props, required: Object.keys(props) }; }
252
+ if (value === null) return { type: 'null' };
253
+ if (typeof value === 'number') return { type: Number.isInteger(value) ? 'integer' : 'number' };
254
+ return { type: typeof value };
255
+ }
256
+ const gdType = s => s.type === 'integer' ? 'int' : s.type === 'number' ? 'float' : s.type === 'boolean' ? 'bool' : s.type === 'string' ? 'String' : s.type === 'array' ? `Array[${gdType(s.items ?? {})}]`.replace('Array[Variant]', 'Array') : s.type === 'object' ? 'Dictionary' : 'Variant';
257
+ const csType = s => s.type === 'integer' ? 'long' : s.type === 'number' ? 'double' : s.type === 'boolean' ? 'bool' : s.type === 'string' ? 'string' : s.type === 'array' ? `List<${csType(s.items ?? {})}>` : s.type === 'object' ? 'Dictionary<string, object>' : 'object';
258
+ const ident = k => k.replace(/[^A-Za-z0-9_]/g, '_').replace(/^(\d)/, '_$1');
259
+ function gdClass(schema, name = 'SaveData') {
260
+ const lines = [`class_name ${name}`, 'extends RefCounted', '# Generated by ReversePort re_save from an existing save file. Field names kept so old saves still load.', ''];
261
+ for (const [k, s] of Object.entries(schema.properties ?? {})) lines.push(`var ${ident(k)}: ${gdType(s)}`);
262
+ lines.push('', 'func to_dict() -> Dictionary:', '\treturn {', ...Object.keys(schema.properties ?? {}).map(k => `\t\t"${k}": ${ident(k)},`), '\t}', '', 'static func from_dict(d: Dictionary) -> ' + name + ':', `\tvar s := ${name}.new()`);
263
+ for (const [k, s] of Object.entries(schema.properties ?? {})) lines.push(`\tif d.has("${k}"): s.${ident(k)} = d["${k}"]`);
264
+ lines.push('\treturn s', '', '# Save: FileAccess.open(path, FileAccess.WRITE).store_string(JSON.stringify(data.to_dict()))', '# Load: from_dict(JSON.parse_string(FileAccess.get_file_as_string(path)))', '');
265
+ return lines.join('\n');
266
+ }
267
+ function csClass(schema, name = 'SaveData') {
268
+ const lines = ['using System;', 'using System.Collections.Generic;', '', '// Generated by ReversePort re_save from an existing save file. Field names kept so old saves still load.', '[Serializable]', `public class ${name}`, '{'];
269
+ for (const [k, s] of Object.entries(schema.properties ?? {})) lines.push(` public ${csType(s)} ${ident(k)};`);
270
+ lines.push('}', '', '// Unity: JsonUtility.ToJson(data) / JsonUtility.FromJson<' + name + '>(text) handle flat fields and lists of primitives;', '// nested dictionaries need a JSON library (e.g. Newtonsoft) or explicit classes.', '');
271
+ return lines.join('\n');
272
+ }
273
+
274
+ // ------------------------------------------------------------------------------------------------ tool
275
+ export async function saveTool({ file, compare, action = 'inspect', out, limit: max = 400 } = {}) {
276
+ if (!file) throw new RPError('INVALID_ARGUMENT', 'file is required: a save file.');
277
+ const { abs, size } = await requireFile(file);
278
+ if (size > 64 * 1024 * 1024) throw new RPError('TOO_LARGE', 'save files over 64 MB are not read whole; use re_probe.');
279
+ const buf = await fs.readFile(abs);
280
+ const peeled = peel(buf, path.basename(abs));
281
+ const result = { file: abs, size, container: peeled.layers.length ? peeled.layers.join(' → ') : identify(buf.subarray(0, 4096)).name, layers: peeled.layers };
282
+ if (peeled.stop) result.stopped = peeled.stop;
283
+ if (peeled.data !== undefined) {
284
+ const fields = flatten(peeled.data);
285
+ const roles = {};
286
+ for (const f of fields) if (f.role) (roles[f.role] ??= []).push(f.path);
287
+ result.fields = limit(fields, max);
288
+ result.roles = roles;
289
+ result.checksums = findChecksums(peeled.data, peeled.text, null);
290
+ const versions = fields.filter(f => f.role === 'version').map(f => `${f.path}=${f.sample}`);
291
+ if (versions.length) result.version = versions;
292
+ } else {
293
+ const inner = peeled.bytes;
294
+ result.checksums = findChecksums({}, null, inner);
295
+ result.strides = strideGuess(inner.subarray(0, 65536));
296
+ result.strings = extractStrings(inner, { min: 4, max: 100 });
297
+ result.entropy = Math.round(entropy(inner.subarray(0, Math.min(inner.length, 65536))) * 100) / 100;
298
+ }
299
+ if (compare) {
300
+ const other = await requireFile(compare);
301
+ const ob = await fs.readFile(other.abs);
302
+ const op = peel(ob, path.basename(other.abs));
303
+ if (peeled.data !== undefined && op.data !== undefined) {
304
+ const changes = diffData(peeled.data, op.data);
305
+ result.compare = { file: other.abs, changed: limit(changes, max), hint: changes.length ? 'The fields that changed between the two saves are the ones the action between them touched. Integrity fields that changed too confirm a checksum covers them.' : 'No field differs.' };
306
+ } else {
307
+ const n = Math.min(buf.length, ob.length); const diffs = []; for (let i = 0; i < n && diffs.length < 200; i++) if (buf[i] !== ob[i]) diffs.push(i);
308
+ result.compare = { file: other.abs, sizeDelta: ob.length - buf.length, firstDifferences: diffs.slice(0, 50), hint: 'Byte offsets that differ; group them by the strides above to find the record and field.' };
309
+ }
310
+ }
311
+ if (action === 'schema') {
312
+ if (peeled.data === undefined) throw new RPError('INVALID_ARGUMENT', 'schema needs a decodable save (JSON, XML, INI, Godot store_var, or those inside gzip/base64/LZ-String).');
313
+ const schema = { $schema: 'https://json-schema.org/draft/2020-12/schema', title: path.basename(abs), ...schemaOf(peeled.data) };
314
+ const dir = out ? path.resolve(out) : await workspaceDir('saves');
315
+ await fs.mkdir(dir, { recursive: true });
316
+ const base = path.basename(abs).replace(/\.[^.]+$/, '').replace(/[^A-Za-z0-9._-]+/g, '_') || 'save';
317
+ const written = [];
318
+ for (const [name, content] of [[`${base}.schema.json`, JSON.stringify(schema, null, 2)], ['SaveData.gd', gdClass(schema)], ['SaveData.cs', csClass(schema)]]) { const p = path.join(dir, name); await fs.writeFile(p, content); written.push(p); }
319
+ result.written = written;
320
+ result.layersToReproduce = peeled.layers;
321
+ }
322
+ result.next = peeled.data !== undefined
323
+ ? `Fields with roles tell you what the save tracks; compare two saves (compare:<file>) to confirm which field is which. ${result.checksums?.some(c => c.verified) ? 'A checksum was verified: regenerate it the same way after editing. ' : ''}action:"schema" writes a JSON schema and SaveData.gd/.cs to port the format; keep the layer order (${peeled.layers.join(' → ')}) if old saves must keep loading.`
324
+ : 'The structure is not decodable here. Use the strides and strings to map records, compare two saves to find the bytes that change, and re_binary on the game to find the code that writes this file.';
325
+ return result;
326
+ }
@@ -263,8 +263,41 @@ export async function parseUnityYaml(text, file) {
263
263
  if (c.type === 'Rigidbody') { out.mass = Number(c.props.m_Mass ?? 1); out.isKinematic = c.props.m_IsKinematic === '1'; out.useGravity = c.props.m_UseGravity !== '0'; }
264
264
  if (c.type === 'AudioSource') out.clip = asset(ref(c.props.m_audioClip));
265
265
  if (c.type === 'Animator') out.controller = asset(ref(c.props.m_Controller));
266
+ // uGUI
267
+ if (c.type === 'Canvas') { out.renderMode = ['overlay', 'camera', 'world'][Number(c.props.m_RenderMode)] ?? 'overlay'; }
268
+ if (c.type === 'CanvasScaler') { out.referenceResolution = vec(c.props.m_ReferenceResolution); out.scaleMode = Number(c.props.m_UiScaleMode ?? 0); }
269
+ if (c.type === 'CanvasGroup') out.alpha = Number(c.props.m_Alpha ?? 1);
270
+ if (c.type === 'MonoBehaviour') {
271
+ const n = c.body;
272
+ const ui = n.match(/^ m_(Sprite|Texture|Text|text|OnClick|MinValue|IsOn|Content|Spacing|FontData|fontSize|HorizontalFit|Alpha|Padding|TargetGraphic|ReferenceResolution|RenderMode|CellSize|ShowMaskGraphic|ChildAlignment|Interactable|Navigation|TextComponent|Transition)\b/m);
273
+ if (ui) {
274
+ const num = k => { const m = n.match(new RegExp(`^ ${k}: (-?[\\d.e+-]+)`, 'm')); return m ? Number(m[1]) : undefined; };
275
+ const str = k => n.match(new RegExp(`^ ${k}: ['"]?([^\\n]*?)['"]?$`, 'm'))?.[1];
276
+ const color = k => { const m = n.match(new RegExp(`^ ${k}: \\{r: ([\\d.e+-]+), g: ([\\d.e+-]+), b: ([\\d.e+-]+), a: ([\\d.e+-]+)\\}`, 'm')); return m ? m.slice(1, 5).map(Number) : undefined; };
277
+ const sprite = n.match(/^ m_Sprite: ([^\n]*)/m)?.[1], texture = n.match(/^ m_Texture: ([^\n]*)/m)?.[1];
278
+ const u = {};
279
+ if (/^ m_text:/m.test(n) && /^ m_fontSize:/m.test(n)) { u.widget = 'TextMeshPro'; u.text = (n.match(/^ m_text: ([\s\S]*?)\n m_isRightToLeft:/m)?.[1] ?? str('m_text') ?? '').replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n').replace(/\n\s{4}/g, '\n'); u.fontSize = num('m_fontSize'); u.color = color('m_fontColor'); u.alignment = num('m_textAlignment'); }
280
+ else if (/^ m_Text:/m.test(n) && /^ m_FontData:/m.test(n)) { u.widget = 'Text'; u.text = (str('m_Text') ?? '').replace(/\\n/g, '\n'); u.fontSize = Number(n.match(/m_FontSize: (\d+)/)?.[1] ?? 14); u.color = color('m_Color'); u.alignment = Number(n.match(/m_Alignment: (\d+)/)?.[1] ?? 0); }
281
+ else if (/^ m_Text:/m.test(n) && /^ m_TextComponent:/m.test(n)) { u.widget = 'InputField'; u.text = str('m_Text') ?? ''; }
282
+ else if (/^ m_OnClick:/m.test(n)) { u.widget = 'Button'; u.onClick = [...n.matchAll(/m_Target: \{fileID: (\d+)\}[\s\S]*?m_MethodName: (\w+)/g)].map(m => ({ target: m[1], method: m[2] })); }
283
+ else if (/^ m_MinValue:/m.test(n) && /^ m_Direction:/m.test(n)) { u.widget = 'Slider'; u.min = num('m_MinValue'); u.max = num('m_MaxValue'); u.value = num('m_Value'); u.wholeNumbers = /m_WholeNumbers: 1/.test(n); u.vertical = (num('m_Direction') ?? 0) >= 2; }
284
+ else if (/^ m_IsOn:/m.test(n)) { u.widget = 'Toggle'; u.isOn = /m_IsOn: 1/.test(n); }
285
+ else if (/^ m_Content:/m.test(n) && /^ m_Horizontal:/m.test(n)) { u.widget = 'ScrollRect'; }
286
+ else if (/^ m_Spacing:/m.test(n) && /^ m_Padding:/m.test(n)) { u.widget = /m_CellSize/.test(n) ? 'GridLayoutGroup' : /m_ChildForceExpandWidth/.test(n) && c.props.m_Script && /vertical/i.test(String(c.props.m_Script)) ? 'VerticalLayoutGroup' : 'LayoutGroup'; u.spacing = num('m_Spacing') ?? (n.match(/m_Spacing: \{x: ([\d.]+), y: ([\d.]+)\}/) ? Number(n.match(/m_Spacing: \{x: ([\d.]+)/)[1]) : 0); u.constraintCount = num('m_ConstraintCount'); }
287
+ else if (/^ m_ShowMaskGraphic:/m.test(n)) u.widget = 'Mask';
288
+ else if (/^ m_ReferenceResolution:/m.test(n)) { u.widget = 'CanvasScaler'; u.referenceResolution = vec(n.match(/^ m_ReferenceResolution: ([^\n]*)/m)?.[1]); u.scaleMode = num('m_UiScaleMode'); }
289
+ else if (texture !== undefined && /^ m_UVRect:/m.test(n)) { u.widget = 'RawImage'; u.texture = asset(ref(texture)); u.color = color('m_Color'); }
290
+ else if (sprite !== undefined && /^ m_Type:/m.test(n)) { u.widget = 'Image'; u.sprite = asset(ref(sprite)); u.color = color('m_Color'); u.imageType = ['simple', 'sliced', 'tiled', 'filled'][num('m_Type') ?? 0]; u.preserveAspect = /m_PreserveAspect: 1/.test(n); }
291
+ if (u.widget) { out.ui = u; }
292
+ }
293
+ }
266
294
  return out;
267
295
  };
296
+ // Prefab instance overrides beyond name and transform.
297
+ const overridesOf = d => [...d.body.matchAll(/- target: \{fileID: (-?\d+)(?:, guid: ([0-9a-f]{32}))?[^}]*\}\s*\n\s*propertyPath: ([^\n]+)\n\s*value: ([^\n]*)\n\s*objectReference: ([^\n]*)/g)]
298
+ .filter(m => !/^m_(Name|LocalPosition|LocalRotation|LocalScale|LocalEulerAnglesHint|RootOrder)\b/.test(m[3]))
299
+ .map(m => ({ property: m[3].trim(), value: m[4].trim(), ...(ref(m[5]) && ref(m[5]).fileID !== '0' ? { reference: asset(ref(m[5])) ?? m[5].trim() } : {}) }))
300
+ .slice(0, 100);
268
301
  const nodeFor = (tId, seen = new Set()) => {
269
302
  if (seen.has(tId)) return null;
270
303
  seen.add(tId);
@@ -273,7 +306,8 @@ export async function parseUnityYaml(text, file) {
273
306
  if (t.stripped) {
274
307
  const pi = byId.get(ref(t.props.m_PrefabInstance)?.fileID);
275
308
  const pn = pi ? prefabName(pi) : { name: 'Prefab', source: null };
276
- return { name: pn.name, type: 'PrefabInstance', prefab: pn.source, ...(pn.transform ? { transform: pn.transform } : {}), children: [] };
309
+ const ov = pi ? overridesOf(pi) : [];
310
+ return { name: pn.name, type: 'PrefabInstance', prefab: pn.source, ...(pn.transform ? { transform: pn.transform } : {}), ...(ov.length ? { overrides: ov } : {}), children: [] };
277
311
  }
278
312
  const go = gos.get(ref(t.props.m_GameObject)?.fileID);
279
313
  const p = vec(t.props.m_LocalPosition), q = vec(t.props.m_LocalRotation), s = vec(t.props.m_LocalScale);
@@ -281,7 +315,7 @@ export async function parseUnityYaml(text, file) {
281
315
  const node = {
282
316
  name: go?.props.m_Name?.trim() || '(unnamed)', type: 'GameObject',
283
317
  transform: { position: [p.x ?? 0, p.y ?? 0, p.z ?? 0].map(r4), rotation: [q.x ?? 0, q.y ?? 0, q.z ?? 0, q.w ?? 1].map(r4), scale: [s.x ?? 1, s.y ?? 1, s.z ?? 1].map(r4) },
284
- ...(t.type === 'RectTransform' ? { ui: true } : {}),
318
+ ...(t.type === 'RectTransform' ? { ui: true, rect: { anchorMin: vec(t.props.m_AnchorMin), anchorMax: vec(t.props.m_AnchorMax), anchoredPosition: vec(t.props.m_AnchoredPosition), sizeDelta: vec(t.props.m_SizeDelta), pivot: vec(t.props.m_Pivot) } } : {}),
285
319
  ...(go?.props.m_TagString && go.props.m_TagString !== 'Untagged' ? { tag: go.props.m_TagString } : {}),
286
320
  ...(go?.props.m_IsActive === '0' ? { active: false } : {}),
287
321
  components: comps, children: [],
@@ -294,7 +328,7 @@ export async function parseUnityYaml(text, file) {
294
328
  // Prefab instances whose parent is the scene root
295
329
  for (const d of docs.filter(x => x.type === 'PrefabInstance')) {
296
330
  const parent = d.body.match(/m_TransformParent: ([^\n]*)/)?.[1];
297
- if (ref(parent)?.fileID === '0') { const pn = prefabName(d); roots.push({ name: pn.name, type: 'PrefabInstance', prefab: pn.source, transform: pn.transform, children: [] }); }
331
+ if (ref(parent)?.fileID === '0') { const pn = prefabName(d); const ov = overridesOf(d); roots.push({ name: pn.name, type: 'PrefabInstance', prefab: pn.source, transform: pn.transform, ...(ov.length ? { overrides: ov } : {}), children: [] }); }
298
332
  }
299
333
  const count = n => 1 + n.children.reduce((s, c) => s + count(c), 0);
300
334
  const ext = path.extname(file).toLowerCase();