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);