122 lines
6.8 KiB
JavaScript
122 lines
6.8 KiB
JavaScript
// Read-only level-config audit. A table's JsonPath is a reference, not proof
|
|
// that the corresponding server-side config was shipped in the client.
|
|
import * as e from './extract.mjs';
|
|
import * as s from './stages.mjs';
|
|
import {table_bindings} from './rounds.mjs';
|
|
import {probeBundles} from './level_bundles.mjs';
|
|
|
|
export const ROOT_TYPES = [
|
|
'GridFightModifierListConfig', 'GridFightConditionListConfig',
|
|
'GridFightActionList', 'GridFightActionTriggerSpotLightSelectConfig',
|
|
'GridFightModifierEliteBranchExtraRewardConfig', 'GridFightModifierAddSpecialGoodsConfig'
|
|
];
|
|
export const INPUTS = ['catalog.raw.json', 'mechanics.json', 'rounds.raw.json', 'items.raw.json', 'petpal.raw.json', 'battle.json'];
|
|
|
|
export function references(inputs) {
|
|
const paths = new Map();
|
|
function visit(value, file, pointer) {
|
|
if (typeof value === 'string' && /^Config\/Level\/GridFight\/[^\0]+\.json$/.test(value)) {
|
|
if (!paths.has(value)) paths.set(value, []);
|
|
paths.get(value).push({file, pointer});
|
|
} else if (value && typeof value === 'object') {
|
|
for (const [key, child] of Object.entries(value)) visit(child, file, pointer + '/' + key.replaceAll('~', '~0').replaceAll('/', '~1'));
|
|
}
|
|
}
|
|
for (const [file, value] of Object.entries(inputs)) visit(value, file, '');
|
|
return [...paths].sort(([a], [b]) => a.localeCompare(b)).map(([path, sources]) => ({path, sources}));
|
|
}
|
|
|
|
export function candidates(path) {
|
|
if (!/^Config\/Level\/GridFight\/.+\.json$/.test(path) || path.split('/').includes('..')) e.fail('Invalid CW config path');
|
|
return [...new Set([path, path.replace(/\.json$/, '.bytes'), 'BakedConfig/' + path,
|
|
'BakedConfig/' + path.replace(/\.json$/, '.bytes'),
|
|
'BakedConfig/' + path.slice('Config/'.length).replace(/\.json$/, '.bytes')])];
|
|
}
|
|
|
|
export function locate(assets, path) {
|
|
const attempts = [];
|
|
for (const asset of candidates(path)) {
|
|
// Test the index directly: corruption/read errors must not become "missing".
|
|
if (!assets.layers.some(layer => layer.has(s.asset_hash(asset)))) { attempts.push(asset); continue; }
|
|
const [raw, proof] = assets.get(asset);
|
|
return {status: 'located', raw, proof, attempts};
|
|
}
|
|
return {status: 'not-in-designv', attempts};
|
|
}
|
|
|
|
export function decodeRecord(codec, raw, type) {
|
|
// Never synthesize an empty presence mask for a stripped native reader.
|
|
if (!codec.fields(type).size) e.fail('No serialized client fields: ' + type);
|
|
const cursor = new e.Cursor(raw), row = codec.record(cursor, type);
|
|
const encoded = codec.encode_record(row, type);
|
|
if (cursor.at !== raw.length || !encoded.equals(raw)) e.fail('Whole level asset round-trip failed: ' + type);
|
|
return {row, round_trip: true, bytes: raw.length, sha256: e.sha256(raw)};
|
|
}
|
|
|
|
export function nativeShape(codec, name) {
|
|
const type = 'RPG.GameCore.' + name;
|
|
const fields = [...codec.fields(type)].map(([offset, [name, type]]) => ({offset, name, type}));
|
|
const methods = codec.binary_methods(type).map(([name, rva]) => {
|
|
const [, bytes] = e.pe_method(codec.assembly, rva, 1);
|
|
const [, dis] = codec.disassemble(rva);
|
|
codec.proof(type.split('.').at(-1) + '_' + name, dis);
|
|
return {name, rva, immediate_ret: bytes[0] === 0xc3, disassembly_sha256: e.sha256(Buffer.from(dis))};
|
|
});
|
|
return {type, fields, methods, status: fields.length ? 'fields-present' : 'no-serialized-client-fields'};
|
|
}
|
|
|
|
export function main() {
|
|
const a = e.cli(['client', 'dump', 'identity', 'literals', 'resources', 'output']);
|
|
const ctx = s.extractionContext(a), inputs = {}, inputHashes = {};
|
|
for (const file of INPUTS) {
|
|
inputs[file] = e.loadJSON(e.path.join(a.resources, file));
|
|
inputHashes[file] = e.sha256_file(e.path.join(a.resources, file));
|
|
}
|
|
const literals = new Map(e.loadJSON(a.literals).map(r => [Number(BigInt(r.Address)), r.Value]));
|
|
const tableName = 'GridFightSpecialGoodsConfigRow', rows = [], tableProofs = [];
|
|
for (const asset of table_bindings(ctx.codec, literals, 'RPG.GameCore.' + tableName, null)) {
|
|
const match = asset.match(/^BakedConfig\/ExcelOutput(GameCore)?\/(\w+)\.bytes$/);
|
|
if (!match) e.fail('Unsupported SpecialGoods table path: ' + asset);
|
|
const [decoded, proof] = s.table(ctx.assets, ctx.codec, tableName, match[1] ?? '', match[2]);
|
|
rows.push(...decoded);
|
|
tableProofs.push(proof);
|
|
}
|
|
inputs['level-links.raw.json'] = {[tableName]: rows};
|
|
e.atomic_json(e.path.join(a.output, 'level-links.raw.json'), inputs['level-links.raw.json']);
|
|
const [manifestRaw, manifestProof] = ctx.assets.get('BakedConfig/ConfigManifest.json');
|
|
const manifest = JSON.parse(manifestRaw.toString('utf8'));
|
|
// Preserve the JSON wire frame separately; normalized JSON is not a byte-exact
|
|
// reserialization of arbitrary whitespace in the original manifest.
|
|
e.fs.mkdirSync(e.path.join(a.output, 'evidence'), {recursive: true});
|
|
e.fs.writeFileSync(e.path.join(a.output, 'evidence/ConfigManifest.json'), manifestRaw);
|
|
const manifestRefs = Object.entries(manifest).flatMap(([type, paths]) => paths.filter(p => p.startsWith('Config/Level/GridFight/')).map(path => ({type, path})));
|
|
const assets = references(inputs).map(ref => {
|
|
const result = locate(ctx.assets, ref.path);
|
|
return {...ref, status: result.status, attempts: result.attempts,
|
|
...(result.proof ? {proof: result.proof, bytes: result.raw.length} : {})};
|
|
});
|
|
const shapes = ROOT_TYPES.map(n => nativeShape(ctx.codec, n));
|
|
const bundles = probeBundles(a.client);
|
|
const output = {
|
|
schema: 1, status: 'blocked-no-decoded-rule-assets',
|
|
scope: 'DesignV indices and ConfigManifest; Unity bundle contents not enumerated',
|
|
decoded_rule_assets: [], rules: {encounter_rewards: null, spotlight_schedule: null, summon_unlocks: null},
|
|
manifest_gridfight_level_paths: manifestRefs, assets
|
|
};
|
|
e.atomic_json(e.path.join(a.output, 'level-availability.json'), output);
|
|
e.atomic_json(e.path.join(a.output, 'level-bundles.json'), bundles);
|
|
e.atomic_json(e.path.join(a.output, 'level-schemas.json'), {
|
|
tables: ctx.codec.schemas, client_roots: Object.fromEntries(shapes.map(shape => [shape.type, shape]))
|
|
});
|
|
e.atomic_json(e.path.join(a.output, 'level-provenance.json'), {
|
|
source: 'installed-client-files', dump_sha256: ctx.identity.dump_sha256,
|
|
game_assembly_sha256: ctx.identity.game_assembly_sha256,
|
|
catalogues: ctx.assets.catalogues, inputs: inputHashes, tables: tableProofs,
|
|
manifest: {...manifestProof, json_parse: true, byte_preserved: true},
|
|
rule_asset_round_trips: 0, gameplay_verified: false,
|
|
outputs: Object.fromEntries(['level-availability.json', 'level-schemas.json', 'level-links.raw.json', 'level-bundles.json'].map(file => [file, e.sha256_file(e.path.join(a.output, file))]))
|
|
});
|
|
console.log(JSON.stringify({references: assets.length, located: assets.filter(r => r.status === 'located').length, manifest_paths: manifestRefs.length, decoded_rules: 0}));
|
|
}
|
|
e.runMain(import.meta.url, main);
|