Files

109 lines
6.1 KiB
JavaScript
Raw Permalink Normal View History

2026-10-30 23:54:07 +07:00
// 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};
}