Hello World
This commit is contained in:
commit
7d342af29f
124 files changed
+627642
No files matched your search
@@ -0,0 +1,248 @@
|
||||
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);
|
||||
Reference in new issue
Block a user