Skip to main content

siglus_scene_vm/audio/
engine.rs

1use std::io::Cursor;
2use std::path::{Path, PathBuf};
3use crate::platform_time::{Duration, Instant};
4
5use anyhow::{anyhow, Context, Result};
6use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
7use kira::sound::{EndPosition, Region};
8use kira::tween::Tween;
9use kira::Volume;
10use siglus_assets::gameexe::{decode_gameexe_dat_bytes, GameexeConfig, GameexeDecodeOptions};
11
12use super::bgm::{decode_bgm_to_playback_bytes, BgmPlaybackFormat};
13use super::{AudioHub, TrackKind};
14
15const TNM_BGM_START_POS_INI: i64 = -1;
16const TNM_BGM_PLAYER_CNT: usize = 2;
17
18#[derive(Debug, Clone)]
19struct WavPcmRegion {
20    data_offset: usize,
21    data_len: usize,
22    data_len_field_offset: usize,
23    riff_len_field_offset: usize,
24    byte_rate: u32,
25    block_align: usize,
26    sample_rate: u32,
27    channels: u16,
28    bits_per_sample: u16,
29    audio_format: u16,
30}
31
32impl WavPcmRegion {
33    fn sample_count(&self) -> u64 {
34        (self.data_len / self.block_align.max(1)) as u64
35    }
36}
37
38fn wav_pcm_region(wav: &[u8]) -> Option<WavPcmRegion> {
39    if wav.len() < 44 {
40        return None;
41    }
42    if &wav[0..4] != b"RIFF" || &wav[8..12] != b"WAVE" {
43        return None;
44    }
45
46    let mut pos = 12usize;
47    let mut byte_rate: Option<u32> = None;
48    let mut block_align: Option<usize> = None;
49    let mut sample_rate: Option<u32> = None;
50    let mut channels: Option<u16> = None;
51    let mut bits_per_sample: Option<u16> = None;
52    let mut audio_format: Option<u16> = None;
53    let mut data_offset: Option<usize> = None;
54    let mut data_len: Option<usize> = None;
55    let mut data_len_field_offset: Option<usize> = None;
56
57    while pos + 8 <= wav.len() {
58        let id = &wav[pos..pos + 4];
59        let sz =
60            u32::from_le_bytes([wav[pos + 4], wav[pos + 5], wav[pos + 6], wav[pos + 7]]) as usize;
61        let sz_field_off = pos + 4;
62        pos += 8;
63        if pos + sz > wav.len() {
64            break;
65        }
66        if id == b"fmt " {
67            if sz >= 16 {
68                audio_format = Some(u16::from_le_bytes([wav[pos], wav[pos + 1]]));
69                channels = Some(u16::from_le_bytes([wav[pos + 2], wav[pos + 3]]));
70                sample_rate = Some(u32::from_le_bytes([
71                    wav[pos + 4],
72                    wav[pos + 5],
73                    wav[pos + 6],
74                    wav[pos + 7],
75                ]));
76                byte_rate = Some(u32::from_le_bytes([
77                    wav[pos + 8],
78                    wav[pos + 9],
79                    wav[pos + 10],
80                    wav[pos + 11],
81                ]));
82                block_align = Some(u16::from_le_bytes([wav[pos + 12], wav[pos + 13]]) as usize);
83                bits_per_sample = Some(u16::from_le_bytes([wav[pos + 14], wav[pos + 15]]));
84            }
85        } else if id == b"data" {
86            data_offset = Some(pos);
87            data_len = Some(sz);
88            data_len_field_offset = Some(sz_field_off);
89        }
90        pos += sz;
91        if (sz & 1) != 0 {
92            pos += 1;
93        }
94    }
95
96    Some(WavPcmRegion {
97        data_offset: data_offset?,
98        data_len: data_len?,
99        data_len_field_offset: data_len_field_offset?,
100        riff_len_field_offset: 4,
101        byte_rate: byte_rate?,
102        block_align: block_align?.max(1),
103        sample_rate: sample_rate?.max(1),
104        channels: channels?.max(1),
105        bits_per_sample: bits_per_sample?,
106        audio_format: audio_format?,
107    })
108}
109
110fn wav_slice_samples(wav: &[u8], start_sample: u64, end_sample: Option<u64>) -> Option<Vec<u8>> {
111    let region = wav_pcm_region(wav)?;
112    let total_samples = region.sample_count();
113    let start_sample = start_sample.min(total_samples);
114    let end_sample = end_sample
115        .unwrap_or(total_samples)
116        .min(total_samples)
117        .max(start_sample);
118
119    let start_byte = (start_sample as usize) * region.block_align;
120    let end_byte = (end_sample as usize) * region.block_align;
121    let src_begin = region.data_offset + start_byte;
122    let src_end = region.data_offset + end_byte;
123    if src_begin > wav.len() || src_end > wav.len() || src_begin > src_end {
124        return None;
125    }
126
127    let mut out = wav.to_vec();
128    out.splice(
129        region.data_offset..region.data_offset + region.data_len,
130        wav[src_begin..src_end].iter().copied(),
131    );
132    let new_data_len = (src_end - src_begin) as u32;
133    out[region.data_len_field_offset..region.data_len_field_offset + 4]
134        .copy_from_slice(&new_data_len.to_le_bytes());
135    let riff_len = (out.len().saturating_sub(8)) as u32;
136    out[region.riff_len_field_offset..region.riff_len_field_offset + 4]
137        .copy_from_slice(&riff_len.to_le_bytes());
138    Some(out)
139}
140
141fn parse_i64_like(s: &str) -> Option<i64> {
142    let s = s.trim();
143    if s.is_empty() {
144        return None;
145    }
146    if let Ok(v) = s.parse::<i64>() {
147        return Some(v);
148    }
149    if let Some(rest) = s.strip_prefix("0x") {
150        return i64::from_str_radix(rest, 16).ok();
151    }
152    if let Some(rest) = s.strip_prefix("-0x") {
153        return i64::from_str_radix(rest, 16).ok().map(|v| -v);
154    }
155    None
156}
157
158fn normalize_regist_name(name: &str) -> String {
159    name.trim().to_ascii_lowercase()
160}
161
162fn clamp_sample_range(
163    total_samples: u64,
164    start_sample: i64,
165    end_sample: i64,
166    restart_sample: i64,
167) -> (u64, u64, u64) {
168    let total_samples_i64 = total_samples as i64;
169    let end_sample = if end_sample < 0 {
170        total_samples_i64
171    } else {
172        end_sample.clamp(0, total_samples_i64)
173    };
174    let start_sample = start_sample.clamp(0, end_sample);
175    let restart_sample = restart_sample.clamp(0, end_sample);
176    (
177        start_sample as u64,
178        end_sample as u64,
179        restart_sample as u64,
180    )
181}
182
183fn tween_ms(ms: i64) -> Tween {
184    if ms > 0 {
185        Tween {
186            duration: Duration::from_millis(ms as u64),
187            ..Tween::default()
188        }
189    } else {
190        Tween::default()
191    }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195enum PendingBgmActionKind {
196    Stop,
197    Pause,
198}
199
200#[derive(Debug, Clone, Copy)]
201struct PendingBgmAction {
202    kind: PendingBgmActionKind,
203    at: Instant,
204}
205
206pub const TNM_PLAYER_STATE_FREE: i32 = 0;
207pub const TNM_PLAYER_STATE_PLAY: i32 = 1;
208pub const TNM_PLAYER_STATE_FADE_OUT: i32 = 2;
209pub const TNM_PLAYER_STATE_PAUSE: i32 = 3;
210
211#[derive(Debug, Clone)]
212struct BgmScriptEntry {
213    file_name: String,
214    start_sample: i64,
215    end_sample: i64,
216    repeat_sample: i64,
217}
218
219#[derive(Debug, Default)]
220struct BgmPlayerSlot {
221    handle: Option<StaticSoundHandle>,
222    source_bytes: Option<Vec<u8>>,
223    source_format: Option<BgmPlaybackFormat>,
224    sample_rate_hz: u32,
225    total_samples: u64,
226    start_sample: u64,
227    end_sample: u64,
228    restart_sample: u64,
229    current_segment_start_sample: u64,
230    current_segment_samples: u64,
231    start_time: Option<Instant>,
232    paused_at: Option<Instant>,
233    paused_total: Duration,
234    pending: Option<PendingBgmAction>,
235    fade_outing: bool,
236    loop_flag: bool,
237    name: Option<String>,
238    file_name: Option<String>,
239    ready_only: bool,
240}
241
242impl BgmPlayerSlot {
243    fn reset_all(&mut self) {
244        if let Some(mut h) = self.handle.take() {
245            let _ = h.stop(Tween::default());
246        }
247        self.source_bytes = None;
248        self.source_format = None;
249        self.sample_rate_hz = 0;
250        self.total_samples = 0;
251        self.start_sample = 0;
252        self.end_sample = 0;
253        self.restart_sample = 0;
254        self.current_segment_start_sample = 0;
255        self.current_segment_samples = 0;
256        self.start_time = None;
257        self.paused_at = None;
258        self.paused_total = Duration::from_millis(0);
259        self.pending = None;
260        self.fade_outing = false;
261        self.loop_flag = false;
262        self.name = None;
263        self.file_name = None;
264        self.ready_only = false;
265    }
266
267    fn clear_runtime_only(&mut self) {
268        self.handle = None;
269        self.current_segment_start_sample = self.start_sample;
270        self.current_segment_samples = self.end_sample.saturating_sub(self.start_sample);
271        self.start_time = None;
272        self.paused_at = None;
273        self.paused_total = Duration::from_millis(0);
274        self.pending = None;
275        self.fade_outing = false;
276        self.ready_only = false;
277    }
278
279    fn elapsed_ms(&self) -> u64 {
280        let Some(start) = self.start_time else {
281            return 0;
282        };
283        let now = self.paused_at.unwrap_or_else(Instant::now);
284        now.saturating_duration_since(start)
285            .saturating_sub(self.paused_total)
286            .as_millis() as u64
287    }
288
289    fn elapsed_samples(&self) -> u64 {
290        if self.sample_rate_hz == 0 {
291            return 0;
292        }
293        self.elapsed_ms().saturating_mul(self.sample_rate_hz as u64) / 1000
294    }
295
296    fn playback_window_samples(&self) -> u64 {
297        self.end_sample.saturating_sub(self.start_sample)
298    }
299
300    fn loop_span_samples(&self) -> u64 {
301        self.end_sample.saturating_sub(self.restart_sample)
302    }
303
304    fn has_loop_region(&self) -> bool {
305        self.loop_flag && self.restart_sample < self.end_sample
306    }
307
308    fn play_pos_samples(&self) -> u64 {
309        let elapsed = self.elapsed_samples();
310        if self.has_loop_region() {
311            let intro_samples = self.restart_sample.saturating_sub(self.start_sample);
312            let loop_span = self.loop_span_samples();
313            if loop_span == 0 {
314                return self.end_sample;
315            }
316            if self.start_sample < self.restart_sample && elapsed < intro_samples {
317                return self.start_sample + elapsed.min(intro_samples);
318            }
319            let loop_elapsed = if self.start_sample < self.restart_sample {
320                elapsed.saturating_sub(intro_samples)
321            } else {
322                elapsed
323            };
324            return self.restart_sample + (loop_elapsed % loop_span);
325        }
326        self.start_sample + elapsed.min(self.playback_window_samples())
327    }
328
329    fn check_state(&self) -> i32 {
330        if self.handle.is_none() {
331            return TNM_PLAYER_STATE_FREE;
332        }
333        if self.paused_at.is_some() {
334            return TNM_PLAYER_STATE_PAUSE;
335        }
336        if self.fade_outing {
337            return TNM_PLAYER_STATE_FADE_OUT;
338        }
339        TNM_PLAYER_STATE_PLAY
340    }
341
342    fn is_playing(&self) -> bool {
343        self.handle.is_some() && !self.fade_outing && self.paused_at.is_none()
344    }
345}
346
347pub struct BgmEngine {
348    project_dir: PathBuf,
349    current_append_dir: String,
350    game_volume_raw: u8,
351    system_volume_raw: u8,
352    current_name: Option<String>,
353    current_player_id: Option<usize>,
354    players: Vec<BgmPlayerSlot>,
355    retired: Vec<(StaticSoundHandle, Instant)>,
356    delay_deadline: Option<Instant>,
357    delayed_fade_in_ms: i64,
358    loop_flag: bool,
359    pause_flag: bool,
360}
361
362impl std::fmt::Debug for BgmEngine {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        f.debug_struct("BgmEngine")
365            .field("game_volume_raw", &self.game_volume_raw)
366            .field("system_volume_raw", &self.system_volume_raw)
367            .field("current_name", &self.current_name)
368            .field("current_player_id", &self.current_player_id)
369            .field("loop_flag", &self.loop_flag)
370            .field("pause_flag", &self.pause_flag)
371            .finish()
372    }
373}
374
375impl BgmEngine {
376    pub fn new(project_dir: PathBuf) -> Self {
377        Self {
378            project_dir,
379            current_append_dir: String::new(),
380            game_volume_raw: 255,
381            system_volume_raw: 255,
382            current_name: None,
383            current_player_id: None,
384            players: (0..TNM_BGM_PLAYER_CNT)
385                .map(|_| BgmPlayerSlot::default())
386                .collect(),
387            retired: Vec::new(),
388            delay_deadline: None,
389            delayed_fade_in_ms: 0,
390            loop_flag: false,
391            pause_flag: false,
392        }
393    }
394
395    pub fn current_name(&self) -> Option<&str> {
396        self.current_name.as_deref()
397    }
398
399    pub fn set_current_append_dir(&mut self, append_dir: impl Into<String>) {
400        self.current_append_dir = append_dir.into();
401    }
402
403    pub fn volume_raw(&self) -> u8 {
404        self.game_volume_raw
405    }
406
407    fn total_gain_amplitude(&self) -> f64 {
408        (self.game_volume_raw as f64 / 255.0) * (self.system_volume_raw as f64 / 255.0)
409    }
410
411    fn apply_all_active_volumes(&mut self, fade_ms: i64) {
412        let amp = self.total_gain_amplitude();
413        let tween = tween_ms(fade_ms);
414        for slot in &mut self.players {
415            if let Some(h) = &mut slot.handle {
416                let _ = h.set_volume(Volume::Amplitude(amp), tween);
417            }
418        }
419    }
420
421    pub fn set_volume_raw(&mut self, _audio: &mut AudioHub, volume_raw: u8) -> Result<()> {
422        self.game_volume_raw = volume_raw;
423        self.apply_all_active_volumes(0);
424        Ok(())
425    }
426
427    pub fn set_volume_raw_fade(
428        &mut self,
429        _audio: &mut AudioHub,
430        volume_raw: u8,
431        fade_ms: i64,
432    ) -> Result<()> {
433        self.game_volume_raw = volume_raw;
434        self.apply_all_active_volumes(fade_ms);
435        Ok(())
436    }
437
438    pub fn set_system_volume_raw(&mut self, volume_raw: u8) {
439        self.system_volume_raw = volume_raw;
440        self.apply_all_active_volumes(0);
441    }
442
443    pub fn set_looping(&mut self, looping: bool) -> Result<()> {
444        self.loop_flag = looping;
445        Ok(())
446    }
447
448    pub fn check_state(&self) -> i32 {
449        self.current_slot()
450            .map(|slot| slot.check_state())
451            .unwrap_or(TNM_PLAYER_STATE_FREE)
452    }
453
454    pub fn is_playing(&self) -> bool {
455        self.current_slot()
456            .map(|slot| slot.is_playing())
457            .unwrap_or(false)
458    }
459
460    pub fn is_fade_out_doing(&self) -> bool {
461        self.check_state() == TNM_PLAYER_STATE_FADE_OUT
462    }
463
464    pub fn can_wait(&self) -> bool {
465        self.is_playing() && !self.loop_flag
466    }
467
468    fn current_slot(&self) -> Option<&BgmPlayerSlot> {
469        self.current_player_id.and_then(|id| self.players.get(id))
470    }
471
472    fn current_slot_mut(&mut self) -> Option<&mut BgmPlayerSlot> {
473        self.current_player_id
474            .and_then(|id| self.players.get_mut(id))
475    }
476
477    fn lookup_gameexe_bgm_entry(&self, regist_name: &str) -> Option<BgmScriptEntry> {
478        let cfg = load_gameexe_config(&self.project_dir)?;
479        let target = normalize_regist_name(regist_name);
480        let cnt = cfg.indexed_count("BGM");
481        for i in 0..cnt {
482            let Some(key_name) = cfg.get_indexed_item_unquoted("BGM", i, 0) else {
483                continue;
484            };
485            if normalize_regist_name(key_name) != target {
486                continue;
487            }
488            let file_name = cfg
489                .get_indexed_item_unquoted("BGM", i, 1)?
490                .trim()
491                .to_string();
492            if file_name.is_empty() {
493                continue;
494            }
495            let start_sample = cfg
496                .get_indexed_item_unquoted("BGM", i, 2)
497                .and_then(parse_i64_like)
498                .unwrap_or(0);
499            let end_sample = cfg
500                .get_indexed_item_unquoted("BGM", i, 3)
501                .and_then(parse_i64_like)
502                .unwrap_or(-1);
503            let repeat_sample = cfg
504                .get_indexed_item_unquoted("BGM", i, 4)
505                .and_then(parse_i64_like)
506                .unwrap_or(0);
507            return Some(BgmScriptEntry {
508                file_name,
509                start_sample,
510                end_sample,
511                repeat_sample,
512            });
513        }
514        None
515    }
516
517    fn resolve_bgm_script(&self, regist_name: &str) -> Result<(BgmScriptEntry, PathBuf)> {
518        if let Some(entry) = self.lookup_gameexe_bgm_entry(regist_name) {
519            let (path, _ty) = crate::resource::find_audio_path_with_append_dir(
520                &self.project_dir,
521                &self.current_append_dir,
522                "bgm",
523                &entry.file_name,
524            )
525            .map_err(|err| {
526                anyhow!(
527                    "BGM file not found for regist name {}: {}; {}",
528                    regist_name,
529                    entry.file_name,
530                    err
531                )
532            })?;
533            return Ok((entry, path));
534        }
535
536        let direct_name = regist_name.trim();
537        let (path, _ty) = crate::resource::find_audio_path_with_append_dir(
538            &self.project_dir,
539            &self.current_append_dir,
540            "bgm",
541            direct_name,
542        )
543        .map_err(|err| anyhow!("BGM regist name not found in script table: {regist_name}; {err}"))?;
544        Ok((
545            BgmScriptEntry {
546                file_name: direct_name.to_string(),
547                start_sample: 0,
548                end_sample: -1,
549                repeat_sample: 0,
550            },
551            path,
552        ))
553    }
554
555    fn prepare_slot(
556        &mut self,
557        slot_id: usize,
558        regist_name: &str,
559        loop_flag: bool,
560        start_pos_sample: i64,
561        ready_only: bool,
562    ) -> Result<()> {
563        let (script_entry, path) = self.resolve_bgm_script(regist_name)?;
564
565
566        let decoded = decode_bgm_to_playback_bytes(&path, None)
567            .with_context(|| format!("prepare BGM playback: {}", path.display()))?;
568
569        let total_samples = decoded.total_samples;
570
571        let script_start = script_entry.start_sample;
572        let script_end = script_entry.end_sample;
573        let script_repeat = script_entry.repeat_sample;
574        let effective_start = if start_pos_sample == TNM_BGM_START_POS_INI {
575            script_start
576        } else {
577            start_pos_sample
578        };
579        let (start_sample, end_sample, restart_sample) =
580            clamp_sample_range(total_samples, effective_start, script_end, script_repeat);
581
582        if std::env::var_os("SG_AUDIO_TRACE").is_some() {
583            eprintln!(
584                "[SG_AUDIO_TRACE] bgm.prepare name={} file={} source_format={:?} container={:?} channels={} sample_rate={} total_frames={} start={} end={} repeat={}",
585                regist_name,
586                path.display(),
587                decoded.format,
588                decoded.container,
589                decoded.channels,
590                decoded.sample_rate,
591                total_samples,
592                start_sample,
593                end_sample,
594                restart_sample
595            );
596        }
597
598        let slot = &mut self.players[slot_id];
599        slot.reset_all();
600        slot.source_bytes = Some(decoded.bytes);
601        slot.source_format = Some(decoded.format);
602        slot.sample_rate_hz = decoded.sample_rate;
603        slot.total_samples = total_samples;
604        slot.start_sample = start_sample;
605        slot.end_sample = end_sample;
606        slot.restart_sample = restart_sample;
607        slot.current_segment_start_sample = start_sample;
608        slot.current_segment_samples = end_sample.saturating_sub(start_sample);
609        slot.loop_flag = loop_flag;
610        slot.name = Some(regist_name.to_string());
611        slot.file_name = Some(path.to_string_lossy().to_string());
612        slot.ready_only = ready_only;
613        Ok(())
614    }
615
616    fn start_slot_internal(
617        &mut self,
618        audio: &mut AudioHub,
619        slot_id: usize,
620        fade_in_ms: i64,
621        start_paused: bool,
622    ) -> Result<()> {
623        let amp = self.total_gain_amplitude();
624        let slot = &mut self.players[slot_id];
625        let Some(source) = slot.source_bytes.as_ref() else {
626            return Err(anyhow!("BGM slot not prepared"));
627        };
628
629        let mut effective_start = slot.start_sample;
630        if effective_start >= slot.end_sample {
631            if slot.has_loop_region() {
632                effective_start = slot.restart_sample;
633            } else {
634                return Err(anyhow!("invalid BGM range: start >= end"));
635            }
636        }
637        if effective_start >= slot.end_sample {
638            return Err(anyhow!("invalid BGM range after restart clamp"));
639        }
640
641        if !audio.is_enabled() {
642            slot.handle = None;
643            slot.current_segment_start_sample = effective_start;
644            slot.current_segment_samples = slot.end_sample.saturating_sub(effective_start);
645            slot.start_time = Some(Instant::now());
646            slot.paused_at = if start_paused { slot.start_time } else { None };
647            slot.paused_total = Duration::from_millis(0);
648            slot.pending = None;
649            slot.fade_outing = false;
650            slot.start_sample = effective_start;
651            slot.ready_only = start_paused;
652            return Ok(());
653        }
654
655        let start_sec = if slot.sample_rate_hz == 0 {
656            0.0
657        } else {
658            effective_start as f64 / slot.sample_rate_hz as f64
659        };
660        let mut data = StaticSoundData::from_cursor(Cursor::new(source.clone()))
661            .context("kira: decode BGM playback bytes")?;
662        data = data.start_position(start_sec);
663        if slot.has_loop_region() {
664            let loop_region = Region {
665                start: (slot.restart_sample as f64 / slot.sample_rate_hz.max(1) as f64).into(),
666                end: EndPosition::Custom(
667                    (slot.end_sample as f64 / slot.sample_rate_hz.max(1) as f64).into(),
668                ),
669            };
670            data = data.loop_region(loop_region);
671        }
672        let mut handle = audio.play_static(TrackKind::Bgm, data)?;
673
674        if start_paused {
675            let _ = handle.set_volume(Volume::Amplitude(amp), Tween::default());
676            let _ = handle.pause(Tween::default());
677        } else if fade_in_ms > 0 {
678            let _ = handle.set_volume(Volume::Amplitude(0.0), Tween::default());
679            let _ = handle.set_volume(Volume::Amplitude(amp), tween_ms(fade_in_ms));
680        } else {
681            let _ = handle.set_volume(Volume::Amplitude(amp), Tween::default());
682        }
683
684        slot.handle = Some(handle);
685        slot.current_segment_start_sample = effective_start;
686        slot.current_segment_samples = slot.end_sample.saturating_sub(effective_start);
687        slot.start_time = Some(Instant::now());
688        slot.paused_at = if start_paused { slot.start_time } else { None };
689        slot.paused_total = Duration::from_millis(0);
690        slot.pending = None;
691        slot.fade_outing = false;
692        slot.start_sample = effective_start;
693        slot.ready_only = start_paused;
694        Ok(())
695    }
696
697    fn start_slot(&mut self, audio: &mut AudioHub, slot_id: usize, fade_in_ms: i64) -> Result<()> {
698        self.start_slot_internal(audio, slot_id, fade_in_ms, false)
699    }
700
701    fn ready_slot(&mut self, audio: &mut AudioHub, slot_id: usize) -> Result<()> {
702        self.start_slot_internal(audio, slot_id, 0, true)
703    }
704
705    fn handoff_current_to_retired(&mut self, fade_out_ms: i64) {
706        let Some(cur_id) = self.current_player_id else {
707            return;
708        };
709        let slot = &mut self.players[cur_id];
710        if let Some(mut h) = slot.handle.take() {
711            if fade_out_ms > 0 {
712                let _ = h.stop(tween_ms(fade_out_ms));
713                self.retired.push((
714                    h,
715                    Instant::now() + Duration::from_millis(fade_out_ms as u64),
716                ));
717            } else {
718                let _ = h.stop(Tween::default());
719            }
720        }
721        slot.clear_runtime_only();
722    }
723
724    pub fn play_name_script(
725        &mut self,
726        audio: &mut AudioHub,
727        name: &str,
728        loop_flag: bool,
729        fade_in_ms: i64,
730        fade_out_ms: i64,
731        start_pos_sample: i64,
732        ready_only: bool,
733        delay_time_ms: i64,
734    ) -> Result<()> {
735        let regist_name = normalize_regist_name(name);
736        if self.current_name.as_deref() == Some(regist_name.as_str()) && self.loop_flag && loop_flag
737        {
738            return Ok(());
739        }
740
741        self.handoff_current_to_retired(fade_out_ms);
742        let next_id = match self.current_player_id {
743            Some(id) => (id + 1) % TNM_BGM_PLAYER_CNT,
744            None => 0,
745        };
746        let total_ready_only = ready_only || delay_time_ms > 0;
747        self.prepare_slot(
748            next_id,
749            &regist_name,
750            loop_flag,
751            start_pos_sample,
752            total_ready_only,
753        )?;
754        self.current_player_id = Some(next_id);
755        self.current_name = Some(regist_name);
756        self.loop_flag = loop_flag;
757        self.pause_flag = ready_only;
758        self.delayed_fade_in_ms = fade_in_ms;
759        self.delay_deadline = if delay_time_ms > 0 {
760            Some(Instant::now() + Duration::from_millis(delay_time_ms.max(0) as u64))
761        } else {
762            None
763        };
764
765        if total_ready_only {
766            self.ready_slot(audio, next_id)?;
767        } else {
768            self.start_slot(audio, next_id, fade_in_ms)?;
769        }
770        Ok(())
771    }
772
773    pub fn ready_name(
774        &mut self,
775        audio: &mut AudioHub,
776        name: &str,
777        start_pos_sample: i64,
778    ) -> Result<()> {
779        self.play_name_script(audio, name, self.loop_flag, 0, 0, start_pos_sample, true, 0)
780    }
781
782    pub fn play_name_with_options(
783        &mut self,
784        audio: &mut AudioHub,
785        name: &str,
786        start_pos_sample: i64,
787        fade_in_ms: i64,
788    ) -> Result<()> {
789        self.play_name_script(
790            audio,
791            name,
792            self.loop_flag,
793            fade_in_ms,
794            0,
795            start_pos_sample,
796            false,
797            0,
798        )
799    }
800
801    pub fn play_name(&mut self, audio: &mut AudioHub, name: &str) -> Result<()> {
802        self.play_name_script(audio, name, true, 0, 0, TNM_BGM_START_POS_INI, false, 0)
803    }
804
805    pub fn play_pos_samples(&self) -> u64 {
806        self.current_slot()
807            .map(|s| s.play_pos_samples())
808            .unwrap_or(0)
809    }
810
811    pub fn pause_fade(&mut self, _audio: &mut AudioHub, fade_ms: i64) -> Result<()> {
812        self.delay_deadline = None;
813        self.pause_flag = true;
814        let amp = self.total_gain_amplitude();
815        let Some(slot) = self.current_slot_mut() else {
816            return Ok(());
817        };
818        if slot.handle.is_none() || slot.paused_at.is_some() {
819            return Ok(());
820        }
821        if fade_ms > 0 {
822            if let Some(h) = &mut slot.handle {
823                let _ = h.set_volume(Volume::Amplitude(amp), Tween::default());
824                let _ = h.set_volume(Volume::Amplitude(0.0), tween_ms(fade_ms));
825            }
826            slot.fade_outing = true;
827            slot.pending = Some(PendingBgmAction {
828                kind: PendingBgmActionKind::Pause,
829                at: Instant::now() + Duration::from_millis(fade_ms as u64),
830            });
831        } else {
832            if let Some(h) = &mut slot.handle {
833                let _ = h.pause(Tween::default());
834            }
835            slot.paused_at = Some(Instant::now());
836        }
837        Ok(())
838    }
839
840    pub fn pause(&mut self) -> Result<()> {
841        self.delay_deadline = None;
842        self.pause_flag = true;
843        let Some(slot) = self.current_slot_mut() else {
844            return Ok(());
845        };
846        if slot.handle.is_none() || slot.paused_at.is_some() {
847            return Ok(());
848        }
849        if let Some(h) = &mut slot.handle {
850            let _ = h.pause(Tween::default());
851        }
852        slot.paused_at = Some(Instant::now());
853        Ok(())
854    }
855
856    pub fn resume_script(
857        &mut self,
858        audio: &mut AudioHub,
859        fade_in_ms: i64,
860        delay_time_ms: i64,
861    ) -> Result<()> {
862        if delay_time_ms > 0 {
863            self.delay_deadline =
864                Some(Instant::now() + Duration::from_millis(delay_time_ms as u64));
865            self.delayed_fade_in_ms = fade_in_ms;
866            self.pause_flag = false;
867            return Ok(());
868        }
869
870        let amp = self.total_gain_amplitude();
871        let Some(cur_id) = self.current_player_id else {
872            return Ok(());
873        };
874        if self.players[cur_id].handle.is_none() {
875            self.start_slot(audio, cur_id, fade_in_ms)?;
876            self.pause_flag = false;
877            self.delay_deadline = None;
878            return Ok(());
879        }
880
881        let slot = &mut self.players[cur_id];
882        if let Some(p) = slot.paused_at.take() {
883            slot.paused_total += Instant::now().saturating_duration_since(p);
884        }
885        if let Some(h) = &mut slot.handle {
886            let _ = h.resume(Tween::default());
887            if fade_in_ms > 0 {
888                let _ = h.set_volume(Volume::Amplitude(0.0), Tween::default());
889                let _ = h.set_volume(Volume::Amplitude(amp), tween_ms(fade_in_ms));
890            } else {
891                let _ = h.set_volume(Volume::Amplitude(amp), Tween::default());
892            }
893        }
894        slot.fade_outing = false;
895        slot.pending = None;
896        slot.ready_only = false;
897        self.pause_flag = false;
898        self.delay_deadline = None;
899        Ok(())
900    }
901
902    pub fn resume_fade(&mut self, audio: &mut AudioHub, fade_ms: i64) -> Result<()> {
903        self.resume_script(audio, fade_ms, 0)
904    }
905
906    pub fn resume(&mut self) -> Result<()> {
907        let amp = self.total_gain_amplitude();
908        let Some(slot) = self.current_slot_mut() else {
909            return Ok(());
910        };
911        if let Some(p) = slot.paused_at.take() {
912            slot.paused_total += Instant::now().saturating_duration_since(p);
913            if let Some(h) = &mut slot.handle {
914                let _ = h.resume(Tween::default());
915                let _ = h.set_volume(Volume::Amplitude(amp), Tween::default());
916            }
917        }
918        slot.ready_only = false;
919        self.pause_flag = false;
920        Ok(())
921    }
922
923    pub fn stop(&mut self) -> Result<()> {
924        self.stop_current_internal(0)
925    }
926
927    fn stop_current_internal(&mut self, fade_out_ms: i64) -> Result<()> {
928        self.delay_deadline = None;
929        if let Some(slot) = self.current_slot_mut() {
930            if slot.handle.is_none() {
931                slot.clear_runtime_only();
932            } else if fade_out_ms > 0 {
933                if let Some(h) = &mut slot.handle {
934                    let _ = h.stop(tween_ms(fade_out_ms));
935                }
936                slot.fade_outing = true;
937                slot.pending = Some(PendingBgmAction {
938                    kind: PendingBgmActionKind::Stop,
939                    at: Instant::now() + Duration::from_millis(fade_out_ms as u64),
940                });
941            } else {
942                if let Some(mut h) = slot.handle.take() {
943                    let _ = h.stop(Tween::default());
944                }
945                slot.clear_runtime_only();
946            }
947        }
948        self.current_name = None;
949        self.loop_flag = false;
950        self.pause_flag = false;
951        Ok(())
952    }
953
954    pub fn stop_fade(&mut self, fade_out_ms: i64) -> Result<()> {
955        self.stop_current_internal(fade_out_ms)
956    }
957
958    pub fn tick(&mut self, audio: &mut AudioHub) -> Result<()> {
959        let now = Instant::now();
960        self.retired.retain_mut(|(h, deadline)| {
961            if now >= *deadline {
962                let _ = h.stop(Tween::default());
963                false
964            } else {
965                true
966            }
967        });
968
969        if let Some(deadline) = self.delay_deadline {
970            if now >= deadline {
971                self.delay_deadline = None;
972                self.resume_script(audio, self.delayed_fade_in_ms, 0)?;
973            }
974        }
975
976        let Some(cur_id) = self.current_player_id else {
977            return Ok(());
978        };
979
980        let pending = self.players[cur_id].pending;
981        if let Some(pending) = pending {
982            if now >= pending.at {
983                let slot = &mut self.players[cur_id];
984                slot.pending = None;
985                match pending.kind {
986                    PendingBgmActionKind::Stop => {
987                        if let Some(mut h) = slot.handle.take() {
988                            let _ = h.stop(Tween::default());
989                        }
990                        slot.clear_runtime_only();
991                    }
992                    PendingBgmActionKind::Pause => {
993                        if let Some(h) = &mut slot.handle {
994                            let _ = h.pause(Tween::default());
995                        }
996                        slot.paused_at = Some(now);
997                        slot.fade_outing = false;
998                    }
999                }
1000            }
1001        }
1002
1003        let (paused, has_handle, segment_samples, elapsed_samples, has_loop_region) = {
1004            let slot = &self.players[cur_id];
1005            (
1006                slot.paused_at.is_some(),
1007                slot.handle.is_some(),
1008                slot.playback_window_samples(),
1009                slot.elapsed_samples(),
1010                slot.has_loop_region(),
1011            )
1012        };
1013        if paused || !has_handle {
1014            return Ok(());
1015        }
1016        if segment_samples == 0 {
1017            return Ok(());
1018        }
1019        if elapsed_samples < segment_samples {
1020            return Ok(());
1021        }
1022        if has_loop_region {
1023            return Ok(());
1024        } else {
1025            let slot = &mut self.players[cur_id];
1026            if let Some(mut h) = slot.handle.take() {
1027                let _ = h.stop(Tween::default());
1028            }
1029            slot.clear_runtime_only();
1030        }
1031        Ok(())
1032    }
1033}
1034
1035
1036fn path_is_file(path: &Path) -> bool {
1037    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1038    { crate::resource::wasm_path_is_file(path) }
1039    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1040    { path.is_file() }
1041}
1042
1043fn load_gameexe_decode_options(project_dir: &Path) -> Result<GameexeDecodeOptions> {
1044    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1045    {
1046        let mut opt = GameexeDecodeOptions::default();
1047        opt.game_angou_code = Some(siglus_assets::keys::GAMEEXE_KEY.to_vec());
1048        for name in ["key.toml", "Key.toml"] {
1049            let p = project_dir.join(name);
1050            if crate::resource::wasm_path_is_file(&p) {
1051                let text = crate::resource::read_file_to_string(&p)?;
1052                let cfg = siglus_assets::key_toml::parse_key_toml(&text)?;
1053                opt.exe_key16 = cfg.exe_key16;
1054                opt.base_angou_code = cfg.base_angou_code;
1055                if cfg.game_angou_code.is_some() { opt.game_angou_code = cfg.game_angou_code; }
1056                if let Some(order) = cfg.chain_order { opt.chain_order = order; }
1057                break;
1058            }
1059        }
1060        Ok(opt)
1061    }
1062    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1063    { GameexeDecodeOptions::from_project_dir(project_dir) }
1064}
1065
1066fn find_gameexe_path(project_dir: &Path) -> Option<PathBuf> {
1067    const CANDIDATES: &[&str] = &[
1068        "Gameexe.dat",
1069        "Gameexe.ini",
1070        "gameexe.dat",
1071        "gameexe.ini",
1072        "GameexeEN.dat",
1073        "GameexeEN.ini",
1074        "GameexeZH.dat",
1075        "GameexeZH.ini",
1076        "GameexeZHTW.dat",
1077        "GameexeZHTW.ini",
1078        "GameexeDE.dat",
1079        "GameexeDE.ini",
1080        "GameexeES.dat",
1081        "GameexeES.ini",
1082        "GameexeFR.dat",
1083        "GameexeFR.ini",
1084        "GameexeID.dat",
1085        "GameexeID.ini",
1086    ];
1087    for name in CANDIDATES {
1088        let p = project_dir.join(name);
1089        if path_is_file(&p) {
1090            return Some(p);
1091        }
1092    }
1093    None
1094}
1095
1096fn load_gameexe_config(project_dir: &Path) -> Option<GameexeConfig> {
1097    let path = find_gameexe_path(project_dir)?;
1098    let raw = crate::resource::read_file_bytes(&path).ok()?;
1099    if path
1100        .extension()
1101        .and_then(|s| s.to_str())
1102        .is_some_and(|ext| ext.eq_ignore_ascii_case("ini"))
1103    {
1104        let text = String::from_utf8(raw).ok()?;
1105        return Some(GameexeConfig::from_text(&text));
1106    }
1107    let opt = load_gameexe_decode_options(project_dir).ok()?;
1108    let (text, _report) = decode_gameexe_dat_bytes(&raw, &opt).ok()?;
1109    Some(GameexeConfig::from_text(&text))
1110}