Files
Currency-War-SR/tools/gridfight-extract-node/native_codec.mjs
T

358 lines
16 KiB
JavaScript
Raw Normal View History

2026-10-30 23:54:07 +07:00
// Local port of client-table-extract's native codec, with GridFight schema modes.
import * as e from './extract.mjs';
const {
fs,
path,
fail,
cat,
matches,
escapeRE,
zigzag,
small
} =e;
export class NativeCodec {
constructor(dump='', assembly='', evidence='', mode='stages'){
this.assembly=assembly;
this.evidence=evidence;
this.mode=mode;
this.blocks=Object.fromEntries(matches(dump, /\/\/ Full Name: ([\w.]+)\n(.*?)(?=\n\/\/ TypeDefIndex:|$)/gs).map(m=>[m[1], m[2]]));
this.rvas=[...new Set(matches(dump, /RVA: (0x[\da-fA-F]+)/g).map(m=>Number(m[1])))].sort((a, b)=>a-b);
this.schemas={
};
this.methods={
};
this.subtypes={
};
this.readers=new Map();
for(const name of Object.keys(this.blocks))for(const [, rva]of this.binary_methods(name)){
if(!this.readers.has(rva))this.readers.set(rva, new Set());
this.readers.get(rva).add(name);
}
}
binary_methods(name){
if(!(name in this.blocks))fail('Missing native metadata class '+name);
return matches(this.blocks[name], new RegExp('^ public static void (\\w+)\\(FCOEMCEDLNL, '+escapeRE(name)+'\\) .*?RVA: (0x[\\da-fA-F]+)', 'gm')).map(m=>[m[1], Number(m[2])]);
}
disassemble(rva, size=null){
const end=this.rvas.find(v=>v>rva)??rva+8192;
const [address, dis]=e.disassemble(this.assembly, rva, size??Math.min(end-rva, 8192));
return [address, dis.slice(dis.indexOf('Disassembly'))];
}
proof(name, dis){
fs.mkdirSync(this.evidence, {
recursive:true
});
fs.writeFileSync(path.join(this.evidence, name+'.asm'), dis);
}
fields(name){
const body=this.blocks[name]??fail('Missing native metadata class '+name),
base=body.match(/public class \w+ : (RPG\.GameCore\.\w+)/),
result=base&&base[1]in this.blocks?this.fields(base[1]):new Map();
for(const m of matches(body, /^ public (.+?) (\w+) \/\/ Offset: (0x[\da-fA-F]+)/gm))result.set(Number(m[3]), [m[2], m[1]]);
return result;
}
schema(name){
if(name in this.schemas)return this.schemas[name];
let known=this.fields(name);
if(this.mode==='rounds')known=new Map([...known].filter(([, v])=>!v[1].startsWith('static ')));
for(const [method, rva]of this.binary_methods(name)){
const [, dis]=this.disassemble(rva),
tests=matches(dis, this.mode==='stages'?/(?:test\s+(?:bl|ebx|rbx|al|eax|rax),0x([\da-f]+)|bt\s+rbx,0x([\da-f]+))/g:/test\s+(?:bl|ebx|rbx|al|eax|rax),0x([\da-f]+)/g),
ordered=new Map(),
used=new Set();
for(let i=0; i<tests.length; i++){
const m=tests[i],
bit=m[1]?BigInt('0x'+m[1]):1n<<BigInt('0x'+m[2]);
if(!bit||(bit&(bit-1n)))continue;
const part=dis.slice(m.index+m[0].length, tests[i+1]?.index??dis.length);
const mov=this.mode==='rounds'?/mov(?:ss)?\s+(?:(?:BYTE|WORD|DWORD|QWORD) PTR )?\[r(?!sp\b)\w+\+0x([\da-f]+)\],(?:[a-z][a-z0-9]*)/g:/mov\s+(?:BYTE|WORD|DWORD|QWORD) PTR \[r(?!sp\b)\w+\+0x([\da-f]+)\],\w+/g;
const offsets=[...matches(part, mov), ...matches(part, /add\s+r(?!sp\b)\w+,0x([\da-f]+)/g), ...matches(part, /lea\s+r\w+,\[r(?!sp\b)\w+\+0x([\da-f]+)\]/g)];
const found=new Set(offsets.map(m=>parseInt(m[1], 16)).filter(v=>known.has(v)&&!used.has(v)));
if(found.size!==1)break;
const offset=[...found][0];
used.add(offset);
const [key, type]=known.get(offset);
ordered.set(bit.toString(2).length-1, {
name:key,
type
});
if(this.mode==='stages'&&ordered.size===known.size)break;
}
if(ordered.size&&Array.from({
length:ordered.size
}, (_, i)=>ordered.has(i)).every(Boolean)){
if(this.mode==='rounds'&&ordered.size<known.size&&!name.endsWith('GridFightLevelConfigRow'))fail('Incomplete native schema '+name);
this.schemas[name]=Array.from({
length:ordered.size
}, (_, i)=>ordered.get(i));
this.methods[name]={
method,
rva
};
this.proof(name.split('.').at(-1), dis);
return this.schemas[name];
}
}
fail('Unproved native field order: '+name);
}
polymorphic(name){
if(name in this.subtypes)return this.subtypes[name];
for(const [, rva]of this.binary_methods(name)){
const [address, dis]=this.disassemble(rva),
limit=dis.match(/cmp\s+eax,0x([\da-f]+)/),
table=dis.match(/lea\s+rcx,\[rip\+[^\n]+# (0x[\da-f]+)\s*\n[^\n]*movsxd rax,DWORD PTR \[rcx\+rax\*4\]/);
if(!limit||!table||!dis.includes('jmp rax'))continue;
const count=parseInt(limit[1], 16)+1;
if(count>64)fail('Unsupported subtype dispatch size');
const base=address-BigInt(rva),
ta=BigInt(table[1]),
[, raw]=e.pe_method(this.assembly, Number(ta-base), count*4),
targets=Array.from({
length:count
}, (_, i)=>ta+BigInt(raw.readInt32LE(i*4))),
result={
};
for(let tag=0; tag<count; tag++){
const target=targets[tag],
end=[...targets, ta].filter(v=>v>target).sort((a, b)=>a<b?-1:1)[0];
if(end===undefined)fail('Missing subtype boundary');
const [, part]=this.disassemble(Number(target-base), Number(end-target)),
types=new Set();
for(const m of matches(part, /jmp\s+(0x[\da-f]+)/g))for(const typ of this.readers.get(Number(BigInt(m[1])-base))??[])if(typ===name||new RegExp('public class \\w+ : '+escapeRE(name)).test(this.blocks[typ]))types.add(typ);
if(types.size!==1)fail('Ambiguous native subtype '+name+':'+tag);
result[tag]=[...types][0];
this.proof(name.split('.').at(-1)+'_case'+tag, part);
}
this.proof(name.split('.').at(-1)+'_dispatch', dis);
this.subtypes[name]=result;
return result;
}
fail('No verified native subtype dispatch: '+name);
}
verify_dynamic_reader(){
const method=this.blocks['RPG.GameCore.DynamicValue']?.match(/public static void FromByteBinary\([^\n]+RVA: (0x[\da-fA-F]+)/);
if(!method)fail('Missing native DynamicValue reader');
const rva=Number(method[1]), [address, dis]=this.disassemble(rva),
result=verify_dynamic_dispatch(dis, address, (table, size)=>e.pe_method(this.assembly, rva+Number(table-address), size)[1]);
this.proof('DynamicValue_FromByteBinary', dis);
e.atomic_json(path.join(this.evidence, 'DynamicValue_reader.json'), {rva, ...result});
return {rva, ...result};
}
dynamic(cursor){
const tag=zigzag(cursor.varint());
let v;
if(tag===0)v=zigzag(cursor.varint());
else if(tag===1){
if(cursor.at+8>cursor.data.length)fail('Truncated DynamicValue double');
v={
double_wire:cursor.data.subarray(cursor.at, cursor.at+8).toString('hex')
};
cursor.at+=8;
} else if(tag===2)v=e.field(cursor, 'bool');
else if(tag===3)v=Array.from({
length:cursor.length()
}, ()=>this.dynamic(cursor));
else if(tag===4)v=Array.from({
length:cursor.length()
}, ()=>[this.dynamic(cursor), this.dynamic(cursor)]);
else if(tag===5)v=e.field(cursor, 'string');
else if(tag===6)v=null;
else fail('Unsupported DynamicValue tag');
return {
[this.mode==='rounds'?'tag':'wire_tag']:tag,
value:v
};
}
encode_dynamic(v){
const tag=v[this.mode==='rounds'?'tag':'wire_tag'],
x=v.value,
b=e.encode_varint(tag*2);
if(tag===0){
const a=BigInt(x);
return cat(b, e.encode_varint((a<<1n)^(a>>63n)));
}
if(tag===1)return cat(b, Buffer.from(x.double_wire, 'hex'));
if(tag===2)return cat(b, e.encode_field(x, 'bool'));
if(tag===3)return cat(b, e.encode_varint(x.length*2), x.map(a=>this.encode_dynamic(a)));
if(tag===4)return cat(b, e.encode_varint(x.length*2), x.flatMap(([a, z])=>[this.encode_dynamic(a), this.encode_dynamic(z)]));
if(tag===5)return cat(b, e.encode_field(x, 'string'));
if(tag===6)return b;
fail('Unsupported DynamicValue tag');
}
field(cursor, typ){
if(typ==='RPG.GameCore.DynamicValue')return this.dynamic(cursor);
if(['RPG.Client.TextID', 'RPG.GameCore.FixPoint'].includes(typ))return e.field(cursor, typ);
if(typ.endsWith('[]'))return Array.from({
length:cursor.length()
}, ()=>this.field(cursor, typ.slice(0, -2)));
if(['uint', 'ushort'].includes(typ)){
const v=cursor.varint();
if(v>=2**(typ==='ushort'?16:32))fail('Unsigned integer overflow');
return v;
}
if(['short', 'int'].includes(typ)||(typ in this.blocks&&this.blocks[typ].includes('public enum '))){
const bits=typ==='short'?16:32;
if(typ in this.blocks&&!/public int value__/.test(this.blocks[typ]))fail('Unsupported enum storage');
const v=cursor.varint();
if(v>=2**bits)fail('Signed integer overflow');
return zigzag(v);
}
if(typ==='double'){
if(cursor.at+8>cursor.data.length)fail('Truncated double');
const v={double_wire:cursor.data.subarray(cursor.at,cursor.at+8).toString('hex')};
cursor.at+=8;return v;
}
if(typ==='float'){
if(cursor.at+4>cursor.data.length)fail('Truncated float');
const v={
float_wire:cursor.data.subarray(cursor.at, cursor.at+4).toString('hex')
};
cursor.at+=4;
return v;
}
if(['string', 'bool'].includes(typ))return e.field(cursor, typ);
if(typ.startsWith('System.Collections.Generic.Dictionary<')){
const m=typ.match(/^System.Collections.Generic.Dictionary<(.+?), (.+)>$/);
if(!m)fail('Unsupported dictionary');
return Array.from({
length:cursor.length()
}, ()=>[this.field(cursor, m[1]), this.field(cursor, m[2])]);
}
if(typ==='RPG.GameCore.FloorCustomValueConfig'){
const tag=cursor.varint(),
sub=this.polymorphic(typ);
if(!(tag in sub))fail('Unknown native subtype: '+tag);
return {
tag,
value:this.record(cursor, sub[tag])
};
}
return this.record(cursor, typ);
}
record(cursor, typ, through=null){
const cols=this.schema(typ),
mask=BigInt(cursor.varint());
if(mask>>BigInt(cols.length))fail('Unsupported presence mask');
const row={
};
for(let i=0; i<cols.length; i++){
const col=cols[i];
if(mask&(1n<<BigInt(i)))row[col.name]=this.field(cursor, col.type);
if(col.name===through)break;
}
return row;
}
encode_field(v, t){
if(t==='RPG.GameCore.DynamicValue')return this.encode_dynamic(v);
if(['RPG.Client.TextID', 'RPG.GameCore.FixPoint'].includes(t))return e.encode_field(v, t);
if(t.endsWith('[]'))return cat(e.encode_varint(v.length*2), v.map(x=>this.encode_field(x, t.slice(0, -2))));
if(t.startsWith('System.Collections.Generic.Dictionary<')){
const m=t.match(/^System.Collections.Generic.Dictionary<(.+?), (.+)>$/);
return cat(e.encode_varint(v.length*2), v.flatMap(([k, x])=>[this.encode_field(k, m[1]), this.encode_field(x, m[2])]));
}
if(t==='RPG.GameCore.FloorCustomValueConfig')return cat(e.encode_varint(v.tag), this.encode_record(v.value, this.polymorphic(t)[v.tag]));
if(t==='double')return Buffer.from(v.double_wire, 'hex');
if(t==='float')return Buffer.from(v.float_wire, 'hex');
if(['short', 'int'].includes(t)||(t in this.blocks&&this.blocks[t].includes('public enum '))){
const x=BigInt(v);
return e.encode_varint((x<<1n)^(x>>BigInt(t==='short'?15:31)));
}
if(['uint', 'ushort', 'string', 'bool'].includes(t))return e.encode_field(v, t);
return this.encode_record(v, t);
}
encode_record(row, typ){
const cols=this.schema(typ);
if(Object.keys(row).some(k=>!cols.some(col=>col.name===k)))fail('Unknown field during round trip');
return cat(e.encode_varint(cols.reduce((m, col, i)=>col.name in row?m|(1n<<BigInt(i)):m, 0n)), cols.filter(col=>col.name in row).map(col=>this.encode_field(row[col.name], col.type)));
}
}
export function indexed_entries(data, id, size){
const marker=Buffer.from(id, 'hex'),
at0=data.indexOf(marker);
if(at0<0||data.indexOf(marker, at0+16)>=0)fail('Missing or ambiguous DesignV blob: '+id);
const at=at0+16;
if(at+12>data.length)fail('Truncated DesignV header');
const expected=data.readBigUInt64BE(at),
count=data.readUInt32BE(at+8);
if(expected!==BigInt(size)||!count||at+12+count*16>data.length)fail('DesignV index does not match installed blob');
const entries=[],
hashes=new Set();
for(let i=0; i<count; i++){
const p=at+12+i*16,
key=data.readBigUInt64BE(p),
length=data.readUInt32BE(p+8),
offset=data.readUInt32BE(p+12);
if(offset+length>size||hashes.has(key))fail('Invalid or overlapping DesignV asset ranges');
entries.push({
hash:key,
offset,
length
});
hashes.add(key);
}
const sorted=entries.toSorted((a, b)=>a.offset-b.offset);
if(sorted.some((a, i)=>i+1<sorted.length&&a.offset+a.length>sorted[i+1].offset))fail('Overlapping DesignV asset ranges');
return entries;
}
// XXH64, seed zero, with explicit 64-bit arithmetic rather than a native FFI.
const MASK=(1n<<64n)-1n, P1=11400714785074694791n, P2=14029467366897019727n, P3=1609587929392839161n, P4=9650029242287828579n, P5=2870177450012600261n;
const rot=(x, n)=>((x<<BigInt(n))|(x>>BigInt(64-n)))&MASK;
const round=(a, x)=>rot((a+x*P2)&MASK, 31)*P1&MASK;
export function asset_hash(text){
const b=Buffer.from(text);
let p=0,
h;
if(b.length>=32){
let v=[(P1+P2)&MASK, P2, 0n, (-P1)&MASK];
while(p<=b.length-32){
for(let i=0;
i<4;
i++)v[i]=round(v[i], b.readBigUInt64LE(p+i*8));
p+=32;
}
h=(rot(v[0], 1)+rot(v[1], 7)+rot(v[2], 12)+rot(v[3], 18))&MASK;
for(const x of v)h=((h^round(0n, x))*P1+P4)&MASK;
} else h=P5;
h=(h+BigInt(b.length))&MASK;
while(p+8<=b.length){
h=(rot(h^round(0n, b.readBigUInt64LE(p)), 27)*P1+P4)&MASK;
p+=8;
}
if(p+4<=b.length){
h=(rot(h^(BigInt(b.readUInt32LE(p))*P1&MASK), 23)*P2+P3)&MASK;
p+=4;
}
while(p<b.length){
h=rot(h^(BigInt(b[p++])*P5&MASK), 11)*P1&MASK;
}
h^=h>>33n;
h=h*P2&MASK;
h^=h>>29n;
h=h*P3&MASK;
return h^(h>>32n);
}
// Both beta readers have this dispatch layout; resolve the RVA from matching metadata.
export function verify_dynamic_dispatch(dis, address, read){
const table=dis.match(/lea\s+rcx,\[rip\+[^\n]+# (0x[\da-f]+)\s*\n[^\n]*movsxd rax,DWORD PTR \[rcx\+rax\*4\]/);
if(!table||![/cmp\s+rax,0x5/, /shr\s+edx,1/, /neg\s+eax/, /xor\s+eax,edx/, /jmp\s+rax/].every(p=>p.test(dis)))fail('DynamicValue native reader changed');
const ta=BigInt(table[1]), raw=read(ta,24),
offsets=[0x85,0xc5,0x102,0x14f,0x18a,0x1cf],
targets=offsets.map((_,i)=>ta+BigInt(raw.readInt32LE(i*4)));
if(ta-address!==0x2b0n||targets.some((v,i)=>v-address!==BigInt(offsets[i])))fail('DynamicValue native tag binding changed');
const fallback=dis.match(/cmp\s+rax,0x5\s*\n[^\n]*ja\s+(0x[\da-f]+)/),
defaultTarget=fallback?BigInt(fallback[1]):0n,
tail=dis.slice(dis.indexOf(defaultTarget.toString(16)+':'),dis.indexOf(targets[5].toString(16)+':'));
if(defaultTarget-address!==0x1c6n||!/nop/.test(tail)||!/add\s+rsp,0x30/.test(tail)||!/ret/.test(tail)||/\b(?:call|jmp)\s/.test(tail))fail('DynamicValue native null/default case changed');
const checks=[[/shr\s+rdx,1/, /neg\s+rax/, /xor\s+rax,rdx/, /mov\s+QWORD PTR \[rcx\+0x28\],rax/],
[/movsd\s+xmm0,QWORD PTR \[rsp\+0x28\]/, /movsd\s+QWORD PTR \[rax\+0x28\],xmm0/],
[/mov\s+edx,0x1/, /movzx\s+ecx,bl/, /mov\s+QWORD PTR \[rax\+0x28\],rcx/],
[/add\s+rbx,0x20/, /jmp\s+0x/], [/add\s+rbx,0x18/, /call\s+0x/], [/add\s+rbx,0x10/, /jmp\s+0x/]];
for(let i=0;i<targets.length;i++){
const start=dis.indexOf(targets[i].toString(16)+':'),
end=i<5?dis.indexOf(targets[i+1].toString(16)+':'):dis.length,
part=dis.slice(start,end);
if(start<0||!checks[i].every(p=>p.test(part)))fail('DynamicValue native case changed: '+i);
}
return {dispatch_offset:0x2b0, default_offset:0x1c6, case_offsets:offsets, tags:['int64-zigzag','double-le','bool-byte','array','map','string','null'], wire_layout:'beta-4.6.51/4.6.53'};
}