1use std::fs;
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4
5use anyhow::{bail, Context, Result};
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
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum BgmPlaybackFormat {
48 Ogg,
49 Wav,
50}
51
52#[derive(Debug, Clone)]
54pub struct BgmPlaybackData {
55 pub container: BgmContainer,
56 pub format: BgmPlaybackFormat,
57 pub bytes: Vec<u8>,
58 pub channels: u16,
59 pub sample_rate: u32,
60 pub total_samples: u64,
61 pub description: String,
62}
63
64#[derive(Debug, Clone, Copy)]
65struct BasicAudioInfo {
66 channels: u16,
67 sample_rate: u32,
68 total_samples: u64,
69}
70
71fn inspect_pcm_wav_bytes(wav: &[u8]) -> Result<BasicAudioInfo> {
72 if wav.len() < 44 || &wav[0..4] != b"RIFF" || &wav[8..12] != b"WAVE" {
73 bail!("not a RIFF/WAVE file");
74 }
75
76 let mut pos = 12usize;
77 let mut channels: Option<u16> = None;
78 let mut sample_rate: Option<u32> = None;
79 let mut block_align: Option<usize> = None;
80 let mut data_len: Option<usize> = None;
81
82 while pos + 8 <= wav.len() {
83 let id = &wav[pos..pos + 4];
84 let sz = u32::from_le_bytes([wav[pos + 4], wav[pos + 5], wav[pos + 6], wav[pos + 7]]) as usize;
85 pos += 8;
86 if pos + sz > wav.len() {
87 bail!("truncated WAV chunk");
88 }
89 if id == b"fmt " {
90 if sz < 16 {
91 bail!("truncated WAV fmt chunk");
92 }
93 channels = Some(u16::from_le_bytes([wav[pos + 2], wav[pos + 3]]).max(1));
94 sample_rate = Some(u32::from_le_bytes([
95 wav[pos + 4],
96 wav[pos + 5],
97 wav[pos + 6],
98 wav[pos + 7],
99 ]).max(1));
100 block_align = Some(u16::from_le_bytes([wav[pos + 12], wav[pos + 13]]) as usize);
101 } else if id == b"data" {
102 data_len = Some(sz);
103 }
104 pos += sz;
105 if (sz & 1) != 0 {
106 pos += 1;
107 }
108 }
109
110 let channels = channels.context("WAV fmt chunk missing channels")?;
111 let sample_rate = sample_rate.context("WAV fmt chunk missing sample rate")?;
112 let block_align = block_align.context("WAV fmt chunk missing block align")?.max(1);
113 let data_len = data_len.context("WAV data chunk missing")?;
114 Ok(BasicAudioInfo {
115 channels,
116 sample_rate,
117 total_samples: (data_len / block_align) as u64,
118 })
119}
120
121fn inspect_ogg_vorbis_bytes(ogg: &[u8]) -> Result<BasicAudioInfo> {
122 let mut pos = 0usize;
123 let mut channels: Option<u16> = None;
124 let mut sample_rate: Option<u32> = None;
125 let mut max_granule: i64 = -1;
126
127 while pos + 27 <= ogg.len() {
128 if &ogg[pos..pos + 4] != b"OggS" {
129 bail!("invalid Ogg capture pattern at byte {}", pos);
130 }
131 let granule = i64::from_le_bytes([
132 ogg[pos + 6],
133 ogg[pos + 7],
134 ogg[pos + 8],
135 ogg[pos + 9],
136 ogg[pos + 10],
137 ogg[pos + 11],
138 ogg[pos + 12],
139 ogg[pos + 13],
140 ]);
141 if granule >= 0 {
142 max_granule = max_granule.max(granule);
143 }
144 let seg_count = ogg[pos + 26] as usize;
145 let seg_table = pos + 27;
146 let data_start = seg_table + seg_count;
147 if data_start > ogg.len() {
148 bail!("truncated Ogg segment table");
149 }
150 let page_data_len = ogg[seg_table..data_start]
151 .iter()
152 .fold(0usize, |acc, b| acc.saturating_add(*b as usize));
153 let data_end = data_start.saturating_add(page_data_len);
154 if data_end > ogg.len() {
155 bail!("truncated Ogg page data");
156 }
157
158 let mut packet_off = data_start;
159 let mut packet_len = 0usize;
160 for lace in &ogg[seg_table..data_start] {
161 packet_len = packet_len.saturating_add(*lace as usize);
162 if *lace < 255 {
163 if packet_off + packet_len <= data_end {
164 let packet = &ogg[packet_off..packet_off + packet_len];
165 if packet.len() >= 16 && packet[0] == 1 && &packet[1..7] == b"vorbis" {
166 channels = Some((packet[11] as u16).max(1));
167 sample_rate = Some(u32::from_le_bytes([
168 packet[12],
169 packet[13],
170 packet[14],
171 packet[15],
172 ]).max(1));
173 }
174 }
175 packet_off = packet_off.saturating_add(packet_len);
176 packet_len = 0;
177 }
178 }
179
180 pos = data_end;
181 }
182
183 let channels = channels.context("Vorbis identification header missing")?;
184 let sample_rate = sample_rate.context("Vorbis identification sample rate missing")?;
185 if max_granule < 0 {
186 bail!("Ogg Vorbis final granule position missing");
187 }
188 Ok(BasicAudioInfo {
189 channels,
190 sample_rate,
191 total_samples: max_granule as u64,
192 })
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 fs::read(path).with_context(|| format!("read audio container: {}", path.display()))
205}
206
207#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
208fn parse_ovk_entries_from_bytes(bytes: &[u8]) -> Result<Vec<ovk::OvkEntry>> {
209 if bytes.len() < 4 {
210 bail!("OVK: header too small");
211 }
212 let count = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
213 if count == 0 {
214 bail!("OVK: zero entries");
215 }
216 let table_len = 4usize.saturating_add(count.saturating_mul(16));
217 if table_len > bytes.len() {
218 bail!("OVK: entry table truncated");
219 }
220 let mut entries = Vec::with_capacity(count);
221 for i in 0..count {
222 let off = 4 + i * 16;
223 let size = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
224 let offset = u32::from_le_bytes(bytes[off + 4..off + 8].try_into().unwrap());
225 let no = u32::from_le_bytes(bytes[off + 8..off + 12].try_into().unwrap());
226 let sample_count = u32::from_le_bytes(bytes[off + 12..off + 16].try_into().unwrap());
227 let end = (offset as usize).saturating_add(size as usize);
228 if size != 0 && end > bytes.len() {
229 bail!("OVK entry[{i}] out of range: offset={} size={} len={}", offset, size, bytes.len());
230 }
231 entries.push(ovk::OvkEntry { size, offset, no, sample_count });
232 }
233 Ok(entries)
234}
235
236#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
237fn extract_ovk_entry_from_bytes(bytes: &[u8], idx: usize) -> Result<Vec<u8>> {
238 let entries = parse_ovk_entries_from_bytes(bytes)?;
239 let entry = entries
240 .get(idx)
241 .copied()
242 .ok_or_else(|| anyhow::anyhow!("OVK entry out of range: idx={} entries={}", idx, entries.len()))?;
243 if entry.size == 0 {
244 bail!("OVK entry[{idx}] has zero size");
245 }
246 let start = entry.offset as usize;
247 let end = start.saturating_add(entry.size as usize);
248 if end > bytes.len() {
249 bail!("OVK entry[{idx}] out of range");
250 }
251 Ok(bytes[start..end].to_vec())
252}
253
254#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
255fn decrypt_owp_bytes(mut bytes: Vec<u8>) -> Vec<u8> {
256 for b in &mut bytes {
257 *b ^= ovk::OwpFile::DEFAULT_XOR_KEY;
258 }
259 bytes
260}
261
262pub fn decode_bgm_to_playback_bytes(
268 input: impl AsRef<Path>,
269 entry_idx: Option<usize>,
270) -> Result<BgmPlaybackData> {
271 let input = input.as_ref();
272 let kind = BgmContainer::from_path(input);
273
274 match kind {
275 BgmContainer::Ovk | BgmContainer::Owp | BgmContainer::Ogg => {
276 let (ogg, description) = extract_ogg_bytes(input, entry_idx)?;
277 let info = inspect_ogg_vorbis_bytes(&ogg)
278 .with_context(|| format!("inspect Ogg/Vorbis BGM: {}", input.display()))?;
279 Ok(BgmPlaybackData {
280 container: kind,
281 format: BgmPlaybackFormat::Ogg,
282 bytes: ogg,
283 channels: info.channels,
284 sample_rate: info.sample_rate,
285 total_samples: info.total_samples,
286 description,
287 })
288 }
289 BgmContainer::Wav => {
290 let wav = read_audio_container_bytes(input)?;
291 let info = inspect_pcm_wav_bytes(&wav)
292 .with_context(|| format!("inspect WAV BGM: {}", input.display()))?;
293 Ok(BgmPlaybackData {
294 container: kind,
295 format: BgmPlaybackFormat::Wav,
296 bytes: wav,
297 channels: info.channels,
298 sample_rate: info.sample_rate,
299 total_samples: info.total_samples,
300 description: format!("WAV:{}", input.display()),
301 })
302 }
303 BgmContainer::Nwa => {
304 let mut reader = open_nwa_reader(input)?;
305 let wav = reader.to_wav_bytes().context("decode NWA -> WAV")?;
306 let info = inspect_pcm_wav_bytes(&wav)
307 .with_context(|| format!("inspect NWA-decoded WAV: {}", 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!("NWA:{}", input.display()),
316 })
317 }
318 BgmContainer::Unknown => {
319 bail!(
320 "unsupported BGM container (by extension): {}",
321 input.display()
322 );
323 }
324 }
325}
326
327
328#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
329fn open_nwa_reader(input: &Path) -> Result<nwa::NwaReader> {
330 let bytes = read_audio_container_bytes(input)?;
331 nwa::NwaReader::open_from_bytes(bytes)
332 .with_context(|| format!("open NWA from wasm VFS: {}", input.display()))
333}
334
335#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
336fn open_nwa_reader(input: &Path) -> Result<nwa::NwaReader> {
337 nwa::NwaReader::open(input).with_context(|| format!("open NWA: {}", input.display()))
338}
339
340#[derive(Debug, Clone)]
342pub struct BgmDecoded {
343 pub container: BgmContainer,
344 pub wav_bytes: Vec<u8>,
346 pub description: String,
348}
349
350#[derive(Debug, Clone)]
351pub enum KoeSource {
352 File(PathBuf),
353 OvkEntryByNo { path: PathBuf, entry_no: u32 },
354}
355
356#[allow(clippy::needless_pass_by_value)]
361pub fn decode_bgm_to_wav_bytes(
362 input: impl AsRef<Path>,
363 entry_idx: Option<usize>,
364) -> Result<BgmDecoded> {
365 let input = input.as_ref();
366 let kind = BgmContainer::from_path(input);
367
368 match kind {
369 BgmContainer::Nwa => {
370 let mut reader = open_nwa_reader(input)?;
371 let wav_bytes = reader.to_wav_bytes().context("decode NWA -> WAV")?;
372 Ok(BgmDecoded {
373 container: kind,
374 wav_bytes,
375 description: format!("NWA:{}", input.display()),
376 })
377 }
378 BgmContainer::Ovk => {
379 let idx = entry_idx.unwrap_or(0);
380 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
381 {
382 let bytes = read_audio_container_bytes(input)?;
383 let ogg = extract_ovk_entry_from_bytes(&bytes, idx).context("extract OVK entry")?;
384 let wav_bytes = siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(ogg))
385 .context("decode OVK(entry) -> WAV")?;
386 Ok(BgmDecoded {
387 container: kind,
388 wav_bytes,
389 description: format!("OVK:{}[{}]", input.display(), idx),
390 })
391 }
392 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
393 {
394 let pack = ovk::OvkPack::open(input)
395 .with_context(|| format!("open OVK: {}", input.display()))?;
396 let entry_cnt = pack.entries().len();
397 if idx >= entry_cnt {
398 bail!("OVK entry out of range: idx={} entries={}", idx, entry_cnt);
399 }
400 let wav_bytes = pack
401 .decode_entry_vorbis_wav(idx)
402 .context("decode OVK(entry) -> WAV")?;
403 Ok(BgmDecoded {
404 container: kind,
405 wav_bytes,
406 description: format!("OVK:{}[{}]", input.display(), idx),
407 })
408 }
409 }
410 BgmContainer::Owp => {
411 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
412 {
413 let bytes = read_audio_container_bytes(input)?;
414 let ogg = decrypt_owp_bytes(bytes);
415 let wav_bytes = siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(ogg))
416 .context("decode OWP -> WAV")?;
417 Ok(BgmDecoded {
418 container: kind,
419 wav_bytes,
420 description: format!("OWP:{}", input.display()),
421 })
422 }
423 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
424 {
425 let owp = ovk::OwpFile::open(input)
426 .with_context(|| format!("open OWP: {}", input.display()))?;
427 let wav_bytes = owp.decode_vorbis_wav().context("decode OWP -> WAV")?;
428 Ok(BgmDecoded {
429 container: kind,
430 wav_bytes,
431 description: format!("OWP:{}", input.display()),
432 })
433 }
434 }
435 BgmContainer::Ogg => {
436 let bytes = read_audio_container_bytes(input)?;
437 let wav_bytes =
438 siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(bytes))
439 .context("decode OGG/Vorbis -> WAV")?;
440 Ok(BgmDecoded {
441 container: kind,
442 wav_bytes,
443 description: format!("OGG:{}", input.display()),
444 })
445 }
446 BgmContainer::Wav => {
447 let wav_bytes = read_audio_container_bytes(input)?;
448 Ok(BgmDecoded {
449 container: kind,
450 wav_bytes,
451 description: format!("WAV:{}", input.display()),
452 })
453 }
454 BgmContainer::Unknown => {
455 bail!(
456 "unsupported BGM container (by extension): {}",
457 input.display()
458 );
459 }
460 }
461}
462
463pub fn decode_ovk_entry_by_no_to_wav_bytes(
464 input: impl AsRef<Path>,
465 entry_no: u32,
466) -> Result<BgmDecoded> {
467 let input = input.as_ref();
468 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
469 {
470 let bytes = read_audio_container_bytes(input)?;
471 let entries = parse_ovk_entries_from_bytes(&bytes)?;
472 let idx = entries
473 .iter()
474 .position(|e| e.no == entry_no)
475 .with_context(|| {
476 format!(
477 "OVK entry not found: no={} file={}",
478 entry_no,
479 input.display()
480 )
481 })?;
482 let ogg = extract_ovk_entry_from_bytes(&bytes, idx)?;
483 let wav_bytes = siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(ogg))
484 .with_context(|| format!("decode OVK(entry no={entry_no}) -> WAV"))?;
485 Ok(BgmDecoded {
486 container: BgmContainer::Ovk,
487 wav_bytes,
488 description: format!("OVK:{}#{}", input.display(), entry_no),
489 })
490 }
491 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
492 {
493 let pack =
494 ovk::OvkPack::open(input).with_context(|| format!("open OVK: {}", input.display()))?;
495 let idx = pack
496 .entries()
497 .iter()
498 .position(|e| e.no == entry_no)
499 .with_context(|| {
500 format!(
501 "OVK entry not found: no={} file={}",
502 entry_no,
503 input.display()
504 )
505 })?;
506
507 let wav_bytes = pack
508 .decode_entry_vorbis_wav(idx)
509 .with_context(|| format!("decode OVK(entry no={entry_no}) -> WAV"))?;
510 Ok(BgmDecoded {
511 container: BgmContainer::Ovk,
512 wav_bytes,
513 description: format!("OVK:{}#{}", input.display(), entry_no),
514 })
515 }
516}
517
518pub fn extract_ogg_bytes(
523 input: impl AsRef<Path>,
524 entry_idx: Option<usize>,
525) -> Result<(Vec<u8>, String)> {
526 let input = input.as_ref();
527 let kind = BgmContainer::from_path(input);
528
529 match kind {
530 BgmContainer::Ovk => {
531 let idx = entry_idx.unwrap_or(0);
532 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
533 {
534 let bytes = read_audio_container_bytes(input)?;
535 let ogg = extract_ovk_entry_from_bytes(&bytes, idx).context("extract OVK entry")?;
536 Ok((ogg, format!("OVK:{}[{}]", input.display(), idx)))
537 }
538 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
539 {
540 let pack = ovk::OvkPack::open(input)
541 .with_context(|| format!("open OVK: {}", input.display()))?;
542 let entry_cnt = pack.entries().len();
543 if idx >= entry_cnt {
544 bail!("OVK entry out of range: idx={} entries={}", idx, entry_cnt);
545 }
546 let ogg = pack.extract_entry(idx).context("extract OVK entry")?;
547 Ok((ogg, format!("OVK:{}[{}]", input.display(), idx)))
548 }
549 }
550 BgmContainer::Owp => {
551 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
552 {
553 let bytes = read_audio_container_bytes(input)?;
554 let ogg = decrypt_owp_bytes(bytes);
555 Ok((ogg, format!("OWP:{}", input.display())))
556 }
557 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
558 {
559 let owp = ovk::OwpFile::open(input)
560 .with_context(|| format!("open OWP: {}", input.display()))?;
561 let ogg = owp.decrypt_to_vec().context("decrypt OWP -> Ogg")?;
562 Ok((ogg, format!("OWP:{}", input.display())))
563 }
564 }
565 BgmContainer::Ogg => {
566 let ogg = read_audio_container_bytes(input)?;
567 Ok((ogg, format!("OGG:{}", input.display())))
568 }
569 _ => bail!("container does not support Ogg extraction: {:?}", kind),
570 }
571}
572
573pub fn resolve_koe_source(project_dir: &Path, koe_no: i64) -> Result<KoeSource> {
574 if koe_no < 0 {
575 bail!("invalid koe number: {koe_no}");
576 }
577
578 let koe_no_u32 = koe_no as u32;
579 let scn_no = koe_no_u32 / 100_000;
580 let base = project_dir.join("koe");
581 for dir in [format!("{:04}", scn_no), scn_no.to_string()] {
582 for stem in [
583 format!("z{:09}", koe_no_u32),
584 format!("Z{:09}", koe_no_u32),
585 format!("z{}", koe_no_u32),
586 format!("Z{}", koe_no_u32),
587 ] {
588 for ext in ["wav", "nwa", "ogg"] {
589 let p = base.join(&dir).join(format!("{stem}.{ext}"));
590 if path_is_file(&p) {
591 return Ok(KoeSource::File(p));
592 }
593 }
594 }
595 }
596
597 let ovk = base.join(format!("z{:04}.ovk", scn_no));
598 if path_is_file(&ovk) {
599 return Ok(KoeSource::OvkEntryByNo {
600 path: ovk,
601 entry_no: koe_no_u32 % 100_000,
602 });
603 }
604
605 bail!("koe resource not found: koe_no={koe_no}")
606}
607
608
609fn path_is_file(path: &Path) -> bool {
610 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
611 { crate::resource::wasm_path_is_file(path) }
612 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
613 { path.is_file() }
614}