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

290 lines
13 KiB
JavaScript

import * as e from './extract.mjs';
import {
NativeCodec,
indexed_entries,
asset_hash
} from './native_codec.mjs';
export {
e,
asset_hash
};
const {
fs,
path,
fail,
cat,
matches,
escapeRE,
sha256_file,
atomic_json,
sha256,
dict,
lookup,
need
} =e;
export class Codec extends NativeCodec {
constructor(d='', a='', ev=''){
super(d, a, ev, 'rounds');
}
}
export const TABLE_TYPES=['GridFightPlayerLevelConfigRow', 'GridFightLevelConfigRow', 'GridFightStageRouteConfigRow', 'GridFightNodeTemplateConfigRow', 'GridFightCampConfigRow', 'GridFightCampSeasonConfigRow', 'GridFightMonsterConfigRow', 'GridFightStageNodeDisplayConfigRow', 'GridFightTutorialStageConfigRow', 'GridFightTutorialStageNodeConfigRow', 'GridFightDivisionConfigRow', 'GridFightDivisionStageRow', 'GridFightSeasonTutorialConfigRow', 'GridFightStageInfoConfigRow', 'GridFightStageLevelValueConfigRow', 'GridFightFormationWaveConfigRow'];
export const CONSTANT_TABLES=new Set(['GridFightConstCommon', 'GridFightConstValueCommonV2', 'GridFightTraitConstValueS2']), STAGE_TABLES=new Set(['StageConfig', 'StageConfigLD', 'StageTestConfig']);
const CLIENT_FILES=['StarRail.exe', 'GameAssembly.dll', 'StarRail_Data/il2cpp_data/Metadata/global-metadata.dat', 'StarRail_Data/il2cpp_data/Metadata/startup-metadata.dat', 'StarRail_Data/StreamingAssets/BinaryVersion.bytes'];
export function client_identity(client){
const v=fs.readFileSync(path.join(client, CLIENT_FILES.at(-1))).toString('latin1').match(/(?:OS|CN)(?:PROD|REL|BETA)?Win\d+\.\d+\.\d+/);
if(!v)fail('Client version is not readable');
return {
version:v[0],
files:CLIENT_FILES.map(name=>({
path:name,
size:fs.statSync(path.join(client, name)).size,
sha256:sha256_file(path.join(client, name))
}))
};
}
export function build_index(client){
const index=new Map(),
catalogues=[];
for(const layer of ['StreamingAssets', 'Persistent']){
const folder=path.join(client, 'StarRail_Data', layer, 'DesignData/Windows'),
paths=e.globBytes(folder).filter(p=>path.basename(p).startsWith('DesignV_'));
if(paths.length!==1)fail('Expected one installed DesignV catalogue in '+layer);
const data=fs.readFileSync(paths[0]),
marker=fs.readFileSync(path.join(folder, 'M_DesignV.bytes')),
identifier=Buffer.from(path.basename(paths[0], '.bytes').slice(8), 'hex'),
mid=cat(...Array.from({
length:4
}, (_, i)=>Buffer.from(identifier.subarray(i*4, i*4+4)).reverse()));
if(!marker.subarray(0, 4).equals(Buffer.from('SRMI'))||!marker.includes(mid))fail('Catalogue is not the installed active DesignV');
for(const p of [paths[0], path.join(folder, 'M_DesignV.bytes')])catalogues.push({
path:path.relative(client, p),
sha256:sha256_file(p)
});
const li=new Map();
for(const blob of e.globBytes(folder)){
const stem=path.basename(blob, '.bytes');
if(!/^[0-9a-f]{32}$/.test(stem)||!data.includes(Buffer.from(stem, 'hex')))continue;
for(const entry of indexed_entries(data, stem, fs.statSync(blob).size)){
if(li.has(entry.hash))fail('Duplicate asset key within one DesignV layer');
li.set(entry.hash, {
...entry,
blob:path.relative(path.join(client, 'StarRail_Data'), blob)
});
}
}
for(const [k, v]of li)index.set(k, v);
}
return [index, catalogues];
}
export function table_bindings(codec, literals, typ, selected=null){
const wrappers=Object.entries(codec.blocks).filter(([, body])=>body.includes('string[]')&&new RegExp('Dictionary<(?:RPG.GameCore.CommonIndexKey|IndexKey), '+escapeRE(typ)+'>').test(body));
if(wrappers.length!==1)fail('Missing or ambiguous table wrapper for '+typ);
const [, body]=wrappers[0],
ctor=body.match(/private static void \.cctor\(\).*?RVA: (0x[\da-fA-F]+)/);
if(!ctor)fail('Table has no native path initializer');
const [, dis]=codec.disassemble(Number(ctor[1]));
codec.proof(typ.split('.').at(-1)+'_binding', dis);
const referenced=text=>new Set(matches(text, /# (0x[\da-f]+)/g).map(m=>literals.get(Number(BigInt(m[1])-0x180000000n))).filter(v=>v!==undefined));
const values=referenced(dis);
let paths=new Set([...values].filter(v=>v.startsWith('BakedConfig/')&&v.endsWith('.bytes')));
if(!paths.size){
const loader=body.match(/private static void (?:PKPJFCDOFML|_LoadData)\(string\).*?RVA: (0x[\da-fA-F]+)/);
if(!loader)fail('Cannot prove Excel path prefix');
const [, ld]=codec.disassemble(Number(loader[1]));
codec.proof(typ.split('.').at(-1)+'_loader', ld);
const prefixes=[...referenced(ld)].filter(v=>['BakedConfig/ExcelOutputGameCore/', 'BakedConfig/ExcelOutput/'].includes(v));
if(prefixes.length!==1)fail('Ambiguous native Excel path prefix');
paths=new Set([...values].filter(v=>/^\w+$/.test(v)).map(v=>prefixes[0]+v+'.bytes'));
}
if(selected!==null){
paths=new Set([...paths].filter(p=>selected.has(path.basename(p, '.bytes'))));
if(paths.size!==selected.size||!e.subset(selected, new Set([...paths].map(p=>path.basename(p, '.bytes')))))fail('Selected tables are not named by the native initializer');
} else if(paths.size!==1)fail('Ambiguous named table for '+typ);
return [...paths].sort();
}
export function read_table(client, index, codec, typ, p, blobs){
const key=asset_hash(p);
if(!index.has(key))fail('Named table is absent from installed DesignV: '+p);
const entry=index.get(key),
blob=entry.blob;
if(!blobs.has(blob)){
const d=fs.readFileSync(path.join(client, 'StarRail_Data', blob));
blobs.set(blob, [d, sha256(d)]);
}
const [data, bd]=blobs.get(blob),
raw=data.subarray(entry.offset, entry.offset+entry.length),
frame=typ==='RPG.GameCore.StageRow'?Buffer.alloc(0):Buffer.from([0]);
if(frame.length&&raw[0]!==0)fail('Excel collection frame changed');
const cursor=new e.Cursor(raw, frame.length),
count=cursor.length(100000),
rows=Array.from({
length:count
}, ()=>codec.record(cursor, typ)),
encoded=cat(frame, e.encode_varint(count*2), rows.map(r=>codec.encode_record(r, typ)));
if(cursor.at!==raw.length||!encoded.equals(raw))fail('Incomplete or non-round-tripping table: '+p);
return [rows, {
type:typ,
asset_path:p,
asset_hash:key,
...entry,
records:count,
table_sha256:sha256(raw),
blob_sha256:bd,
byte_round_trip:true
}];
}
export const unique=(rows, key)=>lookup(rows, r=>{
const k=key(r);
return Array.isArray(k)?JSON.stringify(k):k;
}, 'native table keys');
export function project(t, text, types){
const routes=t.GridFightStageRouteConfigRow;
unique(routes, r=>[r.ID, r.ChapterID, r.SectionID]);
const nodes=unique(t.GridFightNodeTemplateConfigRow, r=>r.NodeTemplateID),
displays=unique(t.GridFightStageNodeDisplayConfigRow, r=>r.NodeType),
stages=unique(t.StageRow, r=>r.StageID),
rounds=[];
for(const route of routes){
const node=need(nodes, route.NodeTemplateID),
display=need(displays, node.NodeType),
stage=need(stages, node.StageID),
native=types[node.NodeType];
if(native===undefined||!text[node.NodeType])fail('Missing node type/text');
rounds.push({
stage:`${route.ChapterID}-${route.SectionID}`,
kind:({
Monster:'battle',
CampMonster:'battle',
EliteBranch:'elite',
Boss:'elite'
})[native]??null,
monster_group:null,
route_id:route.ID,
chapter_id:route.ChapterID,
section_id:route.SectionID,
node_template_id:route.NodeTemplateID,
stage_id:node.StageID,
node_type:native,
node_name:text[node.NodeType].text,
raw:{
route,
node_template:node,
node_display:display,
stage
}
});
}
const levels=unique(t.GridFightPlayerLevelConfigRow, r=>r.PlayerLevel);
if([...levels.keys()].sort((a, b)=>a-b).some((k, i)=>k!==i+1))fail('Player level sequence is incomplete');
return [rounds, dict([...levels].map(([k, r])=>[k, r.LevelUpExp??0]))];
}
export function extract(a){
const identity=client_identity(a.client),
expected=e.loadJSON(a.identity);
if(e.json(expected.client)!==e.json(identity)||expected.metadata_sha256!==sha256_file(a.dump))fail('Installed client and cached metadata identity do not match');
const dump=e.readText(a.dump),
literals=new Map(e.loadJSON(a.literals).map(r=>[Number(BigInt(r.Address)), r.Value])),
codec=new Codec(dump, path.join(a.client, 'GameAssembly.dll'), path.join(a.output, 'evidence'));
const es=e.schema(dump, path.join(a.client, 'GameAssembly.dll'), 'GridFightNodeTemplateConfigRow', path.join(a.output, 'evidence'));
if(e.json(es)!==e.json(codec.schema('RPG.GameCore.GridFightNodeTemplateConfigRow')))fail('Native enum schema disagrees with the route codec');
const ctor=codec.blocks['RPG.GameCore.GridFightPlayerLevelConfigRow'].match(/public void \.ctor\(\).*?RVA: (0x[\da-fA-F]+)/);
if(!ctor)fail('Missing player-level constructor');
const [, cd]=codec.disassemble(Number(ctor[1]));
codec.proof('GridFightPlayerLevelConfigRow_ctor', cd);
if(!e.pe_method(codec.assembly, Number(ctor[1]), 1)[1].equals(Buffer.from([0xc3])))fail('Player-level defaults require native constructor review');
codec.verify_dynamic_reader();
const [index, catalogues]=build_index(a.client),
tables={
},
collections=[],
blobs=new Map();
for(const name of TABLE_TYPES){
const typ='RPG.GameCore.'+name,
[p]=table_bindings(codec, literals, typ),
[rows, proof]=read_table(a.client, index, codec, typ, p, blobs);
tables[name]=rows;
collections.push(proof);
}
for(const [typ, selected]of [['RPG.GameCore.ConstValueCommonRow', CONSTANT_TABLES], ['RPG.GameCore.StageRow', STAGE_TABLES]])for(const p of table_bindings(codec, literals, typ, selected)){
let [rows, proof]=read_table(a.client, index, codec, typ, p, blobs);
if(typ.endsWith('StageRow')){
const required=new Set(tables.GridFightNodeTemplateConfigRow.map(r=>r.StageID));
rows=rows.filter(r=>required.has(r.StageID));
proof.retained_records=rows.length;
(tables.StageRow??=[]).push(...rows);
} else tables[path.basename(p, '.bytes')]=rows;
collections.push(proof);
}
const types=Object.fromEntries(matches(codec.blocks['RPG.GameCore.GridFightNodeType'], /public const RPG.GameCore.GridFightNodeType (\w+) = (-?\d+)/g).map(m=>[m[2], m[1]])),
[text, textProof]=e.trait_names(a.client, tables.GridFightStageNodeDisplayConfigRow.map(r=>({
ID:r.NodeType,
TraitName:r.NodeName
})), a.language),
[rounds, levels]=project(tables, text, types),
constants=unique(tables.GridFightConstCommon, r=>r.ConstValueName),
first=need(constants, 'GridFight_FirstPlay_StageRouteID').Value;
if(first.tag!==0||!rounds.some(r=>r.route_id===first.value))fail('First-play route constant has no complete route');
const tutorial={
stages:tables.GridFightTutorialStageConfigRow.map(row=>({
division_id:row.DivisionID,
raw:row,
node_configs:tables.GridFightTutorialStageNodeConfigRow.filter(n=>n.DivisionID===row.DivisionID)
})),
first_play_route_id:first.value,
raw:{
season_tutorial:tables.GridFightSeasonTutorialConfigRow,
constants:tables.GridFightConstCommon.filter(r=>r.ConstValueName.includes('Tutorial')||r.ConstValueName.includes('FirstPlay'))
}
},
levelRaw=Object.fromEntries(['GridFightPlayerLevelConfigRow', 'GridFightLevelConfigRow'].map(n=>[n, tables[n]])),
documents={
'rounds.json':rounds,
'tutorial.json':tutorial,
'levels.json':levels,
'rounds_raw.json':{
raw:Object.fromEntries(Object.entries(tables).filter(([k])=>!(k in levelRaw)))
},
'levels_raw.json':{
raw:levelRaw
}
},
unknown={
monster_group:'No group mapping is proved. ParamList entries do not identify CampConfig.ID or MonsterConfig.MonsterID; every node StageRow.MonsterList is empty.',
kind:'Supply stays null: no native evidence maps all Supply nodes to bonfire or shop.',
tutorial_pairs:['JKFAMDHMLBC.GAJICDKPHPA', 'JKFAMDHMLBC.BDCOFIOCJFO', 'AOBAIBBHDEL.EEBLMLAEFHO', 'AOBAIBBHDEL.IMDPJFEGIDF'],
tutorial_route:'FirstPlay route 803 is a named client constant. No binding is inferred between tutorial DivisionID 1/2, StartGamePlay 1,1 and routes 801/802.',
level_exp_zero:'Levels 10/11 omit LevelUpExp (zero default); the consumer must block free/infinite upgrades at terminal levels.'
};
for(const p of catalogues)if(sha256_file(path.join(a.client, p.path))!==p.sha256)fail('DesignV changed during extraction');
for(const [blob, [, digest]]of blobs)if(sha256_file(path.join(a.client, 'StarRail_Data', blob))!==digest)fail('DesignData changed during extraction');
if(e.json(client_identity(a.client))!==e.json(identity))fail('Installed client changed during extraction');
for(const [n, v]of Object.entries(documents))atomic_json(path.join(a.output, n), v);
const provenance={
source:'installed-client-only',
client:identity,
metadata_sha256:sha256_file(a.dump),
string_literals_sha256:sha256_file(a.literals),
extractor_sha256:sha256_file(new URL(import.meta.url)),
catalogues,
collections,
node_textmap:textProof,
counts:Object.fromEntries(Object.entries(tables).map(([k, v])=>[k, v.length])),
outputs:Object.fromEntries(Object.keys(documents).map(n=>[n, sha256_file(path.join(a.output, n))])),
unknown,
gameplay_verified:false
};
atomic_json(path.join(a.output, 'provenance.json'), provenance);
atomic_json(path.join(a.output, 'schemas.json'), codec.schemas);
console.log(e.json({
counts:provenance.counts,
outputs:provenance.outputs,
unknown
}));
}
export function main(){
extract(e.cli(['client', 'dump', 'literals', 'identity', 'output'], {
language:'en'
}));
}
e.runMain(import.meta.url, main);