290 lines
13 KiB
JavaScript
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);
|