109 lines
6.1 KiB
JavaScript
109 lines
6.1 KiB
JavaScript
// 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};
|
||
|
|
}
|