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