Skip to main content

siglus_scene_vm/runtime/forms/
pcmch.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, bail};
4use siglus_assets::gameexe::{GameexeConfig, decode_gameexe_dat_bytes};
5
6use crate::audio::bgm::decode_bgm_to_wav_bytes;
7use crate::runtime::{CommandContext, Value};
8
9use super::codes;
10
11fn store_or_push_pcmch_prop(ctx: &mut CommandContext, ch: usize, op: i32, args: &[Value]) {
12    let form_key = if ctx.ids.form_global_pcmch != 0 {
13        ctx.ids.form_global_pcmch
14    } else {
15        super::codes::FORM_GLOBAL_PCMCH
16    };
17    let prop = (((ch as i32) & 0x7fff) << 16) ^ (op & 0xffff);
18    if let Some(v) = args.first().cloned() {
19        match v {
20            Value::Str(s) => {
21                ctx.globals
22                    .str_props
23                    .entry(form_key)
24                    .or_default()
25                    .insert(prop, s);
26            }
27            Value::Int(n) => {
28                ctx.globals
29                    .int_props
30                    .entry(form_key)
31                    .or_default()
32                    .insert(prop, n);
33            }
34            _ => {}
35        }
36        ctx.push(Value::Int(0));
37        return;
38    }
39    if let Some(s) = ctx
40        .globals
41        .str_props
42        .get(&form_key)
43        .and_then(|m| m.get(&prop))
44        .cloned()
45    {
46        ctx.push(Value::Str(s));
47        return;
48    }
49    let v = ctx
50        .globals
51        .int_props
52        .get(&form_key)
53        .and_then(|m| m.get(&prop).copied())
54        .unwrap_or(0);
55    ctx.push(Value::Int(v));
56}
57
58fn positional_arg(args: &[Value], idx: usize) -> Option<&Value> {
59    args.iter()
60        .filter(|v| !matches!(v, Value::NamedArg { .. }))
61        .nth(idx)
62}
63
64fn arg_str(args: &[Value], idx: usize) -> Option<&str> {
65    positional_arg(args, idx).and_then(Value::as_str)
66}
67
68fn arg_int(args: &[Value], idx: usize) -> Option<i64> {
69    positional_arg(args, idx).and_then(Value::as_i64)
70}
71
72fn named_str(args: &[Value], id: i32) -> Option<&str> {
73    args.iter().find_map(|v| match v {
74        Value::NamedArg { id: nid, value } if *nid == id => value.as_str(),
75        _ => None,
76    })
77}
78
79fn named_int(args: &[Value], id: i32) -> Option<i64> {
80    args.iter().find_map(|v| match v {
81        Value::NamedArg { id: nid, value } if *nid == id => value.as_i64(),
82        _ => None,
83    })
84}
85
86fn parse_channel_from_chain(
87    form_id: u32,
88    ctx: &CommandContext,
89    chain: &[i32],
90) -> Option<(usize, i32)> {
91    if chain.len() < 4 {
92        return None;
93    }
94    if chain[0] as u32 != form_id {
95        return None;
96    }
97    let elm_array = ctx.ids.elm_array;
98    if elm_array >= 0 {
99        if chain[1] != elm_array {
100            return None;
101        }
102    } else if chain[1] == 0 {
103        return None;
104    }
105    let ch = chain[2];
106    if ch < 0 {
107        return None;
108    }
109    let op = *chain.last()?;
110    Some((ch as usize, op))
111}
112
113fn resolve_numeric_candidates(n: i64) -> Vec<String> {
114    if n < 0 {
115        return vec![n.to_string()];
116    }
117    vec![
118        format!("{:05}", n),
119        format!("{:04}", n),
120        format!("{:03}", n),
121        n.to_string(),
122    ]
123}
124
125fn resolve_subdir_path(
126    project_dir: &Path,
127    current_append_dir: &str,
128    subdir: &str,
129    file_name: &str,
130) -> Option<PathBuf> {
131    crate::resource::find_audio_path_with_append_dir(
132        project_dir,
133        current_append_dir,
134        subdir,
135        file_name,
136    )
137    .ok()
138    .map(|(path, _ty)| path)
139}
140
141fn find_gameexe_path(project_dir: &Path) -> Option<PathBuf> {
142    const CANDIDATES: &[&str] = &[
143        "Gameexe.dat",
144        "Gameexe.ini",
145        "gameexe.dat",
146        "gameexe.ini",
147        "GameexeEN.dat",
148        "GameexeEN.ini",
149        "GameexeZH.dat",
150        "GameexeZH.ini",
151        "GameexeZHTW.dat",
152        "GameexeZHTW.ini",
153        "GameexeDE.dat",
154        "GameexeDE.ini",
155        "GameexeES.dat",
156        "GameexeES.ini",
157        "GameexeFR.dat",
158        "GameexeFR.ini",
159        "GameexeID.dat",
160        "GameexeID.ini",
161    ];
162    for name in CANDIDATES {
163        let p = project_dir.join(name);
164        if let Some(path) = crate::resource::resolve_game_file(&p).ok().flatten() {
165            return Some(path);
166        }
167    }
168    None
169}
170
171fn load_gameexe_config(project_dir: &Path) -> Option<GameexeConfig> {
172    let path = find_gameexe_path(project_dir)?;
173    let raw = crate::resource::read_file_bytes(&path).ok()?;
174    if path
175        .extension()
176        .and_then(|s| s.to_str())
177        .is_some_and(|ext| ext.eq_ignore_ascii_case("ini"))
178    {
179        let text = String::from_utf8(raw).ok()?;
180        return Some(GameexeConfig::from_text(&text));
181    }
182    let opt = crate::resource::load_gameexe_decode_options(project_dir).ok()?;
183    let (text, _report) = decode_gameexe_dat_bytes(&raw, &opt).ok()?;
184    Some(GameexeConfig::from_text(&text))
185}
186
187fn lookup_gameexe_bgm_file_name(project_dir: &Path, regist_name: &str) -> Option<String> {
188    let cfg = load_gameexe_config(project_dir)?;
189    let target = regist_name.trim().to_ascii_lowercase();
190    let cnt = cfg.indexed_count("BGM");
191    for i in 0..cnt {
192        let Some(key_name) = cfg.get_indexed_item_unquoted("BGM", i, 0) else {
193            continue;
194        };
195        if key_name.trim().to_ascii_lowercase() != target {
196            continue;
197        }
198        let Some(file_name) = cfg.get_indexed_item_unquoted("BGM", i, 1) else {
199            continue;
200        };
201        if !file_name.trim().is_empty() {
202            return Some(file_name.trim().to_string());
203        }
204    }
205    None
206}
207
208fn play_path_on_pcm_slot(
209    ctx: &mut CommandContext,
210    ch: usize,
211    display_name: &str,
212    path: &Path,
213    loop_flag: bool,
214    fade_in_ms: i64,
215    ready_only: bool,
216) -> Result<()> {
217    let decoded = decode_bgm_to_wav_bytes(path, None)
218        .with_context(|| format!("decode audio: {}", path.display()))?;
219    let (pcm, audio) = (&mut ctx.pcm, &mut ctx.audio);
220    pcm.play_decoded_wav_in_slot_with_options(
221        audio,
222        ch,
223        display_name,
224        decoded.wav_bytes,
225        loop_flag,
226        fade_in_ms,
227        ready_only,
228    )
229}
230
231fn play_named_source(
232    ctx: &mut CommandContext,
233    ch: usize,
234    pcm_name: Option<&str>,
235    bgm_name: Option<&str>,
236    koe_no: Option<i64>,
237    se_no: Option<i64>,
238    loop_flag: bool,
239    fade_in_ms: i64,
240    ready_only: bool,
241) -> Result<bool> {
242    if let Some(name) = pcm_name.filter(|s| !s.is_empty()) {
243        let ok = {
244            let (pcm, audio) = (&mut ctx.pcm, &mut ctx.audio);
245            pcm.play_in_slot_with_options(audio, ch, name, loop_flag, fade_in_ms, ready_only)
246                .is_ok()
247        };
248        if !ok {
249            ctx.unknown
250                .record_note(&format!("pcmch.play_pcm.failed:{ch}:{name}"));
251        }
252        return Ok(true);
253    }
254
255    if let Some(name) = bgm_name.filter(|s| !s.is_empty()) {
256        if let Some(mapped_name) = lookup_gameexe_bgm_file_name(&ctx.project_dir, name)
257            && let Some(path) = resolve_subdir_path(
258                &ctx.project_dir,
259                &ctx.globals.append_dir,
260                "bgm",
261                &mapped_name,
262            )
263        {
264            if play_path_on_pcm_slot(
265                ctx,
266                ch,
267                &format!("bgm:{name}"),
268                &path,
269                loop_flag,
270                fade_in_ms,
271                ready_only,
272            )
273            .is_err()
274            {
275                ctx.unknown
276                    .record_note(&format!("pcmch.play_bgm.failed:{ch}:{name}"));
277            }
278            return Ok(true);
279        }
280        if let Some(path) =
281            resolve_subdir_path(&ctx.project_dir, &ctx.globals.append_dir, "bgm", name)
282        {
283            if play_path_on_pcm_slot(
284                ctx,
285                ch,
286                &format!("bgm:{name}"),
287                &path,
288                loop_flag,
289                fade_in_ms,
290                ready_only,
291            )
292            .is_err()
293            {
294                ctx.unknown
295                    .record_note(&format!("pcmch.play_bgm.failed:{ch}:{name}"));
296            }
297            return Ok(true);
298        }
299        ctx.unknown
300            .record_note(&format!("pcmch.bgm.missing:{ch}:{name}"));
301        return Ok(true);
302    }
303
304    if let Some(no) = koe_no {
305        let ok = {
306            let (pcm, audio) = (&mut ctx.pcm, &mut ctx.audio);
307            pcm.play_koe_no_in_slot_with_options(audio, ch, no, loop_flag, 0, ready_only)
308                .is_ok()
309        };
310        if !ok {
311            ctx.unknown
312                .record_note(&format!("pcmch.play_koe.failed:{ch}:{no}"));
313        }
314        return Ok(true);
315    }
316
317    if let Some(no) = se_no {
318        let Some(name) = ctx
319            .tables
320            .se_file_names
321            .get(no as usize)
322            .and_then(|v| v.as_deref())
323            .filter(|s| !s.is_empty())
324            .map(|s| s.to_string())
325        else {
326            ctx.unknown
327                .record_note(&format!("pcmch.se.table.missing:{ch}:{no}"));
328            return Ok(true);
329        };
330        let ok = {
331            let (pcm, audio) = (&mut ctx.pcm, &mut ctx.audio);
332            pcm.play_in_slot_with_options(audio, ch, &name, false, 0, ready_only)
333                .is_ok()
334        };
335        if !ok {
336            ctx.unknown
337                .record_note(&format!("pcmch.play_se.failed:{ch}:{no}:{name}"));
338        }
339        return Ok(true);
340    }
341
342    Ok(false)
343}
344
345fn ensure_persistent_channel(
346    ctx: &mut CommandContext,
347    ch: usize,
348) -> &mut crate::runtime::globals::PcmChPersistentState {
349    if ctx.globals.pcmch_persistent.len() <= ch {
350        ctx.globals.pcmch_persistent.resize_with(
351            ch + 1,
352            crate::runtime::globals::PcmChPersistentState::default,
353        );
354    }
355    &mut ctx.globals.pcmch_persistent[ch]
356}
357
358fn persistent_state_for_play(
359    ctx: &CommandContext,
360    ch: usize,
361    pcm_name: Option<&str>,
362    bgm_name: Option<&str>,
363    koe_no: Option<i64>,
364    se_no: Option<i64>,
365    args: &[Value],
366    loop_flag: bool,
367    fade_in_time: i64,
368    ready_only: bool,
369) -> crate::runtime::globals::PcmChPersistentState {
370    let mut state = ctx
371        .globals
372        .pcmch_persistent
373        .get(ch)
374        .cloned()
375        .unwrap_or_default();
376    state.pcm_name = pcm_name.unwrap_or("").to_string();
377    state.bgm_name = bgm_name.unwrap_or("").to_string();
378    state.koe_no = koe_no.unwrap_or(-1);
379    state.se_no = se_no.unwrap_or(-1);
380    state.volume_type = named_int(args, 3).unwrap_or(2);
381    state.bgm_fade_target_flag = named_int(args, 4).unwrap_or(0) != 0;
382    state.bgm_fade2_target_flag = named_int(args, 5).unwrap_or(0) != 0;
383    state.chara_no = named_int(args, 6).unwrap_or(-1);
384    state.bgm_fade_source_flag = named_int(args, 11).unwrap_or(0) != 0;
385    state.volume = ctx.pcm.slot_volume_raw(ch) as i64;
386    state.delay_time = 0;
387    state.fade_in_time = fade_in_time;
388    state.loop_flag = loop_flag;
389    state.ready_flag = ready_only;
390    state
391}
392
393/// Play one PCMEVENT line through a PCMCH channel using the same routing
394/// parameters as `C_elm_pcmch::play_pcm`.
395pub(crate) fn play_event_line(
396    ctx: &mut CommandContext,
397    ch: usize,
398    file_name: &str,
399    volume_type: i32,
400    chara_no: i32,
401    bgm_fade_target_flag: bool,
402    bgm_fade2_target_flag: bool,
403    bgm_fade_source_flag: bool,
404) -> Result<u64> {
405    let played = {
406        let (pcm, audio) = (&mut ctx.pcm, &mut ctx.audio);
407        pcm.play_in_slot_with_options(audio, ch, file_name, false, 0, false)
408            .is_ok()
409    };
410    if !played {
411        return Ok(0);
412    }
413
414    let mut state = ctx
415        .globals
416        .pcmch_persistent
417        .get(ch)
418        .cloned()
419        .unwrap_or_default();
420    state.pcm_name = file_name.to_string();
421    state.bgm_name.clear();
422    state.koe_no = -1;
423    state.se_no = -1;
424    state.volume_type = volume_type as i64;
425    state.chara_no = chara_no as i64;
426    state.bgm_fade_target_flag = bgm_fade_target_flag;
427    state.bgm_fade2_target_flag = bgm_fade2_target_flag;
428    state.bgm_fade_source_flag = bgm_fade_source_flag;
429    state.delay_time = 0;
430    state.fade_in_time = 0;
431    state.loop_flag = false;
432    state.ready_flag = false;
433    state.volume = ctx.pcm.slot_volume_raw(ch) as i64;
434    *ensure_persistent_channel(ctx, ch) = state;
435    crate::runtime::forms::syscom::update_audio_routing(ctx, 0, true);
436    Ok(ctx.pcm.slot_duration_ms(ch))
437}
438
439/// Restore one `C_elm_pcmch` from an original local-save record.
440///
441/// The original only restarts looped, READY, or delayed channels.  It restores
442/// the channel volume for every record and does not resume one-shot playback.
443pub(crate) fn restore_persistent_channel(
444    ctx: &mut CommandContext,
445    ch: usize,
446    state: crate::runtime::globals::PcmChPersistentState,
447) -> Result<()> {
448    ctx.pcm
449        .set_slot_volume_raw_fade(ch, state.volume.clamp(0, 255) as u8, 0)?;
450
451    if state.loop_flag || state.ready_flag || state.delay_time > 0 {
452        if !state.pcm_name.is_empty() {
453            let _ = play_named_source(
454                ctx,
455                ch,
456                Some(&state.pcm_name),
457                None,
458                None,
459                None,
460                state.loop_flag,
461                0,
462                state.ready_flag,
463            )?;
464        }
465        if !state.bgm_name.is_empty() {
466            let _ = play_named_source(
467                ctx,
468                ch,
469                None,
470                Some(&state.bgm_name),
471                None,
472                None,
473                state.loop_flag,
474                0,
475                state.ready_flag,
476            )?;
477        } else if state.koe_no >= 0 {
478            let _ = play_named_source(
479                ctx,
480                ch,
481                None,
482                None,
483                Some(state.koe_no),
484                None,
485                state.loop_flag,
486                0,
487                state.ready_flag,
488            )?;
489        } else if state.se_no >= 0 {
490            let _ = play_named_source(
491                ctx,
492                ch,
493                None,
494                None,
495                None,
496                Some(state.se_no),
497                state.loop_flag,
498                0,
499                state.ready_flag,
500            )?;
501        }
502    }
503
504    let slot = ensure_persistent_channel(ctx, ch);
505    *slot = state;
506    crate::runtime::forms::syscom::update_audio_routing(ctx, 0, true);
507    Ok(())
508}
509
510pub fn dispatch(ctx: &mut CommandContext, form_id: u32, args: &[Value]) -> Result<bool> {
511    let vm_call = match ctx.vm_call.as_ref() {
512        Some(v) => v,
513        None => return Ok(false),
514    };
515    let Some((ch, op)) = parse_channel_from_chain(form_id, ctx, &vm_call.element) else {
516        return Ok(false);
517    };
518    dispatch_inner(ctx, ch, op, args, Some(vm_call.ret_form))
519}
520
521fn dispatch_inner(
522    ctx: &mut CommandContext,
523    ch: usize,
524    op: i32,
525    args: &[Value],
526    ret_form: Option<i64>,
527) -> Result<bool> {
528    match op {
529        codes::pcmch_op::PLAY
530        | codes::pcmch_op::PLAY_LOOP
531        | codes::pcmch_op::PLAY_WAIT
532        | codes::pcmch_op::READY
533        | codes::pcmch_op::READY_LOOP => {
534            let ready_only = matches!(op, codes::pcmch_op::READY | codes::pcmch_op::READY_LOOP);
535            let default_loop =
536                matches!(op, codes::pcmch_op::PLAY_LOOP | codes::pcmch_op::READY_LOOP);
537            let loop_flag = named_int(args, 0).map(|v| v != 0).unwrap_or(default_loop);
538            let wait_flag = if ready_only {
539                false
540            } else {
541                named_int(args, 1)
542                    .map(|v| v != 0)
543                    .unwrap_or(op == codes::pcmch_op::PLAY_WAIT)
544            };
545            let fade_in_time = if op == codes::pcmch_op::READY {
546                0
547            } else {
548                named_int(args, 2).or_else(|| arg_int(args, 1)).unwrap_or(0)
549            };
550            let pcm_name = named_str(args, 7).or_else(|| arg_str(args, 0));
551            let koe_no = named_int(args, 8).filter(|v| *v >= 0);
552            let se_no = named_int(args, 9).filter(|v| *v >= 0);
553            let bgm_name = named_str(args, 10);
554
555            let persistent = persistent_state_for_play(
556                ctx,
557                ch,
558                pcm_name,
559                bgm_name,
560                koe_no,
561                se_no,
562                args,
563                loop_flag,
564                fade_in_time,
565                ready_only,
566            );
567            let played = play_named_source(
568                ctx,
569                ch,
570                pcm_name,
571                bgm_name,
572                koe_no,
573                se_no,
574                loop_flag,
575                fade_in_time,
576                ready_only,
577            )?;
578            if !played {
579                store_or_push_pcmch_prop(ctx, ch, op, args);
580                return Ok(true);
581            }
582
583            *ensure_persistent_channel(ctx, ch) = persistent;
584            crate::runtime::forms::syscom::update_audio_routing(ctx, 0, true);
585
586            if wait_flag {
587                ctx.wait
588                    .wait_audio(crate::runtime::wait::AudioWait::PcmSlot(ch as u8), false);
589            }
590            Ok(true)
591        }
592
593        // Siglus PCMCH has no standalone PLAY_BY_SE_NO / PLAY_BY_KOE_NO opcodes.
594        // SE/KOE numeric sources are selected through PCMCH PLAY/READY named args
595        // (`se_no` / `koe_no`) in the compiler ELEMENT table.
596        codes::pcmch_op::STOP => {
597            let fade = arg_int(args, 0);
598            ctx.pcm.stop_slot(ch, fade)?;
599            // C_elm_pcmch::stop only clears the saved loop flag.
600            ensure_persistent_channel(ctx, ch).loop_flag = false;
601            crate::runtime::forms::syscom::update_audio_routing(ctx, 0, true);
602            Ok(true)
603        }
604        codes::pcmch_op::PAUSE => {
605            ctx.pcm.pause_slot(ch, arg_int(args, 0))?;
606            Ok(true)
607        }
608        codes::pcmch_op::RESUME | codes::pcmch_op::RESUME_WAIT => {
609            let fade_time = arg_int(args, 0).unwrap_or(0);
610            // Only RESUME accepts named delay. RESUME_WAIT always resumes now
611            // and then waits, matching cmd_sound.cpp.
612            let delay_time = if op == codes::pcmch_op::RESUME {
613                named_int(args, 0).unwrap_or(0)
614            } else {
615                0
616            };
617            ctx.pcm.resume_slot(ch, fade_time, delay_time)?;
618            {
619                let state = ensure_persistent_channel(ctx, ch);
620                state.ready_flag = false;
621                state.delay_time = delay_time.max(0);
622                state.fade_in_time = fade_time.max(0);
623            }
624            if op == codes::pcmch_op::RESUME_WAIT {
625                ctx.wait
626                    .wait_audio(crate::runtime::wait::AudioWait::PcmSlot(ch as u8), false);
627            }
628            Ok(true)
629        }
630        codes::pcmch_op::WAIT => {
631            ctx.wait
632                .wait_audio(crate::runtime::wait::AudioWait::PcmSlot(ch as u8), false);
633            if ret_form.unwrap_or(0) != 0 {
634                ctx.push(Value::Int(0));
635            }
636            Ok(true)
637        }
638        codes::pcmch_op::WAIT_KEY => {
639            ctx.wait.wait_audio_with_return(
640                crate::runtime::wait::AudioWait::PcmSlot(ch as u8),
641                true,
642                ret_form.unwrap_or(0) != 0,
643            );
644            Ok(true)
645        }
646        codes::pcmch_op::WAIT_FADE | codes::pcmch_op::WAIT_FADE_KEY => {
647            let key = op == codes::pcmch_op::WAIT_FADE_KEY;
648            ctx.wait.wait_audio_with_return(
649                crate::runtime::wait::AudioWait::PcmSlotFade(ch as u8),
650                key,
651                ret_form.unwrap_or(0) != 0,
652            );
653            Ok(true)
654        }
655        codes::pcmch_op::CHECK => {
656            let playing = ctx.pcm.is_playing_slot(ch);
657            ctx.push(Value::Int(if playing { 1 } else { 0 }));
658            Ok(true)
659        }
660        codes::pcmch_op::SET_VOLUME => {
661            let vol = match arg_int(args, 0) {
662                Some(v) => v.clamp(0, 255) as u8,
663                None => {
664                    store_or_push_pcmch_prop(ctx, ch, op, args);
665                    return Ok(true);
666                }
667            };
668            let fade_time = arg_int(args, 1).unwrap_or(0);
669            ctx.pcm.set_slot_volume_raw_fade(ch, vol, fade_time)?;
670            ensure_persistent_channel(ctx, ch).volume = vol as i64;
671            Ok(true)
672        }
673        codes::pcmch_op::SET_VOLUME_MAX => {
674            let fade_time = arg_int(args, 0).unwrap_or(0);
675            ctx.pcm.set_slot_volume_raw_fade(ch, 255, fade_time)?;
676            ensure_persistent_channel(ctx, ch).volume = 255;
677            Ok(true)
678        }
679        codes::pcmch_op::SET_VOLUME_MIN => {
680            let fade_time = arg_int(args, 0).unwrap_or(0);
681            ctx.pcm.set_slot_volume_raw_fade(ch, 0, fade_time)?;
682            ensure_persistent_channel(ctx, ch).volume = 0;
683            Ok(true)
684        }
685        codes::pcmch_op::GET_VOLUME => {
686            let v = ctx.pcm.slot_volume_raw(ch) as i64;
687            ctx.push(Value::Int(v));
688            Ok(true)
689        }
690        _ => {
691            store_or_push_pcmch_prop(ctx, ch, op, args);
692            Ok(true)
693        }
694    }
695}