701 lines
26 KiB
JavaScript
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');
|
|
});
|