248 lines
9.3 KiB
JavaScript
248 lines
9.3 KiB
JavaScript
import * as primitive from './extract.mjs';
|
|||
|
|
import {
|
||
|
|
NativeCodec,
|
||
|
|
indexed_entries,
|
||
|
|
asset_hash
|
||
|
|
} from './native_codec.mjs';
|
||
|
|
export {
|
||
|
|
primitive,
|
||
|
|
asset_hash
|
||
|
|
};
|
||
|
|
const {
|
||
|
|
fs,
|
||
|
|
path,
|
||
|
|
fail,
|
||
|
|
sha256_file,
|
||
|
|
sha256,
|
||
|
|
atomic_json,
|
||
|
|
cat,
|
||
|
|
dict,
|
||
|
|
lookup,
|
||
|
|
need
|
||
|
|
} =primitive;
|
||
|
|
export class Codec extends NativeCodec {
|
||
|
|
constructor(d='', a='', ev=''){
|
||
|
|
super(d, a, ev, 'stages');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
export const TABLES={
|
||
|
|
GridFightDivisionStageRow:['GameCore', 'GridFightDivisionStage'],
|
||
|
|
GridFightStageRouteConfigRow:['', 'GridFightStageRoute'],
|
||
|
|
GridFightNodeTemplateConfigRow:['', 'GridFightNodeTemplate'],
|
||
|
|
GridFightCampConfigRow:['', 'GridFightCamp'],
|
||
|
|
GridFightCampSeasonConfigRow:['', 'GridFightCampSeason'],
|
||
|
|
GridFightMonsterConfigRow:['GameCore', 'GridFightMonster'],
|
||
|
|
GridFightTutorialStageConfigRow:['GameCore', 'GridFightTutorialStage'],
|
||
|
|
GridFightTutorialStageNodeConfigRow:['GameCore', 'GridFightTutorialStageNode'],
|
||
|
|
GridFightPlayerLevelConfigRow:['GameCore', 'GridFightPlayerLevel'],
|
||
|
|
GridFightLevelConfigRow:['GameCore', 'GridFightLevelV2'],
|
||
|
|
GridFightStageInfoConfigRow:['GameCore', 'GridFightStage'],
|
||
|
|
GridFightStageNodeDisplayConfigRow:['GameCore', 'GridFightNodeTypeShow'],
|
||
|
|
StageRow:['GameCore', 'StageConfig'],
|
||
|
|
ConstValueCommonRow:['GameCore', 'GridFightConstCommon']
|
||
|
|
};
|
||
|
|
export class Assets {
|
||
|
|
constructor(client){
|
||
|
|
this.client=client;
|
||
|
|
this.layers=[];
|
||
|
|
this.catalogues=[];
|
||
|
|
this.blob_hashes={
|
||
|
|
};
|
||
|
|
for(const layer of ['Persistent', 'StreamingAssets']){
|
||
|
|
const dir=path.join(client, 'StarRail_Data', layer, 'DesignData/Windows'),
|
||
|
|
cats=primitive.globBytes(dir).filter(p=>path.basename(p).startsWith('DesignV_'));
|
||
|
|
if(cats.length!==1)fail('Expected exactly one DesignV in '+layer);
|
||
|
|
const data=fs.readFileSync(cats[0]),
|
||
|
|
entries=new Map();
|
||
|
|
for(const blob of primitive.globBytes(dir)){
|
||
|
|
const stem=path.basename(blob, '.bytes');
|
||
|
|
if(!/^[a-fA-F0-9]{32}$/.test(stem))continue;
|
||
|
|
for(const entry of indexed_entries(data, stem, fs.statSync(blob).size)){
|
||
|
|
if(entries.has(entry.hash))fail('Duplicate asset key');
|
||
|
|
entries.set(entry.hash, {
|
||
|
|
...entry,
|
||
|
|
blob:path.relative(client, blob)
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.layers.push(entries);
|
||
|
|
this.catalogues.push({
|
||
|
|
path:path.relative(client, cats[0]),
|
||
|
|
sha256:sha256_file(cats[0])
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
get(p){
|
||
|
|
const key=asset_hash(p);
|
||
|
|
for(const entries of this.layers)if(entries.has(key)){
|
||
|
|
const entry=entries.get(key),
|
||
|
|
blob=path.join(this.client, entry.blob),
|
||
|
|
fd=fs.openSync(blob, 'r'),
|
||
|
|
data=Buffer.alloc(entry.length);
|
||
|
|
try{
|
||
|
|
const n=fs.readSync(fd, data, 0, data.length, entry.offset);
|
||
|
|
if(n!==entry.length)fail('Truncated indexed asset');
|
||
|
|
} finally{
|
||
|
|
fs.closeSync(fd);
|
||
|
|
}
|
||
|
|
this.blob_hashes[entry.blob]??=sha256_file(blob);
|
||
|
|
return [data, {
|
||
|
|
asset:p,
|
||
|
|
...entry,
|
||
|
|
blob_sha256:this.blob_hashes[entry.blob],
|
||
|
|
asset_sha256:sha256(data)
|
||
|
|
}];
|
||
|
|
}
|
||
|
|
fail('Missing indexed asset: '+p);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
export function table(assets, codec, name, suffix, stem){
|
||
|
|
const [raw, proof]=assets.get(`BakedConfig/ExcelOutput${suffix}/${stem}.bytes`),
|
||
|
|
prefix=name==='StageRow'?Buffer.alloc(0):Buffer.from([0]);
|
||
|
|
if(prefix.length&&raw[0]!==0)fail('Unsupported Excel frame: '+name);
|
||
|
|
const cur=new primitive.Cursor(raw, prefix.length),
|
||
|
|
count=cur.length(1000000),
|
||
|
|
typ='RPG.GameCore.'+name,
|
||
|
|
rows=Array.from({
|
||
|
|
length:count
|
||
|
|
}, ()=>codec.record(cur, typ)),
|
||
|
|
encoded=cat(prefix, primitive.encode_varint(count*2), rows.map(r=>codec.encode_record(r, typ)));
|
||
|
|
if(cur.at!==raw.length||!encoded.equals(raw))fail('Whole indexed table round-trip failed: '+name);
|
||
|
|
return [rows, {
|
||
|
|
table:name,
|
||
|
|
records:count,
|
||
|
|
round_trip:true,
|
||
|
|
...proof
|
||
|
|
}];
|
||
|
|
}
|
||
|
|
export function contracts(t){
|
||
|
|
const routes=t.GridFightStageRouteConfigRow,
|
||
|
|
nodes=new Map(t.GridFightNodeTemplateConfigRow.map(r=>[r.NodeTemplateID, r])),
|
||
|
|
stages=new Map(t.StageRow.map(r=>[r.StageID, r])),
|
||
|
|
displays=new Map(t.GridFightStageNodeDisplayConfigRow.map(r=>[r.NodeType, r]));
|
||
|
|
if(nodes.size!==t.GridFightNodeTemplateConfigRow.length)fail('Duplicate node template');
|
||
|
|
if(!primitive.uniqueValues(routes, r=>[r.ID, r.ChapterID, r.SectionID].join(':')))fail('Duplicate route/chapter/section');
|
||
|
|
const kinds={
|
||
|
|
1:'battle',
|
||
|
|
2:'battle',
|
||
|
|
3:'elite',
|
||
|
|
4:'elite',
|
||
|
|
5:'shop'
|
||
|
|
},
|
||
|
|
rounds=routes.map(route=>{
|
||
|
|
const node=need(nodes, route.NodeTemplateID),
|
||
|
|
stage=need(stages, node.StageID),
|
||
|
|
display=need(displays, node.NodeType);
|
||
|
|
if(!(node.NodeType in kinds))fail('Unknown native node type');
|
||
|
|
return {
|
||
|
|
stage:`${route.ChapterID}-${route.SectionID}`,
|
||
|
|
kind:kinds[node.NodeType],
|
||
|
|
monster_group:null,
|
||
|
|
route_id:route.ID,
|
||
|
|
chapter_id:route.ChapterID,
|
||
|
|
section_id:route.SectionID,
|
||
|
|
node_template_id:node.NodeTemplateID,
|
||
|
|
stage_id:node.StageID,
|
||
|
|
node_type:node.NodeType,
|
||
|
|
node_name:display.NodeName,
|
||
|
|
raw:{
|
||
|
|
GridFightStageRouteConfigRow:route,
|
||
|
|
GridFightNodeTemplateConfigRow:node,
|
||
|
|
StageRow:stage,
|
||
|
|
GridFightStageNodeDisplayConfigRow:display
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
const lr=t.GridFightPlayerLevelConfigRow,
|
||
|
|
levels=dict(lr.map(r=>[r.PlayerLevel, r.LevelUpExp??0]));
|
||
|
|
if(Object.keys(levels).map(Number).sort((a, b)=>a-b).some((k, i)=>k!==i+1)||Object.keys(levels).length!==lr.length)fail('Non-contiguous player level table');
|
||
|
|
const tutorialRows=t.GridFightTutorialStageConfigRow,
|
||
|
|
tutorialNodes=t.GridFightTutorialStageNodeConfigRow,
|
||
|
|
tutorial={
|
||
|
|
stages:tutorialRows.map(r=>({
|
||
|
|
division_id:r.DivisionID,
|
||
|
|
raw:{
|
||
|
|
GridFightTutorialStageConfigRow:r,
|
||
|
|
GridFightTutorialStageNodeConfigRow:tutorialNodes.filter(n=>n.DivisionID===r.DivisionID)
|
||
|
|
}
|
||
|
|
})),
|
||
|
|
raw:{
|
||
|
|
GridFightDivisionStageRow:t.GridFightDivisionStageRow.filter(r=>tutorialRows.some(s=>s.DivisionID===r.DivisionID))
|
||
|
|
},
|
||
|
|
observed_start_gameplay:{
|
||
|
|
values:[1, 1],
|
||
|
|
source:'user-provided client observation',
|
||
|
|
meaning:'Do not equate these values to route/chapter/section without protocol evidence.'
|
||
|
|
}
|
||
|
|
};
|
||
|
|
if(t.ConstValueCommonRow){
|
||
|
|
const constants=Object.fromEntries(t.ConstValueCommonRow.map(r=>[r.ConstValueName, r.Value]));
|
||
|
|
tutorial.raw.GridFightConstCommon=t.ConstValueCommonRow;
|
||
|
|
const first=constants.GridFight_FirstPlay_StageRouteID;
|
||
|
|
if(!first||first.wire_tag!==0)fail('Missing integer first-play route constant');
|
||
|
|
tutorial.first_play_route_id=first.value;
|
||
|
|
}
|
||
|
|
const shared=Object.fromEntries(Object.entries(t).filter(([n])=>n!=='StageRow'));
|
||
|
|
const ids=new Set([...nodes.values()].map(n=>n.StageID));
|
||
|
|
shared.StageRow=t.StageRow.filter(r=>ids.has(r.StageID));
|
||
|
|
return {
|
||
|
|
'rounds.json':rounds,
|
||
|
|
'tutorial.json':tutorial,
|
||
|
|
'levels.json':levels,
|
||
|
|
'rounds.raw.json':shared,
|
||
|
|
'levels.raw.json':lr
|
||
|
|
};
|
||
|
|
}
|
||
|
|
export function extractionContext(a){
|
||
|
|
const identity=primitive.loadJSON(a.identity);
|
||
|
|
if(sha256_file(a.dump)!==identity.dump_sha256||sha256_file(path.join(a.client, 'GameAssembly.dll'))!==identity.game_assembly_sha256)fail('Metadata or installed assembly does not match identity manifest');
|
||
|
|
return {
|
||
|
|
identity,
|
||
|
|
codec:new Codec(primitive.readText(a.dump), path.join(a.client, 'GameAssembly.dll'), path.join(a.output, 'evidence')),
|
||
|
|
assets:new Assets(a.client)
|
||
|
|
};
|
||
|
|
}
|
||
|
|
export function collect(a, TABLES, ctx){
|
||
|
|
const t={
|
||
|
|
},
|
||
|
|
proofs=[];
|
||
|
|
for(const [name, [suffix, stem]]of Object.entries(TABLES)){
|
||
|
|
const [rows, proof]=table(ctx.assets, ctx.codec, name, suffix, stem);
|
||
|
|
t[name]=rows;
|
||
|
|
proofs.push(proof);
|
||
|
|
console.log(`${name}: ${rows.length} whole-asset rows`);
|
||
|
|
}
|
||
|
|
return [t, proofs];
|
||
|
|
}
|
||
|
|
export function publish(a, ctx, t, proofs, outputs, prefix, extra){
|
||
|
|
for(const [n, v]of Object.entries(outputs))atomic_json(path.join(a.output, n), v);
|
||
|
|
atomic_json(path.join(a.output, prefix+'-schemas.json'), ctx.codec.schemas);
|
||
|
|
atomic_json(path.join(a.output, prefix+'-provenance.json'), {
|
||
|
|
source:'installed-client-files',
|
||
|
|
dump_sha256:ctx.identity.dump_sha256,
|
||
|
|
game_assembly_sha256:ctx.identity.game_assembly_sha256,
|
||
|
|
catalogues:ctx.assets.catalogues,
|
||
|
|
tables:proofs,
|
||
|
|
outputs:Object.fromEntries(Object.keys(outputs).map(n=>[n, sha256_file(path.join(a.output, n))])),
|
||
|
|
...extra
|
||
|
|
});
|
||
|
|
}
|
||
|
|
export function main(){
|
||
|
|
const a=primitive.cli(['client', 'dump', 'identity', 'output']),
|
||
|
|
ctx=extractionContext(a),
|
||
|
|
reader=ctx.codec.verify_dynamic_reader();
|
||
|
|
const [t, proofs]=collect(a, TABLES, ctx);
|
||
|
|
publish(a, ctx, t, proofs, contracts(t), 'stage', {
|
||
|
|
dynamic_reader:reader,
|
||
|
|
mapping:{
|
||
|
|
levels:'GridFightPlayerLevelConfigRow.LevelUpExp; absent scalar is zero',
|
||
|
|
'rounds.stage':'ChapterID-SectionID; route_id is required to disambiguate',
|
||
|
|
'rounds.kind':'Monster/CampMonster=battle; EliteBranch/Boss=elite; Supply=shop (shop icon)',
|
||
|
|
'rounds.node_name':'Native TextID pair from GridFightNodeTypeShow; client may override via plugins',
|
||
|
|
'rounds.monster_group':'null: ParamList is not proved to be one monster-group ID'
|
||
|
|
},
|
||
|
|
unresolved:['NodeTemplate.ParamList semantics and dynamic camp/branch selection', 'Tutorial JsonPath modifier assets and exact StartGamePlay(1,1) selector binding', 'JKFAMDHMLBC.GAJICDKPHPA/BDCOFIOCJFO and AOBAIBBHDEL.EEBLMLAEFHO/IMDPJFEGIDF', 'StageConfigData.BFLIFKBEOPJ/MNDFOPKBHKP; kept with native names'],
|
||
|
|
gameplay_verified:false
|
||
|
|
});
|
||
|
|
}
|
||
|
|
primitive.runMain(import.meta.url, main);
|