Hello World
This commit is contained in:
commit
7d342af29f
124 files changed
+627642
No files matched your search
@@ -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);
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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`);
|
||||
@@ -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}$/);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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&¶m 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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)&¶ms.length!==1)e.fail(`goods ${gid}: ${kind} needs one parameter`);
|
||||
if(['stat', 'role'].includes(kind)&¶ms.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);
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user