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