Files
Currency-War-SR/tools/gridfight-extract-node/install_common.mjs
T
2026-10-30 23:54:07 +07:00

115 lines
5.8 KiB
JavaScript

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