mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 14:27:38 +00:00
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:
parent
6b06c45f3a
commit
f482898a72
2
.gitignore
vendored
2
.gitignore
vendored
@ -4,4 +4,4 @@ isle.wasm
|
|||||||
isle.wasm.map
|
isle.wasm.map
|
||||||
isle.js
|
isle.js
|
||||||
LEGO
|
LEGO
|
||||||
asset-ranges.json
|
save-editor.bin
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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);
|
||||||
return manifest;
|
const indexJson = new TextDecoder().decode(new Uint8Array(bundleBuffer, 4, indexLen));
|
||||||
}
|
bundleIndex = JSON.parse(indexJson);
|
||||||
|
dataOffset = 4 + indexLen;
|
||||||
async function fetchRange(siFile, offset, size) {
|
|
||||||
const resp = await fetch(`/LEGO/${siFile}`, {
|
|
||||||
headers: { Range: `bytes=${offset}-${offset + size - 1}` },
|
|
||||||
});
|
});
|
||||||
return resp.arrayBuffer();
|
}
|
||||||
|
await bundlePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchEntry(entry) {
|
function getAsset(type, name) {
|
||||||
if (Array.isArray(entry)) {
|
const entry = bundleIndex[`${type}/${name}`];
|
||||||
// Contiguous: [siFile, offset, size]
|
if (!entry) return null;
|
||||||
return fetchRange(entry[0], entry[1], entry[2]);
|
const [offset, size] = entry;
|
||||||
}
|
return bundleBuffer.slice(dataOffset + offset, dataOffset + offset + size);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user