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