Files
2026-10-30 23:54:07 +07:00

701 lines
26 KiB
JavaScript

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');
});