Files
Currency-War-SR/tools/gridfight-extract-node/extract.mjs
T
2026-10-30 23:54:07 +07:00

549 lines
25 KiB
JavaScript

import {
spawnSync
} from 'node:child_process';
import * as c from './common.mjs';
export * from './common.mjs';
const {
fs,
path,
os,
fail,
small,
zigzag,
cat,
matches,
escapeRE,
sha256,
sha256_file,
atomic_json,
dict
} =c;
export const ENUM_TYPES=new Set(['AvatarPropertyType', 'OfferingRewardType', 'GridFightActivationType', 'GridFightChargeType', 'GridFightPreset', 'GridFightPropertyBindType', 'GridFightRoleSkillTag', 'GridFightTrailLayerQuality', 'GridFightTraitType', 'GridFightNodeType'].map(n=>'RPG.GameCore.'+n));
const ENUM_PROOFS=new Set();
export class Cursor {
constructor(data, at=0){
this.data=data;
this.at=at;
}
varint(){
let v=0n;
for(let shift=0n; shift<70n; shift+=7n){
if(this.at>=this.data.length)fail('truncated varint');
const byte=this.data[this.at++];
if(shift===63n&&byte>1)fail('varint overflow');
v|=BigInt(byte&127)<<shift;
if(byte<128)return small(v);
}
fail('varint overflow');
}
length(limit=10000){
const v=BigInt(this.varint());
if((v&1n)||v/2n>BigInt(limit))fail('invalid collection header');
return Number(v/2n);
}
}
export function pe_method(assembly, rva, size=8192){
const fd=fs.openSync(assembly, 'r');
try{
const h=Buffer.alloc(64);
fs.readSync(fd, h, 0, 64, 0);
const pe=h.readUInt32LE(60),
head=Buffer.alloc(264);
fs.readSync(fd, head, 0, 264, pe);
if(head.subarray(0, 4).toString('hex')!=='50450000')fail('invalid PE');
const count=head.readUInt16LE(6),
sections=pe+24+head.readUInt16LE(20),
base=head.readBigUInt64LE(48),
s=Buffer.alloc(count*40);
fs.readSync(fd, s, 0, s.length, sections);
for(let i=0; i<count; i++){
const at=i*40+8,
vs=s.readUInt32LE(at),
start=s.readUInt32LE(at+4),
rs=s.readUInt32LE(at+8),
raw=s.readUInt32LE(at+12);
if(start<=rva&&rva<start+Math.min(vs, rs)){
const b=Buffer.alloc(Math.min(size, rs-(rva-start)));
fs.readSync(fd, b, 0, b.length, raw+rva-start);
return [base+BigInt(rva), b];
}
}
fail('method RVA outside PE sections');
} finally{
fs.closeSync(fd);
}
}
export function disassemble(assembly, rva, size){
const [address, code]=pe_method(assembly, rva, size),
dir=fs.mkdtempSync(path.join(os.tmpdir(), 'gf-dis-')),
tmp=path.join(dir, 'code');
try{
fs.writeFileSync(tmp, code);
const p=spawnSync('objdump', ['-D', '-b', 'binary', '-m', 'i386:x86-64', '-Mintel', `--adjust-vma=${address}`, tmp], {
encoding:'utf8',
maxBuffer:16*1024*1024
});
if(p.status!==0)fail(p.error?.message??p.stderr);
return [address, p.stdout];
} finally{
fs.rmSync(dir, {
recursive:true,
force:true
});
}
}
export function schema(dump, assembly, name, evidence_dir){
const match=dump.match(new RegExp('// Full Name: RPG.GameCore\\.'+escapeRE(name)+'\\n(.*?)(?=\\n// TypeDefIndex:|$)', 's'));
if(!match)fail('missing client schema '+name);
const block=match[1],
fields=new Map(matches(block, /^ public ([\w.<>\[\]]+) (\w+) \/\/ Offset: (0x[\da-fA-F]+)/gm).map(m=>[Number(m[3]), [m[2], m[1]]]));
const method=block.match(/public static void FromBinary\([^\n]+RVA: (0x[\da-fA-F]+)/);
if(!fields.size||!method)fail('no binary reader for '+name);
const rva=Number(method[1]),
next=matches(dump, /RVA: (0x[\da-fA-F]+)/g).map(m=>Number(m[1])).filter(v=>rva<v&&v<=rva+8192);
const [address, dis]=disassemble(assembly, rva, next.length?Math.min(...next)-rva:8192),
tests=matches(dis, /test\s+(?:bl|ebx|rbx),0x([\da-f]+)/g),
ordered=new Map();
for(let i=0; i<tests.length; i++){
const m=tests[i],
bit=BigInt('0x'+m[1]);
if(!bit||(bit&(bit-1n)))continue;
const part=dis.slice(m.index+m[0].length, tests[i+1]?.index??dis.length);
const offsets=[...matches(part, /mov\s+(?:BYTE|DWORD|QWORD) PTR \[(?:rsi|rdi)\+0x([\da-f]+)\],(?:al|eax|rax|esi|rsi)/g), ...matches(part, /add\s+(?:rdi|rsi),0x([\da-f]+)/g)];
const found=new Set(offsets.map(x=>parseInt(x[1], 16)).filter(x=>fields.has(x)));
if(found.size!==1){
if(ordered.size===fields.size)break;
fail(`${name}: cannot prove field order for mask 0x${bit.toString(16)}`);
}
const [field_name, field_type]=fields.get([...found][0]);
if(ENUM_TYPES.has(field_type)){
const en=dump.match(new RegExp('// Full Name: '+escapeRE(field_type)+'\\n(.*?)(?=\\n// TypeDefIndex:|$)', 's'));
if(!en||!/public int value__/.test(en[1]))fail(field_type+': unsupported enum storage');
const reader=part.match(/mov\s+rdx,(?:rsi|rdi)[^\n]*\n[^\n]*call\s+(0x[\da-f]+)/);
if(!reader)fail(field_type+': enum reader not proved');
const rr=Number(BigInt(reader[1])-(address-BigInt(rva))),
pk=[assembly, rr, evidence_dir].join(':');
if(!ENUM_PROOFS.has(pk)){
const [, ed]=disassemble(assembly, rr, 1104);
if(![/shr\s+ebx,1/, /and\s+esi,0x1/, /neg\s+esi/, /xor\s+ebx,esi/].every(r=>r.test(ed)))fail('native enum codec changed');
fs.mkdirSync(evidence_dir, {
recursive:true
});
fs.writeFileSync(path.join(evidence_dir, `EnumReader_${rr.toString(16)}.asm`), ed.slice(ed.indexOf('Disassembly')));
ENUM_PROOFS.add(pk);
}
}
ordered.set(bit.toString(2).length-1, {
name:field_name,
type:field_type
});
if(ordered.size===fields.size)break;
}
if(!ordered.size||!Array.from({
length:ordered.size
}, (_, i)=>ordered.has(i)).every(Boolean))fail(name+': incomplete native schema');
fs.mkdirSync(evidence_dir, {
recursive:true
});
fs.writeFileSync(path.join(evidence_dir, name+'.asm'), dis.slice(dis.indexOf('Disassembly of section .data:')));
return Array.from({
length:ordered.size
}, (_, i)=>ordered.get(i));
}
export function field(cursor, typ, schemas){
if(typ.endsWith('[]'))return Array.from({
length:cursor.length()
}, ()=>field(cursor, typ.slice(0, -2), schemas));
if(typ==='System.Collections.Generic.List<uint>')return Array.from({
length:cursor.length()
}, ()=>field(cursor, 'uint', schemas));
if(typ==='RPG.Client.TextID'){
const v=cursor.varint();
if(v>0xffffffff)fail('TextID hash overflow');
return {
hash:zigzag(v),
hash64:cursor.varint()
};
}
if(typ==='RPG.GameCore.FixPoint')return {
wire:cursor.varint()
};
if(typ==='RPG.GameCore.AvatarPropertyValue'&&schemas)return record(cursor, schemas.AvatarPropertyValue, schemas);
if(typ==='string'){
const n=cursor.varint();
if(n>16384||cursor.at+Number(n)>cursor.data.length)fail('invalid string');
const raw=cursor.data.subarray(cursor.at, cursor.at+Number(n));
cursor.at+=Number(n);
return new TextDecoder('utf-8', {
fatal:true
}).decode(raw);
}
if(typ==='bool'){
if(cursor.at>=cursor.data.length)fail('truncated bool');
const v=cursor.data[cursor.at++];
if(v>1)fail('invalid bool');
return !!v;
}
if(typ==='uint'||ENUM_TYPES.has(typ)){
const v=cursor.varint();
if(v>0xffffffff)fail('uint overflow');
return ENUM_TYPES.has(typ)?zigzag(v):v;
}
fail('unsupported type '+typ);
}
export function record(cursor, columns, schemas){
const mask=BigInt(cursor.varint());
if(!mask||mask>>BigInt(columns.length))fail('invalid presence mask');
return Object.fromEntries(columns.flatMap((col, i)=>mask&(1n<<BigInt(i))?[[col.name, field(cursor, col.type, schemas)]]:[]));
}
export function encode_varint(value){
let v=BigInt(value);
if(v<0n||v>0xffffffffffffffffn)fail('varint overflow');
const out=[];
while(v>=128n){
out.push(Number(v&127n)|128);
v>>=7n;
}
out.push(Number(v));
return Buffer.from(out);
}
export function encode_field(v, t, s){
if(t.endsWith('[]'))return cat(encode_varint(2*v.length), v.map(x=>encode_field(x, t.slice(0, -2), s)));
if(t==='System.Collections.Generic.List<uint>')return cat(encode_varint(2*v.length), v.map(encode_varint));
if(t==='RPG.Client.TextID'){
const h=BigInt(v.hash);
return cat(encode_varint((h<<1n)^(h>>31n)), encode_varint(v.hash64));
}
if(t==='RPG.GameCore.FixPoint')return encode_varint(v.wire);
if(t==='RPG.GameCore.AvatarPropertyValue'&&s)return encode_record(v, s.AvatarPropertyValue, s);
if(ENUM_TYPES.has(t)){
const x=BigInt(v);
return encode_varint((x<<1n)^(x>>31n));
}
if(t==='bool')return Buffer.from([Number(v)]);
if(t==='string'){
const b=Buffer.from(v);
return cat(encode_varint(b.length), b);
}
return encode_varint(v);
}
export function encode_record(row, cols, s){
return cat(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=>encode_field(row[col.name], col.type, s)));
}
export const valid_roles=rows=>rows.length>=20&&c.uniqueValues(rows, r=>r.ID)&&rows.every(r=>r.ID>0&&r.ID<1000000&&r.AvatarID>=1000&&r.AvatarID<10000&&r.Rarity>=1&&r.Rarity<=5&&r.SeasonID>0&&r.SeasonID<100&&r.TraitList?.length&&r.TraitList.every(t=>t>0&&t<1000000));
export const valid_odds=rows=>rows.length>=5&&rows.length<=30&&rows.every((r, i)=>r.PlayerLevel===i+1&&[1, 2, 3, 4, 5].reduce((s, n)=>s+(r[`Rarity${n}Weight`]??0), 0)===100);
const tableProof=(name, blob, start, end, rows, data)=>({
table:name,
blob,
offset:start,
end,
records:rows.length,
rows,
table_sha256:sha256(data.subarray(start, end)),
blob_sha256:sha256(data)
});
const choose=(found, msg)=>{
if(!found.length)fail(msg);
if(new Set(found.map(m=>m.table_sha256)).size!==1)fail('ambiguous table; refusing to pick one');
return found[0];
};
export function find_table(client, columns, name, anchor_blob=null){
const roles=name==='GridFightRoleConfigRow';
if(roles&&columns.slice(0, 4).map(c=>c.name).join(',')!=='ID,AvatarID,FrontBackType,Rarity')fail('role locator schema changed');
if(!roles&&columns[0].name!=='PlayerLevel')fail('odds locator schema changed');
for(const layer of ['Persistent', 'StreamingAssets']){
const found=[];
for(const p of c.globBytes(path.join(client, 'StarRail_Data', layer, 'DesignData/Windows'))){
const blob=path.relative(path.join(client, 'StarRail_Data'), p);
if(path.basename(p, '.bytes').length!==32||!fs.statSync(p).size||(anchor_blob&&anchor_blob!==blob))continue;
const data=fs.readFileSync(p);
if(roles&&data.indexOf('SpriteOutput/GridFight')<0)continue;
for(let pos=0; pos<data.length; pos++){
if(roles?!(data[pos]>=128&&(data[pos]&11)===11):!(data[pos]<=63&&(data[pos]&3)===3&&data[pos+1]===1))continue;
try{
if(roles){
const prefix=new Cursor(data, pos),
mask=BigInt(prefix.varint());
if(mask>>BigInt(columns.length)||(mask&0x208bn)!==0x208bn)continue;
const id=prefix.varint(),
avatar=prefix.varint();
if(mask&4n)prefix.varint();
const rarity=prefix.varint();
if(!(id>0&&id<1000000&&avatar>=1000&&avatar<10000&&rarity>=1&&rarity<=5))continue;
}
const first=record(new Cursor(data, pos), columns);
if(roles?!(first.AvatarID>=1000&&first.AvatarID<10000&&first.Rarity>=1&&first.Rarity<=5&&first.TraitList?.length):first.PlayerLevel!==1)continue;
for(let start=Math.max(0, pos-5); start<pos; start++){
try{
const cursor=new Cursor(data, start),
count=cursor.length(roles?2000:30);
if(cursor.at!==pos||count<(roles?20:5))continue;
const rows=Array.from({
length:count
}, ()=>record(cursor, columns));
if(!(roles?valid_roles:valid_odds)(rows))continue;
const encoded=cat(encode_varint(count*2), rows.map(r=>encode_record(r, columns)));
if(!data.subarray(start, cursor.at).equals(encoded))continue;
found.push(tableProof(name, blob, start, cursor.at, rows, data));
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof RangeError||e instanceof TypeError))throw e;
}
}
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof RangeError||e instanceof TypeError))throw e;
}
}
}
if(found.length)return choose(found, name+': no verified complete table found');
}
fail(name+': no verified complete table found');
}
export function find_keyed_table(client, anchor, columns, name, first_key, valid, schemas, required_mask=null){
const data=fs.readFileSync(path.join(client, 'StarRail_Data', anchor)),
candidates=[];
if(required_mask!==null){
if(columns.length>10)fail('bounded locator requires at most ten fields');
const prefixes=[];
for(let m=1;
m<2**columns.length;
m++)if((m&required_mask)===required_mask)prefixes.push(cat(encode_varint(m), first_key===null?Buffer.alloc(0):encode_varint(first_key)));
const groups=new Map();
for(const p of prefixes){
if(!groups.has(p[0]))groups.set(p[0], []);
groups.get(p[0]).push(p);
}
for(let pos=0; pos<data.length; pos++){
const group=groups.get(data[pos]);
if(!group)continue;
const p=group.find(p=>data.subarray(pos, pos+p.length).equals(p));
if(p){
candidates.push([pos, [pos]]);
pos+=p.length-1;
}
}
} else{
const needle=encode_varint(first_key);
for(let at=data.indexOf(needle);
at>=0;
at=data.indexOf(needle, at+needle.length))candidates.push([at, Array.from({
length:Math.min(at, 10)
}, (_, i)=>Math.max(0, at-10)+i)]);
}
const found=[];
for(const [pos, starts]of candidates)for(const first of starts){
try{
const prefix=new Cursor(data, first),
mask=BigInt(prefix.varint());
if((required_mask===null&&prefix.at!==pos)||!(mask&1n)||mask>>BigInt(columns.length))continue;
for(let start=Math.max(0, first-5); start<first; start++){
try{
if(!start||data[start-1]!==0)continue;
const cursor=new Cursor(data, start),
count=cursor.length(10000);
if(cursor.at!==first||!count)continue;
const rows=Array.from({
length:count
}, ()=>record(cursor, columns, schemas));
if(!valid(rows))continue;
const encoded=cat(encode_varint(count*2), rows.map(r=>encode_record(r, columns, schemas)));
if(!data.subarray(start, cursor.at).equals(encoded))continue;
found.push(tableProof(name, anchor, start, cursor.at, rows, data));
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof RangeError||e instanceof TypeError))throw e;
}
}
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof RangeError||e instanceof TypeError))throw e;
}
}
return choose(found, name+': no complete related table found');
}
export function find_ui_blob(client){
for(const layer of ['Persistent', 'StreamingAssets']){
const found=c.globBytes(path.join(client, 'StarRail_Data', layer, 'DesignData/Windows')).filter(p=>{
if(!fs.statSync(p).size)return false;
const b=fs.readFileSync(p);
return b[0]!==123&&b.includes(Buffer.from('GridFightEntrancePage'))&&b.includes(Buffer.from('SpriteOutput/Rogue/Tourn/Titan/RankIcon/Small/'));
});
if(found.length===1)return path.relative(path.join(client, 'StarRail_Data'), found[0]);
if(found.length)fail('ambiguous GridFight UI Excel blob');
}
fail('no installed GridFight UI Excel blob');
}
export const valid_divisions=(rows, seasons)=>rows.length>=20&&c.uniqueValues(rows, r=>r.ID)&&c.subset(seasons, new Set(rows.map(r=>r.SeasonID)))&&rows.every(r=>r.ID>0&&r.ID<1000000&&seasons.has(r.SeasonID)&&(r.DivisionLevel??0)>=0&&(r.DivisionLevel??0)<=100&&r.Progress>0&&r.Progress<1000&&(!r.DivisionIcon||r.DivisionIcon.startsWith('SpriteOutput/Rogue/Tourn/Titan/RankIcon/')))&&rows.filter(r=>r.DivisionIcon).length>Math.floor(rows.length/2);
export const valid_season_modules=(rows, seasons)=>c.subset(seasons, new Set(rows.map(r=>r.SeasonID)))&&c.uniqueValues(rows, r=>[r.SeasonID, r.SubSeasonID].join(':'))&&rows.every(r=>seasons.has(r.SeasonID)&&r.SubSeasonID>0&&r.SubSeasonID<100&&r.ActivityModuleID>100000&&r.MaxRewardExp>0&&r.OfferingID>0);
const moduleList=r=>[...(r.ResidentModuleList??[]), ...(r.ActivityModuleIDList??[])];
export const valid_activities=(rows, modules)=>rows.length>=20&&c.uniqueValues(rows, r=>r.ActivityID)&&rows.every(r=>r.ActivityID>0&&r.ActivityID<10000000&&moduleList(r).every(m=>m>0))&&c.subset(modules, new Set(rows.flatMap(moduleList)));
export const valid_offering_levels=(rows, required)=>rows.length>20&&c.subset(required, new Set(rows.map(r=>r.TypeID)))&&c.uniqueValues(rows, r=>[r.TypeID, r.Level].join(':'))&&rows.every(r=>r.TypeID>0&&r.TypeID<10000&&r.Level>0&&r.Level<10000&&[0, 1].includes(r.Type??0)&&r.RewardID>0);
export const valid_offering_types=(rows, required, maxima)=>c.subset(required, new Set(rows.map(r=>r.ID)))&&c.uniqueValues(rows, r=>r.ID)&&rows.every(r=>r.ID>0&&r.ItemID>1000&&r.MaxLevel>0&&(!(r.ID in maxima)||r.MaxLevel===maxima[r.ID]));
export function season_contract(id, divisions, modules, activities){
const selected=modules.filter(r=>r.SeasonID===id).sort((a, b)=>a.SubSeasonID-b.SubSeasonID);
if(!selected.length)fail('requested season is absent');
const module=selected[0],
found=activities.filter(r=>moduleList(r).includes(module.ActivityModuleID));
if(found.length!==1||!found[0].ActivityPanelID)fail('season module has no unique activity panel');
const activity=found[0];
return {
season_id:id,
divisions:divisions.filter(r=>r.SeasonID===id).map(r=>r.ID),
activity_id:activity.ActivityID,
panel_id:activity.ActivityPanelID,
selection:'explicit season; first configured sub-season; not an inferred live schedule',
raw:{
selected_module:module,
selected_activity:activity,
divisions,
season_modules:modules,
activities:activities.filter(r=>modules.some(m=>moduleList(r).includes(m.ActivityModuleID)))
}
};
}
export const valid_trait_bases=(rows, required)=>c.subset(required, new Set(rows.map(r=>r.ID)))&&c.uniqueValues(rows, r=>r.ID)&&rows.every(r=>r.TraitName&&r.SeasonID>0&&(r.IconPath??'').startsWith('SpriteOutput/GridFight/'));
export const valid_trait_layers=(rows, required)=>c.subset(required, new Set(rows.map(r=>r.TraitID)))&&c.uniqueValues(rows, r=>[r.TraitID, r.Layer].join(':'))&&rows.every(r=>r.TraitID>0&&r.Layer>0&&r.Layer<100&&r.MazebuffID>=1000000);
export function text_matches(data, id){
const needle=encode_field(id, 'RPG.Client.TextID'),
found=[];
for(let at=data.indexOf(needle); at>=0; at=data.indexOf(needle, at+needle.length)){
if(!at||(data[at-1]&3)!==3||data[at-1]>=128)continue;
try{
const cursor=new Cursor(data, at+needle.length),
text=field(cursor, 'string');
if(text&&!/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(text))found.push({
offset:at-1,
end:cursor.at,
text
});
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof TypeError))throw e;
}
}
if(new Set(found.map(f=>f.text)).size!==1)fail('TextMap key missing or conflicting');
return found[0];
}
export function trait_names(client, rows, language){
const keys=dict(rows.map(r=>[r.ID, r.TraitName]));
for(const layer of ['Persistent', 'StreamingAssets']){
const found=[];
for(const p of c.globBytes(path.join(client, 'StarRail_Data', layer, 'DesignData/Windows', language))){
if(!fs.statSync(p).size)continue;
const data=fs.readFileSync(p);
let resolved;
try{
resolved=dict(Object.keys(keys).map(k=>[k, text_matches(data, keys[k])]));
} catch(e){
if(e instanceof c.DecodeError)continue;
throw e;
}
found.push([resolved, {
blob:path.relative(path.join(client, 'StarRail_Data'), p),
blob_sha256:sha256(data),
keys:dict(Object.keys(keys).map(k=>[k, {
text_id:keys[k],
...resolved[k]
}]))
}]);
}
if(found.length){
if(new Set(found.map(m=>c.json(m[0]))).size!==1)fail('ambiguous TextMap blobs');
return found[0];
}
}
fail('no complete installed '+language+' TextMap for traits');
}
export function main(){
const a=c.cli(['client', 'dump', 'output'], {
language:'en',
'evidence-dir':null,
'season-id':null
}, ['traits']);
if(!['en', 'cn', 'cht', 'jp', 'kr', 'de', 'es', 'fr', 'id', 'pt', 'ru', 'th', 'vi'].includes(a.language))fail('Invalid language');
const dump_bytes=fs.readFileSync(a.dump),
dump=c.readText(a.dump),
names=['GridFightRoleConfigRow', 'GridFightRarityWeightConfigRow'];
if(a.traits)names.push('AvatarPropertyValue', 'GridFightTraitBaseConfigRow', 'GridFightTraitLayerConfigRow');
if(a['season-id']!==null)names.push('GridFightDivisionConfigRow', 'GridFightSeasonModuleConfigRow', 'ActivityConfigRow', 'OfferingLevelConfigRow', 'OfferingTypeConfigRow');
const schemas=Object.fromEntries(names.map(n=>[n, schema(dump, path.join(a.client, 'GameAssembly.dll'), n, a['evidence-dir']??path.join(a.output, 'evidence'))])),
rt=find_table(a.client, schemas.GridFightRoleConfigRow, 'GridFightRoleConfigRow'),
wt=find_table(a.client, schemas.GridFightRarityWeightConfigRow, 'GridFightRarityWeightConfigRow', rt.blob),
tables={
[rt.table]:rt,
[wt.table]:wt
};
const roles=rt.rows.map(r=>({
id:r.ID,
cost:r.Rarity,
avatar_id:r.AvatarID,
traits:r.TraitList,
raw:r
})),
odds=dict(wt.rows.map(r=>[r.PlayerLevel, [1, 2, 3, 4, 5].map(i=>r[`Rarity${i}Weight`]??0)])),
outputs={
'roles.json':roles,
'shop_odds.json':odds
};
let text_evidence=null;
if(a.traits){
let required=new Set(roles.flatMap(r=>r.traits));
const base=find_keyed_table(a.client, rt.blob, schemas.GridFightTraitBaseConfigRow, 'GridFightTraitBaseConfigRow', Math.min(...required), r=>valid_trait_bases(r, required), schemas);
required=new Set(base.rows.map(r=>r.ID));
const layers=find_keyed_table(a.client, rt.blob, schemas.GridFightTraitLayerConfigRow, 'GridFightTraitLayerConfigRow', Math.min(...required), r=>valid_trait_layers(r, required), schemas);
const [resolved, proof]=trait_names(a.client, base.rows, a.language);
text_evidence=proof;
outputs['traits.json']=base.rows.map(r=>({
id:r.ID,
name:resolved[r.ID].text,
thresholds:layers.rows.filter(t=>t.TraitID===r.ID).map(t=>t.Layer).sort((a, b)=>a-b),
raw:{
base:r,
layers:layers.rows.filter(t=>t.TraitID===r.ID)
}
}));
outputs['trait_layers.json']=layers.rows;
tables[base.table]=base;
tables[layers.table]=layers;
}
if(a['season-id']!==null){
const seasons=new Set(roles.map(r=>r.raw.SeasonID)),
ui=find_ui_blob(a.client),
div=find_keyed_table(a.client, ui, schemas.GridFightDivisionConfigRow, 'GridFightDivisionConfigRow', 1, r=>valid_divisions(r, seasons), schemas, 0x163),
modules=find_keyed_table(a.client, ui, schemas.GridFightSeasonModuleConfigRow, 'GridFightSeasonModuleConfigRow', Math.min(...seasons), r=>valid_season_modules(r, seasons), schemas, 0x1f),
ids=new Set(modules.rows.map(r=>r.ActivityModuleID)),
activities=find_keyed_table(a.client, ui, schemas.ActivityConfigRow, 'ActivityConfigRow', null, r=>valid_activities(r, ids), schemas, 0xd);
outputs['season.json']=season_contract(Number(a['season-id']), div.rows, modules.rows, activities.rows);
for(const t of [div, modules, activities])tables[t.table]=t;
const offerings=new Set(modules.rows.map(r=>r.OfferingID)),
levels=find_keyed_table(a.client, ui, schemas.OfferingLevelConfigRow, 'OfferingLevelConfigRow', 1, r=>valid_offering_levels(r, offerings), schemas, 3),
normal=levels.rows,
maxima={
};
for(const r of normal)if(!r.Type)maxima[r.TypeID]=Math.max(maxima[r.TypeID]??0, r.Level);
const types=find_keyed_table(a.client, ui, schemas.OfferingTypeConfigRow, 'OfferingTypeConfigRow', 1, r=>valid_offering_types(r, offerings, maxima), schemas, 7),
selected=outputs['season.json'].raw.selected_module.OfferingID;
outputs['season.json'].raw.offering_type=types.rows.find(r=>r.ID===selected);
outputs['season.json'].raw.offering_levels=normal.filter(r=>r.TypeID===selected);
outputs['offering_levels.json']=normal;
outputs['offering_types.json']=types.rows;
tables[levels.table]=levels;
tables[types.table]=types;
}
for(const [n, v]of Object.entries(outputs))atomic_json(path.join(a.output, n), v);
atomic_json(path.join(a.output, 'schemas.json'), schemas);
const manifest={
schema:1,
source:'installed-client-files',
dump_sha256:sha256(dump_bytes),
game_assembly_sha256:sha256_file(path.join(a.client, 'GameAssembly.dll')),
outputs:Object.fromEntries(Object.keys(outputs).map(n=>[n, sha256_file(path.join(a.output, n))])),
tables:Object.values(tables).map(t=>Object.fromEntries(Object.entries(t).filter(([k])=>k!=='rows'))),
mapping:{
'roles.cost':'GridFightRoleConfigRow.Rarity (shop tier; not a currency price)',
'roles.raw.SeasonID':'Caller must select its active season; rows from multiple seasons are retained.',
'shop_odds':'GridFightRarityWeightConfigRow.Rarity1Weight..Rarity5Weight; missing weights are zero.'
}
};
if(text_evidence)manifest.trait_textmap=text_evidence;
atomic_json(path.join(a.output, 'provenance.json'), manifest);
console.log(`Validated ${roles.length} roles, ${Object.keys(odds).length} shop levels and ${Object.keys(tables).length} tables from installed client bytes.`);
}
c.runMain(import.meta.url, main);