// Loads assets from a packed binary bundle generated by scripts/generate-save-editor-assets.js // Format: [U32LE indexLen][JSON index][fragment data] let bundleIndex = null; let dataOffset = 0; let bundleBuffer = null; let bundlePromise = null; async function loadBundle() { if (!bundlePromise) { bundlePromise = fetch('/save-editor.bin').then(async (resp) => { 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; }); } await bundlePromise; } function getAsset(type, name) { const entry = bundleIndex[`${type}/${name}`]; if (!entry) return null; const [offset, size] = entry; return bundleBuffer.slice(dataOffset + offset, dataOffset + offset + size); } export async function fetchAnimation(name) { await loadBundle(); return getAsset('animations', name); } export async function fetchTexture(name) { await loadBundle(); return getAsset('textures', name); } async function fetchBitmap(name) { await loadBundle(); return getAsset('bitmaps', name); } async function fetchSound(name) { await loadBundle(); return getAsset('sounds', name); } /** * Build a WAV file from raw MxCh sound data. * Layout: bytes 0-15 = PCMWAVEFORMAT, 16-19 = m_dataSize, 20-23 = m_flags, 24+ = PCM data. * Uses actual available size since sector interleaving may clip the last chunk. */ function buildWav(buffer) { const dataSize = buffer.byteLength - 24; const wavSize = 44 + dataSize; const wav = new ArrayBuffer(wavSize); const view = new DataView(wav); const bytes = new Uint8Array(wav); // RIFF header bytes.set([0x52, 0x49, 0x46, 0x46]); // "RIFF" view.setUint32(4, wavSize - 8, true); bytes.set([0x57, 0x41, 0x56, 0x45], 8); // "WAVE" // fmt chunk — copy PCMWAVEFORMAT (16 bytes) directly from source header bytes.set([0x66, 0x6D, 0x74, 0x20], 12); // "fmt " view.setUint32(16, 16, true); bytes.set(new Uint8Array(buffer, 0, 16), 20); // data chunk bytes.set([0x64, 0x61, 0x74, 0x61], 36); // "data" view.setUint32(40, dataSize, true); bytes.set(new Uint8Array(buffer, 24, dataSize), 44); return wav; } export async function fetchSoundAsWav(name) { const buffer = await fetchSound(name); if (!buffer) return null; return buildWav(buffer); } /** * Decode a raw Windows DIB (no BM file header) into RGBA ImageData. * Supports 8-bit indexed color only. */ function decodeDib(buffer) { const view = new DataView(buffer); const width = view.getInt32(4, true); const height = view.getInt32(8, true); const bpp = view.getUint16(14, true); if (bpp !== 8) return null; // Palette: 256 BGRA entries starting at offset 40 const palette = new Uint8Array(buffer, 40, 1024); // Pixel data starts after header + palette const pixelOffset = 40 + 1024; const rowStride = (width + 3) & ~3; // rows padded to 4-byte boundary const absHeight = Math.abs(height); const bottomUp = height > 0; const imageData = new ImageData(width, absHeight); const pixels = new Uint8Array(buffer, pixelOffset); for (let y = 0; y < absHeight; y++) { const srcRow = bottomUp ? (absHeight - 1 - y) : y; for (let x = 0; x < width; x++) { const idx = pixels[srcRow * rowStride + x] * 4; const dst = (y * width + x) * 4; imageData.data[dst] = palette[idx + 2]; // R (from BGR) imageData.data[dst + 1] = palette[idx + 1]; // G imageData.data[dst + 2] = palette[idx]; // B imageData.data[dst + 3] = 255; } } return imageData; } /** * Fetch a bitmap from an SI file and return a blob URL for use in tags. */ export async function fetchBitmapAsURL(name) { const buffer = await fetchBitmap(name); if (!buffer) return null; const imageData = decodeDib(buffer); if (!imageData) return null; const canvas = new OffscreenCanvas(imageData.width, imageData.height); const ctx = canvas.getContext('2d'); ctx.putImageData(imageData, 0, 0); const blob = await canvas.convertToBlob({ type: 'image/png' }); return URL.createObjectURL(blob); }