Skip to main content

siglus_scene_vm/audio/
bgm.rs

1use std::fs;
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6
7use siglus_assets::{nwa, ovk};
8
9/// Supported container types for Siglus BGM inputs.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum BgmContainer {
12    /// Siglus NWA (packed PCM).
13    Nwa,
14    /// Siglus OVK pack (Ogg/Vorbis entries).
15    Ovk,
16    /// Siglus OWP (XOR-obfuscated Ogg/Vorbis).
17    Owp,
18    /// Plain Ogg/Vorbis.
19    Ogg,
20    /// Plain WAV.
21    Wav,
22    /// Unknown (fallback).
23    Unknown,
24}
25
26impl BgmContainer {
27    pub fn from_path(path: &Path) -> BgmContainer {
28        let ext = path
29            .extension()
30            .and_then(|s| s.to_str())
31            .unwrap_or("")
32            .to_ascii_lowercase();
33        match ext.as_str() {
34            "nwa" => BgmContainer::Nwa,
35            "ovk" => BgmContainer::Ovk,
36            "owp" => BgmContainer::Owp,
37            "ogg" => BgmContainer::Ogg,
38            "wav" => BgmContainer::Wav,
39            _ => BgmContainer::Unknown,
40        }
41    }
42}
43
44/// Encoded audio payload format used directly for BGM playback.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum BgmPlaybackFormat {
47    Ogg,
48    Wav,
49    /// NWA decoded while it plays, from `stream_path` (native builds).
50    NwaStream,
51}
52
53/// BGM payload prepared for playback without unnecessary quality-reducing conversion.
54#[derive(Debug, Clone)]
55pub struct BgmPlaybackData {
56    pub container: BgmContainer,
57    pub format: BgmPlaybackFormat,
58    pub bytes: Vec<u8>,
59    pub channels: u16,
60    pub sample_rate: u32,
61    pub total_samples: u64,
62    pub description: String,
63    /// The file to stream from (`NwaStream`); `bytes` is then empty.
64    pub stream_path: Option<PathBuf>,
65}
66
67#[derive(Debug, Clone, Copy)]
68struct BasicAudioInfo {
69    channels: u16,
70    sample_rate: u32,
71    total_samples: u64,
72}
73
74fn inspect_pcm_wav_bytes(wav: &[u8]) -> Result<BasicAudioInfo> {
75    if wav.len() < 44 || &wav[0..4] != b"RIFF" || &wav[8..12] != b"WAVE" {
76        bail!("not a RIFF/WAVE file");
77    }
78
79    let mut pos = 12usize;
80    let mut channels: Option<u16> = None;
81    let mut sample_rate: Option<u32> = None;
82    let mut block_align: Option<usize> = None;
83    let mut data_len: Option<usize> = None;
84
85    while pos + 8 <= wav.len() {
86        let id = &wav[pos..pos + 4];
87        let sz =
88            u32::from_le_bytes([wav[pos + 4], wav[pos + 5], wav[pos + 6], wav[pos + 7]]) as usize;
89        pos += 8;
90        if pos + sz > wav.len() {
91            bail!("truncated WAV chunk");
92        }
93        if id == b"fmt " {
94            if sz < 16 {
95                bail!("truncated WAV fmt chunk");
96            }
97            channels = Some(u16::from_le_bytes([wav[pos + 2], wav[pos + 3]]).max(1));
98            sample_rate = Some(
99                u32::from_le_bytes([wav[pos + 4], wav[pos + 5], wav[pos + 6], wav[pos + 7]]).max(1),
100            );
101            block_align = Some(u16::from_le_bytes([wav[pos + 12], wav[pos + 13]]) as usize);
102        } else if id == b"data" {
103            data_len = Some(sz);
104        }
105        pos += sz;
106        if (sz & 1) != 0 {
107            pos += 1;
108        }
109    }
110
111    let channels = channels.context("WAV fmt chunk missing channels")?;
112    let sample_rate = sample_rate.context("WAV fmt chunk missing sample rate")?;
113    let block_align = block_align
114        .context("WAV fmt chunk missing block align")?
115        .max(1);
116    let data_len = data_len.context("WAV data chunk missing")?;
117    Ok(BasicAudioInfo {
118        channels,
119        sample_rate,
120        total_samples: (data_len / block_align) as u64,
121    })
122}
123
124fn inspect_ogg_vorbis_bytes(ogg: &[u8]) -> Result<BasicAudioInfo> {
125    let mut pos = 0usize;
126    let mut channels: Option<u16> = None;
127    let mut sample_rate: Option<u32> = None;
128    let mut max_granule: i64 = -1;
129
130    while pos + 27 <= ogg.len() {
131        if &ogg[pos..pos + 4] != b"OggS" {
132            bail!("invalid Ogg capture pattern at byte {}", pos);
133        }
134        let granule = i64::from_le_bytes([
135            ogg[pos + 6],
136            ogg[pos + 7],
137            ogg[pos + 8],
138            ogg[pos + 9],
139            ogg[pos + 10],
140            ogg[pos + 11],
141            ogg[pos + 12],
142            ogg[pos + 13],
143        ]);
144        if granule >= 0 {
145            max_granule = max_granule.max(granule);
146        }
147        let seg_count = ogg[pos + 26] as usize;
148        let seg_table = pos + 27;
149        let data_start = seg_table + seg_count;
150        if data_start > ogg.len() {
151            bail!("truncated Ogg segment table");
152        }
153        let page_data_len = ogg[seg_table..data_start]
154            .iter()
155            .fold(0usize, |acc, b| acc.saturating_add(*b as usize));
156        let data_end = data_start.saturating_add(page_data_len);
157        if data_end > ogg.len() {
158            bail!("truncated Ogg page data");
159        }
160
161        let mut packet_off = data_start;
162        let mut packet_len = 0usize;
163        for lace in &ogg[seg_table..data_start] {
164            packet_len = packet_len.saturating_add(*lace as usize);
165            if *lace < 255 {
166                if packet_off + packet_len <= data_end {
167                    let packet = &ogg[packet_off..packet_off + packet_len];
168                    if packet.len() >= 16 && packet[0] == 1 && &packet[1..7] == b"vorbis" {
169                        channels = Some((packet[11] as u16).max(1));
170                        sample_rate = Some(
171                            u32::from_le_bytes([packet[12], packet[13], packet[14], packet[15]])
172                                .max(1),
173                        );
174                    }
175                }
176                packet_off = packet_off.saturating_add(packet_len);
177                packet_len = 0;
178            }
179        }
180
181        pos = data_end;
182    }
183
184    let channels = channels.context("Vorbis identification header missing")?;
185    let sample_rate = sample_rate.context("Vorbis identification sample rate missing")?;
186    if max_granule < 0 {
187        bail!("Ogg Vorbis final granule position missing");
188    }
189    Ok(BasicAudioInfo {
190        channels,
191        sample_rate,
192        total_samples: max_granule as u64,
193    })
194}
195
196#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
197fn read_audio_container_bytes(path: &Path) -> Result<Vec<u8>> {
198    crate::resource::read_file_bytes(path)
199        .with_context(|| format!("read audio container: {}", path.display()))
200}
201
202#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
203fn read_audio_container_bytes(path: &Path) -> Result<Vec<u8>> {
204    crate::resource::read_file_bytes(path)
205        .with_context(|| format!("read audio container: {}", path.display()))
206}
207
208#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
209fn parse_ovk_entries_from_bytes(bytes: &[u8]) -> Result<Vec<ovk::OvkEntry>> {
210    if bytes.len() < 4 {
211        bail!("OVK: header too small");
212    }
213    let count = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
214    if count == 0 {
215        bail!("OVK: zero entries");
216    }
217    let table_len = 4usize.saturating_add(count.saturating_mul(16));
218    if table_len > bytes.len() {
219        bail!("OVK: entry table truncated");
220    }
221    let mut entries = Vec::with_capacity(count);
222    for i in 0..count {
223        let off = 4 + i * 16;
224        let size = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
225        let offset = u32::from_le_bytes(bytes[off + 4..off + 8].try_into().unwrap());
226        let no = u32::from_le_bytes(bytes[off + 8..off + 12].try_into().unwrap());
227        let sample_count = u32::from_le_bytes(bytes[off + 12..off + 16].try_into().unwrap());
228        let end = (offset as usize).saturating_add(size as usize);
229        if size != 0 && end > bytes.len() {
230            bail!(
231                "OVK entry[{i}] out of range: offset={} size={} len={}",
232                offset,
233                size,
234                bytes.len()
235            );
236        }
237        entries.push(ovk::OvkEntry {
238            size,
239            offset,
240            no,
241            sample_count,
242        });
243    }
244    Ok(entries)
245}
246
247#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
248fn extract_ovk_entry_from_bytes(bytes: &[u8], idx: usize) -> Result<Vec<u8>> {
249    let entries = parse_ovk_entries_from_bytes(bytes)?;
250    let entry = entries.get(idx).copied().ok_or_else(|| {
251        anyhow::anyhow!(
252            "OVK entry out of range: idx={} entries={}",
253            idx,
254            entries.len()
255        )
256    })?;
257    if entry.size == 0 {
258        bail!("OVK entry[{idx}] has zero size");
259    }
260    let start = entry.offset as usize;
261    let end = start.saturating_add(entry.size as usize);
262    if end > bytes.len() {
263        bail!("OVK entry[{idx}] out of range");
264    }
265    Ok(bytes[start..end].to_vec())
266}
267
268#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
269fn decrypt_owp_bytes(mut bytes: Vec<u8>) -> Vec<u8> {
270    for b in &mut bytes {
271        *b ^= ovk::OwpFile::DEFAULT_XOR_KEY;
272    }
273    bytes
274}
275
276/// Prepare BGM bytes for direct playback.
277///
278/// Vorbis-based containers are kept as encoded Ogg/Vorbis bytes so Kira/Symphonia
279/// decodes the original stream directly. Only NWA is converted to PCM WAV because
280/// it is a Siglus PCM compression format rather than a Vorbis stream.
281pub fn decode_bgm_to_playback_bytes(
282    input: impl AsRef<Path>,
283    entry_idx: Option<usize>,
284) -> Result<BgmPlaybackData> {
285    let input = input.as_ref();
286    let kind = BgmContainer::from_path(input);
287
288    match kind {
289        BgmContainer::Ovk | BgmContainer::Owp | BgmContainer::Ogg => {
290            let (ogg, description) = extract_ogg_bytes(input, entry_idx)?;
291            let info = inspect_ogg_vorbis_bytes(&ogg)
292                .with_context(|| format!("inspect Ogg/Vorbis BGM: {}", input.display()))?;
293            Ok(BgmPlaybackData {
294                container: kind,
295                format: BgmPlaybackFormat::Ogg,
296                bytes: ogg,
297                channels: info.channels,
298                sample_rate: info.sample_rate,
299                total_samples: info.total_samples,
300                description,
301                stream_path: None,
302            })
303        }
304        BgmContainer::Wav => {
305            let wav = read_audio_container_bytes(input)?;
306            let info = inspect_pcm_wav_bytes(&wav)
307                .with_context(|| format!("inspect WAV BGM: {}", input.display()))?;
308            Ok(BgmPlaybackData {
309                container: kind,
310                format: BgmPlaybackFormat::Wav,
311                bytes: wav,
312                channels: info.channels,
313                sample_rate: info.sample_rate,
314                total_samples: info.total_samples,
315                description: format!("WAV:{}", input.display()),
316                stream_path: None,
317            })
318        }
319        // A whole track decoded is up to ~50 MB (twice that while it is
320        // converted); native builds decode while playing instead.
321        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
322        BgmContainer::Nwa => {
323            let resolved = crate::resource::resolve_game_file(input)?
324                .with_context(|| format!("NWA not found: {}", input.display()))?;
325            let reader = nwa::NwaReader::open(&resolved)
326                .with_context(|| format!("open NWA: {}", resolved.display()))?;
327            let header = reader.header();
328            Ok(BgmPlaybackData {
329                container: kind,
330                format: BgmPlaybackFormat::NwaStream,
331                bytes: Vec::new(),
332                channels: header.channels,
333                sample_rate: header.samples_per_sec,
334                total_samples: u64::from(header.frame_count()),
335                description: format!("NWA:{}", input.display()),
336                stream_path: Some(resolved),
337            })
338        }
339        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
340        BgmContainer::Nwa => {
341            let mut reader = open_nwa_reader(input)?;
342            let wav = reader.to_wav_bytes().context("decode NWA -> WAV")?;
343            let info = inspect_pcm_wav_bytes(&wav)
344                .with_context(|| format!("inspect NWA-decoded WAV: {}", input.display()))?;
345            Ok(BgmPlaybackData {
346                container: kind,
347                format: BgmPlaybackFormat::Wav,
348                bytes: wav,
349                channels: info.channels,
350                sample_rate: info.sample_rate,
351                total_samples: info.total_samples,
352                description: format!("NWA:{}", input.display()),
353                stream_path: None,
354            })
355        }
356        BgmContainer::Unknown => {
357            bail!(
358                "unsupported BGM container (by extension): {}",
359                input.display()
360            );
361        }
362    }
363}
364
365#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
366fn open_nwa_reader(input: &Path) -> Result<nwa::NwaReader> {
367    let bytes = read_audio_container_bytes(input)?;
368    nwa::NwaReader::open_from_bytes(bytes)
369        .with_context(|| format!("open NWA from wasm VFS: {}", input.display()))
370}
371
372#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
373fn open_nwa_reader(input: &Path) -> Result<nwa::NwaReader> {
374    let resolved = crate::resource::resolve_game_file(input)?
375        .with_context(|| format!("NWA not found: {}", input.display()))?;
376    nwa::NwaReader::open(&resolved).with_context(|| format!("open NWA: {}", resolved.display()))
377}
378
379/// Decoded audio payload ready for export or playback.
380#[derive(Debug, Clone)]
381pub struct BgmDecoded {
382    pub container: BgmContainer,
383    /// WAV (PCM16) bytes.
384    pub wav_bytes: Vec<u8>,
385    /// A helpful description for logs/UI.
386    pub description: String,
387}
388
389#[derive(Debug, Clone)]
390pub enum KoeSource {
391    File(PathBuf),
392    OvkEntryByNo { path: PathBuf, entry_no: u32 },
393}
394
395/// Decode various Siglus audio containers into WAV (PCM16).
396///
397/// * For `.ovk`, `entry_idx` selects which entry to decode.
398/// * For other formats, `entry_idx` is ignored.
399#[allow(clippy::needless_pass_by_value)]
400pub fn decode_bgm_to_wav_bytes(
401    input: impl AsRef<Path>,
402    entry_idx: Option<usize>,
403) -> Result<BgmDecoded> {
404    let requested = input.as_ref();
405    let resolved = crate::resource::resolve_game_file(requested)?
406        .with_context(|| format!("audio file not found: {}", requested.display()))?;
407    let input = resolved.as_path();
408    let kind = BgmContainer::from_path(input);
409
410    match kind {
411        BgmContainer::Nwa => {
412            let mut reader = open_nwa_reader(input)?;
413            let wav_bytes = reader.to_wav_bytes().context("decode NWA -> WAV")?;
414            Ok(BgmDecoded {
415                container: kind,
416                wav_bytes,
417                description: format!("NWA:{}", input.display()),
418            })
419        }
420        BgmContainer::Ovk => {
421            let idx = entry_idx.unwrap_or(0);
422            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
423            {
424                let bytes = read_audio_container_bytes(input)?;
425                let ogg = extract_ovk_entry_from_bytes(&bytes, idx).context("extract OVK entry")?;
426                let wav_bytes =
427                    siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(ogg))
428                        .context("decode OVK(entry) -> WAV")?;
429                Ok(BgmDecoded {
430                    container: kind,
431                    wav_bytes,
432                    description: format!("OVK:{}[{}]", input.display(), idx),
433                })
434            }
435            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
436            {
437                let pack = ovk::OvkPack::open(input)
438                    .with_context(|| format!("open OVK: {}", input.display()))?;
439                let entry_cnt = pack.entries().len();
440                if idx >= entry_cnt {
441                    bail!("OVK entry out of range: idx={} entries={}", idx, entry_cnt);
442                }
443                let wav_bytes = pack
444                    .decode_entry_vorbis_wav(idx)
445                    .context("decode OVK(entry) -> WAV")?;
446                Ok(BgmDecoded {
447                    container: kind,
448                    wav_bytes,
449                    description: format!("OVK:{}[{}]", input.display(), idx),
450                })
451            }
452        }
453        BgmContainer::Owp => {
454            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
455            {
456                let bytes = read_audio_container_bytes(input)?;
457                let ogg = decrypt_owp_bytes(bytes);
458                let wav_bytes =
459                    siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(ogg))
460                        .context("decode OWP -> WAV")?;
461                Ok(BgmDecoded {
462                    container: kind,
463                    wav_bytes,
464                    description: format!("OWP:{}", input.display()),
465                })
466            }
467            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
468            {
469                let owp = ovk::OwpFile::open(input)
470                    .with_context(|| format!("open OWP: {}", input.display()))?;
471                let wav_bytes = owp.decode_vorbis_wav().context("decode OWP -> WAV")?;
472                Ok(BgmDecoded {
473                    container: kind,
474                    wav_bytes,
475                    description: format!("OWP:{}", input.display()),
476                })
477            }
478        }
479        BgmContainer::Ogg => {
480            let bytes = read_audio_container_bytes(input)?;
481            let wav_bytes =
482                siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(bytes))
483                    .context("decode OGG/Vorbis -> WAV")?;
484            Ok(BgmDecoded {
485                container: kind,
486                wav_bytes,
487                description: format!("OGG:{}", input.display()),
488            })
489        }
490        BgmContainer::Wav => {
491            let wav_bytes = read_audio_container_bytes(input)?;
492            Ok(BgmDecoded {
493                container: kind,
494                wav_bytes,
495                description: format!("WAV:{}", input.display()),
496            })
497        }
498        BgmContainer::Unknown => {
499            bail!(
500                "unsupported BGM container (by extension): {}",
501                input.display()
502            );
503        }
504    }
505}
506
507pub fn decode_ovk_entry_by_no_to_wav_bytes(
508    input: impl AsRef<Path>,
509    entry_no: u32,
510) -> Result<BgmDecoded> {
511    let requested = input.as_ref();
512    let resolved = crate::resource::resolve_game_file(requested)?
513        .with_context(|| format!("OVK file not found: {}", requested.display()))?;
514    let input = resolved.as_path();
515    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
516    {
517        let bytes = read_audio_container_bytes(input)?;
518        let entries = parse_ovk_entries_from_bytes(&bytes)?;
519        let idx = entries
520            .iter()
521            .position(|e| e.no == entry_no)
522            .with_context(|| {
523                format!(
524                    "OVK entry not found: no={} file={}",
525                    entry_no,
526                    input.display()
527                )
528            })?;
529        let ogg = extract_ovk_entry_from_bytes(&bytes, idx)?;
530        let wav_bytes = siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(ogg))
531            .with_context(|| format!("decode OVK(entry no={entry_no}) -> WAV"))?;
532        Ok(BgmDecoded {
533            container: BgmContainer::Ovk,
534            wav_bytes,
535            description: format!("OVK:{}#{}", input.display(), entry_no),
536        })
537    }
538    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
539    {
540        let pack =
541            ovk::OvkPack::open(input).with_context(|| format!("open OVK: {}", input.display()))?;
542        let idx = pack
543            .entries()
544            .iter()
545            .position(|e| e.no == entry_no)
546            .with_context(|| {
547                format!(
548                    "OVK entry not found: no={} file={}",
549                    entry_no,
550                    input.display()
551                )
552            })?;
553
554        let wav_bytes = pack
555            .decode_entry_vorbis_wav(idx)
556            .with_context(|| format!("decode OVK(entry no={entry_no}) -> WAV"))?;
557        Ok(BgmDecoded {
558            container: BgmContainer::Ovk,
559            wav_bytes,
560            description: format!("OVK:{}#{}", input.display(), entry_no),
561        })
562    }
563}
564
565/// Extract raw Ogg bytes from Siglus containers.
566///
567/// * `.ovk`: extracts the Ogg segment at `entry_idx`.
568/// * `.owp`: decrypts the whole file to raw Ogg.
569pub fn extract_ogg_bytes(
570    input: impl AsRef<Path>,
571    entry_idx: Option<usize>,
572) -> Result<(Vec<u8>, String)> {
573    let requested = input.as_ref();
574    let resolved = crate::resource::resolve_game_file(requested)?
575        .with_context(|| format!("audio file not found: {}", requested.display()))?;
576    let input = resolved.as_path();
577    let kind = BgmContainer::from_path(input);
578
579    match kind {
580        BgmContainer::Ovk => {
581            let idx = entry_idx.unwrap_or(0);
582            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
583            {
584                let bytes = read_audio_container_bytes(input)?;
585                let ogg = extract_ovk_entry_from_bytes(&bytes, idx).context("extract OVK entry")?;
586                Ok((ogg, format!("OVK:{}[{}]", input.display(), idx)))
587            }
588            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
589            {
590                let pack = ovk::OvkPack::open(input)
591                    .with_context(|| format!("open OVK: {}", input.display()))?;
592                let entry_cnt = pack.entries().len();
593                if idx >= entry_cnt {
594                    bail!("OVK entry out of range: idx={} entries={}", idx, entry_cnt);
595                }
596                let ogg = pack.extract_entry(idx).context("extract OVK entry")?;
597                Ok((ogg, format!("OVK:{}[{}]", input.display(), idx)))
598            }
599        }
600        BgmContainer::Owp => {
601            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
602            {
603                let bytes = read_audio_container_bytes(input)?;
604                let ogg = decrypt_owp_bytes(bytes);
605                Ok((ogg, format!("OWP:{}", input.display())))
606            }
607            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
608            {
609                let owp = ovk::OwpFile::open(input)
610                    .with_context(|| format!("open OWP: {}", input.display()))?;
611                let ogg = owp.decrypt_to_vec().context("decrypt OWP -> Ogg")?;
612                Ok((ogg, format!("OWP:{}", input.display())))
613            }
614        }
615        BgmContainer::Ogg => {
616            let ogg = read_audio_container_bytes(input)?;
617            Ok((ogg, format!("OGG:{}", input.display())))
618        }
619        _ => bail!("container does not support Ogg extraction: {:?}", kind),
620    }
621}
622
623pub fn resolve_koe_source(project_dir: &Path, koe_no: i64) -> Result<KoeSource> {
624    if koe_no < 0 {
625        bail!("invalid koe number: {koe_no}");
626    }
627
628    let koe_no_u32 = koe_no as u32;
629    let scn_no = koe_no_u32 / 100_000;
630    let base = project_dir.join("koe");
631    for dir in [format!("{:04}", scn_no), scn_no.to_string()] {
632        for stem in [
633            format!("z{:09}", koe_no_u32),
634            format!("Z{:09}", koe_no_u32),
635            format!("z{}", koe_no_u32),
636            format!("Z{}", koe_no_u32),
637        ] {
638            for ext in ["wav", "nwa", "ogg"] {
639                let p = base.join(&dir).join(format!("{stem}.{ext}"));
640                if path_is_file(&p) {
641                    return Ok(KoeSource::File(p));
642                }
643            }
644        }
645    }
646
647    let ovk = base.join(format!("z{:04}.ovk", scn_no));
648    if path_is_file(&ovk) {
649        return Ok(KoeSource::OvkEntryByNo {
650            path: ovk,
651            entry_no: koe_no_u32 % 100_000,
652        });
653    }
654
655    bail!("koe resource not found: koe_no={koe_no}")
656}
657
658fn path_is_file(path: &Path) -> bool {
659    crate::resource::game_file_exists(path)
660}