Replace Range request asset loading with packed binary bundle

Extract save editor assets (animations, sounds, textures, bitmaps) into
a single save-editor.bin file at build time instead of fetching byte
ranges from ~550MB SI files at runtime. The bundle packs an embedded
JSON index and all fragment data into one file (~756KB), eliminating
Range request complexity and enabling proper Workbox precaching.
This commit is contained in:
Christian Semmler 2026-02-13 16:30:03 -08:00
parent 6b06c45f3a
commit f482898a72
5 changed files with 74 additions and 90 deletions

2
.gitignore vendored
View File

@ -4,4 +4,4 @@ isle.wasm
isle.wasm.map isle.wasm.map
isle.js isle.js
LEGO LEGO
asset-ranges.json save-editor.bin

View File

@ -4,12 +4,12 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build && cp isle.js isle.wasm asset-ranges.json dist/ && node scripts/workbox-inject.js", "build": "vite build && cp isle.js isle.wasm save-editor.bin dist/ && node scripts/workbox-inject.js",
"build:ci": "vite build && node scripts/workbox-inject.js", "build:ci": "vite build && node scripts/workbox-inject.js",
"check": "svelte-check --fail-on-warnings", "check": "svelte-check --fail-on-warnings",
"preview": "vite preview", "preview": "vite preview",
"prepare:assets": "node scripts/prepare.js", "prepare:assets": "node scripts/prepare.js",
"generate:manifest": "node scripts/generate-manifest.js" "generate:save-editor-assets": "node scripts/generate-save-editor-assets.js"
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.5", "@floating-ui/dom": "^1.7.5",

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
// Scans LEGO Island SI files to find byte offsets of embedded assets. // Scans LEGO Island SI files to extract embedded assets into a packed binary bundle.
// Writes public/asset-ranges.json consumed by the app for HTTP Range request fetching. // Writes save-editor.bin: [U32LE index length][JSON index][fragment data].
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
@ -201,7 +201,7 @@ const MXCH_SIGNATURE = Buffer.from('MxCh');
const MXCH_HEADER_SIZE = 22; // MxCh(4) + chunkSize(4) + flags(2) + objectId(4) + time(4) + dataSize(4) const MXCH_HEADER_SIZE = 22; // MxCh(4) + chunkSize(4) + flags(2) + objectId(4) + time(4) + dataSize(4)
const LEGO_DIR = path.join(process.cwd(), 'LEGO'); const LEGO_DIR = path.join(process.cwd(), 'LEGO');
const OUTPUT_PATH = path.join(process.cwd(), 'asset-ranges.json'); const BIN_PATH = path.join(process.cwd(), 'save-editor.bin');
const siCache = new Map(); const siCache = new Map();
@ -255,41 +255,31 @@ function findMxChByObjectId(siBuf, targetIds) {
return result; return result;
} }
function formatResult(siFile, result) {
// result is [offset, size] for contiguous, or [[o,s], ...] for split
if (Array.isArray(result[0])) {
return { si: siFile, ranges: result };
}
return [siFile, result[0], result[1]];
}
/** /**
* Verify assembled MxCh data against expected size and md5. * Assemble MxCh data from ranges, verify against expected size and md5.
* Only assembles up to `size` bytes (objectIds can be reused across streams). * Only assembles up to `size` bytes (objectIds can be reused across streams).
* Returns the used ranges for formatResult, or null on failure. * Returns the assembled Buffer, or null on failure.
*/ */
function verifyRanges(siBuf, ranges, size, expectedMd5) { function extractAndVerify(siBuf, ranges, size, expectedMd5) {
if (!ranges || ranges.length === 0) return null; if (!ranges || ranges.length === 0) return null;
const assembled = Buffer.alloc(size); const assembled = Buffer.alloc(size);
const usedRanges = [];
let writePos = 0; let writePos = 0;
for (const [rOff, rLen] of ranges) { for (const [rOff, rLen] of ranges) {
if (writePos >= size) break; if (writePos >= size) break;
const take = Math.min(rLen, size - writePos); const take = Math.min(rLen, size - writePos);
siBuf.copy(assembled, writePos, rOff, rOff + take); siBuf.copy(assembled, writePos, rOff, rOff + take);
usedRanges.push([rOff, take]);
writePos += take; writePos += take;
} }
if (writePos !== size || md5(assembled) !== expectedMd5) return null; if (writePos !== size || md5(assembled) !== expectedMd5) return null;
return usedRanges.length === 1 ? usedRanges[0] : usedRanges; return assembled;
} }
async function main() { async function main() {
console.log('Generating asset range manifest...\n'); console.log('Generating asset fragment bundle...\n');
const manifest = { animations: {}, sounds: {}, textures: {}, bitmaps: {} }; const fragments = []; // [{type, name, data: Buffer}, ...]
let found = 0; let found = 0;
let failed = 0; let failed = 0;
@ -301,9 +291,9 @@ async function main() {
const aniRanges = findMxChByObjectId(isleSI, aniObjectIds); const aniRanges = findMxChByObjectId(isleSI, aniObjectIds);
for (const [name, objectId, size, expectedMd5] of ANIMATIONS) { for (const [name, objectId, size, expectedMd5] of ANIMATIONS) {
const result = verifyRanges(isleSI, aniRanges.get(objectId), size, expectedMd5); const data = extractAndVerify(isleSI, aniRanges.get(objectId), size, expectedMd5);
if (result) { if (data) {
manifest.animations[name] = formatResult('Scripts/Isle/ISLE.SI', result); fragments.push({ type: 'animations', name, data });
found++; found++;
} else { } else {
console.error(` FAILED: ${name}.ani (objectId ${objectId})`); console.error(` FAILED: ${name}.ani (objectId ${objectId})`);
@ -321,9 +311,9 @@ async function main() {
let clickFound = 0; let clickFound = 0;
for (const [name, objectId, size, expectedMd5] of CLICK_ANIMATIONS) { for (const [name, objectId, size, expectedMd5] of CLICK_ANIMATIONS) {
const result = verifyRanges(sndanimSI, clickRanges.get(objectId), size, expectedMd5); const data = extractAndVerify(sndanimSI, clickRanges.get(objectId), size, expectedMd5);
if (result) { if (data) {
manifest.animations[name] = formatResult('Scripts/SNDANIM.SI', result); fragments.push({ type: 'animations', name, data });
clickFound++; clickFound++;
found++; found++;
} else { } else {
@ -340,9 +330,9 @@ async function main() {
let soundFound = 0; let soundFound = 0;
for (const [name, objectId, size, expectedMd5] of allSounds) { for (const [name, objectId, size, expectedMd5] of allSounds) {
const result = verifyRanges(sndanimSI, soundRanges.get(objectId), size, expectedMd5); const data = extractAndVerify(sndanimSI, soundRanges.get(objectId), size, expectedMd5);
if (result) { if (data) {
manifest.sounds[name] = formatResult('Scripts/SNDANIM.SI', result); fragments.push({ type: 'sounds', name, data });
soundFound++; soundFound++;
found++; found++;
} else { } else {
@ -353,7 +343,6 @@ async function main() {
console.log(` ${soundFound}/${allSounds.length} sounds found\n`); console.log(` ${soundFound}/${allSounds.length} sounds found\n`);
// --- Textures (across Build SI files) --- // --- Textures (across Build SI files) ---
// Group textures by SI file so we scan each file once
const texBySI = new Map(); const texBySI = new Map();
for (const entry of TEXTURES) { for (const entry of TEXTURES) {
const siFile = entry[1]; const siFile = entry[1];
@ -369,9 +358,9 @@ async function main() {
console.log(`Loaded ${siFile} (${(siBuf.length / 1024).toFixed(0)} KB)`); console.log(`Loaded ${siFile} (${(siBuf.length / 1024).toFixed(0)} KB)`);
for (const [name, , objectId, size, expectedMd5] of entries) { for (const [name, , objectId, size, expectedMd5] of entries) {
const result = verifyRanges(siBuf, texRanges.get(objectId), size, expectedMd5); const data = extractAndVerify(siBuf, texRanges.get(objectId), size, expectedMd5);
if (result) { if (data) {
manifest.textures[name] = formatResult(siFile, result); fragments.push({ type: 'textures', name, data });
texFound++; texFound++;
found++; found++;
} else { } else {
@ -397,9 +386,9 @@ async function main() {
const bmpRanges = findMxChByObjectId(siBuf, objectIds); const bmpRanges = findMxChByObjectId(siBuf, objectIds);
for (const [name, , objectId, size, expectedMd5] of entries) { for (const [name, , objectId, size, expectedMd5] of entries) {
const result = verifyRanges(siBuf, bmpRanges.get(objectId), size, expectedMd5); const data = extractAndVerify(siBuf, bmpRanges.get(objectId), size, expectedMd5);
if (result) { if (data) {
manifest.bitmaps[name] = formatResult(siFile, result); fragments.push({ type: 'bitmaps', name, data });
bmpFound++; bmpFound++;
found++; found++;
} else { } else {
@ -411,12 +400,26 @@ async function main() {
console.log(` ${bmpFound}/${BITMAPS.length} bitmaps found\n`); console.log(` ${bmpFound}/${BITMAPS.length} bitmaps found\n`);
if (failed > 0) { if (failed > 0) {
console.error(`Failed to find ${failed} assets. Manifest not written.`); console.error(`Failed to find ${failed} assets. Bundle not written.`);
process.exit(1); process.exit(1);
} }
await fs.writeFile(OUTPUT_PATH, JSON.stringify(manifest)); // --- Write single bundle: [U32LE indexLen][JSON index][data] ---
console.log(`Wrote ${OUTPUT_PATH}`); const index = {};
let offset = 0;
for (const { type, name, data } of fragments) {
index[`${type}/${name}`] = [offset, data.length];
offset += data.length;
}
const indexBuf = Buffer.from(JSON.stringify(index));
const header = Buffer.alloc(4);
header.writeUInt32LE(indexBuf.length);
const dataBuf = Buffer.concat(fragments.map(f => f.data));
const bundle = Buffer.concat([header, indexBuf, dataBuf]);
await fs.writeFile(BIN_PATH, bundle);
console.log(`Wrote ${BIN_PATH} (${(bundle.length / 1024).toFixed(1)} KB, ${Object.keys(index).length} entries)`);
console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`);
} }

View File

@ -1,68 +1,49 @@
// Loads assets from SI files via HTTP Range requests // Loads assets from a packed binary bundle generated by scripts/generate-save-editor-assets.js
// using the offset manifest generated by scripts/generate-manifest.js // Format: [U32LE indexLen][JSON index][fragment data]
let manifest = null; let bundleIndex = null;
let dataOffset = 0;
let bundleBuffer = null;
let bundlePromise = null;
async function loadManifest() { async function loadBundle() {
if (!manifest) { if (!bundlePromise) {
const resp = await fetch('/asset-ranges.json'); bundlePromise = fetch('/save-editor.bin').then(async (resp) => {
manifest = await resp.json(); bundleBuffer = await resp.arrayBuffer();
const indexLen = new DataView(bundleBuffer).getUint32(0, true);
const indexJson = new TextDecoder().decode(new Uint8Array(bundleBuffer, 4, indexLen));
bundleIndex = JSON.parse(indexJson);
dataOffset = 4 + indexLen;
});
} }
return manifest; await bundlePromise;
} }
async function fetchRange(siFile, offset, size) { function getAsset(type, name) {
const resp = await fetch(`/LEGO/${siFile}`, { const entry = bundleIndex[`${type}/${name}`];
headers: { Range: `bytes=${offset}-${offset + size - 1}` }, if (!entry) return null;
}); const [offset, size] = entry;
return resp.arrayBuffer(); return bundleBuffer.slice(dataOffset + offset, dataOffset + offset + size);
}
async function fetchEntry(entry) {
if (Array.isArray(entry)) {
// Contiguous: [siFile, offset, size]
return fetchRange(entry[0], entry[1], entry[2]);
}
// Split file: { si, ranges: [[offset, size], ...] }
const buffers = await Promise.all(
entry.ranges.map(([offset, size]) => fetchRange(entry.si, offset, size)),
);
const total = buffers.reduce((s, b) => s + b.byteLength, 0);
const result = new Uint8Array(total);
let pos = 0;
for (const buf of buffers) {
result.set(new Uint8Array(buf), pos);
pos += buf.byteLength;
}
return result.buffer;
} }
export async function fetchAnimation(name) { export async function fetchAnimation(name) {
const m = await loadManifest(); await loadBundle();
const entry = m.animations[name]; return getAsset('animations', name);
if (!entry) return null;
return fetchEntry(entry);
} }
export async function fetchTexture(name) { export async function fetchTexture(name) {
const m = await loadManifest(); await loadBundle();
const entry = m.textures[name]; return getAsset('textures', name);
if (!entry) return null;
return fetchEntry(entry);
} }
export async function fetchBitmap(name) { export async function fetchBitmap(name) {
const m = await loadManifest(); await loadBundle();
const entry = m.bitmaps[name]; return getAsset('bitmaps', name);
if (!entry) return null;
return fetchEntry(entry);
} }
async function fetchSound(name) { async function fetchSound(name) {
const m = await loadManifest(); await loadBundle();
const entry = m.sounds[name]; return getAsset('sounds', name);
if (!entry) return null;
return fetchEntry(entry);
} }
/** /**

View File

@ -1,7 +1,7 @@
module.exports = { module.exports = {
globDirectory: 'dist/', globDirectory: 'dist/',
globPatterns: [ globPatterns: [
'**/*.{js,css,html,webp,wasm,pdf,mp3,gif,png,svg,json}' '**/*.{js,css,html,webp,wasm,pdf,mp3,gif,png,svg,json,bin}'
], ],
swSrc: 'src-sw/sw.js', swSrc: 'src-sw/sw.js',
swDest: 'dist/sw.js', swDest: 'dist/sw.js',