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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum BgmContainer {
12 Nwa,
14 Ovk,
16 Owp,
18 Ogg,
20 Wav,
22 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum BgmPlaybackFormat {
47 Ogg,
48 Wav,
49 NwaStream,
51}
52
53#[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 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
276pub 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 #[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#[derive(Debug, Clone)]
381pub struct BgmDecoded {
382 pub container: BgmContainer,
383 pub wav_bytes: Vec<u8>,
385 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#[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
565pub 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}