Hello World

This commit is contained in:
xa o committed 2026-10-30 23:54:07 +07:00
commit 7d342af29f
124 files changed
+627642

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
# GridFight extractor (Node ESM)
Faithful port of the sibling extractor. Node 22+ and built-in modules only;
validated with Node 26.12.0. Native reader proofs require GNU `objdump`.
Linux installers use `flock`. XXH64 is implemented locally: no FFI, libxxhash,
npm install, metadata dumper, network fetch or game launch is required.
Run from the repository root:
```sh
node tools/gridfight-extract-node/extract.mjs \
--client /path/to/client \
--dump /path/to/config/dump.cs \
--output /tmp/gridfight-core \
--traits --season-id 2 \
--evidence-dir /tmp/gridfight-core/evidence
node --test tools/gridfight-extract-node
```
The dump must contain `RPG.GameCore.Config.dll`; traits also need
`RPG.GameCore.Foundation.dll` and `RPG.GameCore.FixedMath.dll`. Use the matching
`config/dump.cs`, rather than assuming the top-level dump contains those rows.
Treat client files as read-only; place outputs and evidence outside the client.
`extract.mjs` retains `--language` (default `en`, choices `en cn cht jp kr de es
fr id pt ru th vi`), `--traits`, `--season-id`, and `--evidence-dir`. It publishes
roles, shop odds, traits/layers, season/divisions, offerings, schemas and
provenance with the same filenames and JSON contracts as the reference.
Season selection is explicit; it does not establish a live calendar.
Native mask tests and metadata establish field order. Every native indexed
collection must decode completely and re-encode to its exact original bytes.
Unknown types, absent/ambiguous assets, conflicting text and invalid references
fail. Required data documents publish only after the requested validations
succeed. Evidence may remain after failure. Each replacement is atomic; use a
staging directory for bundle-level installation. Large integers retain all 64
bits in JSON. Arrays, numeric dictionary ordering, indentation and Unicode
escaping retain the reference output representation.
Other entrypoints preserve their original flags (`.mjs` replaces `.py`):
| Entrypoint | Required flags | Optional flags/defaults |
|---|---|---|
| `stages.mjs` | `--client --dump --identity --output` | None |
| `items.mjs` | `--client --dump --identity --output` | None |
| `petpal.mjs` | `--client --dump --identity --output` | None |
| `rounds.mjs` | `--client --dump --literals --identity --output` | `--language en` |
| `trait_effects.mjs` | `--client` | `--output resources/grid_fight/trait_effects.json` |
| `trailblaze.mjs` | `--client` | `--output resources/grid_fight/trailblaze.json` |
| `jade.mjs` | `--client` | `--output resources/grid_fight/jade.json` |
| `equipment.mjs` | None | `--items`, `--catalog`, `--season 2`, `--output` |
| `portals.mjs` | None | `--items`, `--catalog`, `--season 2`, `--output`, `--augments` |
| `score.mjs` | None | `--catalog`, `--output` |
| `ui_names.mjs` | `--client --dump --resources --english --thai --output --evidence` | None |
| `native_slice.mjs` | `--client --dump --class --output` | Repeat `--class` |
| `audit_coverage.mjs` | `--dump --output` | `--source` defaults to repository root |
| `audit_battle_entry.mjs` | `--client --dump --assembly --roles --evidence` | None |
| `install_stages.mjs` | `--source --launcher --stage --report` | `--package stage` or `petpal` |
| `install_rounds.mjs` | `--server-root --launcher-root --stage` | None |
| `run_tests.mjs` | `--output` | `--zig zig` (requires 0.14.1 and cached protobuf) |
Default derived-table inputs and outputs remain repository-relative:
`resources/grid_fight/items.raw.json`, the matching basename's JSON, and
`protocol/evidence/cw-mechanics/catalog.json`. Portals also writes
`resources/grid_fight/augments.json`. Pass scratch output paths for comparison.
For example:
```sh
node tools/gridfight-extract-node/items.mjs \
--client /path/to/client --dump /path/to/config/dump.cs \
--identity /tmp/gridfight-core/provenance.json --output /tmp/gridfight-items
node tools/gridfight-extract-node/equipment.mjs \
--items /tmp/gridfight-items/items.raw.json --season 2 \
--catalog /path/to/matching/catalog.json --output /tmp/gridfight-equipment.json
```
`stages`, `items` and `petpal` consume the core extractor's identity manifest.
`rounds` consumes the client-table metadata identity (`client` and
`metadata_sha256`) and matching string literals. Its underscore raw filenames,
Supply `null`, and tagged DynamicValue contract intentionally differ from
`stages` (dot raw filenames, Supply `shop`, and `wire_tag`). Neither guesses
monster groups or tutorial selector meanings.
Installers retain identity/hash checks, a nonblocking runtime-refresh lock,
verified backups, atomic replacements, manifest refresh, protected-file checks
and rollback. They use a local Node port of the runtime verification helpers.
`install_rounds` stores its audit evidence in this new directory, preserving
the sibling reference directory. No installer was run against real resources;
installation was exercised only in disposable fixtures.
Full season-2 staging, including a fresh native catalog for equipment/score and a
file-by-file installed-data comparison:
```sh
node tools/gridfight-extract-node/stage_all.mjs \
--client /path/to/client --dump /path/to/config/dump.cs \
--literals /path/to/stringliterals.json \
--english /path/to/active/en/TextMap.bytes \
--thai /path/to/active/th/TextMap.bytes \
--resources /path/to/server/resources \
--baseline /path/to/server/resources/grid_fight \
--output /tmp/cw-stage
```
The output must be outside the client and installed resources. All flags are
required; no derived entrypoint falls back to the older repository catalog in
this workflow. The active language blobs must match the client. Logs, native
proofs, CHANGES.md, comparison.json and a hashed bundle manifest accompany the
runtime JSON. Root rounds use the existing stages contract; the alternate
rounds extractor's distinct contract is retained under rounds-alternative/.
Server-authored node_profiles/orb_rewards are carried byte-identically, marked
as third-party observations rather than freshly extracted client data. Old
backup files are compared in the report but excluded from the runtime bundle.
AvatarConfig/AeonCatalog provide existing UI identity/name lookup hints; UI text
is resolved from the supplied client TextMaps. Staging does not install data.
Validation on OSBETAWin4.6.53, season 2:
- 51 tests pass, including native 4.6.53 dispatch mutation rejection, relocated
4.6.51 layout, nested nulls, signed 64-bit limits, NaN payloads, dictionary
doubles and the Phainon schema's stack-write regression.
- DynamicValue FromByteBinary resolves from metadata (4.6.53 RVA 0x1e745df0;
4.6.51 RVA 0x1e58e120). Both use case offsets 0x85/0xc5/0x102/0x14f/0x18a/0x1cf
and dispatch offset 0x2b0. Typed case operations and dispatch bindings must
match before evidence is published. The wire layout is unchanged; both
projection modes support null tag 6. Integer and float bytes stay lossless.
- Stages: all 14 tables, including 29,715 StageConfig rows, re-encode exactly.
Fresh supplemental catalog: all 20 additional tables re-encode exactly,
including all nine Phainon fields (stack addresses are excluded from native
field candidates). Both stage and alternate-rounds extraction complete.
- 141 roles, 11 shop levels, 67 traits, 304 season-2 augments, 74 season-2 portals,
147 season-2 equipment entries, 493 routes, 12 partners. Trial-avatar audit:
5,200 rows re-encoded exactly and all 141 role identities verified.
- ui_names also exports item/augment/portal/trait hash maps for ID comparisons;
the current run resolves 1,816 English and Thai texts.
- No client data, installed resources, reference Python code, archives or live
processes are changed. No gameplay-correctness claim is made.
Use `node --test tools/gridfight-extract-node`. On hosts whose default test
isolation reports only a file-level result, add `--test-isolation=none` to see
all 51 individual results. Native proof fixtures are static objdump excerpts
from the matching beta DLL; method offsets come from config/dump.cs.
@@ -0,0 +1,52 @@
import * as s from './stages.mjs';
const e=s.primitive;
export function audit(client, dump, assembly, roles, evidence){
const codec=new s.Codec(e.readText(dump), assembly, e.path.join(evidence, 'native')),
assets=new s.Assets(client),
[raw, proof]=assets.get('BakedConfig/ExcelOutputGameCore/SpecialAvatar.bytes'),
cur=new e.Cursor(raw),
count=cur.length(1000000),
typ='RPG.GameCore.SpecialAvatarRow',
schema=codec.schema(typ);
if(schema.length!==codec.fields(typ).size)e.fail('Incomplete SpecialAvatar native schema');
const rows=Array.from({
length:count
}, ()=>codec.record(cur, typ)),
encoded=e.cat(e.encode_varint(2*count), rows.map(r=>codec.encode_record(r, typ)));
if(cur.at!==raw.length||!encoded.equals(raw))e.fail('Whole SpecialAvatar table failed native round trip');
const index=e.lookup(rows, r=>[r.SpecialAvatarID??0, r.WorldLevel??0].join(':'), 'SpecialAvatar identity'),
verified=[];
for(const role of e.loadJSON(roles)){
const trial=role.raw.SpecialAvatarID??0,
row=index.get(trial+':6');
if(!trial||!row||(row.AvatarID??0)!==role.avatar_id||(row.Type??0)!==1)e.fail('Role '+role.id+' cannot resolve as a world-level-6 trial');
verified.push({
role_id:role.id,
season_id:role.raw.SeasonID,
trial_id:trial,
avatar_id:row.AvatarID,
world_level:6
});
}
const report={
round_trip:true,
special_avatar_rows:count,
roles_checked:verified.length,
gameplay_verified:false,
assembly_sha256:e.sha256_file(assembly),
dump_sha256:e.sha256_file(dump),
roles_sha256:e.sha256_file(roles),
table:proof,
catalogues:assets.catalogues,
schema,
roles:verified
};
e.atomic_json(e.path.join(evidence, 'trial-avatar-audit.json'), report, 2, true);
return report;
}
export function main(){
const a=e.cli(['client', 'dump', 'assembly', 'roles', 'evidence']),
r=audit(a.client, a.dump, a.assembly, a.roles, a.evidence);
console.log(`Native round trip: ${r.special_avatar_rows} rows; ${r.roles_checked} role identities verified`);
}
e.runMain(import.meta.url, main);
@@ -0,0 +1,36 @@
import * as e from './common.mjs';
export function main(){
const a=e.cli(['dump', 'output'], {
source:e.ROOT
}),
root=e.path.resolve(a.source),
protocol=e.path.join(root, 'protocol/src/root.zig'),
handlers=e.path.join(root, 'gameserver/src/handlers.zig'),
service=e.path.join(root, 'gameserver/src/services/grid_fight.zig'),
en=e.readText(protocol),
bindings=e.readText(handlers),
requests=[...new Set(e.matches(en, /\bCmdGridFight\w+(?:CsReq|Req)\b/g).map(m=>m[0]))].sort(),
registered=Object.fromEntries(e.matches(bindings, /\.\{\s*CmdID\.(CmdGridFight\w+),\s*([\w.]+)\s*}/g).map(m=>[m[1], m[2]])),
dummy=(bindings.split('const DummyCmdList')[1]??'').split('};')[0],
configs=[...new Set(e.matches(e.readText(a.dump), /public class (GridFight\w+(?:ConfigRow|InfoRow))\b/g).map(m=>m[1]))].sort(),
native=e.path.join(root, 'tools/gridfight-extract/evidence/animation/native.asm'),
actions=[...new Set(e.matches(e.readText(native), /RPG\.Client\.(GridFight\w+(?:Action|Selection))::/g).map(m=>m[1]))].sort();
const result={
scope:'Installed config metadata and this checkout; named requests only, not all obfuscated requests.',
warning:'Registration and config presence do not prove behavior, persistence, or native gameplay. No completion percentage.',
inputs:Object.fromEntries([['protocol', protocol], ['handlers', handlers], ['grid_fight', service], ['config_dump', a.dump], ['pending_action_dispatch', native]].map(([n, p])=>[n, e.sha256_file(p)])),
named_request_count:requests.length,
registered_named_request_count:requests.filter(n=>n in registered).length,
requests:requests.map(name=>({
name,
handler:registered[name]??null,
empty_ack_list_member:new RegExp('^\\s*\\.?'+e.escapeRE(name)+'\\b', 'm').test(dummy)
})),
native_config_row_class_count:configs.length,
native_config_row_classes:configs,
pending_action_classes_observed_in_native_dispatch:actions
};
e.atomic_json(a.output, result, 2, true);
console.log(`${requests.length} named requests; ${result.registered_named_request_count} registered; ${configs.length} native config row classes`);
}
e.runMain(import.meta.url, main);
+36
View File
@@ -0,0 +1,36 @@
import * as e from './extract.mjs';
import * as s from './stages.mjs';
import {table_bindings} from './rounds.mjs';
// Client tables behind node rewards (bonus rules -> basic / combination / random
// bonus pools), Encounter (elite branch) options and the Plot Armor (SpotLight)
// recommendations. Every table is decoded byte-exactly; nothing is derived here.
export const TYPES = [
'GridFightBasicBonusConfigRow',
'GridFightCombinationBonusConfigRow',
'GridFightRandomBonusPoolConfigRow',
'GridFightVictoryBonusConfigRow',
'GridFightRoleRecomSpotLightConfigRow',
'GridFightSpotLightTagConfigRow'
];
export function main() {
const a = e.cli(['client', 'dump', 'identity', 'literals', 'output']);
const ctx = s.extractionContext(a);
const literals = new Map(e.loadJSON(a.literals).map(r => [Number(BigInt(r.Address)), r.Value]));
const tables = {}, proofs = [];
for (const name of TYPES) {
const assets = table_bindings(ctx.codec, literals, 'RPG.GameCore.' + name, null);
tables[name] = [];
for (const asset of assets) {
const match = asset.match(/^BakedConfig\/ExcelOutput(GameCore)?\/(\w+)\.bytes$/);
if (!match) e.fail('Unsupported table path: ' + asset);
const [rows, proof] = s.table(ctx.assets, ctx.codec, name, match[1] ?? '', match[2]);
tables[name].push(...rows);
proofs.push(proof);
console.log(`${name}: ${rows.length} byte-exact rows`);
}
}
s.publish(a, ctx, tables, proofs, {'bonus.raw.json': tables}, 'bonus', {gameplay_verified: false});
}
e.runMain(import.meta.url, main);
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import * as b from './bonus.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const DIR = path.join(ROOT, 'resources/grid_fight');
const load = n => JSON.parse(fs.readFileSync(path.join(DIR, n), 'utf8'));
test('bonus extractor covers the bonus pools and the Plot Armor recommendations', () => {
assert.deepEqual(b.TYPES, [
'GridFightBasicBonusConfigRow', 'GridFightCombinationBonusConfigRow', 'GridFightRandomBonusPoolConfigRow',
'GridFightVictoryBonusConfigRow', 'GridFightRoleRecomSpotLightConfigRow', 'GridFightSpotLightTagConfigRow'
]);
});
test('installed bonus tables: counts, provenance and references', () => {
const t = load('bonus.raw.json'), proof = load('bonus-provenance.json'), mech = load('mechanics-provenance.json');
const counts = Object.fromEntries(b.TYPES.map(n => [n, t[n].length]));
assert.deepEqual(counts, {
GridFightBasicBonusConfigRow: 1116, GridFightCombinationBonusConfigRow: 478, GridFightRandomBonusPoolConfigRow: 110,
GridFightVictoryBonusConfigRow: 7, GridFightRoleRecomSpotLightConfigRow: 126, GridFightSpotLightTagConfigRow: 51
});
assert.equal(proof.dump_sha256, mech.dump_sha256);
assert.ok(proof.tables.every(x => x.round_trip));
// Every recommended SpotLight is a GridFightSpotLightConfigRow, every role a GridFightRoleConfigRow.
const spots = new Set(load('mechanics.json').GridFightSpotLightConfigRow.map(r => r.ID));
const roles = new Set(load('roles.json').map(r => r.id));
for (const r of t.GridFightRoleRecomSpotLightConfigRow) {
assert.ok(roles.has(r.RoleID));
for (const id of [...(r.FirstRecommendSpotLightList ?? []), ...(r.SecondRecommendSpotLightList ?? [])]) assert.ok(spots.has(id));
}
});
test('Encounter tier bonus rules reference bonus IDs no bonus table defines', () => {
// GridFightBonusRuleConfigRow 91c0q (chapter c, quality q) is the only
// per-tier reward hook; its ProgressBonusList IDs resolve nowhere, which is
// why the server sends no Encounter branch drops.
const t = load('bonus.raw.json'), rules = load('battle.json').GridFightBonusRuleConfigRow;
const defined = new Set([
...t.GridFightBasicBonusConfigRow.map(r => r.BonusID),
...t.GridFightCombinationBonusConfigRow.map(r => r.BonusID),
...t.GridFightRandomBonusPoolConfigRow.map(r => r.RandomBonusID)
]);
const tiers = rules.filter(r => r.ID >= 91101 && r.ID <= 91314);
assert.equal(tiers.length, 36);
for (const r of tiers) for (const id of r.ProgressBonusList ?? []) assert.ok(!defined.has(id), `bonus ${id} unexpectedly defined`);
});
+42
View File
@@ -0,0 +1,42 @@
// Fresh native inputs for equipment, augment grants and scoring; never reuse an older catalog.
import * as e from './extract.mjs';
import * as s from './stages.mjs';
import {table_bindings} from './rounds.mjs';
import {text_rows} from './ui_names.mjs';
import {fixpoint} from './trait_effects.mjs';
export const TYPES=['GridFightRoleRecommendEquipConfigRow','GridFightSPSilverWolfChooseConfigRow',
'GridFightClaraEnhanceConfigRow','GridFightClaraLinkRelationConfigRow','GridFightPhanionUniqueTraitConfigRow',
'GridFightForgeConfigRow','GridFightSeasonExpScoreRow','GridFightScoreRewardConfigRow',
'GridFightDivisionStageRow','GridFightTraitElationTemplateConfigRow',
'GridFightBonfireHealConfigRow','GridFightBonfireEventRow','GridFightShopPriceConfigRow',
'GridFightFormationWaveConfigRow','GridFightStageLevelValueConfigRow','GridFightPenaltyRuleConfigRow',
'GridFightEnemyDifficultyLvConfigRow','GridFightBonusRuleConfigRow','GridFightBinaryNodeRuleConfigRow',
'GridFightLevelBaseValueConfigRow'];
export function enrich(value,texts){
if(Array.isArray(value))return value.map(v=>enrich(v,texts));
if(!value||typeof value!=='object')return value;
if('hash'in value&&'hash64'in value)return {text:texts[value.hash64]??'',text_id:value};
if('wire'in value)return {...value,value:fixpoint(value.wire)};
return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,enrich(v,texts)]));
}
export function main(){
const a=e.cli(['client','dump','identity','literals','english','items','output']),ctx=s.extractionContext(a),
literals=new Map(e.loadJSON(a.literals).map(r=>[Number(BigInt(r.Address)),r.Value])),
texts=text_rows(e.fs.readFileSync(a.english)),raw=e.loadJSON(a.items),proofs=[];
for(const name of TYPES){
const [asset]=table_bindings(ctx.codec,literals,'RPG.GameCore.'+name),
match=asset.match(/^BakedConfig\/ExcelOutput(GameCore)?\/(\w+)\.bytes$/);
if(!match)e.fail('Unsupported native table path: '+asset);
const [rows,proof]=s.table(ctx.assets,ctx.codec,name,match[1]??'',match[2]);
if(ctx.codec.schema('RPG.GameCore.'+name).length!==ctx.codec.fields('RPG.GameCore.'+name).size)e.fail('Incomplete catalog schema: '+name);
raw[name]=rows;proofs.push(proof);console.log(`${name}: ${rows.length} byte-exact rows`);
}
const tables=enrich(raw,texts),eq=new Map(tables.GridFightEquipConfigRow.map(r=>[r.ID,r])),
seasons=Object.fromEntries([...new Set(raw.GridFightSeasonItemConfigRow.map(r=>r.SeasonID))].map(id=>[id,{
items:raw.GridFightSeasonItemConfigRow.filter(r=>r.SeasonID===id).map(r=>({id:r.ItemID,config_id:r.ConfigID,equipment:eq.get(r.ConfigID)??null,raw_link:r}))
}]));
s.publish(a,ctx,raw,proofs,{'catalog.json':{identity:ctx.identity,tables,seasons},'catalog.raw.json':raw},'catalog',{
textmap:{file:e.path.basename(a.english),sha256:e.sha256_file(a.english)},gameplay_verified:false
});
}
e.runMain(import.meta.url,main);
+53
View File
@@ -0,0 +1,53 @@
// Writes resources/grid_fight/chimera.json from the decoded 4.6.54 Beast Garden
// tables (GridFightChimeraInfoConfigRow, GridFightChimeraShopPoolConfigRow).
// Input: a directory holding the two decoded *.jsonl files (CW-SPECIAL-PROOF G1,
// cwsp/tables.mjs output). Every FixPoint of the 4.6.54 tables is 4x its real
// value (CW-SPECIAL-PROOF G2), so EffectParamList is divided by 4 here; the
// integer columns (IDs, Star, RoleID, LimitNum, Param) are copied as they are.
//
// node tools/gridfight-extract-node/chimera.mjs --tables <dir> --output resources/grid_fight/chimera.json
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
function arg(name) {
const i = process.argv.indexOf('--' + name);
if (i < 0 || i + 1 >= process.argv.length) throw new Error('missing --' + name);
return process.argv[i + 1];
}
function rows(file) {
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
}
const dir = arg('tables');
const out = arg('output');
const infoFile = path.join(dir, 'GridFightChimeraInfoConfigRow.jsonl');
const poolFile = path.join(dir, 'GridFightChimeraShopPoolConfigRow.jsonl');
const text = (s) => (typeof s === 'string' ? s.replace(/^T:/, '') : '');
// 0.1 steps survive the /4: round to 6 places to drop float noise.
const real = (v) => Math.round((v / 4) * 1e6) / 1e6;
const info = rows(infoFile).map((r) => ({
id: r.ID,
star: r.Star,
role_id: r.RoleID ?? 0,
limit: r.LimitNum ?? 0,
params: (r.EffectParamList ?? []).map(real),
name: text(r.NameDesc),
}));
info.sort((a, b) => a.id - b.id || a.star - b.star);
const pool = rows(poolFile).map((r) => ({ id: r.ID, type: r.Type, param: r.Param }));
pool.sort((a, b) => a.id - b.id);
const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
const doc = {
provenance: {
source: 'RPG.GameCore.GridFightChimeraInfoConfigRow / GridFightChimeraShopPoolConfigRow, client OSBETAWin4.6.54 (BakedConfig/ExcelOutput/GridFightChimeraInfo.bytes, GridFightChimeraShopPool.bytes)',
note: 'params are the table FixPoints divided by 4 (CW-SPECIAL-PROOF G2); limit = LimitNum (cap of owned copies at that star); pool type 1 chimera, 2 consumable item, 3 gold; the pool has no weight column',
info_sha256: sha(infoFile),
pool_sha256: sha(poolFile),
},
info,
pool,
};
fs.writeFileSync(out, JSON.stringify(doc, null, 2) + '\n');
console.log(`chimera.json: ${info.length} info rows, ${pool.length} pool rows`);
+229
View File
@@ -0,0 +1,229 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
createHash,
randomUUID
} from 'node:crypto';
import {
fileURLToPath
} from 'node:url';
export {
fs,
path,
os
};
export const HERE=path.dirname(fileURLToPath(import.meta.url)), ROOT=path.resolve(HERE, '../..');
export class DecodeError extends Error {
}
export const fail=m=>{
throw new DecodeError(m);
};
export const sha256=b=>createHash('sha256').update(b).digest('hex');
export function sha256_file(p){
const fd=fs.openSync(p, 'r'),
h=createHash('sha256'),
b=Buffer.alloc(1024*1024);
try{
let n;
while((n=fs.readSync(fd, b, 0, b.length, null)))h.update(b.subarray(0, n));
return h.digest('hex');
} finally{
fs.closeSync(fd);
}
}
export const readText=p=>fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
export const escapeRE=s=>s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const matches=(s, re)=>[...s.matchAll(re)];
export const small=v=>typeof v==='bigint'&&v<=BigInt(Number.MAX_SAFE_INTEGER)&&v>=BigInt(Number.MIN_SAFE_INTEGER)?Number(v):v;
export const zigzag=v=>small((BigInt(v)>>1n)^-(BigInt(v)&1n));
export const cat=(...parts)=>Buffer.concat(parts.flat());
export function dict(entries=[]){
const keys=[],
target={
};
const out=new Proxy(target, {
ownKeys:()=>keys,
set(t, k, v){
if(!Object.hasOwn(t, k))keys.push(k);
Reflect.defineProperty(t,k,{value:v,writable:true,enumerable:true,configurable:true});
return true;
},
deleteProperty(t, k){
const i=keys.indexOf(k);
if(i>=0)keys.splice(i, 1);
return delete t[k];
}
});
for(const [k, v]of entries)out[String(k)]=v;
return out;
}
export class Float {
constructor(value){
this.value=value;
}
}
export function json(value, indent=2, ascii=false){
const quote=s=>{
let q=JSON.stringify(s);
return ascii?q.replace(/[\u007f-\uffff]/g, c=>'\\u'+c.charCodeAt(0).toString(16).padStart(4, '0')):q;
};
const walk=(v, d)=>{
if(v===null)return 'null';
if(v instanceof Float){
let s=String(v.value);
return s.includes('.')||s.includes('e')?s:s+'.0';
}
if(typeof v==='bigint')return String(v);
if(typeof v==='string')return quote(v);
if(typeof v==='number'){
if(!Number.isFinite(v))fail('Non-finite JSON number');
return String(v);
}
if(typeof v==='boolean')return String(v);
if(typeof v!=='object')fail('Unsupported JSON value');
const arr=Array.isArray(v),
entries=arr?v.map(x=>walk(x, d+1)):Object.keys(v).map(k=>quote(k)+': '+walk(v[k], d+1));
if(!entries.length)return arr?'[]':'{}';
const pad=' '.repeat(indent*(d+1));
return (arr?'[':'{')+'\n'+pad+entries.join(',\n'+pad)+'\n'+' '.repeat(indent*d)+(arr?']':'}');
};
return walk(value, 0)+'\n';
}
// Parse integers losslessly and retain dictionary insertion order, including numeric keys.
export function parseJSON(text){
let i=0;
const ws=()=>{
while(/\s/.test(text[i]??'')&&i<text.length)i++;
};
function value(){
ws();
let c=text[i];
if(c==='"'){
let start=i++;
while(i<text.length){
if(text[i++]==='"')return JSON.parse(text.slice(start, i));
if(text[i-1]==='\\')i++;
}
fail('Unterminated JSON string');
}
if(c==='{'||c==='['){
i++;
const a=c==='[',
out=a?[]:dict();
ws();
if(text[i]===(a?']':'}')){
i++;
return out;
}
for(; ; ){
if(a)out.push(value());
else{
const k=value();
ws();
if(text[i++]!==':')fail('Invalid JSON object');
out[k]=value();
}
ws();
c=text[i++];
if(c===(a?']':'}'))return out;
if(c!==',')fail('Invalid JSON separator');
}
}
for(const [s, v]of [['true', true], ['false', false], ['null', null]])if(text.startsWith(s, i)){
i+=s.length;
return v;
}
const m=text.slice(i).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
if(!m)fail('Invalid JSON value');
i+=m[0].length;
return /[.eE]/.test(m[0])?Number(m[0]):small(BigInt(m[0]));
}
const out=value();
ws();
if(i!==text.length)fail('Trailing JSON');
return out;
}
export const loadJSON=p=>parseJSON(readText(p));
export function replace(p, data){
fs.mkdirSync(path.dirname(p), {
recursive:true
});
const tmp=path.join(path.dirname(p), '.tmp-'+randomUUID());
let fd;
try{
fd=fs.openSync(tmp, 'wx', 0o600);
fs.writeFileSync(fd, data);
fs.fsyncSync(fd);
fs.closeSync(fd);
fd=undefined;
fs.chmodSync(tmp, 0o644);
fs.renameSync(tmp, p);
} finally{
if(fd!==undefined)fs.closeSync(fd);
fs.rmSync(tmp, {
force:true
});
}
}
export const atomic_json=(p, v, indent=2, ascii=false)=>replace(p, json(v, indent, ascii));
export const globBytes=dir=>fs.existsSync(dir)?fs.readdirSync(dir).filter(n=>n.endsWith('.bytes')).sort().map(n=>path.join(dir, n)):[];
export function cli(required=[], defaults={
}, bools=[], multi=[]){
const a={
...defaults
};
for(let i=2; i<process.argv.length; i++){
const flag=process.argv[i];
if(flag==='--help'||flag==='-h'){
console.log(`Usage: node ${path.basename(process.argv[1])} ${required.map(k=>'--'+k+' VALUE').join(' ')}\nOptions: ${[...Object.keys(defaults),...bools,...multi].map(k=>'--'+k).join(', ')}`);
process.exit(0);
}
const m=flag.match(/^--([\w-]+)(?:=(.*))?$/);
if(!m)fail('Unknown argument '+flag);
const k=m[1];
if(![...required, ...Object.keys(defaults), ...bools, ...multi].includes(k))fail('Unknown argument '+flag);
if(bools.includes(k)){
if(m[2]!==undefined)fail('Flag takes no value: --'+k);
a[k]=true;
continue;
}
const v=m[2]??process.argv[++i];
if(v===undefined||v.startsWith('--'))fail('Missing value '+flag);
if(multi.includes(k))(a[k]??=[]).push(v);
else if(typeof defaults[k]==='number'||k==='season-id'){
if(!/^[+-]?\d+$/.test(v)||!Number.isSafeInteger(Number(v)))fail('Expected integer for --'+k);
a[k]=Number(v);
} else a[k]=v;
}
for(const k of required)if(a[k]===undefined)fail('Required --'+k);
return a;
}
export function runMain(url, fn){
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(url)){
try{
const result=fn();
if(result?.catch)result.catch(e=>{
console.error('Extraction stopped: '+e.message);
process.exitCode=1;
});
} catch(e){
console.error('Extraction stopped: '+e.message);
process.exitCode=1;
}
}
}
export const subset=(a, b)=>[...a].every(v=>b.has(v));
export const uniqueValues=(rows, key)=>new Set(rows.map(key)).size===rows.length;
export function lookup(rows, key, label='native key'){
const out=new Map();
for(const r of rows){
const k=key(r);
if(out.has(k))fail('Duplicate '+label);
out.set(k, r);
}
return out;
}
export const need=(map, key)=>map.get(key)??fail('Missing native reference '+key);
export const resource=name=>path.join(ROOT, 'resources/grid_fight', name);
export const catalogDefault=path.join(ROOT, 'protocol/evidence/cw-mechanics/catalog.json');
@@ -0,0 +1,69 @@
import * as e from './common.mjs';
const arrays=(v,p='',out={})=>{
if(Array.isArray(v)){out[p||'rows']=v;return out;}
if(v&&typeof v==='object')for(const [k,x]of Object.entries(v)){
if(['evidence','provenance','source','identity','division_show_proof'].includes(k))continue;
if(p.split('.').length<3)arrays(x,p?p+'.'+k:k,out);
}
return out;
};
export function rowKey(r){
if(!r||typeof r!=='object')return null;
for(const keys of [['route_id'],['TraitID','Layer'],['ID','ChapterID','SectionID'],['SeasonID','SubSeasonID'],
['SeasonID','ItemID'],['SeasonID','AugmentID'],['SeasonID','PortalID'],['SeasonID','CraftID'],
['Rarity','Star'],['id'],['ID'],['CraftID'],['DivisionID'],['NodeTemplateID'],['MonsterID'],
['PlayerLevel'],['Level'],['NodeType'],['StageID'],['ConstValueName'],['ScoreRuleID','ChapterID','SectionID'],['ScoreRank'],
['TypeID','Level'],['TypeID']])if(keys.every(k=>k in r))return keys.map(k=>String(r[k])).join(':');
return null;
}
export function compare(oldValue,newValue){
const a=arrays(oldValue),b=arrays(newValue),result=[];
for(const p of [...new Set([...Object.keys(a),...Object.keys(b)])].sort()){
const oldRows=a[p]??[],newRows=b[p]??[],map=rows=>new Map(rows.map(r=>[rowKey(r),r])),
x=map(oldRows),y=map(newRows),identifiable=!x.has(null)&&!y.has(null)&&x.size===oldRows.length&&y.size===newRows.length;
result.push({collection:p,before:oldRows.length,after:newRows.length,
...(identifiable?{added:[...y.keys()].filter(k=>!x.has(k)),removed:[...x.keys()].filter(k=>!y.has(k)),
changed:[...y.keys()].filter(k=>x.has(k)&&e.json(x.get(k))!==e.json(y.get(k)))}:{})});
}
return result;
}
export function main(){
const a=e.cli(['baseline','stage']),names=e.loadJSON(e.path.join(a.stage,'ui_names.json')),
baseline=e.path.resolve(a.baseline),stage=e.path.resolve(a.stage),files=[...new Set([...e.fs.readdirSync(baseline),...e.fs.readdirSync(stage)])].sort(),
diffs=[],table=[],ids=[];
function name(file,collection,id){
const root=Number(/Season/.test(collection)?id.split(':').at(-1):id.split(':')[0]);
let hash;
if(/trait/i.test(file+' '+collection))hash=names.trait_hashes[root];
else if(/augment/i.test(file+' '+collection))hash=names.augment_hashes[root];
else if(/portal/i.test(file+' '+collection))hash=names.portal_hashes[root];
else if(/item|equipment|basic|consumable|forge/i.test(file+' '+collection))hash=names.item_hashes[root];
else if(/roles/.test(file)){
const row=e.loadJSON(e.path.join(stage,'roles.json')).find(r=>r.id===root);hash=names.avatar_hashes[row?.avatar_id];
}
const text=names.texts[hash]?.en;
return id+(text?' ('+text.replaceAll('|','/').replaceAll('\n',' ')+')':'');
}
for(const file of files){
if(['comparison.json','bundle-manifest.json','CHANGES.md'].includes(file))continue;
const oldPath=e.path.join(baseline,file),newPath=e.path.join(stage,file),
exists=p=>e.fs.existsSync(p)&&e.fs.statSync(p).isFile(),old=exists(oldPath),fresh=exists(newPath);
if(!old&&!fresh)continue;
if(!old&&!file.endsWith('.json'))continue;
const historical=file.includes('.bak.'),before=old?e.sha256_file(oldPath):null,after=fresh?e.sha256_file(newPath):null,
jsonFile=file.endsWith('.json'),oldValue=old&&jsonFile?e.loadJSON(oldPath):null,newValue=fresh&&jsonFile?e.loadJSON(newPath):null,
collections=jsonFile?compare(oldValue,newValue):[],status=historical?'historical backup; excluded':before===after?'identical':!old?'added':!fresh?'missing':'changed',
counts=(!/schemas|provenance|catalog\.json/.test(file)&&collections.length<=20?collections:collections.filter(r=>r.before!==r.after)).map(r=>`${r.collection}: ${r.before}→${r.after}`).join('; ')||`${oldValue&&typeof oldValue==='object'?Object.keys(oldValue).length:old?'file':'—'}→${newValue&&typeof newValue==='object'?Object.keys(newValue).length:fresh?'file':'—'} keys`;
diffs.push({file,status,before_sha256:before,after_sha256:after,collections});
table.push(`| ${file} | ${status} | ${counts} |`);
if(old&&fresh&&!file.includes('schemas')&&!file.includes('provenance'))for(const r of collections){
if(r.added?.length>20&&r.before===0){ids.push(`- ${file}:${r.collection}: ${r.added.length} rows newly exposed by this output contract (all IDs in comparison.json); these are not all new client IDs.`);continue;}
if(r.added?.length)ids.push(`- ${file}${r.collection==='rows'?'':':'+r.collection} added: ${r.added.map(id=>name(file,r.collection,id)).join(', ')}.`);
if(r.removed?.length)ids.push(`- ${file}${r.collection==='rows'?'':':'+r.collection} removed: ${r.removed.map(id=>name(file,r.collection,id)).join(', ')}.`);
}
}
e.atomic_json(e.path.join(stage,'comparison.json'),diffs);
const text=`# CW beta 4.6.53 staging comparison\n\nSeason 2. Installed resources were read only. Native indexed tables decode completely and re-encode byte-exactly; evidence and hashes are staged alongside the outputs. Gameplay was not exercised.\n\nDynamicValue: matching metadata resolves FromByteBinary at RVA 0x1e745df0 (4.6.51 used 0x1e58e120). The table is reader+0x2b0; all six case offsets and their typed operations match the earlier wire layout. Null tag 6 now works in both codec modes. This was relocation, not a wire format change.\n\nAll active baseline JSON files are present. node_profiles.json and orb_rewards.json are copied byte-identically: their own provenance identifies third-party server observations, not client-extractable 4.6.53 data. Historical .bak files are listed but excluded from the staged runtime set.\n\nEquipment, augment descriptions and score use a fresh native catalog with matching English TextMap, not the older repository catalog. ui_names resolves current English/Thai TextMaps; AvatarConfig/AeonCatalog provide existing identity/name lookup hints. rounds-alternative/ retains the distinct rounds.mjs contract; root rounds.json uses the installed stages contract.\n\n## File counts\n\nArray counts are shown by collection; documents without arrays show top-level key counts. Exact hashes and added/removed/changed row IDs are in comparison.json. Auxiliary files newly added to staging have no installed counterpart.\n\n| File | Comparison | Counts (installed → staged) |\n|---|---|---|\n${table.join('\n')}\n\n## Added and removed IDs\n\nNames are included where ui_names resolves them. Compound IDs retain their native collection key order.\n\n${ids.join('\n')}\n\n## Validation\n\nnode --test tools/gridfight-extract-node passes; explicit no-isolation run: 51 tests passed. The trial-avatar audit re-encodes all 5,200 rows and verifies all 141 role identities. Every catalog table and both stage/rounds extractions pass whole-table byte equality. No installed data, client files, processes, reference Python code or archives were modified.\n`;
e.replace(e.path.join(stage,'CHANGES.md'),text);console.log(`${diffs.length} file comparisons; ${ids.length} ID-change collections`);
}
e.runMain(import.meta.url,main);
@@ -0,0 +1,115 @@
# Currency Wars 4.6.53 protocol and pending selections
The six delta cases and their five payload messages were already present in
`protocol/src/protocol.pb.zig` when this verification began. Their definitions
were retained. No additional exports in `protocol/src/root.zig` were needed.
| Container | Field | Payload | Fields |
| --- | ---: | --- | --- |
| MGBHKCMHDOK | 424 | BODGPACJIDJ | 7: AFEDCLCMIPE, uint32 role config ID |
| CHCHAJIANIM | 1506 | CEHMHNJPDGC | 2: packed uint32 role IDs; 12: trait ID; 13: effect ID |
| KFNCAIHIHCM | 1991 | bool | SpotLight return-to-preparation state |
| GridFightHandleBonfireHealHpCsReq | 376 | OCABBNOOBJL | empty message, return from SpotLight |
| GridFightHandleBonfireHealHpCsReq | 1717 | JKNGGJGFFPN | 13: KGHDNKONLNH, uint32 selected role ID |
| GridFightTraitUpdateCsReq | 606 | OOBHMHFHLDA | 1: KGHDNKONLNH, uint32 selected role ID |
Evidence comes from the full OSBETAWin4.6.53 metadata dump and `objdump -d -M
intel` of its GameAssembly.dll. Native addresses below are RVAs (image base
0x180000000).
- Payload WriteTo/MergeFrom(CodedInputStream): BODGPACJIDJ 1F1CED70/1F1CEEB0;
CEHMHNJPDGC 1F22D700/1F22DA50; JKNGGJGFFPN 1F972790/1F9728D0;
OOBHMHFHLDA 1F072CE0/1F072E20; OCABBNOOBJL 1EF7F880/1EF7F8E0.
- GainOtherRoleTraitPendingAction._SyncFromProto DA79590 reads case 1506,
trait and effect IDs, and the role candidate list. SelectRole DA7A000 uses
queued selection E3A8DC0 (1717) or local editing E3A8E90 (606).
- GainOtherRoleTraitTraitEffect.SelectedRoleID DA7AC60 reads case 424 field 7.
Candidate predicate DA7C360 accepts Front and Back placements, excluding
Bench/Overcrowd. GetGainOtherRoleTraitIDs.MoveNext DAD1540 skips TraitType 2.
- Client tables identify Apprenticeship as trait 400502, effect 4005021,
effect type 34, active at one member. The persisted selection is a role
config ID, not a particular owned copy's unique ID.
- SpotLightSelectAction.ReturnPreparation DC01970 calls E3A6450, which builds
case 376. GameSession update case 1991 dispatches to
SpotLightManager.SyncReturnPreparation DBF9E10.
## Supply Phase completion
DraftSelectAction._SyncFromProto DA20BE0 reads the outer queue position into
ActionID and expects pending case 693. IAKEDFLIOFD has item list field 11,
select count field 8, refresh count field 5 and refresh limit field 10.
HMNMJKCHABE carries selected indexes in packed field 9.
GameSession's case 1813 at DAC1C18 calls PendingSelection.SyncFinish DB97470,
which compares the supplied ID with the current action ID before finishing.
BonusSummarizer.UpdateAfter DA07340 distributes source-2 initial-supply rewards.
Accepted start choices now put state/reward changes and the finishing 1813
update in one bundle, with completion after the state changes and before
UpdateAfter distributes rewards. A subsequent pending choice still has its
own notification. The draft regression decodes actual framed replies,
asserts source 2, role grant, final completion ID 9002 and absence of another
notification, then rejects a duplicate pick and verifies restored state.
This ordering is covered by server tests; UI closure still needs an in-game
check on the current client.
## Scope
Apprenticeship initial selections, edits, stale queues, battle tallies,
session effect state and settlement snapshots are connected. Native ordinary
mentor traits are added once per learner; unique origins are excluded.
The return-to-preparation handler emits state 1991 and completes a nonzero
client action ID; it refuses to cancel an unrelated server-side pending choice.
No running process or player save is modified.
## SpotLight picks: how Apprenticeship is earned
No role carries trait 400502 (or any of 400102/400202/400302/400402). The only
source is a SpotLight: `GridFightSpotLightConfigRow.TraitID` is non-zero for
exactly five rows (100705 -> 400502 Apprenticeship, 100800 -> 400102,
110801 -> 400202, 100801 -> 400302, 120902 -> 400402), and the client derives
the Bond from the binding (`GridFightSpotLightConfig.TraitID`).
Wire, read from the 4.6.53 dump and GameAssembly (RVAs):
- Offer: pending case 1817 (`SpotLightSelectAction._SyncFromProto` DC00CD0).
f4 IsGrant, f5 candidates `{f13 SpotLight ID}`, f9 TargetRoleId (role
config ID), f14 IsAbandonForbidden, f2 refresh count, f7 unlimited tier,
f8 refresh cost, f12 target SpotLight UID (replace flow).
- Answer: `HandlePendingAction` case 263 `{f1 SpotLight ID, f9 role}`
(`Select` DC01390 -> E3A61E0; `SelectCandidate` passes the candidate ID as f1).
- Binding: `FMBACDOFCAL` `{f1 role UID, f2 SpotLight UID, f9 role config ID,
f11 IsLoss, f13 SpotLight ID}`. Update cases 1433 and 1651 call
`SpotLightManager.AddOrUpdate` (DBFB3F0, keyed by f2), case 166 calls
`Remove` (DBFB520); the GameInfo section carries them in f1 (`Sync` DBF7610).
- `SpotLightReplace` takes a SpotLight UID and spends resource
`GridFightS2_SpotLightReplace_Resource_ID` (3); not implemented.
Server: bindings (`spot_bound`, 8 slots) persist with the match, feed
`wearerHasTrait` and the battle tally, follow a role through a merge and are
freed when it is sold or absorbed. `spot_queue` counts granted picks; each
opens one selection (first unbound on-field role, up to three candidates the
role suits and that are not bound elsewhere). A pick of 100705 activates
Apprenticeship, so the existing mentor choice follows. Nothing in the client
data says when the native server grants a pick, so none is granted by default;
`gridfight.json` `"spotlight_picks": N` (max 4) queues N at match start.
Still open: the native grant rule, the Replace RPC, candidate refresh costs,
and spendable weekly credits (`item.zig` rebuilds the bag from
`custom.json` materials and has no grant API; it is outside this change).
Temporary native extracts and test logs are under `/tmp/cwdelta`.
## Verification
- Isolated current-source Zig suite: 138 passed, zero skipped (`-fstrip`, Debug).
Log: `/tmp/cwdelta/current-spot/all-tests.log`.
- `gameserver/zig build test`: passed.
- Node extractor suite: passed.
- Final root `zig build -j2`: passed.
Log: `/tmp/cwdelta/root-build-final.log`.
Concurrent optional handlers are preserved. `handleRest` has no dispatcher call
in the permitted files; wiring those extra commands requires a separate shared
file change. This does not affect the registered pending-action and trait-update
handlers used for the protocol delta.
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
const read = name => JSON.parse(fs.readFileSync(new URL('../../resources/grid_fight/' + name, import.meta.url)));
const special = read('special-units.raw.json');
const mechanics = read('mechanics-4654.raw.json');
test('CW MECH 4.6.54 decoded Aha family excludes the obsolete three-cost form', () => {
const rows = special.GridFightRoleConfigRow.filter(r => r.AvatarID === 1511 && r.SeasonID === 2);
assert.deepEqual(rows.map(r => [r.ID, r.Rarity]), [[151112, 4], [151122, 5], [151132, 5]]);
const noStars = mechanics.ConstValueCommonRow.find(r => r.ConstValueName === 'GridFight_Aha_No_Star_Role_IDList');
assert.deepEqual(noStars.Value.value.map(r => r.value), [151112, 151122]);
assert.equal(mechanics.ConstValueCommonRow.find(r => r.ConstValueName === 'GridFight_Aha_Level_SavedValueKey').Value.value, 'GP_Avatar_S2_Aha_00');
});
test('CW MECH restaurant menus retain native weights and signature component pairs', () => {
const menus = mechanics.GridFightTraitEquipTemplateConfigRow.filter(r => r.TraitID === 101202);
assert.equal(menus.length, 20);
assert.equal(new Set(menus.map(r => r.TemplateID)).size, 20);
for (const menu of menus) {
assert.ok([25, 50, 100].includes(menu.Weight));
assert.equal(menu.EquipList.length, 2);
for (const id of menu.EquipList) assert.ok(special.GridFightEquipConfigRow.some(e => e.ID === id));
}
assert.ok(special.GridFightRoleConfigRow.find(r => r.ID === 141502).TraitList.includes(101202));
});
test('CW MECH Twin Dragons IDs come from effect 3001022 rather than avatar-ID arithmetic', () => {
const pair = special.GridFightTraitEffectLayerParamConfigRow.find(r => r.ID === 3001022 && r.Layer === 1);
assert.deepEqual(pair.EffectParamList.map(p => p.wire / 2 ** 34), [121302, 141402]);
for (const id of [121302, 141402]) assert.ok(special.GridFightRoleConfigRow.find(r => r.ID === id).TraitList.includes(300102));
});
test('CW MECH additional tables retain complete client-asset round-trip proofs', () => {
const proof = read('mechanics-4654-provenance.json');
assert.equal(proof.game_assembly_sha256, '4aa08a0706f27d2cf9951b61920c179140ece9ea94f7e34422c18e46922939b6');
assert.ok(proof.tables.length >= 7);
for (const p of proof.tables) {
assert.equal(p.round_trip, true);
assert.match(p.asset_sha256, /^[a-f0-9]{64}$/);
}
});
test('CW MECH takeover chip contains stat properties, not a decoded budget or payout specification', () => {
const chip = special.GridFightEquipConfigRow.find(r => r.ID === 353601);
assert.equal(chip.ParamList.length, 6);
assert.equal(chip.GeneralPropertyList.length, 6);
assert.ok(chip.ParamList.every(p => Math.abs(p.wire / 2 ** 34 - 0.99) < 1e-8));
assert.equal(chip.JsonPath, 'Config/Level/GridFight/Equip/GridFightEquip353601.json');
});
test('CW MECH future-node weakening preserves native reduction tiers independently of Trotter data', () => {
for (const [id, reduction] of [[353303, 0.08], [353403, 0.12], [353503, 0.16]]) {
const chip = special.GridFightEquipConfigRow.find(r => r.ID === id);
for (const p of chip.ParamList.slice(1)) assert.ok(Math.abs(p.wire / 2 ** 34 - reduction) < 1e-8);
}
assert.equal(special.GridFightEquipConfigRow.find(r => r.ID === 353405).ParamList.length, 1);
assert.equal(special.GridFightEquipConfigRow.find(r => r.ID === 353505).ParamList.length, 2);
});
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { fixpoint } from './trait_effects.mjs';
const read = name => JSON.parse(fs.readFileSync(new URL('../../resources/grid_fight/' + name, import.meta.url)));
const raw = read('wiki-4654.raw.json');
const rules = read('wiki-4654.json');
const equipment = read('equipment.json');
test('CW WIKI runtime rule parameters retain the fresh native table values and season', () => {
for (const [key, type] of [
['permanent', 'GridFightTalentConfigRow'],
['season', 'GridFightSeasonTalentConfigRow'],
['portals', 'GridFightPortalBuffConfigRow'],
['augments', 'GridFightAugmentConfigRow'],
]) {
assert.equal(rules[key].length, raw[type].length);
for (const row of rules[key]) {
const native = raw[type].find(r => r.ID === row.id);
assert.ok(native);
assert.equal(row.season, native.SeasonID ?? 0);
assert.equal(row.oc, !!native.IsOCEffective);
assert.deepEqual(row.params, native.EffectParamList.map(p => fixpoint(p.wire)));
}
}
});
test('CW WIKI all 57 S2 crafting recipes preserve the exact ingredient multiset and output', () => {
const ids = new Set(raw.GridFightSeasonCraftConfigRow.filter(r => r.SeasonID === 2).map(r => r.CraftID));
assert.equal(ids.size, 57);
for (const native of raw.GridFightCraftConfigRow.filter(r => ids.has(r.CraftID))) {
const recipe = equipment.recipes.find(r => r.craft_id === native.CraftID);
assert.ok(recipe, 'missing recipe ' + native.CraftID);
assert.equal(recipe.result, native.CraftEquipID);
assert.deepEqual([...recipe.cost].sort((a, b) => a - b), [...native.CostEquipList].sort((a, b) => a - b));
}
});
test('CW WIKI S2 changed the S1 insurance, compensation, streak and talent layout', () => {
const params = id => rules.season.find(r => r.id === id).params.map(p => Math.round(p * 1e6) / 1e6);
assert.deepEqual(params(4011), [1, 0.1, 0.1]);
assert.deepEqual(params(3061), [50, 5, 1]);
assert.deepEqual(params(4061), [0.1, 6, 4]);
assert.equal(rules.permanent.length, 13);
assert.equal(rules.season.length, 40);
assert.ok(rules.season.every(r => r.season === 2));
});
test('CW WIKI provenance ties every decoded table to complete 4.6.54 asset bytes', () => {
const proof = read('wiki-4654-provenance.json');
assert.equal(proof.game_assembly_sha256, '4aa08a0706f27d2cf9951b61920c179140ece9ea94f7e34422c18e46922939b6');
assert.equal(proof.dump_sha256, 'f46de09bd1f14dca6fc0ba07b819e2328f3c4910dc833d33bf48f10e9b7402bd');
for (const p of proof.tables) {
assert.equal(p.round_trip, true, p.table);
assert.equal(raw[p.table].length, p.records);
assert.match(p.asset_sha256, /^[a-f0-9]{64}$/);
}
});
+148
View File
@@ -0,0 +1,148 @@
import * as e from './common.mjs';
export function build(raw, season){
const equips=new Map(raw.GridFightEquipConfigRow.map(r=>[r.ID, r])),
seasonItems=new Set(raw.GridFightSeasonItemConfigRow.filter(r=>r.SeasonID===season).map(r=>r.ItemID)),
seasonCrafts=new Set(raw.GridFightSeasonCraftConfigRow.filter(r=>r.SeasonID===season).map(r=>r.CraftID)),
pool=[];
for(const id of [...seasonItems].sort((a, b)=>a-b)){
const row=equips.get(id);
if(!row)continue;
const entry={
id,
category:row.EquipCategory??0
};
if(row.EquipFunc){
entry.func=row.EquipFunc;
entry.func_params=row.EquipFuncParamList??[];
}
if(row.DressRule){
entry.dress_rule=row.DressRule;
entry.dress_params=row.DressRuleParamList??[];
}
pool.push(entry);
}
const basic=pool.filter(e=>e.category===1).map(e=>e.id),
recipes=[];
for(const row of raw.GridFightCraftConfigRow){
if(!seasonCrafts.has(row.CraftID))continue;
const cost=row.CostEquipList??[];
if(cost.length!==2||!equips.has(row.CraftEquipID)||cost.some(c=>!equips.has(c)))e.fail('craft '+row.CraftID+' has an unexpected shape');
recipes.push({
craft_id:row.CraftID,
result:row.CraftEquipID,
cost:cost.toSorted((a, b)=>a-b)
});
}
recipes.sort((a, b)=>a.craft_id-b.craft_id);
if(!basic.length||!recipes.length)e.fail('season has no basic components or recipes');
const poolIds=new Set(pool.map(e=>e.id)),
consumables=raw.GridFightConsumablesConfigRow.filter(r=>seasonItems.has(r.ID)).map(r=>({
id:r.ID,
rule:r.ConsumableRule??0,
params:r.ConsumableParamList??[],
stack:!!r.IfStack,
consume:!!r.IfConsume
})),
upgrades=raw.GridFightEquipUpgradeConfigRow.filter(r=>poolIds.has(r.PreID)&&poolIds.has(r.UpgradeID)).map(r=>({
from:r.PreID,
to:r.UpgradeID
}));
return {
season_id:season,
basic,
equipment:pool,
recipes,
consumables,
upgrades
};
}
export function add_catalog(data, catalog, seasonItems){
const tables=catalog.tables,
poolIds=new Set(data.equipment.map(e=>e.id)),
recommend=e.dict();
for(const r of tables.GridFightRoleRecommendEquipConfigRow){
const ids=(r.FirstRecommendEquipList??[]).filter(i=>poolIds.has(i)),
merged=recommend[r.RoleID]??=[];
for(const id of ids)if(!merged.includes(id))merged.push(id);
}
data.recommend=e.dict(Object.keys(recommend).sort((a, b)=>Number(a)-Number(b)).filter(k=>recommend[k].length).map(k=>[k, recommend[k]]));
const byId=new Map(data.equipment.map(e=>[e.id, e])),
items=new Map(catalog.seasons[data.season_id].items.map(i=>[i.id, i]));
for(const [id, target]of [[353301, 'wearer'], [353401, 'wearer'], [353501, 'teammate']]){
if(!byId.has(id))continue;
const row=e.need(items, id).equipment,
text=row.EquipDesc.text,
expected=target==='teammate'?'random teammate':'copy of this unit';
if(!text.includes(expected)||e.json(row.DressRuleParamList)!==e.json([150602]))e.fail('chip '+id+': reward contract changed; review matching client');
const period=row.ParamList[0].value;
if(period<=0||!Number.isInteger(period))e.fail('chip '+id+': invalid battle cadence');
byId.get(id).battle_reward={
period,
target
};
if(target==='wearer'){
const chance=row.ParamList[2].value;
if(!text.includes('whenever the wearer is obtained through any means')||!(chance>0&&chance<1))e.fail('chip '+id+': acquisition contract changed');
byId.get(id).acquisition_chance=chance;
}
}
if(byId.has(353507)){
const row=e.need(items, 353507).equipment;
if(!row.EquipDesc.text.includes('For every 2 battles')||e.json(row.EquipFuncParamList)!==e.json([100302]))e.fail('Phantasmoon cartridge reward contract changed');
byId.get(353507).battle_reward={
period:2,
target:'bond',
bond:100302
};
}
data.sw_console=tables.GridFightSPSilverWolfChooseConfigRow.filter(r=>poolIds.has(r.EquipID)).map(r=>({
star:r.Star??0,
equip:r.EquipID
}));
data.clara=tables.GridFightClaraEnhanceConfigRow.map(r=>({
id:r.ID,
category:r.Category??0,
cost:r.CostCoin??0,
limit:r.Limit??0,
module:'EnhanceSelectName'in r,
buff:r.BuffID??0
}));
data.clara_links=tables.GridFightClaraLinkRelationConfigRow.map(r=>({
id:r.ID,
convert:r.EnhanceConvert??[],
valid:r.ValidEnhanceList??[]
}));
data.phainon=tables.GridFightPhanionUniqueTraitConfigRow.map(r=>{
const normal=r.SavedValueAddList??[],
special=r.SpecialSavedValueAddList??[];
if(normal.length!==1||special.length!==1||normal[0][0]!=='GP_Avatar_S2_Phainon_00'||special[0][0]!==normal[0][0])e.fail(`Phainon row ${r.Rarity}/${r.Star}: saved value contract changed`);
return {
cost:r.Rarity,
star:r.Star,
add:normal[0][1],
special_add:special[0][1]
};
});
data.forges=tables.GridFightForgeConfigRow.filter(r=>seasonItems.has(r.ID)).map(r=>({
id:r.ID,
category:r.EquipCategory??0,
num:r.EquipNum??0,
func:r.FuncType??0,
params:r.ParamList??[]
}));
}
export function main(){
const a=e.cli([], {
items:e.resource('items.raw.json'),
season:2,
output:e.resource('equipment.json'),
catalog:e.catalogDefault
}),
raw=e.loadJSON(a.items),
data=build(raw, a.season),
items=new Set(raw.GridFightSeasonItemConfigRow.filter(r=>r.SeasonID===a.season).map(r=>r.ItemID));
add_catalog(data, e.loadJSON(a.catalog), items);
e.atomic_json(a.output, data, 1, true);
console.log(`${a.output}: ${data.equipment.length} equipment, ${data.basic.length} basic, ${data.recipes.length} recipes for season ${a.season}`);
}
e.runMain(import.meta.url, main);
+548
View File
@@ -0,0 +1,548 @@
import {
spawnSync
} from 'node:child_process';
import * as c from './common.mjs';
export * from './common.mjs';
const {
fs,
path,
os,
fail,
small,
zigzag,
cat,
matches,
escapeRE,
sha256,
sha256_file,
atomic_json,
dict
} =c;
export const ENUM_TYPES=new Set(['AvatarPropertyType', 'OfferingRewardType', 'GridFightActivationType', 'GridFightChargeType', 'GridFightPreset', 'GridFightPropertyBindType', 'GridFightRoleSkillTag', 'GridFightTrailLayerQuality', 'GridFightTraitType', 'GridFightNodeType'].map(n=>'RPG.GameCore.'+n));
const ENUM_PROOFS=new Set();
export class Cursor {
constructor(data, at=0){
this.data=data;
this.at=at;
}
varint(){
let v=0n;
for(let shift=0n; shift<70n; shift+=7n){
if(this.at>=this.data.length)fail('truncated varint');
const byte=this.data[this.at++];
if(shift===63n&&byte>1)fail('varint overflow');
v|=BigInt(byte&127)<<shift;
if(byte<128)return small(v);
}
fail('varint overflow');
}
length(limit=10000){
const v=BigInt(this.varint());
if((v&1n)||v/2n>BigInt(limit))fail('invalid collection header');
return Number(v/2n);
}
}
export function pe_method(assembly, rva, size=8192){
const fd=fs.openSync(assembly, 'r');
try{
const h=Buffer.alloc(64);
fs.readSync(fd, h, 0, 64, 0);
const pe=h.readUInt32LE(60),
head=Buffer.alloc(264);
fs.readSync(fd, head, 0, 264, pe);
if(head.subarray(0, 4).toString('hex')!=='50450000')fail('invalid PE');
const count=head.readUInt16LE(6),
sections=pe+24+head.readUInt16LE(20),
base=head.readBigUInt64LE(48),
s=Buffer.alloc(count*40);
fs.readSync(fd, s, 0, s.length, sections);
for(let i=0; i<count; i++){
const at=i*40+8,
vs=s.readUInt32LE(at),
start=s.readUInt32LE(at+4),
rs=s.readUInt32LE(at+8),
raw=s.readUInt32LE(at+12);
if(start<=rva&&rva<start+Math.min(vs, rs)){
const b=Buffer.alloc(Math.min(size, rs-(rva-start)));
fs.readSync(fd, b, 0, b.length, raw+rva-start);
return [base+BigInt(rva), b];
}
}
fail('method RVA outside PE sections');
} finally{
fs.closeSync(fd);
}
}
export function disassemble(assembly, rva, size){
const [address, code]=pe_method(assembly, rva, size),
dir=fs.mkdtempSync(path.join(os.tmpdir(), 'gf-dis-')),
tmp=path.join(dir, 'code');
try{
fs.writeFileSync(tmp, code);
const p=spawnSync('objdump', ['-D', '-b', 'binary', '-m', 'i386:x86-64', '-Mintel', `--adjust-vma=${address}`, tmp], {
encoding:'utf8',
maxBuffer:16*1024*1024
});
if(p.status!==0)fail(p.error?.message??p.stderr);
return [address, p.stdout];
} finally{
fs.rmSync(dir, {
recursive:true,
force:true
});
}
}
export function schema(dump, assembly, name, evidence_dir){
const match=dump.match(new RegExp('// Full Name: RPG.GameCore\\.'+escapeRE(name)+'\\n(.*?)(?=\\n// TypeDefIndex:|$)', 's'));
if(!match)fail('missing client schema '+name);
const block=match[1],
fields=new Map(matches(block, /^ public ([\w.<>\[\]]+) (\w+) \/\/ Offset: (0x[\da-fA-F]+)/gm).map(m=>[Number(m[3]), [m[2], m[1]]]));
const method=block.match(/public static void FromBinary\([^\n]+RVA: (0x[\da-fA-F]+)/);
if(!fields.size||!method)fail('no binary reader for '+name);
const rva=Number(method[1]),
next=matches(dump, /RVA: (0x[\da-fA-F]+)/g).map(m=>Number(m[1])).filter(v=>rva<v&&v<=rva+8192);
const [address, dis]=disassemble(assembly, rva, next.length?Math.min(...next)-rva:8192),
tests=matches(dis, /test\s+(?:bl|ebx|rbx),0x([\da-f]+)/g),
ordered=new Map();
for(let i=0; i<tests.length; i++){
const m=tests[i],
bit=BigInt('0x'+m[1]);
if(!bit||(bit&(bit-1n)))continue;
const part=dis.slice(m.index+m[0].length, tests[i+1]?.index??dis.length);
const offsets=[...matches(part, /mov\s+(?:BYTE|DWORD|QWORD) PTR \[(?:rsi|rdi)\+0x([\da-f]+)\],(?:al|eax|rax|esi|rsi)/g), ...matches(part, /add\s+(?:rdi|rsi),0x([\da-f]+)/g)];
const found=new Set(offsets.map(x=>parseInt(x[1], 16)).filter(x=>fields.has(x)));
if(found.size!==1){
if(ordered.size===fields.size)break;
fail(`${name}: cannot prove field order for mask 0x${bit.toString(16)}`);
}
const [field_name, field_type]=fields.get([...found][0]);
if(ENUM_TYPES.has(field_type)){
const en=dump.match(new RegExp('// Full Name: '+escapeRE(field_type)+'\\n(.*?)(?=\\n// TypeDefIndex:|$)', 's'));
if(!en||!/public int value__/.test(en[1]))fail(field_type+': unsupported enum storage');
const reader=part.match(/mov\s+rdx,(?:rsi|rdi)[^\n]*\n[^\n]*call\s+(0x[\da-f]+)/);
if(!reader)fail(field_type+': enum reader not proved');
const rr=Number(BigInt(reader[1])-(address-BigInt(rva))),
pk=[assembly, rr, evidence_dir].join(':');
if(!ENUM_PROOFS.has(pk)){
const [, ed]=disassemble(assembly, rr, 1104);
if(![/shr\s+ebx,1/, /and\s+esi,0x1/, /neg\s+esi/, /xor\s+ebx,esi/].every(r=>r.test(ed)))fail('native enum codec changed');
fs.mkdirSync(evidence_dir, {
recursive:true
});
fs.writeFileSync(path.join(evidence_dir, `EnumReader_${rr.toString(16)}.asm`), ed.slice(ed.indexOf('Disassembly')));
ENUM_PROOFS.add(pk);
}
}
ordered.set(bit.toString(2).length-1, {
name:field_name,
type:field_type
});
if(ordered.size===fields.size)break;
}
if(!ordered.size||!Array.from({
length:ordered.size
}, (_, i)=>ordered.has(i)).every(Boolean))fail(name+': incomplete native schema');
fs.mkdirSync(evidence_dir, {
recursive:true
});
fs.writeFileSync(path.join(evidence_dir, name+'.asm'), dis.slice(dis.indexOf('Disassembly of section .data:')));
return Array.from({
length:ordered.size
}, (_, i)=>ordered.get(i));
}
export function field(cursor, typ, schemas){
if(typ.endsWith('[]'))return Array.from({
length:cursor.length()
}, ()=>field(cursor, typ.slice(0, -2), schemas));
if(typ==='System.Collections.Generic.List<uint>')return Array.from({
length:cursor.length()
}, ()=>field(cursor, 'uint', schemas));
if(typ==='RPG.Client.TextID'){
const v=cursor.varint();
if(v>0xffffffff)fail('TextID hash overflow');
return {
hash:zigzag(v),
hash64:cursor.varint()
};
}
if(typ==='RPG.GameCore.FixPoint')return {
wire:cursor.varint()
};
if(typ==='RPG.GameCore.AvatarPropertyValue'&&schemas)return record(cursor, schemas.AvatarPropertyValue, schemas);
if(typ==='string'){
const n=cursor.varint();
if(n>16384||cursor.at+Number(n)>cursor.data.length)fail('invalid string');
const raw=cursor.data.subarray(cursor.at, cursor.at+Number(n));
cursor.at+=Number(n);
return new TextDecoder('utf-8', {
fatal:true
}).decode(raw);
}
if(typ==='bool'){
if(cursor.at>=cursor.data.length)fail('truncated bool');
const v=cursor.data[cursor.at++];
if(v>1)fail('invalid bool');
return !!v;
}
if(typ==='uint'||ENUM_TYPES.has(typ)){
const v=cursor.varint();
if(v>0xffffffff)fail('uint overflow');
return ENUM_TYPES.has(typ)?zigzag(v):v;
}
fail('unsupported type '+typ);
}
export function record(cursor, columns, schemas){
const mask=BigInt(cursor.varint());
if(!mask||mask>>BigInt(columns.length))fail('invalid presence mask');
return Object.fromEntries(columns.flatMap((col, i)=>mask&(1n<<BigInt(i))?[[col.name, field(cursor, col.type, schemas)]]:[]));
}
export function encode_varint(value){
let v=BigInt(value);
if(v<0n||v>0xffffffffffffffffn)fail('varint overflow');
const out=[];
while(v>=128n){
out.push(Number(v&127n)|128);
v>>=7n;
}
out.push(Number(v));
return Buffer.from(out);
}
export function encode_field(v, t, s){
if(t.endsWith('[]'))return cat(encode_varint(2*v.length), v.map(x=>encode_field(x, t.slice(0, -2), s)));
if(t==='System.Collections.Generic.List<uint>')return cat(encode_varint(2*v.length), v.map(encode_varint));
if(t==='RPG.Client.TextID'){
const h=BigInt(v.hash);
return cat(encode_varint((h<<1n)^(h>>31n)), encode_varint(v.hash64));
}
if(t==='RPG.GameCore.FixPoint')return encode_varint(v.wire);
if(t==='RPG.GameCore.AvatarPropertyValue'&&s)return encode_record(v, s.AvatarPropertyValue, s);
if(ENUM_TYPES.has(t)){
const x=BigInt(v);
return encode_varint((x<<1n)^(x>>31n));
}
if(t==='bool')return Buffer.from([Number(v)]);
if(t==='string'){
const b=Buffer.from(v);
return cat(encode_varint(b.length), b);
}
return encode_varint(v);
}
export function encode_record(row, cols, s){
return cat(encode_varint(cols.reduce((m, col, i)=>col.name in row?m|(1n<<BigInt(i)):m, 0n)), cols.filter(col=>col.name in row).map(col=>encode_field(row[col.name], col.type, s)));
}
export const valid_roles=rows=>rows.length>=20&&c.uniqueValues(rows, r=>r.ID)&&rows.every(r=>r.ID>0&&r.ID<1000000&&r.AvatarID>=1000&&r.AvatarID<10000&&r.Rarity>=1&&r.Rarity<=5&&r.SeasonID>0&&r.SeasonID<100&&r.TraitList?.length&&r.TraitList.every(t=>t>0&&t<1000000));
export const valid_odds=rows=>rows.length>=5&&rows.length<=30&&rows.every((r, i)=>r.PlayerLevel===i+1&&[1, 2, 3, 4, 5].reduce((s, n)=>s+(r[`Rarity${n}Weight`]??0), 0)===100);
const tableProof=(name, blob, start, end, rows, data)=>({
table:name,
blob,
offset:start,
end,
records:rows.length,
rows,
table_sha256:sha256(data.subarray(start, end)),
blob_sha256:sha256(data)
});
const choose=(found, msg)=>{
if(!found.length)fail(msg);
if(new Set(found.map(m=>m.table_sha256)).size!==1)fail('ambiguous table; refusing to pick one');
return found[0];
};
export function find_table(client, columns, name, anchor_blob=null){
const roles=name==='GridFightRoleConfigRow';
if(roles&&columns.slice(0, 4).map(c=>c.name).join(',')!=='ID,AvatarID,FrontBackType,Rarity')fail('role locator schema changed');
if(!roles&&columns[0].name!=='PlayerLevel')fail('odds locator schema changed');
for(const layer of ['Persistent', 'StreamingAssets']){
const found=[];
for(const p of c.globBytes(path.join(client, 'StarRail_Data', layer, 'DesignData/Windows'))){
const blob=path.relative(path.join(client, 'StarRail_Data'), p);
if(path.basename(p, '.bytes').length!==32||!fs.statSync(p).size||(anchor_blob&&anchor_blob!==blob))continue;
const data=fs.readFileSync(p);
if(roles&&data.indexOf('SpriteOutput/GridFight')<0)continue;
for(let pos=0; pos<data.length; pos++){
if(roles?!(data[pos]>=128&&(data[pos]&11)===11):!(data[pos]<=63&&(data[pos]&3)===3&&data[pos+1]===1))continue;
try{
if(roles){
const prefix=new Cursor(data, pos),
mask=BigInt(prefix.varint());
if(mask>>BigInt(columns.length)||(mask&0x208bn)!==0x208bn)continue;
const id=prefix.varint(),
avatar=prefix.varint();
if(mask&4n)prefix.varint();
const rarity=prefix.varint();
if(!(id>0&&id<1000000&&avatar>=1000&&avatar<10000&&rarity>=1&&rarity<=5))continue;
}
const first=record(new Cursor(data, pos), columns);
if(roles?!(first.AvatarID>=1000&&first.AvatarID<10000&&first.Rarity>=1&&first.Rarity<=5&&first.TraitList?.length):first.PlayerLevel!==1)continue;
for(let start=Math.max(0, pos-5); start<pos; start++){
try{
const cursor=new Cursor(data, start),
count=cursor.length(roles?2000:30);
if(cursor.at!==pos||count<(roles?20:5))continue;
const rows=Array.from({
length:count
}, ()=>record(cursor, columns));
if(!(roles?valid_roles:valid_odds)(rows))continue;
const encoded=cat(encode_varint(count*2), rows.map(r=>encode_record(r, columns)));
if(!data.subarray(start, cursor.at).equals(encoded))continue;
found.push(tableProof(name, blob, start, cursor.at, rows, data));
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof RangeError||e instanceof TypeError))throw e;
}
}
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof RangeError||e instanceof TypeError))throw e;
}
}
}
if(found.length)return choose(found, name+': no verified complete table found');
}
fail(name+': no verified complete table found');
}
export function find_keyed_table(client, anchor, columns, name, first_key, valid, schemas, required_mask=null){
const data=fs.readFileSync(path.join(client, 'StarRail_Data', anchor)),
candidates=[];
if(required_mask!==null){
if(columns.length>10)fail('bounded locator requires at most ten fields');
const prefixes=[];
for(let m=1;
m<2**columns.length;
m++)if((m&required_mask)===required_mask)prefixes.push(cat(encode_varint(m), first_key===null?Buffer.alloc(0):encode_varint(first_key)));
const groups=new Map();
for(const p of prefixes){
if(!groups.has(p[0]))groups.set(p[0], []);
groups.get(p[0]).push(p);
}
for(let pos=0; pos<data.length; pos++){
const group=groups.get(data[pos]);
if(!group)continue;
const p=group.find(p=>data.subarray(pos, pos+p.length).equals(p));
if(p){
candidates.push([pos, [pos]]);
pos+=p.length-1;
}
}
} else{
const needle=encode_varint(first_key);
for(let at=data.indexOf(needle);
at>=0;
at=data.indexOf(needle, at+needle.length))candidates.push([at, Array.from({
length:Math.min(at, 10)
}, (_, i)=>Math.max(0, at-10)+i)]);
}
const found=[];
for(const [pos, starts]of candidates)for(const first of starts){
try{
const prefix=new Cursor(data, first),
mask=BigInt(prefix.varint());
if((required_mask===null&&prefix.at!==pos)||!(mask&1n)||mask>>BigInt(columns.length))continue;
for(let start=Math.max(0, first-5); start<first; start++){
try{
if(!start||data[start-1]!==0)continue;
const cursor=new Cursor(data, start),
count=cursor.length(10000);
if(cursor.at!==first||!count)continue;
const rows=Array.from({
length:count
}, ()=>record(cursor, columns, schemas));
if(!valid(rows))continue;
const encoded=cat(encode_varint(count*2), rows.map(r=>encode_record(r, columns, schemas)));
if(!data.subarray(start, cursor.at).equals(encoded))continue;
found.push(tableProof(name, anchor, start, cursor.at, rows, data));
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof RangeError||e instanceof TypeError))throw e;
}
}
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof RangeError||e instanceof TypeError))throw e;
}
}
return choose(found, name+': no complete related table found');
}
export function find_ui_blob(client){
for(const layer of ['Persistent', 'StreamingAssets']){
const found=c.globBytes(path.join(client, 'StarRail_Data', layer, 'DesignData/Windows')).filter(p=>{
if(!fs.statSync(p).size)return false;
const b=fs.readFileSync(p);
return b[0]!==123&&b.includes(Buffer.from('GridFightEntrancePage'))&&b.includes(Buffer.from('SpriteOutput/Rogue/Tourn/Titan/RankIcon/Small/'));
});
if(found.length===1)return path.relative(path.join(client, 'StarRail_Data'), found[0]);
if(found.length)fail('ambiguous GridFight UI Excel blob');
}
fail('no installed GridFight UI Excel blob');
}
export const valid_divisions=(rows, seasons)=>rows.length>=20&&c.uniqueValues(rows, r=>r.ID)&&c.subset(seasons, new Set(rows.map(r=>r.SeasonID)))&&rows.every(r=>r.ID>0&&r.ID<1000000&&seasons.has(r.SeasonID)&&(r.DivisionLevel??0)>=0&&(r.DivisionLevel??0)<=100&&r.Progress>0&&r.Progress<1000&&(!r.DivisionIcon||r.DivisionIcon.startsWith('SpriteOutput/Rogue/Tourn/Titan/RankIcon/')))&&rows.filter(r=>r.DivisionIcon).length>Math.floor(rows.length/2);
export const valid_season_modules=(rows, seasons)=>c.subset(seasons, new Set(rows.map(r=>r.SeasonID)))&&c.uniqueValues(rows, r=>[r.SeasonID, r.SubSeasonID].join(':'))&&rows.every(r=>seasons.has(r.SeasonID)&&r.SubSeasonID>0&&r.SubSeasonID<100&&r.ActivityModuleID>100000&&r.MaxRewardExp>0&&r.OfferingID>0);
const moduleList=r=>[...(r.ResidentModuleList??[]), ...(r.ActivityModuleIDList??[])];
export const valid_activities=(rows, modules)=>rows.length>=20&&c.uniqueValues(rows, r=>r.ActivityID)&&rows.every(r=>r.ActivityID>0&&r.ActivityID<10000000&&moduleList(r).every(m=>m>0))&&c.subset(modules, new Set(rows.flatMap(moduleList)));
export const valid_offering_levels=(rows, required)=>rows.length>20&&c.subset(required, new Set(rows.map(r=>r.TypeID)))&&c.uniqueValues(rows, r=>[r.TypeID, r.Level].join(':'))&&rows.every(r=>r.TypeID>0&&r.TypeID<10000&&r.Level>0&&r.Level<10000&&[0, 1].includes(r.Type??0)&&r.RewardID>0);
export const valid_offering_types=(rows, required, maxima)=>c.subset(required, new Set(rows.map(r=>r.ID)))&&c.uniqueValues(rows, r=>r.ID)&&rows.every(r=>r.ID>0&&r.ItemID>1000&&r.MaxLevel>0&&(!(r.ID in maxima)||r.MaxLevel===maxima[r.ID]));
export function season_contract(id, divisions, modules, activities){
const selected=modules.filter(r=>r.SeasonID===id).sort((a, b)=>a.SubSeasonID-b.SubSeasonID);
if(!selected.length)fail('requested season is absent');
const module=selected[0],
found=activities.filter(r=>moduleList(r).includes(module.ActivityModuleID));
if(found.length!==1||!found[0].ActivityPanelID)fail('season module has no unique activity panel');
const activity=found[0];
return {
season_id:id,
divisions:divisions.filter(r=>r.SeasonID===id).map(r=>r.ID),
activity_id:activity.ActivityID,
panel_id:activity.ActivityPanelID,
selection:'explicit season; first configured sub-season; not an inferred live schedule',
raw:{
selected_module:module,
selected_activity:activity,
divisions,
season_modules:modules,
activities:activities.filter(r=>modules.some(m=>moduleList(r).includes(m.ActivityModuleID)))
}
};
}
export const valid_trait_bases=(rows, required)=>c.subset(required, new Set(rows.map(r=>r.ID)))&&c.uniqueValues(rows, r=>r.ID)&&rows.every(r=>r.TraitName&&r.SeasonID>0&&(r.IconPath??'').startsWith('SpriteOutput/GridFight/'));
export const valid_trait_layers=(rows, required)=>c.subset(required, new Set(rows.map(r=>r.TraitID)))&&c.uniqueValues(rows, r=>[r.TraitID, r.Layer].join(':'))&&rows.every(r=>r.TraitID>0&&r.Layer>0&&r.Layer<100&&r.MazebuffID>=1000000);
export function text_matches(data, id){
const needle=encode_field(id, 'RPG.Client.TextID'),
found=[];
for(let at=data.indexOf(needle); at>=0; at=data.indexOf(needle, at+needle.length)){
if(!at||(data[at-1]&3)!==3||data[at-1]>=128)continue;
try{
const cursor=new Cursor(data, at+needle.length),
text=field(cursor, 'string');
if(text&&!/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(text))found.push({
offset:at-1,
end:cursor.at,
text
});
} catch(e){
if(!(e instanceof c.DecodeError||e instanceof TypeError))throw e;
}
}
if(new Set(found.map(f=>f.text)).size!==1)fail('TextMap key missing or conflicting');
return found[0];
}
export function trait_names(client, rows, language){
const keys=dict(rows.map(r=>[r.ID, r.TraitName]));
for(const layer of ['Persistent', 'StreamingAssets']){
const found=[];
for(const p of c.globBytes(path.join(client, 'StarRail_Data', layer, 'DesignData/Windows', language))){
if(!fs.statSync(p).size)continue;
const data=fs.readFileSync(p);
let resolved;
try{
resolved=dict(Object.keys(keys).map(k=>[k, text_matches(data, keys[k])]));
} catch(e){
if(e instanceof c.DecodeError)continue;
throw e;
}
found.push([resolved, {
blob:path.relative(path.join(client, 'StarRail_Data'), p),
blob_sha256:sha256(data),
keys:dict(Object.keys(keys).map(k=>[k, {
text_id:keys[k],
...resolved[k]
}]))
}]);
}
if(found.length){
if(new Set(found.map(m=>c.json(m[0]))).size!==1)fail('ambiguous TextMap blobs');
return found[0];
}
}
fail('no complete installed '+language+' TextMap for traits');
}
export function main(){
const a=c.cli(['client', 'dump', 'output'], {
language:'en',
'evidence-dir':null,
'season-id':null
}, ['traits']);
if(!['en', 'cn', 'cht', 'jp', 'kr', 'de', 'es', 'fr', 'id', 'pt', 'ru', 'th', 'vi'].includes(a.language))fail('Invalid language');
const dump_bytes=fs.readFileSync(a.dump),
dump=c.readText(a.dump),
names=['GridFightRoleConfigRow', 'GridFightRarityWeightConfigRow'];
if(a.traits)names.push('AvatarPropertyValue', 'GridFightTraitBaseConfigRow', 'GridFightTraitLayerConfigRow');
if(a['season-id']!==null)names.push('GridFightDivisionConfigRow', 'GridFightSeasonModuleConfigRow', 'ActivityConfigRow', 'OfferingLevelConfigRow', 'OfferingTypeConfigRow');
const schemas=Object.fromEntries(names.map(n=>[n, schema(dump, path.join(a.client, 'GameAssembly.dll'), n, a['evidence-dir']??path.join(a.output, 'evidence'))])),
rt=find_table(a.client, schemas.GridFightRoleConfigRow, 'GridFightRoleConfigRow'),
wt=find_table(a.client, schemas.GridFightRarityWeightConfigRow, 'GridFightRarityWeightConfigRow', rt.blob),
tables={
[rt.table]:rt,
[wt.table]:wt
};
const roles=rt.rows.map(r=>({
id:r.ID,
cost:r.Rarity,
avatar_id:r.AvatarID,
traits:r.TraitList,
raw:r
})),
odds=dict(wt.rows.map(r=>[r.PlayerLevel, [1, 2, 3, 4, 5].map(i=>r[`Rarity${i}Weight`]??0)])),
outputs={
'roles.json':roles,
'shop_odds.json':odds
};
let text_evidence=null;
if(a.traits){
let required=new Set(roles.flatMap(r=>r.traits));
const base=find_keyed_table(a.client, rt.blob, schemas.GridFightTraitBaseConfigRow, 'GridFightTraitBaseConfigRow', Math.min(...required), r=>valid_trait_bases(r, required), schemas);
required=new Set(base.rows.map(r=>r.ID));
const layers=find_keyed_table(a.client, rt.blob, schemas.GridFightTraitLayerConfigRow, 'GridFightTraitLayerConfigRow', Math.min(...required), r=>valid_trait_layers(r, required), schemas);
const [resolved, proof]=trait_names(a.client, base.rows, a.language);
text_evidence=proof;
outputs['traits.json']=base.rows.map(r=>({
id:r.ID,
name:resolved[r.ID].text,
thresholds:layers.rows.filter(t=>t.TraitID===r.ID).map(t=>t.Layer).sort((a, b)=>a-b),
raw:{
base:r,
layers:layers.rows.filter(t=>t.TraitID===r.ID)
}
}));
outputs['trait_layers.json']=layers.rows;
tables[base.table]=base;
tables[layers.table]=layers;
}
if(a['season-id']!==null){
const seasons=new Set(roles.map(r=>r.raw.SeasonID)),
ui=find_ui_blob(a.client),
div=find_keyed_table(a.client, ui, schemas.GridFightDivisionConfigRow, 'GridFightDivisionConfigRow', 1, r=>valid_divisions(r, seasons), schemas, 0x163),
modules=find_keyed_table(a.client, ui, schemas.GridFightSeasonModuleConfigRow, 'GridFightSeasonModuleConfigRow', Math.min(...seasons), r=>valid_season_modules(r, seasons), schemas, 0x1f),
ids=new Set(modules.rows.map(r=>r.ActivityModuleID)),
activities=find_keyed_table(a.client, ui, schemas.ActivityConfigRow, 'ActivityConfigRow', null, r=>valid_activities(r, ids), schemas, 0xd);
outputs['season.json']=season_contract(Number(a['season-id']), div.rows, modules.rows, activities.rows);
for(const t of [div, modules, activities])tables[t.table]=t;
const offerings=new Set(modules.rows.map(r=>r.OfferingID)),
levels=find_keyed_table(a.client, ui, schemas.OfferingLevelConfigRow, 'OfferingLevelConfigRow', 1, r=>valid_offering_levels(r, offerings), schemas, 3),
normal=levels.rows,
maxima={
};
for(const r of normal)if(!r.Type)maxima[r.TypeID]=Math.max(maxima[r.TypeID]??0, r.Level);
const types=find_keyed_table(a.client, ui, schemas.OfferingTypeConfigRow, 'OfferingTypeConfigRow', 1, r=>valid_offering_types(r, offerings, maxima), schemas, 7),
selected=outputs['season.json'].raw.selected_module.OfferingID;
outputs['season.json'].raw.offering_type=types.rows.find(r=>r.ID===selected);
outputs['season.json'].raw.offering_levels=normal.filter(r=>r.TypeID===selected);
outputs['offering_levels.json']=normal;
outputs['offering_types.json']=types.rows;
tables[levels.table]=levels;
tables[types.table]=types;
}
for(const [n, v]of Object.entries(outputs))atomic_json(path.join(a.output, n), v);
atomic_json(path.join(a.output, 'schemas.json'), schemas);
const manifest={
schema:1,
source:'installed-client-files',
dump_sha256:sha256(dump_bytes),
game_assembly_sha256:sha256_file(path.join(a.client, 'GameAssembly.dll')),
outputs:Object.fromEntries(Object.keys(outputs).map(n=>[n, sha256_file(path.join(a.output, n))])),
tables:Object.values(tables).map(t=>Object.fromEntries(Object.entries(t).filter(([k])=>k!=='rows'))),
mapping:{
'roles.cost':'GridFightRoleConfigRow.Rarity (shop tier; not a currency price)',
'roles.raw.SeasonID':'Caller must select its active season; rows from multiple seasons are retained.',
'shop_odds':'GridFightRarityWeightConfigRow.Rarity1Weight..Rarity5Weight; missing weights are zero.'
}
};
if(text_evidence)manifest.trait_textmap=text_evidence;
atomic_json(path.join(a.output, 'provenance.json'), manifest);
console.log(`Validated ${roles.length} roles, ${Object.keys(odds).length} shop levels and ${Object.keys(tables).length} tables from installed client bytes.`);
}
c.runMain(import.meta.url, main);
@@ -0,0 +1,700 @@
import {
test
} from 'node:test';
import assert from 'node:assert/strict';
import * as e from './extract.mjs';
import * as rounds from './rounds.mjs';
import * as stages from './stages.mjs';
import * as portals from './portals.mjs';
import * as trailblaze from './trailblaze.mjs';
import * as jade from './jade.mjs';
import {
indexed_entries,
verify_dynamic_dispatch
} from './native_codec.mjs';
import {
text_rows
} from './ui_names.mjs';
import {
transaction,
manifest,
verify_bundle,
replace
} from './install_common.mjs';
const B=(...a)=>Buffer.from(a), v=e.encode_varint, join=e.cat;
const throws=fn=>assert.throws(fn);
test('truncation and overflow', ()=>{
for(const data of [B(128), Buffer.concat([Buffer.alloc(9, 255), B(2)])])throws(()=>new e.Cursor(data).varint());
});
test('invalid collection and bool', ()=>{
for(const [b, t]of [[B(3), 'uint[]'], [B(2), 'bool'], [B(4, 1), 'uint[]']])throws(()=>e.field(new e.Cursor(b), t));
});
test('unknown presence bit', ()=>throws(()=>e.record(new e.Cursor(B(2, 1)), [{
name:'ID',
type:'uint'
}])));
test('TextID signed hash and 64 bit hash', ()=>{
const cols=[{
name:'TraitName',
type:'RPG.Client.TextID'
}],
row={
TraitName:{
hash:-2147483648,
hash64:18446744073709551615n
}
};
assert.deepEqual(e.record(new e.Cursor(e.encode_record(row, cols)), cols), row);
});
test('odds must be complete and normalized', ()=>{
const rows=Array.from({
length:11
}, (_, i)=>({
PlayerLevel:i+1,
Rarity1Weight:100
}));
assert.ok(e.valid_odds(rows));
rows[5].Rarity1Weight=99;
assert.equal(e.valid_odds(rows), false);
rows[5].Rarity1Weight=100;
rows[5].PlayerLevel=7;
assert.equal(e.valid_odds(rows), false);
});
test('duplicate role IDs rejected', ()=>{
const rows=Array.from({
length:20
}, (_, i)=>({
ID:1000+i,
AvatarID:1001,
Rarity:1,
SeasonID:1,
TraitList:[1001]
}));
assert.ok(e.valid_roles(rows));
rows.at(-1).ID=rows[0].ID;
assert.equal(e.valid_roles(rows), false);
});
test('struct names are not assumed to be enums', ()=>throws(()=>e.field(new e.Cursor(B(1)), 'RPG.GameCore.GridFightUnrecognizedRow')));
test('TextID hash overflow', ()=>throws(()=>e.field(new e.Cursor(join(Buffer.alloc(5, 255), B(1, 1))), 'RPG.Client.TextID')));
test('signed enum distinct from uint', ()=>{
assert.equal(e.field(new e.Cursor(B(2)), 'uint'), 2);
assert.equal(e.field(new e.Cursor(B(2)), 'RPG.GameCore.OfferingRewardType'), 1);
assert.equal(e.field(new e.Cursor(B(1)), 'RPG.GameCore.GridFightNodeType'), -1);
assert.deepEqual(e.encode_field(1, 'RPG.GameCore.OfferingRewardType'), B(2));
});
test('trait layers require coverage and unique thresholds', ()=>{
const rows=[{
TraitID:1001,
Layer:2,
MazebuffID:35100101
}, {
TraitID:2501,
Layer:3,
MazebuffID:35100102
}];
assert.ok(e.valid_trait_layers(rows, new Set([1001])));
assert.equal(e.valid_trait_layers(rows, new Set([1001, 1002])), false);
assert.equal(e.valid_trait_layers([...rows, rows[0]], new Set([1001])), false);
});
test('trait bases require names and coverage', ()=>{
const r={
ID:1001,
TraitName:{
hash:1,
hash64:2
},
SeasonID:1,
IconPath:'SpriteOutput/GridFight/1.png'
};
assert.ok(e.valid_trait_bases([r], new Set([1001])));
assert.equal(e.valid_trait_bases([r, r], new Set([1001])), false);
assert.equal(e.valid_trait_bases([r], new Set([1002])), false);
});
test('TextMap missing or conflicting names', ()=>{
const key={
hash:-1,
hash64:123
},
encoded=e.encode_field(key, 'RPG.Client.TextID'),
entry=t=>join(B(3), encoded, e.encode_field(t, 'string'));
assert.equal(e.text_matches(entry('Express Cohort'), key).text, 'Express Cohort');
for(const data of [B(), join(entry('first'), entry('different'))])throws(()=>e.text_matches(data, key));
});
test('season selection and panel must match', ()=>{
const d=[{
ID:10101,
SeasonID:1
}, {
ID:20101,
SeasonID:2
}],
m=[{
SeasonID:1,
SubSeasonID:1,
ActivityModuleID:7100201
}, {
SeasonID:2,
SubSeasonID:1,
ActivityModuleID:7110101
}],
a=[{
ActivityID:71002,
ActivityPanelID:71002,
ActivityModuleIDList:[7100201]
}, {
ActivityID:71101,
ActivityPanelID:71101,
ActivityModuleIDList:[7110101]
}];
assert.deepEqual(e.season_contract(1, d, m, a).divisions, [10101]);
assert.equal(e.season_contract(2, d, m, a).panel_id, 71101);
throws(()=>e.season_contract(3, d, m, a));
throws(()=>e.season_contract(1, d, m, a.slice(1)));
});
test('locator rejects unframed/conflicting collections', ()=>{
const cols=[{
name:'ID',
type:'uint'
}, {
name:'Value',
type:'uint'
}],
table=x=>join(v(4), e.encode_record({
ID:1,
Value:x
}, cols), e.encode_record({
ID:2,
Value:10
}, cols)),
tmp=e.fs.mkdtempSync(e.path.join(e.os.tmpdir(), 'gf-locator-'));
try{
const blob=e.path.join(tmp, 'StarRail_Data/test.bytes');
e.fs.mkdirSync(e.path.dirname(blob));
const locate=()=>e.find_keyed_table(tmp, 'test.bytes', cols, 'test', 1, r=>r.length===2&&r[0].ID===1&&r[1].ID===2, {
}, 3);
e.fs.writeFileSync(blob, join(B(0), table(20)));
assert.equal(locate().records, 2);
e.fs.writeFileSync(blob, join(B(153), table(20)));
throws(locate);
e.fs.writeFileSync(blob, join(B(0), table(20), B(0), table(30)));
throws(locate);
} finally{
e.fs.rmSync(tmp, {
recursive:true,
force:true
});
}
});
test('item and avatar grants use numbered params', ()=>{
const [g, complete]=portals.parse_grants('Obtains #2 "<gridfightinfo type=item id=355100302>" and #4 "<gridfightinfo type=role id=1501 color=rarity><name></gridfightinfo>."', [355100302, 1, 1501, 1]);
assert.deepEqual(g, [{
kind:'item',
count:1,
id:355100302
}, {
kind:'role',
count:1,
id:1501
}]);
assert.equal(complete, true);
});
test('conditional income not an immediate grant', ()=>assert.deepEqual(portals.parse_grants('Obtain #2 Gold Coin(s) for every #1 <color=#ffd471>5-cost character(s)</color> purchased.', [3, 3]), [[], false]));
test('delayed clause reported but not granted', ()=>assert.deepEqual(portals.parse_grants('Obtains #1 "<gridfightinfo type=item id=350105>." After #2 Node(s), obtains #3 "<gridfightinfo type=item id=350105>."', [1, 4, 1]), [[{
kind:'item',
count:1,
id:350105
}], false]));
test('unknown Obtain phrase grants nothing', ()=>assert.deepEqual(portals.parse_grants('Obtain 1 "furbo." It starts with #1 Gold Coin(s) as an initial fund.', [5]), [null, false]));
test('fractional count rejected', ()=>assert.deepEqual(portals.parse_grants('Obtain <unbreak>#1[i]</unbreak> free refresh(es).', [0.5]), [null, false]));
test('bare Obtain names one item', ()=>assert.deepEqual(portals.parse_grants('The "Energy" Bond\'s activated Ultimate effect can trigger 1 extra time. Obtain "<gridfightinfo type=item id=350208>" and <unbreak>#3[i]</unbreak> Gold Coin(s).', [35402057, 1, 5]), [[{
kind:'item',
count:1,
id:350208
}, {
kind:'gold',
count:5
}], false]));
test('item param not granted fails', ()=>throws(()=>portals.check_grants(1, [{
kind:'gold',
count:5
}], [350208, 5], new Set([350208]))));
function roundFixture(){
return {
GridFightStageRouteConfigRow:[{
ID:801,
ChapterID:1,
SectionID:1,
NodeTemplateID:10
}, {
ID:803,
ChapterID:1,
SectionID:1,
NodeTemplateID:11
}],
GridFightNodeTemplateConfigRow:[{
NodeTemplateID:10,
StageID:100,
NodeType:2,
ParamList:[801101]
}, {
NodeTemplateID:11,
StageID:101,
NodeType:5,
ParamList:[1]
}],
GridFightStageNodeDisplayConfigRow:[{
NodeType:2
}, {
NodeType:5
}],
StageRow:[{
StageID:100,
MonsterList:[]
}, {
StageID:101
}],
GridFightPlayerLevelConfigRow:[{
PlayerLevel:1,
LevelUpExp:2
}, {
PlayerLevel:2
}],
GridFightLevelConfigRow:[{
GridFightLevel:1,
LevelUpExp:999
}]
};
}
const project=t=>rounds.project(t, {
2:{
text:'Battle'
},
5:{
text:'Supply'
}
}, {
2:'CampMonster',
5:'Supply'
});
test('round route identity survives repeated chapter/section', ()=>{
const [r]=project(roundFixture());
assert.deepEqual(r.map(r=>r.stage), ['1-1', '1-1']);
assert.deepEqual(r.map(r=>r.route_id), [801, 803]);
});
test('round Supply and monster group not invented', ()=>{
const [r]=project(roundFixture());
assert.equal(r[1].kind, null);
assert.ok(r.every(r=>r.monster_group===null));
assert.deepEqual(r[0].raw.node_template.ParamList, [801101]);
});
test('wrong level curve never used', ()=>{
const [, costs]=project(roundFixture());
assert.deepEqual(costs, {
'1':2,
'2':0
});
assert.ok(!('raw'in costs));
});
test('round missing stage fails', ()=>{
const t=roundFixture();
t.StageRow.pop();
throws(()=>project(t));
});
test('round duplicate native route rejected', ()=>{
const t=roundFixture();
t.GridFightStageRouteConfigRow.push({
...t.GridFightStageRouteConfigRow[0]
});
throws(()=>project(t));
});
test('round gap in levels rejected', ()=>{
const t=roundFixture();
t.GridFightPlayerLevelConfigRow[1].PlayerLevel=3;
throws(()=>project(t));
});
test('truncated dynamic double fails', ()=>throws(()=>new rounds.Codec().dynamic(new e.Cursor(B(2, 0)))));
test('unknown dynamic tag fails', ()=>throws(()=>new rounds.Codec().dynamic(new e.Cursor(B(14)))));
test('signed/nested dynamic values round trip', ()=>{
const codec=new rounds.Codec(),
value={
tag:4,
value:[[{
tag:0,
value:-1
}, {
tag:3,
value:[{
tag:5,
value:'tutorial'
}]
}]]
},
data=codec.encode_dynamic(value),
cur=new e.Cursor(data);
assert.deepEqual(codec.dynamic(cur), value);
assert.equal(cur.at, data.length);
});
test('named hash matches previously verified odds', ()=>assert.equal(rounds.asset_hash('BakedConfig/ExcelOutputGameCore/GridFightRarityWeight.bytes'), 0xeb77d1b5a1204b00n));
export function stageFixture(){
return {
GridFightStageRouteConfigRow:[{
ID:801,
ChapterID:1,
SectionID:1,
NodeTemplateID:10
}, {
ID:1000,
ChapterID:1,
SectionID:1,
NodeTemplateID:11
}],
GridFightNodeTemplateConfigRow:[{
NodeTemplateID:10,
StageID:70000001,
NodeType:1,
ParamList:[900]
}, {
NodeTemplateID:11,
StageID:70000001,
NodeType:3,
ParamList:[113]
}],
StageRow:[{
StageID:70000001
}],
GridFightStageNodeDisplayConfigRow:[{
NodeType:1,
NodeName:{
hash:-1,
hash64:2
}
}, {
NodeType:3,
NodeName:{
hash:-3,
hash64:4
}
}],
GridFightPlayerLevelConfigRow:[{
PlayerLevel:1,
LevelUpExp:2
}, {
PlayerLevel:2
}],
GridFightLevelConfigRow:[{
GridFightLevel:1,
LevelUpExp:99
}],
GridFightTutorialStageConfigRow:[{
DivisionID:1,
ForbiddenSellRoleList:[{
GAJICDKPHPA:1220
}]
}],
GridFightTutorialStageNodeConfigRow:[{
DivisionID:1,
ChapterID:1,
SectionID:1
}],
GridFightDivisionStageRow:[{
DivisionID:1,
ScoreRule:801
}]
};
}
test('named player curve and missing EXP', ()=>{
const o=stages.contracts(stageFixture());
assert.deepEqual(o['levels.json'], {
'1':2,
'2':0
});
assert.ok(!('LevelUpExp'in o['levels.raw.json'][1]));
});
test('stage route identity and uncertain monster group preserved', ()=>{
const r=stages.contracts(stageFixture())['rounds.json'];
assert.deepEqual(r.map(r=>r.route_id), [801, 1000]);
assert.deepEqual(r.map(r=>r.stage), ['1-1', '1-1']);
assert.ok(r.every(r=>r.monster_group===null));
assert.deepEqual(r[1].raw.GridFightNodeTemplateConfigRow.ParamList, [113]);
});
test('first-play constant does not replace tutorial selector', ()=>{
const t=stageFixture();
t.ConstValueCommonRow=[{
ConstValueName:'GridFight_FirstPlay_StageRouteID',
Value:{
wire_tag:0,
value:803
}
}];
const o=stages.contracts(t)['tutorial.json'];
assert.equal(o.first_play_route_id, 803);
assert.deepEqual(o.observed_start_gameplay.values, [1, 1]);
});
test('stages DynamicValue signed integer/map round trip', ()=>{
const codec=new stages.Codec(),
value={
wire_tag:4,
value:[[{
wire_tag:0,
value:-803
}, {
wire_tag:3,
value:[{
wire_tag:5,
value:'raw'
}, {
wire_tag:2,
value:false
}]
}]]
},
wire=codec.encode_field(value, 'RPG.GameCore.DynamicValue'),
cur=new e.Cursor(wire);
assert.deepEqual(codec.field(cur, 'RPG.GameCore.DynamicValue'), value);
assert.equal(cur.at, wire.length);
assert.deepEqual(codec.field(new e.Cursor(B(12)), 'RPG.GameCore.DynamicValue'), {wire_tag:6,value:null});
throws(()=>codec.field(new e.Cursor(B(14)), 'RPG.GameCore.DynamicValue'));
});
test('stage duplicate route rejected', ()=>{
const t=stageFixture();
t.GridFightStageRouteConfigRow.push({
...t.GridFightStageRouteConfigRow[0]
});
throws(()=>stages.contracts(t));
});
test('stage missing native stage rejected', ()=>{
const t=stageFixture();
t.StageRow=[];
throws(()=>stages.contracts(t));
});
test('obfuscated tutorial fields unchanged', ()=>assert.deepEqual(stages.contracts(stageFixture())['tutorial.json'].stages[0].raw.GridFightTutorialStageConfigRow.ForbiddenSellRoleList, [{
GAJICDKPHPA:1220
}]));
const row=(mask, ...parts)=>join(v(mask), ...parts), table=rows=>join(B(0), v(rows.length*2), ...rows);
test('Trailblaze goods and father chain', ()=>{
const data=table([row(7, v(101), v(6), join(v(2), v(110000021))), row(7|32|2048, v(201), v(6), join(v(2), v(110000022)), v(10), v(101)), row(7|32, v(501), v(10), join(v(4), v(122402), v(1)), v(15))]),
goods=trailblaze.goods(trailblaze.decode(data));
assert.deepEqual(goods.map(g=>g.kind), ['maze_buff', 'maze_buff', 'role']);
assert.equal(goods[1].father, 101);
assert.deepEqual(goods[2].params, [122402, 1]);
assert.equal(goods[0].cost, 0);
});
test('Trailblaze rejects trailing bytes/missing father', ()=>{
const good=row(7, v(101), v(6), join(v(2), v(110000021)));
throws(()=>trailblaze.decode(join(table([good]), B(0))));
const orphan=row(7|2048, v(201), v(6), join(v(2), v(1)), v(999));
throws(()=>trailblaze.goods(trailblaze.decode(table([orphan]))));
});
// Additional coverage for Node-specific precision, publication and dependency ports.
test('lossless JSON integers, numeric key order and Unicode formatting', ()=>{
const raw='{\n "9": 18446744073709551615,\n "1": "\\u0e01"\n}\n',
parsed=e.parseJSON(raw);
assert.equal(parsed[9], 18446744073709551615n);
assert.equal(e.json(parsed, 1, true), raw);
throws(()=>e.parseJSON('{}garbage'));
});
test('DesignV rejects size mismatch, duplicate hashes and overlaps', ()=>{
const id='0123456789abcdef0123456789abcdef',
b=Buffer.alloc(60);
Buffer.from(id, 'hex').copy(b);
b.writeBigUInt64BE(20n, 16);
b.writeUInt32BE(2, 24);
b.writeBigUInt64BE(1n, 28);
b.writeUInt32BE(10, 36);
b.writeBigUInt64BE(2n, 44);
b.writeUInt32BE(10, 52);
b.writeUInt32BE(10, 56);
assert.equal(indexed_entries(b, id, 20).length, 2);
throws(()=>indexed_entries(b, id, 21));
b.writeUInt32BE(9, 56);
throws(()=>indexed_entries(b, id, 20));
b.writeUInt32BE(10, 56);
b.writeBigUInt64BE(1n, 44);
throws(()=>indexed_entries(b, id, 20));
});
test('Jade rejects cost types, duplicate IDs and invalid resource amounts', ()=>{
throws(()=>jade.goods([{
id:1
}]));
throws(()=>jade.goods([{
id:1,
cost_type:1
}, {
id:1,
cost_type:2
}]));
throws(()=>jade.goods([{
id:1,
cost_type:1,
name:{
},
param:1,
amounts:[]
}]));
assert.deepEqual(jade.goods([{
id:1,
cost_type:1,
name:{
},
param:1,
amounts:[5]
}]), [{
id:1,
cost:'gold',
price:0,
param:1,
resource:'gold',
amount:5
}]);
});
test('TextMap flags, count and key conflicts rejected', ()=>{
const text=e.encode_field('name', 'string'),
entry=join(B(2), v(123), text);
assert.equal(text_rows(join(v(2), entry, v(2)))[123], 'name');
throws(()=>text_rows(join(v(2), B(8))));
throws(()=>text_rows(join(v(2), entry, v(4))));
throws(()=>text_rows(join(v(4), entry, B(2), v(123), e.encode_field('other', 'string'), v(4))));
});
test('installer rollback restores old files and removes new files', ()=>{
const tmp=e.fs.mkdtempSync(e.path.join(e.os.tmpdir(), 'gf-install-'));
try{
const old=e.path.join(tmp, 'old'),
fresh=e.path.join(tmp, 'fresh'),
backup=e.path.join(tmp, 'backup');
replace(old, 'prior');
throws(()=>transaction([['source', old], ['source', fresh]], backup, write=>{
write(old, 'new');
write(fresh, 'new');
}, ()=>e.fail('post-publish check failed')));
assert.equal(e.readText(old), 'prior');
assert.equal(e.fs.existsSync(fresh), false);
assert.ok(e.fs.readdirSync(e.path.join(backup, 'source')).some(n=>n.includes('.bak.')));
} finally{
e.fs.rmSync(tmp, {
recursive:true,
force:true
});
}
});
test('managed manifests reject symlinks and traversal', ()=>{
const tmp=e.fs.mkdtempSync(e.path.join(e.os.tmpdir(), 'gf-manifest-'));
try{
e.fs.writeFileSync(e.path.join(tmp, 'data'), 'x');
e.fs.symlinkSync('data', e.path.join(tmp, 'link'));
throws(()=>manifest(tmp));
e.fs.unlinkSync(e.path.join(tmp, 'link'));
replace(e.path.join(tmp, 'BUILD-INFO.txt'), 'client_version=OSBETAWin1.2.3\nmode=full\n');
replace(e.path.join(tmp, 'SHA256SUMS'), '0'.repeat(64)+' ../outside\n');
throws(()=>verify_bundle(tmp, 'OSBETAWin1.2.3'));
} finally{
e.fs.rmSync(tmp, {
recursive:true,
force:true
});
}
});
test('stage installer publishes only verified data with backups',async()=>{
const {spawnSync}=await import('node:child_process');
const tmp=e.fs.mkdtempSync(e.path.join(e.os.tmpdir(),'gf-install-full-'));
try {
const launcher=e.path.join(tmp,'launcher'),source=e.path.join(tmp,'source'),stage=e.path.join(tmp,'stage');
const bundle=e.path.join(tmp,'bundle'),runtime=e.path.join(tmp,'runtime'),client=e.path.join(tmp,'client');
const version='OSBETAWin1.2.3',write=(p,b)=>e.replace(p,b);
const clientNames=['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'];
for(const n of clientNames)write(e.path.join(client,n),n.endsWith('BinaryVersion.bytes')?version:n);
const identity={version,files:clientNames.map(n=>({path:n,size:e.fs.statSync(e.path.join(client,n)).size,sha256:e.sha256_file(e.path.join(client,n))}))};
for(const n of ['server/gameserver','server/dispatch','server/config.json','server/hotfix.json','server/resources/AvatarConfig.json','protocol/root.zig','protocol/protocol.pb.zig','client-patch/launcher.exe','client-patch/hkrpg.dll','dump/design-data/catalogue.json'])write(e.path.join(bundle,n),n);
e.atomic_json(e.path.join(bundle,'dump/client.json'),identity);
write(e.path.join(bundle,'BUILD-INFO.txt'),`client_version=${version}\nmode=full\n`);
write(e.path.join(runtime,'PROFILE-BUILD'),version);
for(const n of ['root.zig','protocol.pb.zig'])write(e.path.join(runtime,'protocol/src',n),e.fs.readFileSync(e.path.join(bundle,'protocol',n)));
write(e.path.join(runtime,'zig-out/bin/gameserver'),'protected binary');
const roots=[e.path.join(source,'resources/grid_fight'),e.path.join(bundle,'server/resources/grid_fight'),e.path.join(runtime,'resources/grid_fight')];
for(const root of roots)write(e.path.join(root,'rounds.json'),'previous');
e.atomic_json(e.path.join(launcher,'client-profiles.json'),{beta:{bundle_dir:bundle,runtime_dir:runtime,client_dir:client,version}});
write(e.path.join(launcher,'.pearl-cache/runtime-refresh.lock'),'');
e.atomic_json(e.path.join(stage,'rounds.json'),[{route_id:803}]);
e.atomic_json(e.path.join(stage,'stage-schemas.json'),{});
e.atomic_json(e.path.join(stage,'stage-provenance.json'),{game_assembly_sha256:e.sha256_file(e.path.join(client,'GameAssembly.dll')),catalogues:[],tables:[],outputs:{'rounds.json':e.sha256_file(e.path.join(stage,'rounds.json'))}});
write(e.path.join(bundle,'SHA256SUMS'),manifest(bundle));
const script=e.path.join(e.HERE,'install_stages.mjs'),report=e.path.join(tmp,'report.json');
const result=spawnSync(process.execPath,[script,'--source',source,'--launcher',launcher,'--stage',stage,'--report',report],{encoding:'utf8',env:{...process.env,NODE_TEST_CONTEXT:''}});
assert.equal(result.status,0,result.stderr);
const installed=e.loadJSON(report);
assert.equal(installed.source_bundle_runtime_identical,true);
assert.equal(installed.processes_restarted,false);
assert.equal(e.readText(e.path.join(runtime,'zig-out/bin/gameserver')),'protected binary');
for(const root of roots)assert.equal(e.sha256_file(e.path.join(root,'rounds.json')),e.sha256_file(e.path.join(stage,'rounds.json')));
const backup=e.path.join(launcher,installed.backup,'source');
assert.ok(e.fs.readdirSync(backup).some(n=>n.startsWith('rounds.json.bak.gridfight-stage-')));
write(e.path.join(stage,'rounds.json'),'tampered');
const rejected=spawnSync(process.execPath,[script,'--source',source,'--launcher',launcher,'--stage',stage,'--report',report],{encoding:'utf8',env:{...process.env,NODE_TEST_CONTEXT:''}});
assert.notEqual(rejected.status,0);
for(const root of roots)assert.deepEqual(e.loadJSON(e.path.join(root,'rounds.json')),[{route_id:803}]);
} finally {e.fs.rmSync(tmp,{recursive:true,force:true});}
});
test('4.6.53 native DynamicValue dispatch is proved at metadata RVA', ()=>{
const dis=e.fs.readFileSync(new URL('./fixtures/dynamic-4.6.53.asm',import.meta.url),'utf8'),
address=0x19e745df0n, offsets=[0x85,0xc5,0x102,0x14f,0x18a,0x1cf],
read=(ta,size)=>{
assert.equal(ta,address+0x2b0n);assert.equal(size,24);
const raw=Buffer.alloc(24);offsets.forEach((v,i)=>raw.writeInt32LE(v-0x2b0,i*4));return raw;
};
assert.equal(verify_dynamic_dispatch(dis,address,read).tags.length,7);
throws(()=>verify_dynamic_dispatch(dis.replace('ja 0x19e745fb6','ja 0x19e745fbf'),address,read));
throws(()=>verify_dynamic_dispatch(dis.replace('shr rdx,1','shr rdx,2'),address,read));
throws(()=>verify_dynamic_dispatch(dis,address,(ta,size)=>{const b=read(ta,size);b.writeInt32LE(0xc5-0x2b0,0);return b;}));
throws(()=>verify_dynamic_dispatch(dis.replace('add rbx,0x20','add rbx,0x18'),address,read));
// The 4.6.51 dispatch uses the same relative case/table offsets at its older RVA.
const oldAddress=0x19e58e120n, delta=address-oldAddress,
relocated=dis.replace(/(?:0x)?19e[0-9a-f]{6}/g,v=>{const prefix=v.startsWith('0x')?'0x':'';const n=BigInt('0x'+v.replace(/^0x/,''));return prefix+(n-delta).toString(16);});
assert.equal(verify_dynamic_dispatch(relocated,oldAddress,(_,size)=>read(address+0x2b0n,size)).dispatch_offset,0x2b0);
});
test('DynamicValue exact bytes for all beta tags, nested null and 64-bit extremes', ()=>{
const values=[{wire_tag:0,value:-(1n<<63n)},{wire_tag:0,value:(1n<<63n)-1n},
{wire_tag:1,value:{double_wire:'010000000000f87f'}}, {wire_tag:2,value:true},
{wire_tag:3,value:[{wire_tag:6,value:null}]},
{wire_tag:4,value:[[{wire_tag:5,value:'beta'}, {wire_tag:6,value:null}]]},
{wire_tag:5,value:'4.6.53 ทดสอบ'}, {wire_tag:6,value:null}];
for(const codec of [new stages.Codec(),new rounds.Codec()])for(const value of values){
const v=codec.mode==='rounds'?JSON.parse(JSON.stringify(value,(_,x)=>typeof x==='bigint'?x.toString():x).replaceAll('"wire_tag"','"tag"')):value,
wire=codec.encode_dynamic(v),cursor=new e.Cursor(wire),decoded=codec.dynamic(cursor);
assert.equal(cursor.at,wire.length);assert.deepEqual(codec.encode_dynamic(decoded),wire);
for(let n=0;n<wire.length;n++)throws(()=>codec.dynamic(new e.Cursor(wire.subarray(0,n))));
}
});
test('4.6.53 Phainon schema excludes stack writes from object field candidates', ()=>{
const dump=e.fs.readFileSync(new URL('./fixtures/phainon-4.6.53.metadata.txt',import.meta.url),'utf8'),
dis=e.fs.readFileSync(new URL('./fixtures/phainon-4.6.53.asm',import.meta.url),'utf8'),
codec=new stages.Codec(dump);
codec.disassemble=()=>[0x19e825440n,dis];codec.proof=()=>{};
assert.deepEqual(codec.schema('RPG.GameCore.GridFightPhanionUniqueTraitConfigRow').map(r=>r.name),
['Rarity','Star','SavedValueAddList','SpecialSavedValueAddList','OwnerPropertyList','AllMemberPropertyList','SpecialOwnerPropertyList','SpecialAllMemberPropertyList','MaxStarBonusID']);
});
test('native double dictionaries preserve NaN payloads and signed zero', ()=>{
const codec=new stages.Codec(),typ='System.Collections.Generic.Dictionary<uint, double>',
raw=Buffer.from('0401010000000000f87f020000000000000080','hex'),cursor=new e.Cursor(raw),
decoded=codec.field(cursor,typ);
assert.equal(cursor.at,raw.length);assert.deepEqual(codec.encode_field(decoded,typ),raw);
throws(()=>codec.field(new e.Cursor(raw.subarray(0,-1)),typ));
});
test('SpotLight rows are the only source of Origin Bonds, 100705 carries Apprenticeship', ()=>{
const root=new URL('../../resources/grid_fight/', import.meta.url),
read=name=>JSON.parse(e.fs.readFileSync(new URL(name, root), 'utf8')),
spot=read('mechanics.json').GridFightSpotLightConfigRow,
traits=read('traits.json'),
roles=read('roles.json'),
carriers=spot.filter(row=>row.TraitID);
assert.deepEqual(carriers.map(row=>[row.ID, row.TraitID]).sort((x, y)=>x[0]-y[0]),
[[100705, 400502], [100800, 400102], [100801, 400302], [110801, 400202], [120902, 400402]]);
for(const row of carriers){
const trait=traits.find(t=>t.id===row.TraitID);
assert.ok(trait, 'trait '+row.TraitID);
assert.equal(trait.raw.base.TraitType, 3);
assert.ok(!roles.some(r=>r.traits.includes(row.TraitID)), 'no natural bearer of '+row.TraitID);
}
assert.equal(traits.find(t=>t.id===400502).name, 'Apprenticeship');
});
@@ -0,0 +1,176 @@
Disassembly of section .data:
000000019e745df0 <.data>:
19e745df0: 56 push rsi
19e745df1: 57 push rdi
19e745df2: 53 push rbx
19e745df3: 48 83 ec 30 sub rsp,0x30
19e745df7: 48 89 d7 mov rdi,rdx
19e745dfa: 48 89 ce mov rsi,rcx
19e745dfd: 48 c7 44 24 28 00 00 mov QWORD PTR [rsp+0x28],0x0
19e745e04: 00 00
19e745e06: 48 8b 0d a3 04 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb1304a3] # 0x1898762b0
19e745e0d: e8 be cb 81 e5 call 0x183f629d0
19e745e12: 48 89 07 mov QWORD PTR [rdi],rax
19e745e15: 48 8b 0d 4c 16 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb13164c] # 0x189877468
19e745e1c: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e745e23: 0f 84 c0 01 00 00 je 0x19e745fe9
19e745e29: 48 89 f1 mov rcx,rsi
19e745e2c: e8 ff 55 b8 ff call 0x19e2cb430
19e745e31: 48 8b 0f mov rcx,QWORD PTR [rdi]
19e745e34: 48 85 c9 test rcx,rcx
19e745e37: 0f 84 cf 01 00 00 je 0x19e74600c
19e745e3d: 89 c2 mov edx,eax
19e745e3f: d1 ea shr edx,1
19e745e41: 83 e0 01 and eax,0x1
19e745e44: f7 d8 neg eax
19e745e46: 31 d0 xor eax,edx
19e745e48: 88 41 30 mov BYTE PTR [rcx+0x30],al
19e745e4b: 48 8b 1f mov rbx,QWORD PTR [rdi]
19e745e4e: 48 85 db test rbx,rbx
19e745e51: 0f 84 ba 01 00 00 je 0x19e746011
19e745e57: 0f b6 43 30 movzx eax,BYTE PTR [rbx+0x30]
19e745e5b: 48 83 f8 05 cmp rax,0x5
19e745e5f: 0f 87 51 01 00 00 ja 0x19e745fb6
19e745e65: 48 8d 0d 34 02 00 00 lea rcx,[rip+0x234] # 0x19e7460a0
19e745e6c: 48 63 04 81 movsxd rax,DWORD PTR [rcx+rax*4]
19e745e70: 48 01 c8 add rax,rcx
19e745e73: ff e0 jmp rax
19e745e75: 48 8b 0d ec 15 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb1315ec] # 0x189877468
19e745e7c: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e745e83: 0f 84 8d 01 00 00 je 0x19e746016
19e745e89: 48 89 f1 mov rcx,rsi
19e745e8c: e8 9f 55 b8 ff call 0x19e2cb430
19e745e91: 48 8b 0f mov rcx,QWORD PTR [rdi]
19e745e94: 48 85 c9 test rcx,rcx
19e745e97: 0f 84 9c 01 00 00 je 0x19e746039
19e745e9d: 48 89 c2 mov rdx,rax
19e745ea0: 48 d1 ea shr rdx,1
19e745ea3: 83 e0 01 and eax,0x1
19e745ea6: 48 f7 d8 neg rax
19e745ea9: 48 31 d0 xor rax,rdx
19e745eac: 48 89 41 28 mov QWORD PTR [rcx+0x28],rax
19e745eb0: e9 01 01 00 00 jmp 0x19e745fb6
19e745eb5: 48 8b 0d ac 15 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb1315ac] # 0x189877468
19e745ebc: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e745ec3: 0f 84 75 01 00 00 je 0x19e74603e
19e745ec9: 48 8d 54 24 28 lea rdx,[rsp+0x28]
19e745ece: 48 89 f1 mov rcx,rsi
19e745ed1: e8 6a 2b bc ff call 0x19e308a40
19e745ed6: 48 8b 07 mov rax,QWORD PTR [rdi]
19e745ed9: 48 85 c0 test rax,rax
19e745edc: 0f 84 66 01 00 00 je 0x19e746048
19e745ee2: f2 0f 10 44 24 28 movsd xmm0,QWORD PTR [rsp+0x28]
19e745ee8: f2 0f 11 40 28 movsd QWORD PTR [rax+0x28],xmm0
19e745eed: e9 c4 00 00 00 jmp 0x19e745fb6
19e745ef2: 48 8b 0d 6f 15 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb13156f] # 0x189877468
19e745ef9: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e745f00: 0f 84 47 01 00 00 je 0x19e74604d
19e745f06: 48 85 f6 test rsi,rsi
19e745f09: 0f 84 4c 01 00 00 je 0x19e74605b
19e745f0f: 48 8b 4e 10 mov rcx,QWORD PTR [rsi+0x10]
19e745f13: 8b 56 18 mov edx,DWORD PTR [rsi+0x18]
19e745f16: e8 b5 41 9c fe call 0x19d10a0d0
19e745f1b: 89 c3 mov ebx,eax
19e745f1d: 48 89 f1 mov rcx,rsi
19e745f20: ba 01 00 00 00 mov edx,0x1
19e745f25: e8 46 39 6d 00 call 0x19ee19870
19e745f2a: 48 8b 07 mov rax,QWORD PTR [rdi]
19e745f2d: 48 85 c0 test rax,rax
19e745f30: 0f 84 61 01 00 00 je 0x19e746097
19e745f36: 0f b6 cb movzx ecx,bl
19e745f39: 48 89 48 28 mov QWORD PTR [rax+0x28],rcx
19e745f3d: eb 77 jmp 0x19e745fb6
19e745f3f: 48 8b 3d 42 ae 27 eb mov rdi,QWORD PTR [rip+0xffffffffeb27ae42] # 0x1899c0d88
19e745f46: 48 83 c3 20 add rbx,0x20
19e745f4a: 48 8b 0d 17 15 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb131517] # 0x189877468
19e745f51: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e745f58: 0f 84 02 01 00 00 je 0x19e746060
19e745f5e: 4c 8b 0d 43 ae 27 eb mov r9,QWORD PTR [rip+0xffffffffeb27ae43] # 0x1899c0da8
19e745f65: 48 89 f1 mov rcx,rsi
19e745f68: 48 89 fa mov rdx,rdi
19e745f6b: 49 89 d8 mov r8,rbx
19e745f6e: 48 83 c4 30 add rsp,0x30
19e745f72: 5b pop rbx
19e745f73: 5f pop rdi
19e745f74: 5e pop rsi
19e745f75: e9 66 ed 5a f9 jmp 0x197cf4ce0
19e745f7a: 48 8b 3d 07 ae 27 eb mov rdi,QWORD PTR [rip+0xffffffffeb27ae07] # 0x1899c0d88
19e745f81: 48 83 c3 18 add rbx,0x18
19e745f85: 48 8b 0d dc 14 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb1314dc] # 0x189877468
19e745f8c: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e745f93: 0f 84 d1 00 00 00 je 0x19e74606a
19e745f99: 48 8b 05 f8 ad 27 eb mov rax,QWORD PTR [rip+0xffffffffeb27adf8] # 0x1899c0d98
19e745fa0: 48 89 44 24 20 mov QWORD PTR [rsp+0x20],rax
19e745fa5: 48 89 f1 mov rcx,rsi
19e745fa8: 48 89 fa mov rdx,rdi
19e745fab: 49 89 f8 mov r8,rdi
19e745fae: 49 89 d9 mov r9,rbx
19e745fb1: e8 6a 00 5e f9 call 0x197d26020
19e745fb6: 90 nop
19e745fb7: 48 83 c4 30 add rsp,0x30
19e745fbb: 5b pop rbx
19e745fbc: 5f pop rdi
19e745fbd: 5e pop rsi
19e745fbe: c3 ret
19e745fbf: 48 83 c3 10 add rbx,0x10
19e745fc3: 48 8b 0d 9e 14 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb13149e] # 0x189877468
19e745fca: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e745fd1: 0f 84 9d 00 00 00 je 0x19e746074
19e745fd7: 48 89 f1 mov rcx,rsi
19e745fda: 48 89 da mov rdx,rbx
19e745fdd: 48 83 c4 30 add rsp,0x30
19e745fe1: 5b pop rbx
19e745fe2: 5f pop rdi
19e745fe3: 5e pop rsi
19e745fe4: e9 07 1e bb ff jmp 0x19e2f7df0
19e745fe9: e8 32 e6 80 e5 call 0x183f54620
19e745fee: 48 8b 0d 73 14 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb131473] # 0x189877468
19e745ff5: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e745ffc: 0f 85 27 fe ff ff jne 0x19e745e29
19e746002: e8 19 e6 80 e5 call 0x183f54620
19e746007: e9 1d fe ff ff jmp 0x19e745e29
19e74600c: e8 df a8 7a e5 call 0x183ef08f0
19e746011: e8 da a8 7a e5 call 0x183ef08f0
19e746016: e8 05 e6 80 e5 call 0x183f54620
19e74601b: 48 8b 0d 46 14 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb131446] # 0x189877468
19e746022: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e746029: 0f 85 5a fe ff ff jne 0x19e745e89
19e74602f: e8 ec e5 80 e5 call 0x183f54620
19e746034: e9 50 fe ff ff jmp 0x19e745e89
19e746039: e8 b2 a8 7a e5 call 0x183ef08f0
19e74603e: e8 dd e5 80 e5 call 0x183f54620
19e746043: e9 81 fe ff ff jmp 0x19e745ec9
19e746048: e8 a3 a8 7a e5 call 0x183ef08f0
19e74604d: e8 ce e5 80 e5 call 0x183f54620
19e746052: 48 85 f6 test rsi,rsi
19e746055: 0f 85 b4 fe ff ff jne 0x19e745f0f
19e74605b: e8 90 a8 7a e5 call 0x183ef08f0
19e746060: e8 bb e5 80 e5 call 0x183f54620
19e746065: e9 f4 fe ff ff jmp 0x19e745f5e
19e74606a: e8 b1 e5 80 e5 call 0x183f54620
19e74606f: e9 25 ff ff ff jmp 0x19e745f99
19e746074: e8 a7 e5 80 e5 call 0x183f54620
19e746079: 48 8b 0d e8 13 13 eb mov rcx,QWORD PTR [rip+0xffffffffeb1313e8] # 0x189877468
19e746080: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e746087: 0f 85 4a ff ff ff jne 0x19e745fd7
19e74608d: e8 8e e5 80 e5 call 0x183f54620
19e746092: e9 40 ff ff ff jmp 0x19e745fd7
19e746097: e8 54 a8 7a e5 call 0x183ef08f0
19e74609c: cc int3
19e74609d: 0f 1f 00 nop DWORD PTR [rax]
19e7460a0: d5 fd ff ff {rex2 0xfd} ud0 r31,r31
19e7460a4: 15 fe ff ff 52 adc eax,0x52fffffe
19e7460a9: fe (bad)
19e7460aa: ff (bad)
19e7460ab: ff 9f fe ff ff da call FWORD PTR [rdi-0x25000002]
19e7460b1: fe (bad)
19e7460b2: ff (bad)
19e7460b3: ff 1f call FWORD PTR [rdi]
19e7460b5: ff (bad)
19e7460b6: ff (bad)
19e7460b7: ff 0f dec DWORD PTR [rdi]
19e7460b9: 1f (bad)
19e7460ba: 84 00 test BYTE PTR [rax],al
19e7460bc: 00 00 add BYTE PTR [rax],al
...
@@ -0,0 +1,213 @@
Disassembly of section .data:
000000019e825440 <.data>:
19e825440: 41 57 push r15
19e825442: 41 56 push r14
19e825444: 41 54 push r12
19e825446: 56 push rsi
19e825447: 57 push rdi
19e825448: 53 push rbx
19e825449: 48 83 ec 28 sub rsp,0x28
19e82544d: 49 89 d7 mov r15,rdx
19e825450: 49 89 ce mov r14,rcx
19e825453: 80 3d 22 76 52 eb 00 cmp BYTE PTR [rip+0xffffffffeb527622],0x0 # 0x189d4ca7c
19e82545a: 0f 84 c7 02 00 00 je 0x19e825727
19e825460: 48 8b 0d 39 6e 0a eb mov rcx,QWORD PTR [rip+0xffffffffeb0a6e39] # 0x1898cc2a0
19e825467: e8 64 d5 73 e5 call 0x183f629d0
19e82546c: 49 89 07 mov QWORD PTR [r15],rax
19e82546f: 48 8b 0d f2 1f 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051ff2] # 0x189877468
19e825476: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e82547d: 0f 84 ba 02 00 00 je 0x19e82573d
19e825483: 4c 89 f1 mov rcx,r14
19e825486: e8 a5 5f aa ff call 0x19e2cb430
19e82548b: 48 89 c3 mov rbx,rax
19e82548e: f6 c3 01 test bl,0x1
19e825491: 74 2b je 0x19e8254be
19e825493: 49 8b 37 mov rsi,QWORD PTR [r15]
19e825496: 48 85 f6 test rsi,rsi
19e825499: 0f 84 da 02 00 00 je 0x19e825779
19e82549f: 48 8b 0d c2 1f 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051fc2] # 0x189877468
19e8254a6: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e8254ad: 0f 84 d5 02 00 00 je 0x19e825788
19e8254b3: 4c 89 f1 mov rcx,r14
19e8254b6: e8 75 5f aa ff call 0x19e2cb430
19e8254bb: 89 46 44 mov DWORD PTR [rsi+0x44],eax
19e8254be: f6 c3 02 test bl,0x2
19e8254c1: 74 2b je 0x19e8254ee
19e8254c3: 49 8b 37 mov rsi,QWORD PTR [r15]
19e8254c6: 48 85 f6 test rsi,rsi
19e8254c9: 0f 84 af 02 00 00 je 0x19e82577e
19e8254cf: 48 8b 0d 92 1f 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051f92] # 0x189877468
19e8254d6: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e8254dd: 0f 84 b4 02 00 00 je 0x19e825797
19e8254e3: 4c 89 f1 mov rcx,r14
19e8254e6: e8 45 5f aa ff call 0x19e2cb430
19e8254eb: 89 46 48 mov DWORD PTR [rsi+0x48],eax
19e8254ee: f6 c3 04 test bl,0x4
19e8254f1: 74 4f je 0x19e825542
19e8254f3: 49 8b 37 mov rsi,QWORD PTR [r15]
19e8254f6: 48 85 f6 test rsi,rsi
19e8254f9: 0f 84 84 02 00 00 je 0x19e825783
19e8254ff: 48 8b 3d f2 4b 15 eb mov rdi,QWORD PTR [rip+0xffffffffeb154bf2] # 0x18997a0f8
19e825506: 4c 8b 25 e3 4b 15 eb mov r12,QWORD PTR [rip+0xffffffffeb154be3] # 0x18997a0f0
19e82550d: 48 83 c6 10 add rsi,0x10
19e825511: 48 8b 0d 50 1f 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051f50] # 0x189877468
19e825518: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e82551f: 0f 84 81 02 00 00 je 0x19e8257a6
19e825525: 48 8b 05 4c 55 1a eb mov rax,QWORD PTR [rip+0xffffffffeb1a554c] # 0x1899caa78
19e82552c: 48 89 44 24 20 mov QWORD PTR [rsp+0x20],rax
19e825531: 4c 89 f1 mov rcx,r14
19e825534: 48 89 fa mov rdx,rdi
19e825537: 4d 89 e0 mov r8,r12
19e82553a: 49 89 f1 mov r9,rsi
19e82553d: e8 7e 0f 50 f9 call 0x197d264c0
19e825542: f6 c3 08 test bl,0x8
19e825545: 74 4f je 0x19e825596
19e825547: 49 8b 37 mov rsi,QWORD PTR [r15]
19e82554a: 48 85 f6 test rsi,rsi
19e82554d: 0f 84 3f 02 00 00 je 0x19e825792
19e825553: 48 8b 3d 9e 4b 15 eb mov rdi,QWORD PTR [rip+0xffffffffeb154b9e] # 0x18997a0f8
19e82555a: 4c 8b 25 8f 4b 15 eb mov r12,QWORD PTR [rip+0xffffffffeb154b8f] # 0x18997a0f0
19e825561: 48 83 c6 28 add rsi,0x28
19e825565: 48 8b 0d fc 1e 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051efc] # 0x189877468
19e82556c: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e825573: 0f 84 3c 02 00 00 je 0x19e8257b5
19e825579: 48 8b 05 f8 54 1a eb mov rax,QWORD PTR [rip+0xffffffffeb1a54f8] # 0x1899caa78
19e825580: 48 89 44 24 20 mov QWORD PTR [rsp+0x20],rax
19e825585: 4c 89 f1 mov rcx,r14
19e825588: 48 89 fa mov rdx,rdi
19e82558b: 4d 89 e0 mov r8,r12
19e82558e: 49 89 f1 mov r9,rsi
19e825591: e8 2a 0f 50 f9 call 0x197d264c0
19e825596: f6 c3 10 test bl,0x10
19e825599: 74 4f je 0x19e8255ea
19e82559b: 49 8b 37 mov rsi,QWORD PTR [r15]
19e82559e: 48 85 f6 test rsi,rsi
19e8255a1: 0f 84 fa 01 00 00 je 0x19e8257a1
19e8255a7: 48 8b 3d 72 fc 1c eb mov rdi,QWORD PTR [rip+0xffffffffeb1cfc72] # 0x1899f5220
19e8255ae: 4c 8b 25 53 43 1f eb mov r12,QWORD PTR [rip+0xffffffffeb1f4353] # 0x189a19908
19e8255b5: 48 83 c6 30 add rsi,0x30
19e8255b9: 48 8b 0d a8 1e 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051ea8] # 0x189877468
19e8255c0: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e8255c7: 0f 84 f7 01 00 00 je 0x19e8257c4
19e8255cd: 48 8b 05 3c 43 1f eb mov rax,QWORD PTR [rip+0xffffffffeb1f433c] # 0x189a19910
19e8255d4: 48 89 44 24 20 mov QWORD PTR [rsp+0x20],rax
19e8255d9: 4c 89 f1 mov rcx,r14
19e8255dc: 48 89 fa mov rdx,rdi
19e8255df: 4d 89 e0 mov r8,r12
19e8255e2: 49 89 f1 mov r9,rsi
19e8255e5: e8 26 31 4d f9 call 0x197cf8710
19e8255ea: f6 c3 20 test bl,0x20
19e8255ed: 74 4f je 0x19e82563e
19e8255ef: 49 8b 37 mov rsi,QWORD PTR [r15]
19e8255f2: 48 85 f6 test rsi,rsi
19e8255f5: 0f 84 b5 01 00 00 je 0x19e8257b0
19e8255fb: 48 8b 3d 1e fc 1c eb mov rdi,QWORD PTR [rip+0xffffffffeb1cfc1e] # 0x1899f5220
19e825602: 4c 8b 25 ff 42 1f eb mov r12,QWORD PTR [rip+0xffffffffeb1f42ff] # 0x189a19908
19e825609: 48 83 c6 38 add rsi,0x38
19e82560d: 48 8b 0d 54 1e 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051e54] # 0x189877468
19e825614: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e82561b: 0f 84 b2 01 00 00 je 0x19e8257d3
19e825621: 48 8b 05 e8 42 1f eb mov rax,QWORD PTR [rip+0xffffffffeb1f42e8] # 0x189a19910
19e825628: 48 89 44 24 20 mov QWORD PTR [rsp+0x20],rax
19e82562d: 4c 89 f1 mov rcx,r14
19e825630: 48 89 fa mov rdx,rdi
19e825633: 4d 89 e0 mov r8,r12
19e825636: 49 89 f1 mov r9,rsi
19e825639: e8 d2 30 4d f9 call 0x197cf8710
19e82563e: f6 c3 40 test bl,0x40
19e825641: 74 4f je 0x19e825692
19e825643: 49 8b 37 mov rsi,QWORD PTR [r15]
19e825646: 48 85 f6 test rsi,rsi
19e825649: 0f 84 70 01 00 00 je 0x19e8257bf
19e82564f: 48 8b 3d ca fb 1c eb mov rdi,QWORD PTR [rip+0xffffffffeb1cfbca] # 0x1899f5220
19e825656: 4c 8b 25 ab 42 1f eb mov r12,QWORD PTR [rip+0xffffffffeb1f42ab] # 0x189a19908
19e82565d: 48 83 c6 18 add rsi,0x18
19e825661: 48 8b 0d 00 1e 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051e00] # 0x189877468
19e825668: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e82566f: 0f 84 6d 01 00 00 je 0x19e8257e2
19e825675: 48 8b 05 94 42 1f eb mov rax,QWORD PTR [rip+0xffffffffeb1f4294] # 0x189a19910
19e82567c: 48 89 44 24 20 mov QWORD PTR [rsp+0x20],rax
19e825681: 4c 89 f1 mov rcx,r14
19e825684: 48 89 fa mov rdx,rdi
19e825687: 4d 89 e0 mov r8,r12
19e82568a: 49 89 f1 mov r9,rsi
19e82568d: e8 7e 30 4d f9 call 0x197cf8710
19e825692: f6 c3 80 test bl,0x80
19e825695: 74 4f je 0x19e8256e6
19e825697: 49 8b 37 mov rsi,QWORD PTR [r15]
19e82569a: 48 85 f6 test rsi,rsi
19e82569d: 0f 84 2b 01 00 00 je 0x19e8257ce
19e8256a3: 48 8b 3d 76 fb 1c eb mov rdi,QWORD PTR [rip+0xffffffffeb1cfb76] # 0x1899f5220
19e8256aa: 4c 8b 25 57 42 1f eb mov r12,QWORD PTR [rip+0xffffffffeb1f4257] # 0x189a19908
19e8256b1: 48 83 c6 20 add rsi,0x20
19e8256b5: 48 8b 0d ac 1d 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051dac] # 0x189877468
19e8256bc: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e8256c3: 0f 84 23 01 00 00 je 0x19e8257ec
19e8256c9: 48 8b 05 40 42 1f eb mov rax,QWORD PTR [rip+0xffffffffeb1f4240] # 0x189a19910
19e8256d0: 48 89 44 24 20 mov QWORD PTR [rsp+0x20],rax
19e8256d5: 4c 89 f1 mov rcx,r14
19e8256d8: 48 89 fa mov rdx,rdi
19e8256db: 4d 89 e0 mov r8,r12
19e8256de: 49 89 f1 mov r9,rsi
19e8256e1: e8 2a 30 4d f9 call 0x197cf8710
19e8256e6: f7 c3 00 01 00 00 test ebx,0x100
19e8256ec: 74 2b je 0x19e825719
19e8256ee: 49 8b 37 mov rsi,QWORD PTR [r15]
19e8256f1: 48 85 f6 test rsi,rsi
19e8256f4: 0f 84 e3 00 00 00 je 0x19e8257dd
19e8256fa: 48 8b 0d 67 1d 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051d67] # 0x189877468
19e825701: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e825708: 0f 84 e8 00 00 00 je 0x19e8257f6
19e82570e: 4c 89 f1 mov rcx,r14
19e825711: e8 1a 5d aa ff call 0x19e2cb430
19e825716: 89 46 40 mov DWORD PTR [rsi+0x40],eax
19e825719: 48 83 c4 28 add rsp,0x28
19e82571d: 5b pop rbx
19e82571e: 5f pop rdi
19e82571f: 5e pop rsi
19e825720: 41 5c pop r12
19e825722: 41 5e pop r14
19e825724: 41 5f pop r15
19e825726: c3 ret
19e825727: b9 6c 0e 01 00 mov ecx,0x10e6c
19e82572c: e8 5f 62 73 e5 call 0x183f5b990
19e825731: c6 05 44 73 52 eb 01 mov BYTE PTR [rip+0xffffffffeb527344],0x1 # 0x189d4ca7c
19e825738: e9 23 fd ff ff jmp 0x19e825460
19e82573d: e8 de ee 72 e5 call 0x183f54620
19e825742: 48 8b 0d 1f 1d 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051d1f] # 0x189877468
19e825749: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e825750: 0f 85 2d fd ff ff jne 0x19e825483
19e825756: e8 c5 ee 72 e5 call 0x183f54620
19e82575b: 48 8b 0d 06 1d 05 eb mov rcx,QWORD PTR [rip+0xffffffffeb051d06] # 0x189877468
19e825762: 80 b9 b7 00 00 00 00 cmp BYTE PTR [rcx+0xb7],0x0
19e825769: 0f 85 14 fd ff ff jne 0x19e825483
19e82576f: e8 ac ee 72 e5 call 0x183f54620
19e825774: e9 0a fd ff ff jmp 0x19e825483
19e825779: e8 72 b1 6c e5 call 0x183ef08f0
19e82577e: e8 6d b1 6c e5 call 0x183ef08f0
19e825783: e8 68 b1 6c e5 call 0x183ef08f0
19e825788: e8 93 ee 72 e5 call 0x183f54620
19e82578d: e9 21 fd ff ff jmp 0x19e8254b3
19e825792: e8 59 b1 6c e5 call 0x183ef08f0
19e825797: e8 84 ee 72 e5 call 0x183f54620
19e82579c: e9 42 fd ff ff jmp 0x19e8254e3
19e8257a1: e8 4a b1 6c e5 call 0x183ef08f0
19e8257a6: e8 75 ee 72 e5 call 0x183f54620
19e8257ab: e9 75 fd ff ff jmp 0x19e825525
19e8257b0: e8 3b b1 6c e5 call 0x183ef08f0
19e8257b5: e8 66 ee 72 e5 call 0x183f54620
19e8257ba: e9 ba fd ff ff jmp 0x19e825579
19e8257bf: e8 2c b1 6c e5 call 0x183ef08f0
19e8257c4: e8 57 ee 72 e5 call 0x183f54620
19e8257c9: e9 ff fd ff ff jmp 0x19e8255cd
19e8257ce: e8 1d b1 6c e5 call 0x183ef08f0
19e8257d3: e8 48 ee 72 e5 call 0x183f54620
19e8257d8: e9 44 fe ff ff jmp 0x19e825621
19e8257dd: e8 0e b1 6c e5 call 0x183ef08f0
19e8257e2: e8 39 ee 72 e5 call 0x183f54620
19e8257e7: e9 89 fe ff ff jmp 0x19e825675
19e8257ec: e8 2f ee 72 e5 call 0x183f54620
19e8257f1: e9 d3 fe ff ff jmp 0x19e8256c9
19e8257f6: e8 25 ee 72 e5 call 0x183f54620
19e8257fb: e9 0e ff ff ff jmp 0x19e82570e
@@ -0,0 +1,20 @@
// Full Name: RPG.GameCore.GridFightPhanionUniqueTraitConfigRow
public class GridFightPhanionUniqueTraitConfigRow : object {
// Fields:
public System.Collections.Generic.Dictionary<string, uint> SavedValueAddList // Offset: 0x10
public System.Collections.Generic.Dictionary<RPG.GameCore.AvatarPropertyType, double> SpecialOwnerPropertyList // Offset: 0x18
public System.Collections.Generic.Dictionary<RPG.GameCore.AvatarPropertyType, double> SpecialAllMemberPropertyList // Offset: 0x20
public System.Collections.Generic.Dictionary<string, uint> SpecialSavedValueAddList // Offset: 0x28
public System.Collections.Generic.Dictionary<RPG.GameCore.AvatarPropertyType, double> OwnerPropertyList // Offset: 0x30
public System.Collections.Generic.Dictionary<RPG.GameCore.AvatarPropertyType, double> AllMemberPropertyList // Offset: 0x38
public uint MaxStarBonusID // Offset: 0x40
public uint Rarity // Offset: 0x44
public uint Star // Offset: 0x48
// Constructors:
public void .ctor() {} // VA: 0x19E825800, RVA: 0x1E825800
// Methods:
public static void FromBinary(FCOEMCEDLNL, RPG.GameCore.GridFightPhanionUniqueTraitConfigRow) {} // VA: 0x19E825440, RVA: 0x1E825440
}
@@ -0,0 +1,114 @@
import {
spawnSync
} from 'node:child_process';
import * as e from './common.mjs';
export {
e
};
export const digest=e.sha256_file, replace=e.replace;
export function files(tree){
if(!e.fs.existsSync(tree))return [];
const result=[];
function visit(p){
for(const name of e.fs.readdirSync(p).sort()){
const full=e.path.join(p, name),
s=e.fs.lstatSync(full);
if(s.isSymbolicLink())e.fail('Symlink in managed build: '+full);
if(s.isDirectory())visit(full);
else if(s.isFile())result.push(full);
}
}
visit(tree);
return result.sort();
}
export const manifest=tree=>files(tree).filter(p=>e.path.basename(p)!=='SHA256SUMS').map(p=>`${digest(p)} ./${e.path.relative(tree,p)}\n`).join('');
export 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'];
const REQUIRED=['server/gameserver', 'server/dispatch', 'server/config.json', 'server/hotfix.json', 'server/resources/AvatarConfig.json', 'protocol/root.zig', 'protocol/protocol.pb.zig', 'client-patch/launcher.exe', 'client-patch/hkrpg.dll', 'dump/client.json', 'dump/design-data/catalogue.json', 'BUILD-INFO.txt'];
export function verify_bundle(bundle, version){
const info=Object.fromEntries(e.readText(e.path.join(bundle, 'BUILD-INFO.txt')).split('\n').filter(l=>l.includes('=')).map(l=>{
const at=l.indexOf('=');
return [l.slice(0, at), l.slice(at+1)];
}));
if(info.client_version!==version||info.mode!=='full')e.fail('Cached bundle does not match this full client profile');
const seen=new Set();
for(const line of e.readText(e.path.join(bundle, 'SHA256SUMS')).trimEnd().split('\n')){
const m=line.match(/^([0-9a-f]{64}) (?:\.\/)?(.+)$/);
if(!m)e.fail('Invalid bundle manifest');
const [, checksum, name]=m;
if(e.path.isAbsolute(name)||name.split('/').includes('..')||name.includes('\\')||seen.has(name))e.fail('Invalid or duplicate bundle path');
seen.add(name);
if(digest(e.path.join(bundle, name))!==checksum)e.fail('Bundle checksum mismatch: '+name);
}
const actual=new Set(files(bundle).filter(p=>e.path.basename(p)!=='SHA256SUMS').map(p=>e.path.relative(bundle, p)));
if(!e.subset(new Set(REQUIRED), seen)||seen.size!==actual.size||!e.subset(seen, actual))e.fail('Incomplete bundle manifest');
return info;
}
export function verify_client(bundle, client, version, mode){
const identity=e.loadJSON(e.path.join(bundle, 'dump/client.json')),
names=identity.files.map(x=>x.path);
if(identity.version!==version||names.length!==CLIENT_FILES.length||new Set(names).size!==CLIENT_FILES.length||!CLIENT_FILES.every(n=>names.includes(n)))e.fail('Incomplete or wrong client identity');
const found=e.fs.readFileSync(e.path.join(client, CLIENT_FILES.at(-1))).toString('latin1').match(/(?<![A-Z])(?:OS|CN)(?:PROD|REL|BETA)?Win\d+\.\d+\.\d+(?![\d.])/);
if(!found||found[0]!==version||(version.includes('BETA')?'beta':'live')!==mode)e.fail('Client version/channel changed; prepare that client first');
for(const entry of identity.files){
const p=e.path.join(client, entry.path);
if(e.fs.statSync(p).size!==entry.size||digest(p)!==entry.sha256)e.fail('Client build changed: '+entry.path);
}
}
export function verify_runtime(runtime, bundle, version){
if(e.readText(e.path.join(runtime, 'PROFILE-BUILD'))!==version)e.fail('Wrong runtime profile');
for(const name of ['root.zig', 'protocol.pb.zig'])if(digest(e.path.join(runtime, 'protocol/src', name))!==digest(e.path.join(bundle, 'protocol', name)))e.fail('Runtime protocol changed; refusing to bless an unrelated build');
const expected=e.path.join(bundle, 'server/resources/AeonCatalog.json');
if(e.fs.existsSync(expected)&&e.loadJSON(expected).client_version!==e.loadJSON(e.path.join(runtime, 'resources/AeonCatalog.json')).client_version)e.fail('Runtime resources belong to another client build');
}
export function withLock(launcher, fn){
const lock=e.path.resolve(launcher, '.pearl-cache/runtime-refresh.lock');
if(process.env.GRIDFIGHT_INSTALL_LOCK===lock)return fn();
const r=spawnSync('flock', ['-n', lock, process.execPath, process.argv[1], ...process.argv.slice(2)], {
stdio:'inherit',
env:{
...process.env,
GRIDFIGHT_INSTALL_LOCK:lock
}
});
if(r.status!==0)e.fail(r.error?.message??'Installation failed or runtime refresh lock is held');
}
export const token=()=>new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')+'-'+globalThis.crypto.randomUUID().replaceAll('-', '').slice(0, 8);
export function snapshot(root, excluded=new Set()){
return Object.fromEntries(files(root).filter(p=>!excluded.has(p)).map(p=>[e.path.relative(root, p), digest(p)]));
}
export const equal=(a, b)=>{
const ak=Object.keys(a).sort(),
bk=Object.keys(b).sort();
return ak.length===bk.length&&ak.every((k, i)=>k===bk[i]&&a[k]===b[k]);
};
export function transaction(targets, backup, publish, check, reason='gridfight'){
const saved=[];
for(const [area, target]of targets){
let previous=null;
if(e.fs.existsSync(target)){
previous=e.path.join(backup, area, e.path.basename(target)+'.bak.'+reason+'-'+e.path.basename(backup));
e.fs.mkdirSync(e.path.dirname(previous), {
recursive:true
});
e.fs.copyFileSync(target, previous);
if(digest(previous)!==digest(target))e.fail('Backup verification failed');
}
saved.push([target, previous]);
}
const published=new Set();
try{
publish((p, b)=>{
replace(p, b);
published.add(p);
});
check();
} catch(error){
for(const [p, prior]of saved.toReversed())if(published.has(p)){
if(prior)replace(p, e.fs.readFileSync(prior));
else e.fs.rmSync(p, {
force:true
});
}
throw error;
}
}
@@ -0,0 +1,89 @@
import * as r from './install_common.mjs';
const {
e
} =r;
export const NAMES=['rounds.json', 'tutorial.json', 'levels.json', 'rounds_raw.json', 'levels_raw.json'];
export const replace=r.replace;
export function install(a){
const provenance=e.loadJSON(e.path.join(a.stage, 'provenance.json'));
return r.withLock(a['launcher-root'], ()=>{
const launcher=a['launcher-root'],
registryPath=e.path.join(launcher, 'client-profiles.json'),
registry=e.loadJSON(registryPath),
profile=registry.beta,
bundle=profile.bundle_dir,
runtime=profile.runtime_dir,
version=profile.version,
client=profile.client_dir,
identity=e.loadJSON(e.path.join(bundle, 'dump/client.json'));
if(e.json(identity)!==e.json(provenance.client)||identity.version!==version||!version.includes('BETA'))e.fail('Extraction is not for the installed BETA profile');
r.verify_bundle(bundle, version);
r.verify_client(bundle, client, version, 'beta');
r.verify_runtime(runtime, bundle, version);
for(const name of NAMES)if(r.digest(e.path.join(a.stage, name))!==provenance.outputs[name])e.fail('Extracted resource hash changed');
const proofs=new Map([...provenance.collections, provenance.node_textmap].map(p=>[p.blob, p]));
for(const proof of proofs.values())if(r.digest(e.path.join(client, 'StarRail_Data', proof.blob))!==proof.blob_sha256)e.fail('Client DesignData changed after extraction');
for(const proof of provenance.catalogues)if(r.digest(e.path.join(client, proof.path))!==proof.sha256)e.fail('Installed active DesignV changed');
const token=r.token(),
backup=e.path.join(launcher, '.pearl-cache/gridfight-data-backups', token),
roots={
source:e.path.join(a['server-root'], 'resources/grid_fight'),
bundle:e.path.join(bundle, 'server/resources/grid_fight'),
runtime:e.path.join(runtime, 'resources/grid_fight')
},
targets=Object.entries(roots).flatMap(([area, root])=>NAMES.map(n=>[area, e.path.join(root, n), e.path.join(a.stage, n)]));
targets.push(['bundle', e.path.join(bundle, 'SHA256SUMS'), null]);
const excluded=new Set(targets.map(([, p])=>p)),
bundleBefore=r.snapshot(bundle, excluded),
runtimeBefore=r.snapshot(e.path.join(runtime, 'resources'), excluded),
binaries=Object.fromEntries(['gameserver', 'dispatch'].map(n=>[n, r.digest(e.path.join(runtime, 'zig-out/bin', n))])),
registryBefore=r.digest(registryPath),
liveBundle=registry.live.bundle_dir,
liveBefore=r.snapshot(liveBundle, excluded);
r.transaction(targets, backup, write=>{
for(const [, p, incoming]of targets)if(incoming)write(p, e.fs.readFileSync(incoming));
write(e.path.join(bundle, 'SHA256SUMS'), r.manifest(bundle));
}, ()=>{
r.verify_bundle(bundle, version);
r.verify_runtime(runtime, bundle, version);
for(const name of NAMES)if(new Set(Object.values(roots).map(root=>r.digest(e.path.join(root, name)))).size!==1||r.digest(e.path.join(roots.source, name))!==provenance.outputs[name])e.fail('Source/bundle/runtime resource mismatch');
if(!r.equal(r.snapshot(bundle, excluded), bundleBefore)||!r.equal(r.snapshot(e.path.join(runtime, 'resources'), excluded), runtimeBefore))e.fail('An unrelated managed resource changed');
if(!r.equal(r.snapshot(liveBundle, excluded), liveBefore)||r.digest(registryPath)!==registryBefore)e.fail('LIVE bundle or profile registry changed');
for(const [name, d]of Object.entries(binaries))if(r.digest(e.path.join(runtime, 'zig-out/bin', name))!==d)e.fail('Runtime binaries changed');
}, 'gridfight-data');
const cache=e.path.join(launcher, '.pearl-cache/gridfight-rounds', version, token);
e.fs.cpSync(a.stage, cache, {
recursive:true,
errorOnExist:true,
force:false
});
const evidence=e.path.join(a['server-root'], 'tools/gridfight-extract-node/evidence/rounds');
e.fs.cpSync(e.path.join(a.stage, 'evidence'), evidence, {
recursive:true
});
for(const name of ['schemas.json', 'provenance.json'])r.replace(e.path.join(evidence, name), e.fs.readFileSync(e.path.join(a.stage, name)));
const report={
version,
token,
outputs:provenance.outputs,
counts:provenance.counts,
backup:e.path.relative(launcher, backup),
bundle_manifest_verified:true,
source_bundle_runtime_match:true,
beta_non_target_resources_preserved:Object.keys(runtimeBefore).length,
live_bundle_files_preserved:Object.keys(liveBefore).length,
runtime_binaries_preserved:true,
registry_preserved:true,
processes_started_or_stopped:0,
gameplay_verified:false
};
e.atomic_json(e.path.join(evidence, 'validation.json'), report, 2, true);
e.atomic_json(e.path.join(cache, 'install.json'), report, 2, true);
console.log(e.json(report));
return report;
});
}
export function main(){
install(e.cli(['server-root', 'launcher-root', 'stage']));
}
e.runMain(import.meta.url, main);
@@ -0,0 +1,88 @@
import * as r from './install_common.mjs';
const {
e
} =r;
export const replace=r.replace;
export function install(a){
const provenanceName=a.package+'-provenance.json',
schemasName=a.package+'-schemas.json',
provenance=e.loadJSON(e.path.join(a.stage, provenanceName));
return r.withLock(a.launcher, ()=>{
const registryPath=e.path.join(a.launcher, 'client-profiles.json'),
registryHash=r.digest(registryPath),
registry=e.loadJSON(registryPath),
profile=registry.beta,
bundle=profile.bundle_dir,
runtime=profile.runtime_dir,
client=profile.client_dir;
r.verify_bundle(bundle, profile.version);
r.verify_client(bundle, client, profile.version, 'beta');
r.verify_runtime(runtime, bundle, profile.version);
if(provenance.game_assembly_sha256!==r.digest(e.path.join(client, 'GameAssembly.dll')))e.fail('Staged tables belong to another installed client');
for(const entry of provenance.catalogues)if(r.digest(e.path.join(client, entry.path))!==entry.sha256)e.fail('DesignV changed since extraction');
const blobs=Object.fromEntries(provenance.tables.map(x=>[x.blob, x.blob_sha256]));
for(const [blob, checksum]of Object.entries(blobs))if(r.digest(e.path.join(client, blob))!==checksum)e.fail('DesignData changed since extraction');
for(const [name, digest]of Object.entries(provenance.outputs))if(r.digest(e.path.join(a.stage, name))!==digest)e.fail('Staged output changed: '+name);
const names=[...Object.keys(provenance.outputs), provenanceName, schemasName],
source=e.path.join(a.source, 'resources/grid_fight'),
destinations={
source,
bundle:e.path.join(bundle, 'server/resources/grid_fight'),
runtime:e.path.join(runtime, 'resources/grid_fight')
},
targets=Object.entries(destinations).flatMap(([area, root])=>names.map(name=>[area, e.path.join(root, name), e.path.join(a.stage, name)]));
targets.push(['bundle', e.path.join(bundle, 'SHA256SUMS'), null]);
const changed=new Set(targets.map(([, p])=>p)),
protectedFiles={
};
for(const root of [bundle, e.path.join(runtime, 'resources'), e.path.join(runtime, 'zig-out/bin')])for(const p of r.files(root))if(!changed.has(p))protectedFiles[p]=r.digest(p);
for(const name of ['gameserver', 'dispatch']){
const p=e.path.join(runtime, name);
if(e.fs.existsSync(p)&&e.fs.statSync(p).isFile())protectedFiles[p]=r.digest(p);
}
const token=r.token(),
backup=e.path.join(a.launcher, '.pearl-cache/gridfight-'+a.package+'-backups', token);
e.fs.mkdirSync(backup, {
recursive:true
});
r.transaction(targets, backup, write=>{
for(const [, target, incoming]of targets)if(incoming)write(target, e.fs.readFileSync(incoming));
write(e.path.join(bundle, 'SHA256SUMS'), r.manifest(bundle));
}, ()=>{
r.verify_bundle(bundle, profile.version);
r.verify_runtime(runtime, bundle, profile.version);
for(const name of names)if(new Set(Object.values(destinations).map(root=>r.digest(e.path.join(root, name)))).size!==1)e.fail('Source/bundle/runtime differ');
if(r.digest(registryPath)!==registryHash)e.fail('Profile registry changed during installation');
for(const [p, d]of Object.entries(protectedFiles))if(r.digest(p)!==d)e.fail('Non-target bundle/resource or runtime binary changed');
}, 'gridfight-'+a.package);
const report={
version:profile.version,
mode:'beta',
token,
backup:e.path.relative(a.launcher, backup),
outputs:Object.fromEntries(names.map(n=>[n, r.digest(e.path.join(source, n))])),
source_bundle_runtime_identical:true,
bundle_manifest_verified:true,
non_target_files_preserved:Object.keys(protectedFiles).length,
runtime_binaries_preserved:true,
registry_unchanged:true,
processes_restarted:false,
gameplay_verified:false,
written_targets:targets.map(([area, p])=>({
area,
file:e.path.basename(p)
}))
};
e.atomic_json(a.report, report, 2, true);
console.log(e.json(report));
return report;
});
}
export function main(){
const a=e.cli(['source', 'launcher', 'stage', 'report'], {
package:'stage'
});
if(!['stage', 'petpal'].includes(a.package))e.fail('Invalid package');
install(a);
}
e.runMain(import.meta.url, main);
+42
View File
@@ -0,0 +1,42 @@
import * as s from './stages.mjs';
const e=s.primitive;
export const TABLES={
GridFightItemConfigRow:['GameCore', 'GridFightItems'],
GridFightEquipConfigRow:['GameCore', 'GridFightEquipment'],
GridFightCraftConfigRow:['GameCore', 'GridFightCraftConfig'],
GridFightEquipUpgradeConfigRow:['GameCore', 'GridFightEquipUpgrade'],
GridFightConsumablesConfigRow:['GameCore', 'GridFightConsumables'],
GridFightAugmentConfigRow:['GameCore', 'GridFightAugment'],
GridFightPortalBuffConfigRow:['', 'GridFightPortalBuff'],
GridFightSeasonAugmentConfigRow:['', 'GridFightSeasonAugment'],
GridFightSeasonPortalConfigRow:['', 'GridFightSeasonPortal'],
GridFightSeasonItemConfigRow:['', 'GridFightSeasonItem'],
GridFightSeasonCraftConfigRow:['', 'GridFightSeasonCraft']
};
export function validate(t){
for(const [row, link, target, key]of [['GridFightSeasonAugmentConfigRow', 'AugmentID', 'GridFightAugmentConfigRow', 'ID'], ['GridFightSeasonPortalConfigRow', 'PortalID', 'GridFightPortalBuffConfigRow', 'ID'], ['GridFightSeasonCraftConfigRow', 'CraftID', 'GridFightCraftConfigRow', 'CraftID']]){
const known=new Set(t[target].map(i=>i[key]));
if(t[row].some(i=>!known.has(i[link])))e.fail(`Unresolved season reference: ${row}.${link}`);
}
if(!e.subset(new Set(t.GridFightEquipConfigRow.map(i=>i.ID)), new Set(t.GridFightItemConfigRow.map(i=>i.ID))))e.fail('Equipment missing from native item catalog');
}
export function main(){
const a=e.cli(['client', 'dump', 'identity', 'output']),
ctx=s.extractionContext(a),
[t, proofs]=s.collect(a, TABLES, ctx);
validate(t);
e.atomic_json(e.path.join(a.output, 'items.raw.json'), t);
e.atomic_json(e.path.join(a.output, 'items-schemas.json'), ctx.codec.schemas);
e.atomic_json(e.path.join(a.output, 'items-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(['items.raw.json', 'items-schemas.json'].map(n=>[n, e.sha256_file(e.path.join(a.output, n))])),
purpose:'Raw inventory, recipe and selection contracts; no effect implementation',
unresolved:['Forge asset binding', 'Native choice generation and effect execution', 'Equipment transfer on merge/sell', 'Season item ConfigID semantics'],
gameplay_verified:false
});
}
e.runMain(import.meta.url, main);
+75
View File
@@ -0,0 +1,75 @@
import * as s from './stages.mjs';
import {
Reader,
zigzag
} from './trait_effects.mjs';
const e=s.primitive;
export const COST={
1:'gold',
2:'hp'
}, RESOURCES={
1:'gold',
2:'refresh',
3:'exp',
4:'hp'
};
export function decode(raw){
const r=new Reader(raw),
rows=[],
count=Math.floor(Number(r.var())/2);
for(let i=0; i<count; i++){
const mask=BigInt(r.var()),
row={
};
if(mask&1n)row.id=r.var();
if(mask&2n)row.name={
hash:r.var(),
hash64:r.var()
};
if(mask&4n)row.amounts=Array.from({
length:Math.floor(Number(r.var())/2)
}, ()=>r.var());
if(mask&8n)row.param=r.var();
if(mask&16n)row.cost_type=zigzag(r.var());
if(mask&32n)row.price=r.var();
if(mask>>6n)e.fail('GridFightJadeUniqueTrait: unknown fields');
rows.push(row);
}
r.done('GridFightJadeUniqueTrait');
return rows;
}
export function goods(rows){
const out=[];
for(const r of rows){
const cost=COST[r.cost_type??0];
if(cost===undefined)e.fail('Jade goods '+r.id+': unknown cost type '+r.cost_type);
const param=r.param??0,
good={
id:r.id,
cost,
price:r.price??0,
param
};
if('name'in r&&param in RESOURCES){
if((r.amounts??[]).length!==1)e.fail('Jade goods '+r.id+': resource reward needs one amount');
good.resource=RESOURCES[param];
good.amount=r.amounts[0];
}
out.push(good);
}
if(!e.uniqueValues(out, g=>g.id))e.fail('duplicate Jade goods ID');
return out;
}
export function main(){
const a=e.cli(['client'], {
output:e.resource('jade.json')
}),
[raw, proof]=new s.Assets(a.client).get('BakedConfig/ExcelOutput/GridFightJadeUniqueTrait.bytes'),
out={
goods:goods(decode(raw)),
provenance:proof
};
e.atomic_json(a.output, out, 1, true);
console.log(`${a.output}: ${out.goods.length} goods`);
}
e.runMain(import.meta.url, main);
+121
View File
@@ -0,0 +1,121 @@
// 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);
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {Codec, asset_hash} from './stages.mjs';
import {references, candidates, locate, decodeRecord, ROOT_TYPES} from './level.mjs';
import {sha256} from './extract.mjs';
test('level references deduplicate paths while retaining JSON pointer citations', () => {
const p = 'Config/Level/GridFight/PortalBuff/GridFightPortal149.json';
assert.deepEqual(references({'a.json': {'a/b': [p, p], other: 'other.json'}}), [
{path: p, sources: [{file: 'a.json', pointer: '/a~1b/0'}, {file: 'a.json', pointer: '/a~1b/1'}]}
]);
assert.throws(() => candidates('Config/Level/GridFight/../secret.json'), /Invalid/);
});
test('level lookup distinguishes missing assets from corrupt indexed assets', () => {
const path = 'Config/Level/GridFight/SpotLight/GridFightSpotLight100000.json';
const missing = locate({layers: [new Map()], get() { assert.fail('missing index must not read'); }}, path);
assert.equal(missing.status, 'not-in-designv');
assert.equal(missing.attempts.length, 5);
const asset = 'BakedConfig/' + path.replace('.json', '.bytes');
const present = {layers: [new Map([[asset_hash(asset), {}]])], get(p) { assert.equal(p, asset); throw Error('Truncated indexed asset'); }};
assert.throws(() => locate(present, path), /Truncated/);
present.get = () => [Buffer.from([1]), {asset}];
assert.equal(locate(present, path).proof.asset, asset);
});
test('whole-record proof rejects trailing bytes, changed encoders, and stripped readers', () => {
const codec = new Codec(), type = 'RPG.GameCore.TestLevel';
codec.schemas[type] = [{name: 'Nodes', type: 'int[]'}, {name: 'Count', type: 'int'}];
codec.fields = () => new Map([[16, ['Nodes', 'int[]']], [24, ['Count', 'int']]]);
const row = {Nodes: [1, 6, -1], Count: 3}, raw = codec.encode_record(row, type);
assert.deepEqual(decodeRecord(codec, raw, type).row, row);
assert.throws(() => decodeRecord(codec, Buffer.concat([raw, Buffer.from([0])]), type), /round-trip/);
codec.encode_record = () => Buffer.from([0]);
assert.throws(() => decodeRecord(codec, raw, type), /round-trip/);
codec.fields = () => new Map();
assert.throws(() => decodeRecord(codec, Buffer.from([0]), type), /No serialized client fields/);
});
test('installed level audit exposes unknown rules without false round-trip proofs', () => {
const dir = new URL('../../resources/grid_fight/', import.meta.url);
const read = name => fs.readFileSync(new URL(name, dir));
const data = JSON.parse(read('level-availability.json')), proof = JSON.parse(read('level-provenance.json'));
const schemaFile = JSON.parse(read('level-schemas.json')), schemas = schemaFile.client_roots;
assert.equal(data.status, 'blocked-no-decoded-rule-assets');
assert.deepEqual(data.decoded_rule_assets, []);
assert.deepEqual(Object.values(data.rules), [null, null, null]);
assert.equal(data.assets.length, 914);
assert.ok(data.assets.every(a => a.status === 'not-in-designv' && a.attempts.length === 5));
assert.deepEqual(data.manifest_gridfight_level_paths, []);
assert.equal(proof.rule_asset_round_trips, 0);
const links = JSON.parse(read('level-links.raw.json')).GridFightSpecialGoodsConfigRow;
assert.equal(links.length, 164);
assert.ok(proof.tables.length > 0 && proof.tables.every(t => t.round_trip));
assert.equal(proof.tables.reduce((n, t) => n + t.records, 0), links.length);
assert.ok(schemaFile.tables['RPG.GameCore.GridFightSpecialGoodsConfigRow'].some(f => f.name === 'JsonPath'));
for (const row of links) assert.ok(data.assets.some(a => a.path === row.JsonPath));
assert.equal(proof.dump_sha256, JSON.parse(read('bonus-provenance.json')).dump_sha256);
for (const [name, hash] of Object.entries(proof.outputs)) assert.equal(sha256(read(name)), hash);
for (const name of ROOT_TYPES) {
const shape = schemas['RPG.GameCore.' + name];
assert.deepEqual(shape.fields, []);
assert.equal(shape.status, 'no-serialized-client-fields');
}
for (const name of ['GridFightActionTriggerSpotLightSelectConfig', 'GridFightModifierEliteBranchExtraRewardConfig']) {
assert.ok(schemas['RPG.GameCore.' + name].methods.some(m => m.immediate_ret));
}
assert.ok(!JSON.stringify(proof).includes('/home/'));
});
@@ -0,0 +1,109 @@
// Bounded, read-only probes for this client's SRAM index and ENCR directories.
// Unknown payload codecs remain explicit; directory decoding is not asset decoding.
import * as e from './extract.mjs';
export function lz4(data, size) {
if (!Number.isSafeInteger(size) || size < 0 || size > 64 * 1024 * 1024) e.fail('LZ4 output limit');
const out = Buffer.alloc(size);
let input = 0, output = 0;
function byte() { if (input >= data.length) e.fail('Truncated LZ4'); return data[input++]; }
function length(base) {
if (base !== 15) return base;
let extra;
do { extra = byte(); base += extra; } while (extra === 255);
return base;
}
while (input < data.length) {
const token = byte(), literals = length(token >>> 4);
if (input + literals > data.length || output + literals > size) e.fail('Invalid LZ4 literal range');
data.copy(out, output, input, input + literals);
input += literals; output += literals;
if (input === data.length) break;
const offset = byte() | byte() << 8, count = length(token & 15) + 4;
if (!offset || offset > output || output + count > size) e.fail('Invalid LZ4 match range');
// Overlapping matches deliberately read the bytes just written.
for (let i = 0; i < count; i++) out[output + i] = out[output + i - offset];
output += count;
}
if (output !== size) e.fail('LZ4 output size mismatch');
return out;
}
export function asbIndex(data) {
if (data.length < 168 || data.subarray(0, 8).toString('hex') !== '5352414d00050001' ||
data.readUInt32LE(8) !== 40 || data.readUInt16LE(12) !== 8 || data.readUInt16LE(14) !== 16) e.fail('Unsupported SRAM header');
const sections = Array.from({length: 8}, (_, i) => {
const p = 44 + i * 16, offset = data.readUInt32LE(p), count = data.readUInt32LE(p + 4), stride = data.readUInt32LE(p + 8);
if (stride !== [24, 4, 32, 4, 8, 12, 4, 8][i] || offset < 168 || offset + count * stride > data.length) e.fail('Invalid SRAM section');
return {offset, count, stride};
});
for (let i = 0; i + 1 < sections.length; i++) {
if (sections[i].offset + sections[i].count * sections[i].stride !== sections[i + 1].offset) e.fail('Noncontiguous SRAM sections');
}
const block = index => {
if (index >= sections[0].count) e.fail('Invalid SRAM block index');
const p = sections[0].offset + index * 24;
return Buffer.from(data.subarray(p, p + 16)).swap32().toString('hex') + '.block';
};
const bundle = index => {
if (index >= sections[2].count) e.fail('Invalid SRAM bundle index');
const p = sections[2].offset + index * 32;
return {index, block: block(data.readUInt32LE(p + 4)), offset: data.readUInt32LE(p + 8), size: data.readUInt32LE(p + 12)};
};
return {sections, bundle};
}
export function encrDirectory(raw) {
if (raw.length < 25 || raw.subarray(0, 5).toString('hex') !== '454e435200' || raw.readBigUInt64BE(5) !== BigInt(raw.length)) e.fail('Invalid ENCR header');
const compressed = raw.readUInt32BE(13), size = raw.readUInt32BE(17), flags = raw.readUInt32BE(21);
if (flags !== 0x43 || compressed > raw.length - 25) e.fail('Unsupported ENCR directory layout');
const info = lz4(raw.subarray(25, 25 + compressed), size);
let p = 0;
function read(bytes) { if (p + bytes > info.length) e.fail('Truncated ENCR directory'); const at = p; p += bytes; return at; }
const count = info.readUInt32BE(read(4));
if (count > info.length / 10) e.fail('Invalid ENCR block count');
let payloadAt = 25 + compressed, uncompressed = 0;
const blocks = Array.from({length: count}, () => {
const at = read(10), size = info.readUInt32BE(at), compressed_size = info.readUInt32BE(at + 4), flags = info.readUInt16BE(at + 8);
const block = {offset: payloadAt, size, compressed_size, flags, compression: flags & 63};
payloadAt += compressed_size; uncompressed += size;
if (payloadAt > raw.length) e.fail('Invalid ENCR payload range');
return block;
});
const nodesCount = info.readUInt32BE(read(4));
if (nodesCount > info.length / 21) e.fail('Invalid ENCR node count');
const nodes = Array.from({length: nodesCount}, () => {
const at = read(20), offset = info.readBigUInt64BE(at), size = info.readBigUInt64BE(at + 8), flags = info.readUInt32BE(at + 16);
const end = info.indexOf(0, p);
if (end < 0 || offset + size > BigInt(uncompressed)) e.fail('Invalid ENCR node range');
const path = info.subarray(p, end).toString('utf8'); p = end + 1;
return {offset: Number(offset), size: Number(size), flags, path};
});
if (p !== info.length || payloadAt !== raw.length) e.fail('Trailing ENCR data');
return {blocks, nodes, directory_sha256: e.sha256(info),
unsupported_payload_compressions: [...new Set(blocks.map(b => b.compression).filter(t => ![0, 2, 3].includes(t)))],
payload_decoded: false};
}
export function probeBundles(client) {
const dirs = ['Persistent', 'StreamingAssets'].map(layer => e.path.join(client, 'StarRail_Data', layer, 'Asb/Windows'));
const catalogues = [];
let sample = null;
for (const dir of dirs) {
if (!e.fs.existsSync(dir)) continue;
const files = e.fs.readdirSync(dir).filter(n => /^AsbV_[a-f0-9]{32}\.bytes$/.test(n));
if (files.length !== 1) e.fail('Ambiguous AsbV catalogue');
const file = e.path.join(dir, files[0]), index = asbIndex(e.fs.readFileSync(file));
catalogues.push({path: e.path.relative(client, file), sha256: e.sha256_file(file), sections: index.sections});
if (sample) continue;
const entry = index.bundle(0);
const block = dirs.map(d => e.path.join(d, entry.block)).find(p => e.fs.existsSync(p));
if (!block) e.fail('Missing indexed bundle block');
if (entry.size > 2 * 1024 * 1024 || entry.offset + entry.size > e.fs.statSync(block).size) e.fail('Bundle probe range limit');
const fd = e.fs.openSync(block, 'r'), raw = Buffer.alloc(entry.size);
try { if (e.fs.readSync(fd, raw, 0, raw.length, entry.offset) !== raw.length) e.fail('Truncated bundle probe'); }
finally { e.fs.closeSync(fd); }
sample = {...entry, block: e.path.relative(client, block), bundle_sha256: e.sha256(raw), ...encrDirectory(raw)};
}
return {scope: 'first indexed ENCR bundle only; no exhaustive asset search', catalogues, sample};
}
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {lz4, asbIndex, encrDirectory} from './level_bundles.mjs';
test('LZ4 validates literal sizes and supports overlapping matches', () => {
assert.equal(lz4(Buffer.from([0x50, ...Buffer.from('hello')]), 5).toString(), 'hello');
assert.equal(lz4(Buffer.from([0x11, 97, 1, 0]), 6).toString(), 'aaaaaa');
assert.throws(() => lz4(Buffer.from([0xf0]), 15), /Truncated/);
assert.throws(() => lz4(Buffer.from([0x11, 97, 0, 0]), 6), /match/);
assert.throws(() => lz4(Buffer.from([0x50, 97]), 5), /literal/);
assert.throws(() => lz4(Buffer.from([0]), 0x4000001), /limit/);
});
test('SRAM bundle ranges use block Hash128 word order and reject corrupt sections', () => {
const data = Buffer.alloc(224);
Buffer.from('5352414d00050001', 'hex').copy(data);
data.writeUInt32LE(40, 8); data.writeUInt16LE(8, 12); data.writeUInt16LE(16, 14);
let offset = 168;
[24, 4, 32, 4, 8, 12, 4, 8].forEach((stride, i) => {
const count = [1, 0, 1, 0, 0, 0, 0, 0][i], p = 44 + i * 16;
data.writeUInt32LE(offset, p); data.writeUInt32LE(count, p + 4); data.writeUInt32LE(stride, p + 8);
offset += count * stride;
});
Buffer.from('000102030405060708090a0b0c0d0e0f', 'hex').copy(data, 168);
data.writeUInt32LE(12, 200); data.writeUInt32LE(100, 204);
assert.deepEqual(asbIndex(data).bundle(0), {index: 0, block: '03020100070605040b0a09080f0e0d0c.block', offset: 12, size: 100});
assert.throws(() => asbIndex(data).bundle(1), /bundle index/);
data.writeUInt32LE(0xffffffff, 48);
assert.throws(() => asbIndex(data), /section/);
});
test('ENCR directory parsing exposes codec 6 without pretending to decode its payload', () => {
const info = Buffer.alloc(42);
info.writeUInt32BE(1, 0); info.writeUInt32BE(4, 4); info.writeUInt32BE(2, 8); info.writeUInt16BE(6, 12);
info.writeUInt32BE(1, 14); info.writeBigUInt64BE(4n, 26); info.writeUInt32BE(4, 34);
info.write('CAB\0', 38);
const compressed = Buffer.concat([Buffer.from([0xf0, info.length - 15]), info]);
const raw = Buffer.alloc(25 + compressed.length + 2);
raw.write('ENCR\0'); raw.writeBigUInt64BE(BigInt(raw.length), 5);
raw.writeUInt32BE(compressed.length, 13); raw.writeUInt32BE(info.length, 17); raw.writeUInt32BE(0x43, 21);
compressed.copy(raw, 25);
const result = encrDirectory(raw);
assert.deepEqual(result.unsupported_payload_compressions, [6]);
assert.equal(result.payload_decoded, false);
assert.equal(result.nodes[0].path, 'CAB');
assert.throws(() => encrDirectory(raw.subarray(0, -1)), /header/);
raw.writeUInt32BE(0x44, 21);
assert.throws(() => encrDirectory(raw), /layout/);
});
@@ -0,0 +1,34 @@
import * as e from './extract.mjs';
import * as s from './stages.mjs';
import {table_bindings} from './rounds.mjs';
export const TYPES = ['GridFightProjectionConfigRow', 'GridFightSpotLightConfigRow', 'GridFightBinaryDiffAddRuleConfigRow', 'RewardRow'];
function rewardTable(ctx, asset) {
const [raw, proof] = ctx.assets.get(asset), cur = new e.Cursor(raw);
const count = cur.length(1000000), type = 'RPG.GameCore.RewardRow';
const rows = Array.from({length: count}, () => ctx.codec.record(cur, type));
const encoded = e.cat(e.encode_varint(count * 2), rows.map(r => ctx.codec.encode_record(r, type)));
if (cur.at !== raw.length || !encoded.equals(raw)) e.fail('Reward table round-trip failed');
return [rows, {table: 'RewardRow', records: count, round_trip: true, ...proof}];
}
export function main() {
const a = e.cli(['client', 'dump', 'identity', 'literals', 'output']);
const ctx = s.extractionContext(a);
const literals = new Map(e.loadJSON(a.literals).map(r => [Number(BigInt(r.Address)), r.Value]));
const tables = {}, proofs = [];
for (const name of TYPES) {
const assets = table_bindings(ctx.codec, literals, 'RPG.GameCore.' + name, name === 'RewardRow' ? new Set(['RewardData', 'RewardDataLD']) : null);
tables[name] = [];
for (const asset of assets) {
const match = asset.match(/^BakedConfig\/ExcelOutput(GameCore)?\/(\w+)\.bytes$/);
if (!match) e.fail('Unsupported table path: ' + asset);
const [rows, proof] = name === 'RewardRow' ? rewardTable(ctx, asset) : s.table(ctx.assets, ctx.codec, name, match[1] ?? '', match[2]);
tables[name].push(...(name === 'RewardRow' ? rows.filter(r => r.RewardID >= 312901 && r.RewardID <= 312912) : rows));
proofs.push(proof);
console.log(`${name}: ${rows.length} byte-exact rows (${tables[name].length} retained)`);
}
}
if (tables.RewardRow.length !== 12) e.fail("Expected all twelve weekly reward definitions");
s.publish(a, ctx, tables, proofs, {'mechanics.json': tables}, 'mechanics', {gameplay_verified: false});
}
e.runMain(import.meta.url, main);
@@ -0,0 +1,358 @@
// Local port of client-table-extract's native codec, with GridFight schema modes.
import * as e from './extract.mjs';
const {
fs,
path,
fail,
cat,
matches,
escapeRE,
zigzag,
small
} =e;
export class NativeCodec {
constructor(dump='', assembly='', evidence='', mode='stages'){
this.assembly=assembly;
this.evidence=evidence;
this.mode=mode;
this.blocks=Object.fromEntries(matches(dump, /\/\/ Full Name: ([\w.]+)\n(.*?)(?=\n\/\/ TypeDefIndex:|$)/gs).map(m=>[m[1], m[2]]));
this.rvas=[...new Set(matches(dump, /RVA: (0x[\da-fA-F]+)/g).map(m=>Number(m[1])))].sort((a, b)=>a-b);
this.schemas={
};
this.methods={
};
this.subtypes={
};
this.readers=new Map();
for(const name of Object.keys(this.blocks))for(const [, rva]of this.binary_methods(name)){
if(!this.readers.has(rva))this.readers.set(rva, new Set());
this.readers.get(rva).add(name);
}
}
binary_methods(name){
if(!(name in this.blocks))fail('Missing native metadata class '+name);
return matches(this.blocks[name], new RegExp('^ public static void (\\w+)\\(FCOEMCEDLNL, '+escapeRE(name)+'\\) .*?RVA: (0x[\\da-fA-F]+)', 'gm')).map(m=>[m[1], Number(m[2])]);
}
disassemble(rva, size=null){
const end=this.rvas.find(v=>v>rva)??rva+8192;
const [address, dis]=e.disassemble(this.assembly, rva, size??Math.min(end-rva, 8192));
return [address, dis.slice(dis.indexOf('Disassembly'))];
}
proof(name, dis){
fs.mkdirSync(this.evidence, {
recursive:true
});
fs.writeFileSync(path.join(this.evidence, name+'.asm'), dis);
}
fields(name){
const body=this.blocks[name]??fail('Missing native metadata class '+name),
base=body.match(/public class \w+ : (RPG\.GameCore\.\w+)/),
result=base&&base[1]in this.blocks?this.fields(base[1]):new Map();
for(const m of matches(body, /^ public (.+?) (\w+) \/\/ Offset: (0x[\da-fA-F]+)/gm))result.set(Number(m[3]), [m[2], m[1]]);
return result;
}
schema(name){
if(name in this.schemas)return this.schemas[name];
let known=this.fields(name);
if(this.mode==='rounds')known=new Map([...known].filter(([, v])=>!v[1].startsWith('static ')));
for(const [method, rva]of this.binary_methods(name)){
const [, dis]=this.disassemble(rva),
tests=matches(dis, this.mode==='stages'?/(?:test\s+(?:bl|ebx|rbx|al|eax|rax),0x([\da-f]+)|bt\s+rbx,0x([\da-f]+))/g:/test\s+(?:bl|ebx|rbx|al|eax|rax),0x([\da-f]+)/g),
ordered=new Map(),
used=new Set();
for(let i=0; i<tests.length; i++){
const m=tests[i],
bit=m[1]?BigInt('0x'+m[1]):1n<<BigInt('0x'+m[2]);
if(!bit||(bit&(bit-1n)))continue;
const part=dis.slice(m.index+m[0].length, tests[i+1]?.index??dis.length);
const mov=this.mode==='rounds'?/mov(?:ss)?\s+(?:(?:BYTE|WORD|DWORD|QWORD) PTR )?\[r(?!sp\b)\w+\+0x([\da-f]+)\],(?:[a-z][a-z0-9]*)/g:/mov\s+(?:BYTE|WORD|DWORD|QWORD) PTR \[r(?!sp\b)\w+\+0x([\da-f]+)\],\w+/g;
const offsets=[...matches(part, mov), ...matches(part, /add\s+r(?!sp\b)\w+,0x([\da-f]+)/g), ...matches(part, /lea\s+r\w+,\[r(?!sp\b)\w+\+0x([\da-f]+)\]/g)];
const found=new Set(offsets.map(m=>parseInt(m[1], 16)).filter(v=>known.has(v)&&!used.has(v)));
if(found.size!==1)break;
const offset=[...found][0];
used.add(offset);
const [key, type]=known.get(offset);
ordered.set(bit.toString(2).length-1, {
name:key,
type
});
if(this.mode==='stages'&&ordered.size===known.size)break;
}
if(ordered.size&&Array.from({
length:ordered.size
}, (_, i)=>ordered.has(i)).every(Boolean)){
if(this.mode==='rounds'&&ordered.size<known.size&&!name.endsWith('GridFightLevelConfigRow'))fail('Incomplete native schema '+name);
this.schemas[name]=Array.from({
length:ordered.size
}, (_, i)=>ordered.get(i));
this.methods[name]={
method,
rva
};
this.proof(name.split('.').at(-1), dis);
return this.schemas[name];
}
}
fail('Unproved native field order: '+name);
}
polymorphic(name){
if(name in this.subtypes)return this.subtypes[name];
for(const [, rva]of this.binary_methods(name)){
const [address, dis]=this.disassemble(rva),
limit=dis.match(/cmp\s+eax,0x([\da-f]+)/),
table=dis.match(/lea\s+rcx,\[rip\+[^\n]+# (0x[\da-f]+)\s*\n[^\n]*movsxd rax,DWORD PTR \[rcx\+rax\*4\]/);
if(!limit||!table||!dis.includes('jmp rax'))continue;
const count=parseInt(limit[1], 16)+1;
if(count>64)fail('Unsupported subtype dispatch size');
const base=address-BigInt(rva),
ta=BigInt(table[1]),
[, raw]=e.pe_method(this.assembly, Number(ta-base), count*4),
targets=Array.from({
length:count
}, (_, i)=>ta+BigInt(raw.readInt32LE(i*4))),
result={
};
for(let tag=0; tag<count; tag++){
const target=targets[tag],
end=[...targets, ta].filter(v=>v>target).sort((a, b)=>a<b?-1:1)[0];
if(end===undefined)fail('Missing subtype boundary');
const [, part]=this.disassemble(Number(target-base), Number(end-target)),
types=new Set();
for(const m of matches(part, /jmp\s+(0x[\da-f]+)/g))for(const typ of this.readers.get(Number(BigInt(m[1])-base))??[])if(typ===name||new RegExp('public class \\w+ : '+escapeRE(name)).test(this.blocks[typ]))types.add(typ);
if(types.size!==1)fail('Ambiguous native subtype '+name+':'+tag);
result[tag]=[...types][0];
this.proof(name.split('.').at(-1)+'_case'+tag, part);
}
this.proof(name.split('.').at(-1)+'_dispatch', dis);
this.subtypes[name]=result;
return result;
}
fail('No verified native subtype dispatch: '+name);
}
verify_dynamic_reader(){
const method=this.blocks['RPG.GameCore.DynamicValue']?.match(/public static void FromByteBinary\([^\n]+RVA: (0x[\da-fA-F]+)/);
if(!method)fail('Missing native DynamicValue reader');
const rva=Number(method[1]), [address, dis]=this.disassemble(rva),
result=verify_dynamic_dispatch(dis, address, (table, size)=>e.pe_method(this.assembly, rva+Number(table-address), size)[1]);
this.proof('DynamicValue_FromByteBinary', dis);
e.atomic_json(path.join(this.evidence, 'DynamicValue_reader.json'), {rva, ...result});
return {rva, ...result};
}
dynamic(cursor){
const tag=zigzag(cursor.varint());
let v;
if(tag===0)v=zigzag(cursor.varint());
else if(tag===1){
if(cursor.at+8>cursor.data.length)fail('Truncated DynamicValue double');
v={
double_wire:cursor.data.subarray(cursor.at, cursor.at+8).toString('hex')
};
cursor.at+=8;
} else if(tag===2)v=e.field(cursor, 'bool');
else if(tag===3)v=Array.from({
length:cursor.length()
}, ()=>this.dynamic(cursor));
else if(tag===4)v=Array.from({
length:cursor.length()
}, ()=>[this.dynamic(cursor), this.dynamic(cursor)]);
else if(tag===5)v=e.field(cursor, 'string');
else if(tag===6)v=null;
else fail('Unsupported DynamicValue tag');
return {
[this.mode==='rounds'?'tag':'wire_tag']:tag,
value:v
};
}
encode_dynamic(v){
const tag=v[this.mode==='rounds'?'tag':'wire_tag'],
x=v.value,
b=e.encode_varint(tag*2);
if(tag===0){
const a=BigInt(x);
return cat(b, e.encode_varint((a<<1n)^(a>>63n)));
}
if(tag===1)return cat(b, Buffer.from(x.double_wire, 'hex'));
if(tag===2)return cat(b, e.encode_field(x, 'bool'));
if(tag===3)return cat(b, e.encode_varint(x.length*2), x.map(a=>this.encode_dynamic(a)));
if(tag===4)return cat(b, e.encode_varint(x.length*2), x.flatMap(([a, z])=>[this.encode_dynamic(a), this.encode_dynamic(z)]));
if(tag===5)return cat(b, e.encode_field(x, 'string'));
if(tag===6)return b;
fail('Unsupported DynamicValue tag');
}
field(cursor, typ){
if(typ==='RPG.GameCore.DynamicValue')return this.dynamic(cursor);
if(['RPG.Client.TextID', 'RPG.GameCore.FixPoint'].includes(typ))return e.field(cursor, typ);
if(typ.endsWith('[]'))return Array.from({
length:cursor.length()
}, ()=>this.field(cursor, typ.slice(0, -2)));
if(['uint', 'ushort'].includes(typ)){
const v=cursor.varint();
if(v>=2**(typ==='ushort'?16:32))fail('Unsigned integer overflow');
return v;
}
if(['short', 'int'].includes(typ)||(typ in this.blocks&&this.blocks[typ].includes('public enum '))){
const bits=typ==='short'?16:32;
if(typ in this.blocks&&!/public int value__/.test(this.blocks[typ]))fail('Unsupported enum storage');
const v=cursor.varint();
if(v>=2**bits)fail('Signed integer overflow');
return zigzag(v);
}
if(typ==='double'){
if(cursor.at+8>cursor.data.length)fail('Truncated double');
const v={double_wire:cursor.data.subarray(cursor.at,cursor.at+8).toString('hex')};
cursor.at+=8;return v;
}
if(typ==='float'){
if(cursor.at+4>cursor.data.length)fail('Truncated float');
const v={
float_wire:cursor.data.subarray(cursor.at, cursor.at+4).toString('hex')
};
cursor.at+=4;
return v;
}
if(['string', 'bool'].includes(typ))return e.field(cursor, typ);
if(typ.startsWith('System.Collections.Generic.Dictionary<')){
const m=typ.match(/^System.Collections.Generic.Dictionary<(.+?), (.+)>$/);
if(!m)fail('Unsupported dictionary');
return Array.from({
length:cursor.length()
}, ()=>[this.field(cursor, m[1]), this.field(cursor, m[2])]);
}
if(typ==='RPG.GameCore.FloorCustomValueConfig'){
const tag=cursor.varint(),
sub=this.polymorphic(typ);
if(!(tag in sub))fail('Unknown native subtype: '+tag);
return {
tag,
value:this.record(cursor, sub[tag])
};
}
return this.record(cursor, typ);
}
record(cursor, typ, through=null){
const cols=this.schema(typ),
mask=BigInt(cursor.varint());
if(mask>>BigInt(cols.length))fail('Unsupported presence mask');
const row={
};
for(let i=0; i<cols.length; i++){
const col=cols[i];
if(mask&(1n<<BigInt(i)))row[col.name]=this.field(cursor, col.type);
if(col.name===through)break;
}
return row;
}
encode_field(v, t){
if(t==='RPG.GameCore.DynamicValue')return this.encode_dynamic(v);
if(['RPG.Client.TextID', 'RPG.GameCore.FixPoint'].includes(t))return e.encode_field(v, t);
if(t.endsWith('[]'))return cat(e.encode_varint(v.length*2), v.map(x=>this.encode_field(x, t.slice(0, -2))));
if(t.startsWith('System.Collections.Generic.Dictionary<')){
const m=t.match(/^System.Collections.Generic.Dictionary<(.+?), (.+)>$/);
return cat(e.encode_varint(v.length*2), v.flatMap(([k, x])=>[this.encode_field(k, m[1]), this.encode_field(x, m[2])]));
}
if(t==='RPG.GameCore.FloorCustomValueConfig')return cat(e.encode_varint(v.tag), this.encode_record(v.value, this.polymorphic(t)[v.tag]));
if(t==='double')return Buffer.from(v.double_wire, 'hex');
if(t==='float')return Buffer.from(v.float_wire, 'hex');
if(['short', 'int'].includes(t)||(t in this.blocks&&this.blocks[t].includes('public enum '))){
const x=BigInt(v);
return e.encode_varint((x<<1n)^(x>>BigInt(t==='short'?15:31)));
}
if(['uint', 'ushort', 'string', 'bool'].includes(t))return e.encode_field(v, t);
return this.encode_record(v, t);
}
encode_record(row, typ){
const cols=this.schema(typ);
if(Object.keys(row).some(k=>!cols.some(col=>col.name===k)))fail('Unknown field during round trip');
return cat(e.encode_varint(cols.reduce((m, col, i)=>col.name in row?m|(1n<<BigInt(i)):m, 0n)), cols.filter(col=>col.name in row).map(col=>this.encode_field(row[col.name], col.type)));
}
}
export function indexed_entries(data, id, size){
const marker=Buffer.from(id, 'hex'),
at0=data.indexOf(marker);
if(at0<0||data.indexOf(marker, at0+16)>=0)fail('Missing or ambiguous DesignV blob: '+id);
const at=at0+16;
if(at+12>data.length)fail('Truncated DesignV header');
const expected=data.readBigUInt64BE(at),
count=data.readUInt32BE(at+8);
if(expected!==BigInt(size)||!count||at+12+count*16>data.length)fail('DesignV index does not match installed blob');
const entries=[],
hashes=new Set();
for(let i=0; i<count; i++){
const p=at+12+i*16,
key=data.readBigUInt64BE(p),
length=data.readUInt32BE(p+8),
offset=data.readUInt32BE(p+12);
if(offset+length>size||hashes.has(key))fail('Invalid or overlapping DesignV asset ranges');
entries.push({
hash:key,
offset,
length
});
hashes.add(key);
}
const sorted=entries.toSorted((a, b)=>a.offset-b.offset);
if(sorted.some((a, i)=>i+1<sorted.length&&a.offset+a.length>sorted[i+1].offset))fail('Overlapping DesignV asset ranges');
return entries;
}
// XXH64, seed zero, with explicit 64-bit arithmetic rather than a native FFI.
const MASK=(1n<<64n)-1n, P1=11400714785074694791n, P2=14029467366897019727n, P3=1609587929392839161n, P4=9650029242287828579n, P5=2870177450012600261n;
const rot=(x, n)=>((x<<BigInt(n))|(x>>BigInt(64-n)))&MASK;
const round=(a, x)=>rot((a+x*P2)&MASK, 31)*P1&MASK;
export function asset_hash(text){
const b=Buffer.from(text);
let p=0,
h;
if(b.length>=32){
let v=[(P1+P2)&MASK, P2, 0n, (-P1)&MASK];
while(p<=b.length-32){
for(let i=0;
i<4;
i++)v[i]=round(v[i], b.readBigUInt64LE(p+i*8));
p+=32;
}
h=(rot(v[0], 1)+rot(v[1], 7)+rot(v[2], 12)+rot(v[3], 18))&MASK;
for(const x of v)h=((h^round(0n, x))*P1+P4)&MASK;
} else h=P5;
h=(h+BigInt(b.length))&MASK;
while(p+8<=b.length){
h=(rot(h^round(0n, b.readBigUInt64LE(p)), 27)*P1+P4)&MASK;
p+=8;
}
if(p+4<=b.length){
h=(rot(h^(BigInt(b.readUInt32LE(p))*P1&MASK), 23)*P2+P3)&MASK;
p+=4;
}
while(p<b.length){
h=rot(h^(BigInt(b[p++])*P5&MASK), 11)*P1&MASK;
}
h^=h>>33n;
h=h*P2&MASK;
h^=h>>29n;
h=h*P3&MASK;
return h^(h>>32n);
}
// Both beta readers have this dispatch layout; resolve the RVA from matching metadata.
export function verify_dynamic_dispatch(dis, address, read){
const table=dis.match(/lea\s+rcx,\[rip\+[^\n]+# (0x[\da-f]+)\s*\n[^\n]*movsxd rax,DWORD PTR \[rcx\+rax\*4\]/);
if(!table||![/cmp\s+rax,0x5/, /shr\s+edx,1/, /neg\s+eax/, /xor\s+eax,edx/, /jmp\s+rax/].every(p=>p.test(dis)))fail('DynamicValue native reader changed');
const ta=BigInt(table[1]), raw=read(ta,24),
offsets=[0x85,0xc5,0x102,0x14f,0x18a,0x1cf],
targets=offsets.map((_,i)=>ta+BigInt(raw.readInt32LE(i*4)));
if(ta-address!==0x2b0n||targets.some((v,i)=>v-address!==BigInt(offsets[i])))fail('DynamicValue native tag binding changed');
const fallback=dis.match(/cmp\s+rax,0x5\s*\n[^\n]*ja\s+(0x[\da-f]+)/),
defaultTarget=fallback?BigInt(fallback[1]):0n,
tail=dis.slice(dis.indexOf(defaultTarget.toString(16)+':'),dis.indexOf(targets[5].toString(16)+':'));
if(defaultTarget-address!==0x1c6n||!/nop/.test(tail)||!/add\s+rsp,0x30/.test(tail)||!/ret/.test(tail)||/\b(?:call|jmp)\s/.test(tail))fail('DynamicValue native null/default case changed');
const checks=[[/shr\s+rdx,1/, /neg\s+rax/, /xor\s+rax,rdx/, /mov\s+QWORD PTR \[rcx\+0x28\],rax/],
[/movsd\s+xmm0,QWORD PTR \[rsp\+0x28\]/, /movsd\s+QWORD PTR \[rax\+0x28\],xmm0/],
[/mov\s+edx,0x1/, /movzx\s+ecx,bl/, /mov\s+QWORD PTR \[rax\+0x28\],rcx/],
[/add\s+rbx,0x20/, /jmp\s+0x/], [/add\s+rbx,0x18/, /call\s+0x/], [/add\s+rbx,0x10/, /jmp\s+0x/]];
for(let i=0;i<targets.length;i++){
const start=dis.indexOf(targets[i].toString(16)+':'),
end=i<5?dis.indexOf(targets[i+1].toString(16)+':'):dis.length,
part=dis.slice(start,end);
if(start<0||!checks[i].every(p=>p.test(part)))fail('DynamicValue native case changed: '+i);
}
return {dispatch_offset:0x2b0, default_offset:0x1c6, case_offsets:offsets, tags:['int64-zigzag','double-le','bool-byte','array','map','string','null'], wire_layout:'beta-4.6.51/4.6.53'};
}
@@ -0,0 +1,145 @@
import * as e from './common.mjs';
const {
fs,
path,
fail,
sha256,
matches,
escapeRE,
atomic_json
} =e;
export const align=(v, b)=>Math.ceil(v/b)*b;
export function build(client, dump, classes, output){
const text=e.readText(dump),
methods=new Map();
for(const name of classes){
const m=text.match(new RegExp('// Full Name: [^\\n]*\\.'+escapeRE(name)+'\\n(.*?)(?=\\n// TypeDefIndex:|$)', 's'));
if(!m)fail('Missing native metadata class '+name);
for(const line of m[1].split('\n')){
const mm=line.match(/\b([\w.<>]+)\([^\n]*RVA: (0x[\da-fA-F]+)/);
if(!mm)continue;
const rva=Number(mm[2]);
if(rva&&!methods.has(rva))methods.set(rva, (name+'_'+mm[1]).replace(/[^A-Za-z0-9_]/g, '_'));
}
}
if(!methods.size)fail('No methods selected');
const starts=[...methods.keys()].sort((a, b)=>a-b),
all=[...new Set(matches(text, /RVA: (0x[\da-fA-F]+)/g).map(m=>Number(m[1])).filter(Boolean))].sort((a, b)=>a-b),
ends=new Map(starts.map(r=>[r, all.find(v=>v>r)??r+0x1000])),
windows=[];
for(const rva of starts){
if(windows.length&&rva<windows.at(-1)[1]+0x1000)windows.at(-1)[1]=align(rva+0x1000, 0x1000);
else windows.push([Math.floor(rva/0x1000)*0x1000, align(rva+0x1000, 0x1000)]);
}
const blob=fs.readFileSync(path.join(client, 'GameAssembly.dll')),
pe=blob.readUInt32LE(0x3c);
if(blob.subarray(pe, pe+4).toString('hex')!=='50450000'||blob.readUInt16LE(pe+4)!==0x8664)fail('Expected x64 PE source');
const opt=blob.readUInt16LE(pe+20);
if(opt!==240||blob.readUInt16LE(pe+24)!==0x20b)fail('Expected PE32+ optional header');
const ss=pe+24+opt,
sections=Array.from({
length:blob.readUInt16LE(pe+6)
}, (_, i)=>{
const a=ss+i*40;
return {
vs:blob.readUInt32LE(a+8),
rva:blob.readUInt32LE(a+12),
size:blob.readUInt32LE(a+16),
raw:blob.readUInt32LE(a+20)
};
}),
segments=windows.map(([start, end])=>{
const s=sections.find(s=>s.rva<=start&&end<=s.rva+Math.min(s.vs, s.size));
if(!s)fail('Code window exceeds original file-backed section');
const offset=s.raw+start-s.rva;
return {
rva:start,
source_offset:offset,
bytes:blob.subarray(offset, offset+end-start)
};
}),
base=blob.readBigUInt64LE(pe+48),
nt=Buffer.from(blob.subarray(pe, pe+24+opt)),
exportRva=align(Math.max(...segments.map(s=>s.rva+s.bytes.length)), 0x1000),
count=starts.length,
order=Array.from({
length:count
}, (_, i)=>i).sort((a, b)=>{
const x=methods.get(starts[a])+'_'+starts[a].toString(16),
y=methods.get(starts[b])+'_'+starts[b].toString(16);
return x<y?-1:x>y?1:0;
});
let exports=Buffer.alloc(40+count*10);
const names=[];
for(const i of order){
names.push(exports.length);
exports=Buffer.concat([exports, Buffer.from(methods.get(starts[i])+'_'+starts[i].toString(16)+'\0')]);
}
const dllName=exports.length;
exports=Buffer.concat([exports, Buffer.from('cw-native-analysis-only.dll\0')]);
for(const [at, v]of [[0, 0], [4, 0], [12, exportRva+dllName], [16, 1], [20, count], [24, count], [28, exportRva+40], [32, exportRva+40+count*4], [36, exportRva+40+count*8]])exports.writeUInt32LE(v, at);
for(let i=0; i<count; i++){
exports.writeUInt32LE(starts[i], 40+i*4);
exports.writeUInt32LE(exportRva+names[i], 40+count*4+i*4);
exports.writeUInt16LE(order[i], 40+count*8+i*2);
}
const dest=segments.map((s, i)=>({
name:'cw'+i,
rva:s.rva,
data:s.bytes,
flags:0x60000020
}));
dest.push({
name:'.edata',
rva:exportRva,
data:exports,
flags:0x40000040
});
const headerSize=align(0x80+nt.length+dest.length*40, 0x200);
nt.writeUInt16LE(dest.length, 6);
nt.writeUInt16LE(nt.readUInt16LE(22)&~2, 22);
for(const [at, v]of [[40, 0], [44, segments[0].rva], [56, 0x1000], [60, 0x200], [80, align(exportRva+exports.length, 0x1000)], [84, headerSize], [88, 0]])nt.writeUInt32LE(v, at);
nt.fill(0, 136, 264);
nt.writeUInt32LE(exportRva, 136);
nt.writeUInt32LE(exports.length, 140);
let carrier=Buffer.alloc(headerSize);
carrier.write('MZ');
carrier.writeUInt32LE(0x80, 0x3c);
nt.copy(carrier, 0x80);
for(let i=0; i<dest.length; i++){
const s=dest[i],
at=0x80+nt.length+i*40,
rawOffset=carrier.length;
carrier.write(s.name, at, 8, 'ascii');
for(const [off, v]of [[8, s.data.length], [12, s.rva], [16, align(s.data.length, 0x200)], [20, rawOffset], [36, s.flags]])carrier.writeUInt32LE(v, at+off);
carrier=Buffer.concat([carrier, s.data, Buffer.alloc(align(s.data.length, 0x200)-s.data.length)]);
}
e.replace(output, carrier);
const hex=v=>'0x'+v.toString(16),
manifest={
source_sha256:sha256(blob),
dump_sha256:e.sha256_file(dump),
carrier_sha256:sha256(carrier),
image_base:hex(base),
classes,
methods:e.dict(starts.map(r=>[hex(base+BigInt(r)), methods.get(r)])),
method_ends:e.dict(starts.map(r=>[hex(base+BigInt(r)), hex(base+BigInt(ends.get(r)))])),
ranges:segments.map(s=>({
rva:hex(s.rva),
source_offset:s.source_offset,
size:s.bytes.length,
sha256:sha256(s.bytes)
})),
limitations:['Derived analysis carrier; never execute it.', 'External functions, original data and dynamic runtime state are absent.', 'Exports are analysis annotations derived from the matching metadata dump.', 'Method ends are metadata boundaries; compiler cold tails may live elsewhere.', 'Runtime hotfix overrides are not established by static analysis.']
};
const ext=path.extname(output);
atomic_json(ext?output.slice(0, -ext.length)+'.manifest.json':output+'.manifest.json', manifest, 2, true);
console.log(`${methods.size} methods, ${segments.length} exact code windows, ${carrier.length} bytes`);
return manifest;
}
export function main(){
const a=e.cli(['client', 'dump', 'class', 'output'], {
}, [], ['class']);
build(a.client, a.dump, a.class, a.output);
}
e.runMain(import.meta.url, main);
+73
View File
@@ -0,0 +1,73 @@
import * as s from './stages.mjs';
const e=s.primitive;
export const TABLES={
GridFightPetPalConfigRow:['', 'GridFightPetPal'],
GridFightPetPalEffRow:['', 'GridFightPetPalEff'],
GridFightCampConfigRow:['', 'GridFightCamp'],
GridFightCampSeasonConfigRow:['', 'GridFightCampSeason'],
GridFightMonsterConfigRow:['GameCore', 'GridFightMonster']
};
export function contracts(t){
const effects=new Map(t.GridFightPetPalEffRow.map(r=>[r.ID, r])),
partners=[];
for(const row of t.GridFightPetPalConfigRow.toSorted((a, b)=>a.ID-b.ID)){
if(!(row.HeadImage??'').includes('/Season2/'))continue;
const ids=row.EffIDList??[];
if(!row.ID||ids.some(i=>!effects.has(i)))e.fail('Invalid partner/effect reference');
partners.push({
id:row.ID,
season_id:2,
head_image:row.HeadImage,
effect_ids:ids,
raw:row
});
}
if(!partners.length||!e.uniqueValues(partners, r=>r.id))e.fail('Missing or duplicate season-2 partners');
const monsters=new Map(t.GridFightMonsterConfigRow.map(r=>[r.MonsterID, r])),
campRows=new Map(t.GridFightCampConfigRow.map(r=>[r.ID, r])),
camps=[];
for(const row of t.GridFightCampSeasonConfigRow){
const ids=row.MonsterList??[];
if(row.SeasonID!==2)continue;
if(!campRows.has(row.CampID)||ids.some(i=>!monsters.has(i)))e.fail('Invalid season-2 camp/monster reference');
camps.push({
id:row.CampID,
season_id:2,
monsters:ids.map(i=>({
id:i,
tier:monsters.get(i).MonsterTier??0
})),
raw:{
GridFightCampConfigRow:campRows.get(row.CampID),
GridFightCampSeasonConfigRow:row
}
});
}
if(!camps.length)e.fail('Missing season-2 preview camps');
return {
'petpal.json':partners,
'petpal.raw.json':Object.fromEntries(['GridFightPetPalConfigRow', 'GridFightPetPalEffRow'].map(n=>[n, t[n]])),
'preparation.json':{
camps,
raw:{
GridFightMonsterConfigRow:t.GridFightMonsterConfigRow
}
}
};
}
export function main(){
const a=e.cli(['client', 'dump', 'identity', 'output']),
ctx=s.extractionContext(a),
[t, p]=s.collect(a, TABLES, ctx);
s.publish(a, ctx, t, p, contracts(t), 'petpal', {
mapping:{
season_id:'Season2 portrait paths; table has no SeasonID',
effect_ids:'EffIDList, validated against GridFightPetPalEff',
empty_effects:'Partner 1 has no effect; send effect ID 0 inactive',
preparation:'Native camp membership and MonsterTier, not battle wave generation'
},
unresolved:['Dynamic camp/monster selection from node-template ParamList', 'Per-node battle wave, formation, difficulty and reward rules'],
gameplay_verified:false
});
}
e.runMain(import.meta.url, main);
@@ -0,0 +1,42 @@
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
// Derived table: what each Investment Environment grants, taken from the
// client's GridFightPortalBuffConfigRow.ShowBonusIDList (granted when the
// environment is picked; the client shows exactly these bonus bundles on the
// portal page, GridFightPortalData.BuildBonusData) and DelayedShowBonus (granted
// later, at the moment the portal text names). Both resolve through
// GridFightCombinationBonusConfigRow / GridFightBasicBonusConfigRow. Nothing is
// read from the client here; the inputs are the installed raw tables.
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const DIR = path.join(ROOT, 'resources/grid_fight');
const load = n => JSON.parse(fs.readFileSync(path.join(DIR, n), 'utf8'));
export function build(catalog, bonus, portals) {
const buffs = new Map(catalog.GridFightPortalBuffConfigRow.map(r => [r.ID, r]));
const defined = new Set([
...bonus.GridFightBasicBonusConfigRow.map(r => r.BonusID),
...bonus.GridFightCombinationBonusConfigRow.map(r => r.BonusID)
]);
const out = {};
for (const p of portals.portals) {
const row = buffs.get(p.id);
if (!row) throw new Error('portal ' + p.id + ' has no PortalBuff row');
const show = row.ShowBonusIDList ?? [], delayed = row.DelayedShowBonus ?? [];
for (const id of [...show, ...delayed]) if (!defined.has(id)) throw new Error('portal ' + p.id + ': bonus ' + id + ' undefined');
out[p.id] = {show, delayed, traits: row.PortalGameRefTrait ?? []};
}
return {
provenance: 'Derived from catalog.raw.json GridFightPortalBuffConfigRow (ShowBonusIDList, DelayedShowBonus, PortalGameRefTrait) for the season portals of portals.json; bonus IDs resolve in bonus.raw.json. Gameplay not verified in game.',
portals: out
};
}
export function main() {
const out = build(load('catalog.raw.json'), load('bonus.raw.json'), load('portals.json'));
const target = process.argv[2] ?? path.join(DIR, 'portal-bonus.json');
fs.writeFileSync(target, JSON.stringify(out, null, 1) + '\n');
console.log('portal-bonus: ' + Object.keys(out.portals).length + ' portals -> ' + target);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import * as p from './portal_bonus.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const DIR = path.join(ROOT, 'resources/grid_fight');
const load = n => JSON.parse(fs.readFileSync(path.join(DIR, n), 'utf8'));
test('installed portal-bonus.json equals the derivation from the raw tables', () => {
const built = p.build(load('catalog.raw.json'), load('bonus.raw.json'), load('portals.json'));
assert.deepEqual(load('portal-bonus.json'), built);
assert.equal(Object.keys(built.portals).length, 74);
});
test('every Index Fund bundle resolves to season-2 cards of its own Bond and a basic component', () => {
const bonus = load('bonus.raw.json'), roles = load('roles.json'), wiki = load('wiki-4654.json'), table = load('portal-bonus.json');
const season = load('season.json').season_id;
const basic = id => bonus.GridFightBasicBonusConfigRow.find(r => r.BonusID === id);
const sold = row => row.raw.IsInPool && row.raw.SeasonID === season &&
!roles.some(o => o.avatar_id === row.avatar_id && o.id !== row.id && o.raw.IsInPool && o.raw.SeasonID === season && o.cost < row.cost);
const cheapest = avatar => roles.filter(r => r.avatar_id === avatar && sold(r)).sort((a, b) => a.cost - b.cost)[0];
for (const portal of wiki.portals.filter(x => x.id >= 2201 && x.id <= 2214)) {
const [bundle, trait, boost] = portal.params;
assert.equal(boost, 40);
assert.deepEqual(table.portals[portal.id].show, [bundle]);
assert.deepEqual(table.portals[portal.id].traits, [trait]);
const combo = bonus.GridFightCombinationBonusConfigRow.find(r => r.BonusID === bundle);
assert.equal(combo.CombinationBonusList[0], 1); // Gold Coins
assert.equal(combo.CombinationBonusList.at(-1), 101); // two random 1-cost cards (OrbConfig 200001)
assert.equal(combo.BonusNumberList.at(-1), 20000);
const rows = combo.CombinationBonusList.map(basic);
const cards = rows.filter(r => r.BonusType === 5).map(r => cheapest(r.BonusTypeParamList[0]));
assert.equal(cards.length, 2, 'portal ' + portal.id);
for (const card of cards) assert.ok(card && card.traits.includes(trait), `portal ${portal.id}: ${card && card.id} lacks trait ${trait}`);
const items = rows.filter(r => r.BonusType === 7).map(r => r.BonusTypeParamList[0]);
assert.equal(items.length, 1);
assert.ok(items[0] >= 350201 && items[0] <= 350208); // a basic component
}
});
test('Invitation, weapon-box and delayed bundles match the portal params', () => {
const bonus = load('bonus.raw.json'), wiki = load('wiki-4654.json'), table = load('portal-bonus.json').portals;
const item = id => bonus.GridFightBasicBonusConfigRow.find(r => r.BonusID === id).BonusTypeParamList[0];
for (const portal of wiki.portals.filter(x => x.id >= 2101 && x.id <= 2119)) assert.equal(item(table[portal.id].show[0]), portal.params[0]);
// 101: two Basic Weapon Boxes (99999) = params[0]; 102: Privilege box (99997) later; 104: Wealth Gem; 106: Staff Projector.
const combo = bonus.GridFightCombinationBonusConfigRow.find(r => r.BonusID === table[101].show[0]);
assert.equal(combo.BonusNumberList[0] / 10000, wiki.portals.find(x => x.id === 101).params[0]);
assert.equal(item(209), 99997);
assert.deepEqual(table[102].delayed, [209]);
assert.equal(item(table[104].delayed[0]), 350701);
assert.equal(item(205), 350105);
assert.deepEqual(table[106].delayed, [205]);
// 103: orbs 140021..140023 hold 6/8/12 coins (BonusID 1 x N).
for (const [i, coins] of [6, 8, 12].entries()) {
const c = bonus.GridFightCombinationBonusConfigRow.find(r => r.BonusID === 40021 + i);
assert.deepEqual(c.CombinationBonusList, [1]);
assert.equal(c.BonusNumberList[0] / 10000, coins);
assert.equal(wiki.portals.find(x => x.id === 103).params[i], coins);
}
});
+137
View File
@@ -0,0 +1,137 @@
import * as e from './common.mjs';
import {
fixpoint
} from './trait_effects.mjs';
const CONDITION=/\b(after|each|every|when|whenever|upon|until|at the (beginning|start|end)|in this run|lasting|if|per|subsequent|next)\b/i;
const COUNT='(#\\d|\\d+|a|an|one|Obtains?)';
const GRANTS=[['item', new RegExp(COUNT+' "?<gridfightinfo type=item id=(\\d+)', 'g')], ['role', new RegExp(COUNT+' "?<gridfightinfo type=role id=(\\d+)', 'g')], ['gold', new RegExp(COUNT+' Gold Coin', 'g')], ['refresh', new RegExp(COUNT+' free refresh', 'g')], ['cost_role', new RegExp(COUNT+' random (?:1-star )?(\\d)-cost character', 'g')], ['basic', new RegExp(COUNT+' (?:random basic equipment|Random Basic Weapon)', 'gi')], ['emblem', new RegExp(COUNT+' random Emblem', 'g')]];
export const _fixpoint=fixpoint;
function count(token, params){
if(token.startsWith('#')){
const i=Number(token.slice(1))-1;
if(i>=params.length)return null;
const v=params[i];
return v>0&&Math.abs(v-Math.round(v))<1e-6?Math.round(v):null;
}
return ['a', 'an', 'one', 'Obtain', 'Obtains'].includes(token)?1:Number(token);
}
export function parse_grants(text, params){
text=text.replace(/<\/?unbreak>|<color=[^>]*>|<\/color>/g, '').replaceAll('[i]', '');
let grants=[],
complete=true;
for(const sentence of text.split(/(?<=[.!])"?\s+(?=[A-Z])/)){
if(!/^(Immediately )?Obtains?\b/.test(sentence)||CONDITION.test(sentence.replace(/<[^>]*>/g, ''))){
complete=false;
continue;
}
const found=[];
for(const [kind, pattern]of GRANTS)for(const m of sentence.matchAll(pattern)){
const n=count(m[1], params);
if(n===null)return [null, false];
const g={
kind,
count:n
};
if(['item', 'role'].includes(kind))g.id=Number(m[2]);
if(kind==='cost_role')g.cost=Number(m[2]);
found.push([m.index, g]);
}
if(!found.length)return [null, false];
grants.push(...found.sort((a, b)=>a[0]-b[0]).map(([, g])=>g));
}
return [grants, complete];
}
export function build(raw, season){
const buffs=new Map(raw.GridFightPortalBuffConfigRow.map(r=>[r.ID, r])),
portals=[];
for(const link of raw.GridFightSeasonPortalConfigRow){
if(link.SeasonID!==season)continue;
const row=buffs.get(link.PortalID);
if(!row)e.fail('season portal '+link.PortalID+' has no PortalBuff row');
portals.push({
id:row.ID,
oc:!!(row.IsOCEffective??0),
title_hash:row.PortalBuffTitle.hash,
json_path:row.JsonPath??''
});
}
portals.sort((a, b)=>a.id-b.id);
if(!portals.length||!e.uniqueValues(portals, p=>p.id))e.fail('missing or duplicate season portals');
return {
season_id:season,
portals
};
}
const PINNED={
211701:[{
kind:'item',
count:1,
id:355100302
}, {
kind:'role',
count:1,
id:1501
}],
100301:[{
kind:'refresh',
count:11
}]
};
export function check_grants(augment, grants, params, item_ids){
const granted=new Set(grants.filter(g=>g.kind==='item').map(g=>g.id));
for(const v of params)if(Math.abs(v-Math.round(v))<1e-6&&item_ids.has(Math.round(v))&&!granted.has(Math.round(v)))e.fail(`strategy ${augment}: item param ${Math.trunc(v)} is not granted`);
if(PINNED[augment]&&e.json(grants)!==e.json(PINNED[augment]))e.fail('strategy '+augment+': grants differ from pinned');
}
export function build_augments(raw, season, texts=null){
const rows=new Map(raw.GridFightAugmentConfigRow.map(r=>[r.ID, r])),
item_ids=new Set(raw.GridFightItemConfigRow.map(r=>r.ID)),
augments=[];
for(const link of raw.GridFightSeasonAugmentConfigRow){
if(link.SeasonID!==season)continue;
const row=rows.get(link.AugmentID);
if(!row)e.fail('season augment '+link.AugmentID+' has no Augment row');
const a={
id:row.ID,
quality:row.Quality,
chapters:row.ChapterLimitList??[],
oc:!!(row.IsOCEffective??0)
};
augments.push(a);
const text=texts?.[row.ID];
if(text){
const params=(row.EffectParamList??[]).map(p=>fixpoint(p.wire)),
[grants, complete]=parse_grants(text, params);
if(grants?.length){
check_grants(row.ID, grants, params, item_ids);
a.grants=grants;
a.grants_complete=complete;
}
}
}
augments.sort((a, b)=>a.id-b.id);
if(!augments.length||!e.uniqueValues(augments, a=>a.id))e.fail('missing or duplicate season augments');
return {
season_id:season,
augments
};
}
export function main(){
const a=e.cli([], {
items:e.resource('items.raw.json'),
season:2,
output:e.resource('portals.json'),
augments:e.resource('augments.json'),
catalog:e.catalogDefault
}),
raw=e.loadJSON(a.items),
data=build(raw, a.season);
let texts=null;
if(e.fs.existsSync(a.catalog))texts=Object.fromEntries(e.loadJSON(a.catalog).tables.GridFightAugmentConfigRow.map(r=>[r.ID, r.HexDesc?.text??'']));
const augments=build_augments(raw, a.season, texts);
e.atomic_json(a.output, data, 1, true);
e.atomic_json(a.augments, augments, 1, true);
console.log(`${a.output}: ${data.portals.length} portals for season ${a.season}`);
const granting=augments.augments.filter(a=>a.grants);
console.log(`${a.augments}: ${augments.augments.length} augments for season ${a.season}, ${granting.length} with immediate grants (${granting.filter(a=>!a.grants_complete).length} partial)`);
}
e.runMain(import.meta.url, main);
+104
View File
@@ -0,0 +1,104 @@
import * as e from './extract.mjs';
import * as s from './stages.mjs';
import {table_bindings} from './rounds.mjs';
// Client tables behind the Elite branch (binary node rule, enemy difficulty) and
// the SpecialGoods-gated projections. Every table is decoded byte-exactly.
export const TYPES = [
'GridFightBinaryNodeRuleConfigRow',
'GridFightBinaryDiffAddRuleConfigRow',
'GridFightSpecialGoodsConfigRow',
'GridFightProjectionConfigRow',
'GridFightEnemyDifficultyLvConfigRow'
];
// GridFightProjectionUnlockType: 0 TraitUnlock, 1 SpecialGoods.
export const UNLOCK_SPECIAL_GOODS = 1;
// Update-union case 1684 (CDKJHJNFDIH) -> GridFightModule.UpdateUnlockProjectionID (DC17B10).
export const UNLOCK_PROJECTION_CASE = 1684;
// SpecialGoods JsonPath folder names, keyed by GroupID (1 Cyrene, 2 Aha, 3 Aha upper).
export const SERIES = ['Cyrene', 'Aha'];
// FixPoint wire is signed Q32.32.
export function fixed(p) {
return Math.round(Number(BigInt.asIntN(64, BigInt(p?.wire ?? 0))) / 4294967296 * 1e6) / 1e6;
}
export function eliteResource(t) {
const node_rules = t.GridFightBinaryNodeRuleConfigRow.map(r => ({
id: r.ID,
// Elite nodes carry ParamList[0] = family; option id = family * 100 + quality.
family: r.ID >= 100 ? Math.floor(r.ID / 100) : 0,
quality: r.Quality ?? 0,
perform_level: r.PerformLevel ?? 0
})).sort((a, b) => a.id - b.id);
for (const r of node_rules) {
if (r.id >= 100 && r.id !== r.family * 100 + r.quality) e.fail('Binary node rule id does not follow family*100+quality: ' + r.id);
}
const diff_add = t.GridFightBinaryDiffAddRuleConfigRow.map(r => ({
rule: r.ID, quality: r.Quality, add: r.EnemyDifficultyAddValue ?? 0
})).sort((a, b) => a.rule - b.rule || a.quality - b.quality);
const difficulty = t.GridFightEnemyDifficultyLvConfigRow.map(r => ({
chapter: r.ChapterID ?? 0,
level: r.EnemyDifficultyLevel ?? 0,
attack: fixed(r.AttackRatio),
defence: fixed(r.DefenceRatio),
hp: fixed(r.HPRatio),
speed: fixed(r.SpeedRatio),
stance: fixed(r.StanceRatio),
damage_reduce: fixed(r.AllDamageReduce)
})).sort((a, b) => a.chapter - b.chapter || a.level - b.level);
return {schema: 1, node_rules, diff_add, difficulty, extra_rewards: []};
}
export function goodsResource(t) {
const projections = t.GridFightProjectionConfigRow.filter(r => r.UnlockType === UNLOCK_SPECIAL_GOODS).map(r => ({
id: r.ID,
role_id: r.RoleID,
rarity: r.Rarity,
mazebuff_id: r.MazebuffID,
params: (r.ParamList ?? []).map(fixed)
})).sort((a, b) => a.id - b.id);
const special_goods = t.GridFightSpecialGoodsConfigRow.map(g => {
const series = SERIES.find(n => g.JsonPath.includes('/' + n + '/'));
if (!series) e.fail('Unknown SpecialGoods series: ' + g.JsonPath);
return {
id: g.ID,
group: g.GroupID,
series,
cost: g.Cost ?? 0,
quality: g.Quality ?? 0,
lower_good_id: g.LowerGoodID ?? 0,
params: (g.EffectParamList ?? []).map(fixed)
};
}).sort((a, b) => a.id - b.id);
// The good -> projection entitlement lives in the SpecialGoods JsonPath assets,
// which no installed table or tool decodes. Kept empty until proven.
return {schema: 1, unlock_case: UNLOCK_PROJECTION_CASE, projections, special_goods, grants: []};
}
export function main() {
const a = e.cli(['client', 'dump', 'identity', 'literals', 'output']);
const ctx = s.extractionContext(a);
const literals = new Map(e.loadJSON(a.literals).map(r => [Number(BigInt(r.Address)), r.Value]));
const tables = {}, proofs = [];
for (const name of TYPES) {
const assets = table_bindings(ctx.codec, literals, 'RPG.GameCore.' + name, null);
tables[name] = [];
for (const asset of assets) {
const match = asset.match(/^BakedConfig\/ExcelOutput(GameCore)?\/(\w+)\.bytes$/);
if (!match) e.fail('Unsupported table path: ' + asset);
const [rows, proof] = s.table(ctx.assets, ctx.codec, name, match[1] ?? '', match[2]);
tables[name].push(...rows);
proofs.push(proof);
console.log(`${name}: ${rows.length} byte-exact rows`);
}
}
const source = {dump_sha256: ctx.identity.dump_sha256, game_assembly_sha256: ctx.identity.game_assembly_sha256, tables: TYPES};
s.publish(a, ctx, tables, proofs, {
'rewards.raw.json': tables,
'elite_rewards.json': {...eliteResource(tables), source},
'projection_goods.json': {...goodsResource(tables), source}
}, 'rewards', {gameplay_verified: false});
}
e.runMain(import.meta.url, main);
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import * as r from './rewards.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const fix = n => ({wire: BigInt(Math.round(n * 4294967296))});
test('fixed decodes Q32.32 and rounds the float noise of the client', () => {
assert.equal(r.fixed({wire: 4294967300}), 1);
assert.equal(r.fixed({wire: 343597384}), 0.08);
assert.equal(r.fixed({wire: 103079215104}), 24);
assert.equal(r.fixed(undefined), 0);
});
test('elite resource: ids follow family*100+quality and tables are sorted', () => {
const out = r.eliteResource({
GridFightBinaryNodeRuleConfigRow: [{ID: 10302, Quality: 2, PerformLevel: 2}, {ID: 1, Quality: 1, PerformLevel: 1}, {ID: 10301, Quality: 1, PerformLevel: 1}],
GridFightBinaryDiffAddRuleConfigRow: [{ID: 2, Quality: 1}, {ID: 1, Quality: 2, EnemyDifficultyAddValue: 5}],
GridFightEnemyDifficultyLvConfigRow: [{ChapterID: 2, EnemyDifficultyLevel: 1, HPRatio: fix(4.16)}, {ChapterID: 1, HPRatio: fix(4)}]
});
assert.deepEqual(out.node_rules.map(x => [x.id, x.family, x.quality]), [[1, 0, 1], [10301, 103, 1], [10302, 103, 2]]);
assert.deepEqual(out.diff_add, [{rule: 1, quality: 2, add: 5}, {rule: 2, quality: 1, add: 0}]);
assert.deepEqual(out.difficulty.map(x => [x.chapter, x.level, x.hp]), [[1, 0, 4], [2, 1, 4.16]]);
assert.throws(() => r.eliteResource({
GridFightBinaryNodeRuleConfigRow: [{ID: 10399, Quality: 2}],
GridFightBinaryDiffAddRuleConfigRow: [],
GridFightEnemyDifficultyLvConfigRow: []
}), /family\*100\+quality/);
});
test('goods resource keeps only SpecialGoods projections and classifies series', () => {
const out = r.goodsResource({
GridFightProjectionConfigRow: [
{ID: 1509, RoleID: 1509, Rarity: 2, MazebuffID: 35610001, UnlockType: 1, ParamList: [{wire: 343597384}]},
{ID: 7, RoleID: 7, UnlockType: 0}
],
GridFightSpecialGoodsConfigRow: [
{ID: 401, GroupID: 3, Quality: 1, LowerGoodID: 301, JsonPath: 'Config/Level/GridFight/SpecialGoods/Aha/GridFightAhaGoods401.json', EffectParamList: [{wire: 51539607552}]},
{ID: 101, GroupID: 1, Cost: 24, Quality: 1, JsonPath: 'Config/Level/GridFight/SpecialGoods/Cyrene/GridFightCyreneGoods101.json'}
]
});
assert.deepEqual(out.projections, [{id: 1509, role_id: 1509, rarity: 2, mazebuff_id: 35610001, params: [0.08]}]);
assert.deepEqual(out.special_goods.map(g => [g.id, g.series, g.cost, g.lower_good_id, g.params]), [[101, 'Cyrene', 24, 0, []], [401, 'Aha', 0, 301, [12]]]);
assert.equal(out.unlock_case, 1684);
assert.deepEqual(out.grants, []);
assert.throws(() => r.goodsResource({
GridFightProjectionConfigRow: [],
GridFightSpecialGoodsConfigRow: [{ID: 1, GroupID: 9, JsonPath: 'Config/Other/x.json'}]
}), /Unknown SpecialGoods series/);
});
test('installed resources agree with the extractor contract', () => {
const dir = path.join(ROOT, 'resources/grid_fight');
const elite = JSON.parse(fs.readFileSync(path.join(dir, 'elite_rewards.json'), 'utf8'));
const goods = JSON.parse(fs.readFileSync(path.join(dir, 'projection_goods.json'), 'utf8'));
assert.equal(elite.node_rules.length, 44);
assert.equal(elite.diff_add.length, 8);
assert.equal(elite.difficulty.length, 903);
for (const rule of elite.node_rules) assert.equal(rule.id, rule.family ? rule.family * 100 + rule.quality : rule.quality);
assert.deepEqual(goods.projections.map(p => p.id), [1508, 1509]);
assert.equal(goods.special_goods.length, 164);
assert.equal(elite.source.dump_sha256, goods.source.dump_sha256);
});
+289
View File
@@ -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);
@@ -0,0 +1,78 @@
import {
spawnSync
} from 'node:child_process';
import * as e from './common.mjs';
function command(cmd, args, opts={
}){
const r=spawnSync(cmd, args, {
encoding:'utf8',
...opts
});
if(r.status!==0)e.fail(r.error?.message??`Command failed (${r.status}): ${cmd}`);
return r.stdout;
}
export function main(){
const a=e.cli(['output'], {
zig:'zig',
optimize:'ReleaseSafe'
}),
repo=e.ROOT,
output=e.path.resolve(a.output);
e.fs.mkdirSync(output, {
recursive:true
});
const version=command(a.zig, ['version']).trim();
if(version!=='0.14.1')e.fail('Zig 0.14.1 required; found '+version);
const zon=e.readText(e.path.join(repo, 'protocol/build.zig.zon')),
pkg=zon.match(/\.hash\s*=\s*"(protobuf-[^"]+)"/)?.[1];
if(!pkg)e.fail('Missing protobuf package hash');
const cache=process.env.ZIG_GLOBAL_CACHE_DIR??e.path.join(e.os.homedir(), '.cache/zig'),
protobuf=e.path.join(cache, 'p', pkg, 'src/protobuf.zig');
if(!e.fs.existsSync(protobuf))e.fail('Cached protobuf dependency not found: '+protobuf);
const scratch=e.fs.mkdtempSync(e.path.join(output, 'gridfight-test-'));
e.fs.cpSync(e.path.join(repo, 'gameserver/src'), e.path.join(scratch, 'source/src'), {
recursive:true
});
e.fs.cpSync(e.path.join(repo, 'protocol/src'), e.path.join(scratch, 'protocol'), {
recursive:true
});
const runtime=e.path.join(scratch, 'runtime');
e.fs.mkdirSync(runtime);
e.fs.symlinkSync(e.path.join(repo, 'resources'), e.path.join(runtime, 'resources'), 'dir');
const entry=e.path.join(scratch, 'source/src/gridfight_test_root.zig');
e.fs.writeFileSync(entry, 'const std = @import("std");\ntest "GridFight imports" { std.testing.refAllDecls(@import("services/grid_fight.zig")); }\n');
const executable=e.path.join(scratch, 'gridfight-tests'),
args=['test', `-O${a.optimize}`, '--dep', 'protocol', `-Mroot=${entry}`, '--dep', 'protobuf', `-Mprotocol=${e.path.join(scratch,'protocol/root.zig')}`, `-Mprotobuf=${protobuf}`, '--cache-dir', e.path.join(output, 'test-cache'), '--global-cache-dir', e.path.join(output, 'zig-cache'), `-femit-bin=${executable}`, '--test-no-exec'];
function logged(cmd, args, log, opts){
const fd=e.fs.openSync(e.path.join(output, log), 'w');
try{
command(cmd, args, {
...opts,
stdio:['ignore', fd, fd]
});
} finally{
e.fs.closeSync(fd);
}
}
logged(a.zig, args, 'test-build.log', {
cwd:scratch
});
logged(executable, [], 'tests.log', {
cwd:runtime,
env:{
...process.env,
GRIDFIGHT_TEST_RUNTIME:runtime
}
});
const text=e.readText(e.path.join(output, 'tests.log')),
m=text.match(/All (\d+) tests passed\./);
if(text.includes('SKIP')||!m)e.fail('Skipped or incomplete suite; inspect tests.log');
e.atomic_json(e.path.join(output, 'test-result.json'), {
zig:version,
tests_passed:Number(m[1]),
runtime,
gameplay_verified:false
}, 2, true);
process.stdout.write(text);
}
e.runMain(import.meta.url, main);
+47
View File
@@ -0,0 +1,47 @@
import * as e from './common.mjs';
export function build(t){
const rules=e.dict();
for(const r of t.GridFightSeasonExpScoreRow)(rules[r.ScoreRuleID]??=[]).push({
chapter:r.ChapterID,
section:r.SectionID,
weekly:r.WeeklyScore??0,
exp:r.Exp??0
});
for(const rows of Object.values(rules)){
rows.sort((a, b)=>a.chapter-b.chapter||a.section-b.section);
if(!e.uniqueValues(rows, r=>[r.chapter, r.section].join(':')))e.fail('duplicate score node');
}
const divisions=e.dict();
for(const r of t.GridFightDivisionStageRow){
const d={
rule:r.ScoreRule??0,
oc_rule:r.OCScoreRule??0,
weekly_modify:r.WeeklyScoreModify??100,
exp_modify:r.ExpModify??100
};
divisions[r.DivisionID]=d;
for(const key of ['rule', 'oc_rule'])if(d[key]&&!(d[key]in rules))e.fail(`division ${r.DivisionID}: score rule ${d[key]} has no rows`);
}
const ranks=t.GridFightScoreRewardConfigRow.map(r=>({
rank:r.ScoreRank,
score:r.Score,
reward:r.Reward??0
})).sort((a, b)=>a.rank-b.rank);
if(!ranks.length)e.fail('Empty score reward table');
return {
rules,
divisions,
weekly_ranks:ranks,
weekly_cap:Math.max(...ranks.map(r=>r.score))
};
}
export function main(){
const a=e.cli([], {
catalog:e.catalogDefault,
output:e.resource('score.json')
}),
data=build(e.loadJSON(a.catalog).tables);
e.atomic_json(a.output, data, 1, true);
console.log(`${a.output}: ${Object.keys(data.rules).length} score rules, ${Object.keys(data.divisions).length} divisions, weekly cap ${data.weekly_cap}`);
}
e.runMain(import.meta.url, main);
@@ -0,0 +1,56 @@
// Complete staged bundle. All paths are explicit; installation is a separate operation.
import {spawnSync} from 'node:child_process';
import * as e from './common.mjs';
import {client_identity} from './rounds.mjs';
export function main(){
const a=e.cli(['client','dump','literals','english','thai','resources','baseline','output']),
out=e.path.resolve(a.output),baseline=e.path.resolve(a.baseline),client=e.path.resolve(a.client),
inside=(p,root)=>p===root||p.startsWith(root+e.path.sep);
for(const root of [client,baseline,e.path.resolve(a.resources)])if(inside(out,root)||inside(root,out))e.fail('Staging must be separate from client and installed resources');
e.fs.mkdirSync(e.path.join(out,'logs'),{recursive:true});
function run(script,args){
const log=e.path.join(out,'logs',script+'.log'),fd=e.fs.openSync(log,'w');
let p;
try{p=spawnSync(process.execPath,[e.path.join(e.HERE,script+'.mjs'),...args],{stdio:['ignore',fd,fd]});}
finally{e.fs.closeSync(fd);}
if(p.status!==0)e.fail(`${script} failed; see ${log}: ${p.error?.message??p.status}`);
console.log(`${script}: complete`);
}
const base=['--client',client,'--dump',a.dump],identity=e.path.join(out,'provenance.json'),
extra=['--identity',identity,'--output',out],catalog=e.path.join(out,'catalog.json'),items=e.path.join(out,'items.raw.json');
run('extract',[...base,'--output',out,'--traits','--season-id','2','--evidence-dir',e.path.join(out,'evidence/core')]);
for(const name of ['stages','items','petpal'])run(name,[...base,...extra]);
for(const name of ['trait_effects','trailblaze','jade'])run(name,['--client',client,'--output',e.path.join(out,name+'.json')]);
run('catalog',[...base,...extra,'--literals',a.literals,'--english',a.english,'--items',items]);
for(const name of ['equipment','portals'])run(name,['--items',items,'--catalog',catalog,'--season','2','--output',e.path.join(out,name+'.json'),
...(name==='portals'?['--augments',e.path.join(out,'augments.json')]:[])]);
run('score',['--catalog',catalog,'--output',e.path.join(out,'score.json')]);
run('supplemental',['--stage',out]);
const roundIdentity=e.path.join(out,'evidence/metadata-identity.json');
e.atomic_json(roundIdentity,{client:client_identity(client),metadata_sha256:e.sha256_file(a.dump)});
run('rounds',[...base,'--literals',a.literals,'--identity',roundIdentity,'--output',e.path.join(out,'rounds-alternative')]);
run('native_slice',[...base,'--class','DynamicValue','--output',e.path.join(out,'evidence/dynamic-carrier.exe')]);
run('audit_coverage',['--dump',a.dump,'--output',e.path.join(out,'evidence/coverage.json')]);
run('audit_battle_entry',[...base,'--assembly',e.path.join(client,'GameAssembly.dll'),'--roles',e.path.join(out,'roles.json'),'--evidence',e.path.join(out,'evidence/battle-entry')]);
const hints=e.path.join(out,'evidence/ui-input');e.fs.mkdirSync(hints,{recursive:true});
// UI inputs are staged copies; no symlink grants the extractor write access to resources.
const grid=e.path.join(hints,'grid_fight');e.fs.mkdirSync(grid,{recursive:true});
for(const file of ['season','rounds','preparation','petpal','traits','roles','items.raw'])e.fs.copyFileSync(e.path.join(out,file+'.json'),e.path.join(grid,file+'.json'));
for(const file of ['AvatarConfig.json','AeonCatalog.json'])e.fs.copyFileSync(e.path.join(a.resources,file),e.path.join(hints,file));
run('ui_names',[...base,'--resources',hints,'--english',a.english,'--thai',a.thai,'--output',e.path.join(out,'ui_names.json'),'--evidence',e.path.join(out,'evidence/ui')]);
const carried={};
for(const file of ['node_profiles.json','orb_rewards.json']){
const source=e.path.join(baseline,file),target=e.path.join(out,file);e.fs.copyFileSync(source,target);
carried[file]={sha256:e.sha256_file(source),source:'installed server-authored data; not extracted from this client'};
}
e.atomic_json(e.path.join(out,'carryover-provenance.json'),carried);
run('compare_stage',['--baseline',baseline,'--stage',out]);
const missing=e.fs.readdirSync(baseline).filter(n=>n.endsWith('.json')&&!e.fs.existsSync(e.path.join(out,n)));
if(missing.length)e.fail('Incomplete bundle: '+missing.join(', '));
e.atomic_json(e.path.join(out,'bundle-manifest.json'),{
season_id:2,client:client_identity(client),
outputs:Object.fromEntries(e.fs.readdirSync(out).filter(n=>e.fs.statSync(e.path.join(out,n)).isFile()&&n!=='bundle-manifest.json').sort().map(n=>[n,e.sha256_file(e.path.join(out,n))])),
carried,gameplay_verified:false
});
}
e.runMain(import.meta.url,main);
+248
View File
@@ -0,0 +1,248 @@
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);
@@ -0,0 +1,39 @@
// Supplemental runtime documents use the freshly proved catalog and stage tables.
import * as e from './common.mjs';
export const BATTLE_TYPES=['GridFightBonfireHealConfigRow','GridFightBonfireEventRow','ConstValueCommonRow',
'GridFightShopPriceConfigRow','GridFightFormationWaveConfigRow','GridFightStageLevelValueConfigRow',
'GridFightPenaltyRuleConfigRow','GridFightEnemyDifficultyLvConfigRow','GridFightBonusRuleConfigRow',
'GridFightBinaryNodeRuleConfigRow','GridFightLevelBaseValueConfigRow'];
export function build(raw,stage,season){
const battle=Object.fromEntries(BATTLE_TYPES.map(n=>[n,raw[n]??stage[n]??e.fail('Missing battle table '+n)])),
template=raw.GridFightTraitElationTemplateConfigRow.find(r=>r.ID===1);
if(!template)e.fail('Missing deterministic elation template 1');
const ids=new Set(['PreEquipList','FirstRecommendEquipList','SecondRecommendEquipList'].flatMap(k=>template[k]??[])),
equipment=raw.GridFightEquipConfigRow.filter(r=>ids.has(r.ID));
if(equipment.length!==ids.size)e.fail('Unresolved elation equipment');
return {'battle.json':battle,'elation.json':{
season_id:season.season_id,sub_season_id:season.raw.selected_module.SubSeasonID,template_id:template.ID,
selection:'Deterministic valid fallback; native template and equipment tables have no season selector. Not a weighted gameplay roll.',
raw:{GridFightTraitElationTemplateConfigRow:template,GridFightEquipConfigRow:equipment},
evidence:{limitations:['Native tables and field bindings proved; runtime lookup and hotfix behavior not established for this build.']}
}};
}
export function main(){
const a=e.cli(['stage']),load=n=>e.loadJSON(e.path.join(a.stage,n+'.json')),
raw=load('catalog.raw'),catalogProof=load('catalog-provenance'),stageProof=load('stage-provenance'),
outputs=build(raw,load('rounds.raw'),load('season')),
proofs=[...catalogProof.tables,...stageProof.tables, ...load('items-provenance').tables],
proof=n=>proofs.find(p=>p.table===n)??e.fail('Missing proof '+n);
outputs['battle-provenance.json']=BATTLE_TYPES.map(proof);
outputs['elation.json'].evidence.tables=['GridFightTraitElationTemplateConfigRow','GridFightEquipConfigRow'].map(proof);
const eq=load('equipment');
outputs['equipment-chip-rewards-provenance.json']={
source:'Matching staged beta client native tables and English TextMap',catalog_sha256:e.sha256_file(e.path.join(a.stage,'catalog.json')),
client_identity:catalogProof,exporter:'tools/gridfight-extract-node/equipment.mjs',
tables:['GridFightEquipConfigRow','GridFightSPSilverWolfChooseConfigRow','GridFightSeasonItemConfigRow'],
contracts:Object.fromEntries(eq.equipment.filter(r=>r.battle_reward).map(r=>[r.id,r.battle_reward])),
behavior_limits:['Static configuration and description checks; gameplay not verified.']
};
for(const [file,value]of Object.entries(outputs))e.atomic_json(e.path.join(a.stage,file),value);
}
e.runMain(import.meta.url,main);
@@ -0,0 +1,84 @@
import * as s from './stages.mjs';
import {
Reader,
zigzag,
fixpoint,
rounded
} from './trait_effects.mjs';
const e=s.primitive;
export const TYPES={
0:'item',
1:'refresh',
3:'maze_buff',
4:'stat',
5:'role',
6:'power'
};
export function decode(raw){
const r=new Reader(raw),
rows=[],
count=Math.floor(Number(r.var())/2);
for(let i=0; i<count; i++){
const mask=BigInt(r.var()),
row={
};
if(mask&1n)row.ID=r.var();
if(mask&2n)row.Type=zigzag(r.var());
if(mask&4n)row.ParamList=Array.from({
length:Math.floor(Number(r.var())/2)
}, ()=>r.var());
if(mask&8n)row.JsonPath=r.text();
if(mask&16n)row.EffectParamList=Array.from({
length:Math.floor(Number(r.var())/2)
}, ()=>rounded(fixpoint(r.var())));
if(mask&32n)row.Cost=r.var();
if(mask&64n)row.Icon=r.text();
if(mask&128n)row.Desc=r.text();
for(const [bit, key]of [[256n, 'TrailGoodTitle'], [512n, 'TrailGoodDesc'], [1024n, 'TrailGoodSimpleDesc']])if(mask&bit)row[key]={
hash:r.var(),
hash64:r.var()
};
if(mask&2048n)row.FatherGoodID=r.var();
if(mask>>12n)e.fail('GridFightTrailblazeGoods: unknown fields');
rows.push(row);
}
r.done('GridFightTrailblazeGoods');
return rows;
}
export function goods(rows){
const out=[],
ids=new Set();
for(const r of rows){
const gid=r.ID;
if(gid===undefined)e.fail('Missing goods ID');
if(ids.has(gid))e.fail('duplicate Trailblaze goods '+gid);
ids.add(gid);
const kind=TYPES[r.Type??0];
if(kind===undefined)e.fail('unknown Trailblaze goods type '+r.Type+' for '+gid);
const params=(r.ParamList??[]).map(Number);
if(['maze_buff', 'item', 'power'].includes(kind)&&params.length!==1)e.fail(`goods ${gid}: ${kind} needs one parameter`);
if(['stat', 'role'].includes(kind)&&params.length!==2)e.fail(`goods ${gid}: ${kind} needs two parameters`);
out.push({
id:gid,
kind,
cost:r.Cost??0,
father:r.FatherGoodID??0,
params
});
}
for(const g of out)if(g.father&&!ids.has(g.father))e.fail(`goods ${g.id}: missing father ${g.father}`);
return out;
}
export function main(){
const a=e.cli(['client'], {
output:e.resource('trailblaze.json')
}),
[raw, proof]=new s.Assets(a.client).get('BakedConfig/ExcelOutput/GridFightTrailblazeGoods.bytes'),
out={
goods:goods(decode(raw)),
provenance:proof
};
e.atomic_json(a.output, out, 1, true);
console.log(`${a.output}: ${out.goods.length} goods`);
}
e.runMain(import.meta.url, main);
@@ -0,0 +1,137 @@
import * as s from './stages.mjs';
const e=s.primitive;
export class Reader {
constructor(data){
this.b=data;
this.p=1;
}
var(){
let v=0n,
shift=0n;
for(; ; ){
if(this.p>=this.b.length)e.fail('Truncated table varint');
const x=this.b[this.p++];
v|=BigInt(x&127)<<shift;
shift+=7n;
if(x<128)return e.small(v);
}
}
text(){
const n=Number(this.var());
if(this.p+n>this.b.length)e.fail('Truncated table string');
const text=new TextDecoder('utf-8', {
fatal:true
}).decode(this.b.subarray(this.p, this.p+n));
this.p+=n;
return text;
}
done(name){
if(this.p!==this.b.length)e.fail(`${name}: decode stopped at ${this.p} of ${this.b.length}`);
}
}
export const zigzag=e.zigzag;
export function fixpoint(wire){
const raw=BigInt(zigzag(wire));
return Number(raw&~1n)/2**((raw&1n)?26:33);
}
// Python round uses ties-to-even; retain a float marker for identical JSON 0.0/1.0.
export function rounded(v, n=6){
const scale=10**n,
x=v*scale,
lo=Math.floor(x),
f=x-lo;
return new e.Float((f===0.5?(lo%2===0?lo:lo+1):Math.round(x))/scale);
}
export function effects(raw){
const r=new Reader(raw),
rows=e.dict(),
count=Number(r.var())/2;
for(let i=0; i<Math.floor(count); i++){
const mask=BigInt(r.var()),
row={
};
if(mask&1n)row.id=r.var();
if(mask&2n)row.type=zigzag(r.var());
if(mask&4n)row.f2=r.var();
if(mask&8n)row.json=r.text();
if(mask&16n)row.icon=r.text();
if(mask>>5n)e.fail('GridFightTraitEffect: unknown fields');
if(row.id===undefined||row.type===undefined)e.fail('Missing effect id/type');
rows[row.id]=row.type;
}
r.done('GridFightTraitEffect');
return rows;
}
export function layer_params(raw){
const r=new Reader(raw),
rows=e.dict(),
count=Number(r.var())/2;
for(let i=0; i<Math.floor(count); i++){
const mask=BigInt(r.var()),
row={
id:0,
layer:0,
params:[]
};
if(mask&1n)row.id=r.var();
if(mask&2n)row.layer=r.var();
if(mask&4n)row.params=Array.from({
length:Math.floor(Number(r.var())/2)
}, ()=>rounded(fixpoint(r.var())));
if(mask&8n)row.f3=[r.var(), r.var()];
if(mask&16n){
const count=Math.floor(Number(r.var())/2);
row.doubles=Array.from({
length:count
}, (_, k)=>r.b.readDoubleLE(r.p+k*8));
r.p+=8*count;
}
if(mask>>5n)e.fail('GridFightTraitEffectLayerPa: unknown fields');
rows[row.id]??=e.dict();
rows[row.id][row.layer]=row.params;
}
r.done('GridFightTraitEffectLayerPa');
return rows;
}
export function constants(raw){
const r=new Reader(raw);
function value(tag){
if(tag===0)return zigzag(r.var());
if(tag===6)return Array.from({
length:Math.floor(Number(r.var())/2)
}, ()=>value(r.b[r.p++]));
if(tag===10)return r.text();
e.fail('GridFightTraitConstValueS2: unknown value tag '+tag);
}
const rows=e.dict(),
count=Math.floor(Number(r.var())/2);
for(let i=0; i<count; i++){
if(r.var()!==3)e.fail('GridFightTraitConstValueS2: unknown fields');
const key=r.text(),
tag=r.b[r.p++];
rows[key]=value(tag);
}
r.done('GridFightTraitConstValueS2');
return rows;
}
export function main(){
const a=e.cli(['client'], {
output:e.resource('trait_effects.json')
}),
assets=new s.Assets(a.client),
proofs=[],
out={
};
for(const [key, name, decode]of [['effects', 'GridFightTraitEffect', effects], ['layer_params', 'GridFightTraitEffectLayerPa', layer_params], ['constants', 'GridFightTraitConstValueS2', constants]]){
const [raw, proof]=assets.get('BakedConfig/ExcelOutputGameCore/'+name+'.bytes');
out[key]=decode(raw);
proofs.push({
table:key,
...proof
});
}
out.provenance=proofs;
e.atomic_json(a.output, out, 1, true);
console.log(`${a.output}: ${Object.keys(out.effects).length} effects, ${Object.keys(out.layer_params).length} effect param sets, ${Object.keys(out.constants).length} constants`);
}
e.runMain(import.meta.url, main);
+122
View File
@@ -0,0 +1,122 @@
import * as extract from './extract.mjs';
import * as stages from './stages.mjs';
const e=extract;
export function text_rows(blob){
const cursor=new e.Cursor(blob),
count=cursor.length(1000000),
result=e.dict();
for(let i=0; i<count; i++){
if(cursor.at>=blob.length)e.fail('Truncated TextMap flags');
const flags=blob[cursor.at++];
if(flags&~7)e.fail('Invalid TextMap row flags');
if(flags&1)cursor.varint();
const key=flags&2?cursor.varint():0,
size=Number(cursor.varint());
if(cursor.at+size>blob.length)e.fail('Truncated TextMap text');
const text=new TextDecoder('utf-8', {
fatal:true
}).decode(blob.subarray(cursor.at, cursor.at+size));
cursor.at+=size;
if(flags&4)cursor.varint();
if(key){
if(key in result&&result[key]!==text)e.fail('Conflicting TextMap key');
result[key]=text;
}
}
if(cursor.length(1000000)!==count)e.fail('Invalid TextMap index count');
return result;
}
export function main(){
const a=e.cli(['client', 'dump', 'resources', 'english', 'thai', 'output', 'evidence']),
load=name=>e.loadJSON(e.path.join(a.resources, name)),
grid=name=>load('grid_fight/'+name+'.json'),
codec=new stages.Codec(e.readText(a.dump), e.path.join(a.client, 'GameAssembly.dll'), a.evidence),
[divisions, proof]=stages.table(new stages.Assets(a.client), codec, 'GridFightDivisionLevelShowRow', '', 'GridFightDivisionLevelShow'),
blobs={
en:e.fs.readFileSync(a.english),
th:e.fs.readFileSync(a.thai)
},
maps=Object.fromEntries(Object.entries(blobs).map(([lang, b])=>[lang, text_rows(b)])),
keys=e.dict();
function take(value){
if(Array.isArray(value)){
value.forEach(take);
return;
}
if(value&&typeof value==='object'){
if(value.hash&&value.hash64){
const k=String(value.hash);
if(k in keys&&e.json(keys[k])!==e.json(value))e.fail('TextID hash32 collision; use full keys before exporting');
keys[k]=value;
} else Object.values(value).forEach(take);
}
}
take(divisions);
take(grid('season').raw.divisions.map(r=>r.DivisionName));
take(grid('rounds').map(r=>r.node_name));
take(grid('preparation').camps.map(r=>r.raw.GridFightCampConfigRow.CampName));
take(grid('petpal').map(r=>r.raw.Name));
take(grid('traits').map(r=>r.raw.base.TraitName));
const itemRaw=grid('items.raw');
take(itemRaw);
const hashes=(rows,id,name)=>e.dict(rows.filter(r=>r[name]?.hash).map(r=>[r[id],r[name].hash])),
item_hashes=hashes(itemRaw.GridFightItemConfigRow,'ID','ItemName'),
augment_hashes=hashes(itemRaw.GridFightAugmentConfigRow,'ID','HexName'),
portal_hashes=hashes(itemRaw.GridFightPortalBuffConfigRow,'ID','PortalBuffTitle'),
trait_hashes=e.dict(grid('traits').map(r=>[r.id,r.raw.base.TraitName.hash]));
const avatars=load('AvatarConfig.json').avatar_config,
avatarIds=new Set(grid('roles').map(r=>r.avatar_id)),
avatarHashes=e.dict();
for(const avatar of avatars){
const id=avatar.AvatarName??{
};
if(avatarIds.has(avatar.AvatarID)&&id.hash){
take(id);
avatarHashes[avatar.AvatarID]=id.hash;
}
}
const texts=e.dict();
for(const [key, id]of Object.entries(keys)){
const values=Object.fromEntries(Object.entries(maps).filter(([, rows])=>rows[id.hash64]).map(([lang, rows])=>[lang, rows[id.hash64]]));
if(Object.keys(values).length)texts[key]=values;
}
const bosses=new Set(grid('preparation').camps.flatMap(c=>c.monsters.filter(r=>r.tier===5).map(r=>r.id))),
monsters=e.dict(),
reverse=new Map();
for(const [key, text]of Object.entries(maps.en))if(key in maps.th){
if(!reverse.has(text))reverse.set(text, new Set());
reverse.get(text).add(maps.th[key]);
}
for(const monster of load('AeonCatalog.json').monsters){
if(!bosses.has(monster.id))continue;
const translations=reverse.get(monster.name)??new Set();
monsters[monster.id]={
en:monster.name,
...(translations.size===1?{
th:[...translations][0]
}
:{
})
};
}
const output={
source:{
text_blobs:Object.fromEntries([['en', a.english], ['th', a.thai]].map(([lang, p])=>[lang, {
file:e.path.basename(p),
sha256:e.sha256(blobs[lang])
}]))
},
division_show_proof:proof,
rank_templates:e.dict(divisions.map(r=>[`${r.SeasonID}:${r.DivisionLevel??0}`, {
name:r.DivisionNameWithNum.hash,
abbr:r.DivisionAbbr?.hash??0
}])),
texts,
monsters,
avatar_hashes:avatarHashes,
item_hashes, augment_hashes, portal_hashes, trait_hashes
};
e.atomic_json(a.output, output);
console.log(`Resolved ${Object.keys(texts).length}/${Object.keys(keys).length} names; ${Object.values(texts).filter(r=>'th'in r).length} Thai names; division asset round-trip verified.`);
}
e.runMain(import.meta.url, main);