diff --git a/scripts/generate-manifest.js b/scripts/generate-manifest.js index bcd46a0..08a62bb 100644 --- a/scripts/generate-manifest.js +++ b/scripts/generate-manifest.js @@ -114,6 +114,29 @@ const CLICK_ANIMATIONS = [ ['ClickAnim3', 13, 4218, 'e25f074d7012f89868011dc2bd5c0586'], ]; +// Click sounds from SNDANIM.SI (objectId = m_sound + 50) +// [name, objectId, size, md5] +const CLICK_SOUNDS = [ + ['ClickSound0', 50, 10078, '928eeb70f8dadbc400f5c150727fde69'], + ['ClickSound1', 51, 15988, '9c8aa04b0e4683976c3f2c2be868b37e'], + ['ClickSound2', 52, 4114, 'a94a6dc7ae24fc42b1b9be962bbf3bf1'], + ['ClickSound3', 53, 7741, '96bd26dc212ffd31da365ea1d088bfa3'], + ['ClickSound4', 54, 23705, 'ca79cc736729c12aed6da018725fb0e3'], + ['ClickSound5', 55, 24179, 'b7c97cb776f0afbba40f2e21fc0b309d'], + ['ClickSound6', 56, 17675, 'b69b07bba21c6667d0af651c89828815'], + ['ClickSound7', 57, 18953, '65d9cc0d09e3bfb831cee014a84085f7'], + ['ClickSound8', 58, 7344, '7bbc41251b750835989cb3b35c8546a4'], +]; + +// Mood sounds from SNDANIM.SI (objectId = m_mood + 66) +// [name, objectId, size, md5] +const MOOD_SOUNDS = [ + ['MoodSound0', 66, 11534, '91379f36012f600a4b7432e003e16c3a'], + ['MoodSound1', 67, 11534, '91379f36012f600a4b7432e003e16c3a'], + ['MoodSound2', 68, 11534, '91379f36012f600a4b7432e003e16c3a'], + ['MoodSound3', 69, 11534, '91379f36012f600a4b7432e003e16c3a'], +]; + // [name, siFile, objectId, size, md5] const TEXTURES = [ ['CHJETL1', 'Scripts/Build/COPTER.SI', 112, 4235, 'af5010e9de08240c1ff7ad08ae90087e'], @@ -266,7 +289,7 @@ function verifyRanges(siBuf, ranges, size, expectedMd5) { async function main() { console.log('Generating asset range manifest...\n'); - const manifest = { animations: {}, textures: {}, bitmaps: {} }; + const manifest = { animations: {}, sounds: {}, textures: {}, bitmaps: {} }; let found = 0; let failed = 0; @@ -310,6 +333,25 @@ async function main() { } console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`); + // --- Sounds (in SNDANIM.SI) --- + const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS]; + const soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId)); + const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds); + + let soundFound = 0; + for (const [name, objectId, size, expectedMd5] of allSounds) { + const result = verifyRanges(sndanimSI, soundRanges.get(objectId), size, expectedMd5); + if (result) { + manifest.sounds[name] = formatResult('Scripts/SNDANIM.SI', result); + soundFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + console.log(` ${soundFound}/${allSounds.length} sounds found\n`); + // --- Textures (across Build SI files) --- // Group textures by SI file so we scan each file once const texBySI = new Map(); @@ -375,7 +417,7 @@ async function main() { await fs.writeFile(OUTPUT_PATH, JSON.stringify(manifest)); console.log(`Wrote ${OUTPUT_PATH}`); - console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${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)`); } main().catch(err => { diff --git a/src/core/assetLoader.js b/src/core/assetLoader.js index 481f0d1..ee41535 100644 --- a/src/core/assetLoader.js +++ b/src/core/assetLoader.js @@ -58,6 +58,49 @@ export async function fetchBitmap(name) { return fetchEntry(entry); } +async function fetchSound(name) { + const m = await loadManifest(); + const entry = m.sounds[name]; + if (!entry) return null; + return fetchEntry(entry); +} + +/** + * 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. diff --git a/src/lib/save-editor/ActorEditor.svelte b/src/lib/save-editor/ActorEditor.svelte index abed7e7..1168438 100644 --- a/src/lib/save-editor/ActorEditor.svelte +++ b/src/lib/save-editor/ActorEditor.svelte @@ -4,6 +4,7 @@ import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js'; import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js'; import { Actor } from '../../core/savegame/constants.js'; + import { fetchSoundAsWav } from '../../core/assetLoader.js'; import NavButton from '../NavButton.svelte'; import ResetButton from '../ResetButton.svelte'; import EditorTooltip from '../EditorTooltip.svelte'; @@ -26,6 +27,39 @@ let loadedActorKey = null; let showVehicle = false; + let audioContext = null; + let gainNode = null; + const soundCache = new Map(); + + async function playSound(name) { + try { + if (!audioContext) { + audioContext = new AudioContext(); + gainNode = audioContext.createGain(); + gainNode.gain.value = 0.3; + gainNode.connect(audioContext.destination); + } + if (audioContext.state === 'suspended') { + await audioContext.resume(); + } + + let audioBuffer = soundCache.get(name); + if (!audioBuffer) { + const wav = await fetchSoundAsWav(name); + if (!wav) return; + audioBuffer = await audioContext.decodeAudioData(wav); + soundCache.set(name, audioBuffer); + } + + const source = audioContext.createBufferSource(); + source.buffer = audioBuffer; + source.connect(gainNode); + source.start(); + } catch (e) { + console.error(`Failed to play sound ${name}:`, e); + } + } + $: actorInfo = ActorInfoInit[actorIndex]; $: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown'; $: charState = slot?.characters?.[actorIndex]; @@ -132,6 +166,7 @@ onDestroy(() => { renderer?.dispose(); + audioContext?.close(); }); // Reload actor when index, character state, or vehicle toggle changes @@ -183,6 +218,17 @@ if (!acted) return; + // Play click sound (Mama plays the *new* sound after cycling) + const soundIdx = playerId === Actor.MAMA + ? (charState.sound + 1) % 9 + : charState.sound; + playSound(`ClickSound${soundIdx}`); + + // Laura additionally plays a mood sound + if (playerId === Actor.LAURA) { + playSound(`MoodSound${(charState.mood + 1) % 4}`); + } + // Queue click animation — consumed by loadAnimationForActor renderer.queueClickAnimation(clickMove);