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