Skip to main content

siglus_scene_vm/
host.rs

1//! Host-driven Siglus runtime entry points shared by desktop pump and mobile FFI.
2//!
3//! This module deliberately keeps platform event-loop code out of the VM.  A host owns
4//! the native event loop or view/surface and calls into this driver for one frame at a
5//! time.  The VM semantics are the same proc-stack loop used by the desktop winit
6//! shell: script execution runs until an original-engine boundary asks to present a
7//! frame, wait for input, or wait for runtime work.
8
9use crate::platform_time::Instant;
10use std::cell::{RefCell, RefMut};
11use std::ffi::{CStr, CString, c_char, c_void};
12use std::path::{Path, PathBuf};
13use std::rc::Rc;
14use std::sync::Arc;
15
16use anyhow::{Context, Result};
17use siglus_assets::gameexe::{GameexeConfig, GameexeDecodeOptions, decode_gameexe_dat_bytes};
18use siglus_assets::scene_pck::{ScenePck, ScenePckDecodeOptions};
19
20use crate::render::Renderer;
21use crate::runtime::forms::syscom as syscom_form;
22use crate::runtime::globals::{
23    SyscomPendingProc, SyscomPendingProcKind, SystemMessageBoxButton, SystemMessageBoxModalState,
24    WipeState,
25};
26use crate::runtime::input::{VmKey, VmMouseButton};
27use crate::runtime::wait::VmWait;
28use crate::runtime::{CommandContext, FrameCaptureBackendRef, ProcKind, native_ui};
29use crate::scene_stream::SceneStream;
30use crate::vm::{SceneVm, VmConfig};
31
32const FRAME_INTERVAL_MS: u32 = 16;
33
34// The Switch shell can persist these markers even when a startup abort occurs
35// before Rust's normal stderr is usable. Keep the instrumentation out of every
36// other target and at the actual fallible/allocation boundaries.
37#[cfg(target_os = "horizon")]
38macro_rules! switch_startup_marker {
39    ($message:expr) => {
40        crate::switch_host::report_switch_marker($message)
41    };
42}
43#[cfg(target_os = "vita")]
44unsafe extern "C" {
45    fn siglus_vita_log_marker(message: *const u8);
46}
47#[cfg(target_os = "vita")]
48macro_rules! switch_startup_marker {
49    ($message:expr) => {
50        unsafe { siglus_vita_log_marker($message.as_ptr()) }
51    };
52}
53#[cfg(not(any(target_os = "horizon", target_os = "vita")))]
54macro_rules! switch_startup_marker {
55    ($message:expr) => {};
56}
57
58#[derive(Debug, Clone)]
59pub struct SiglusHostConfig {
60    pub project_dir: PathBuf,
61    pub scene_name: Option<String>,
62    pub scene_id: Option<usize>,
63    pub width: Option<u32>,
64    pub height: Option<u32>,
65}
66
67impl SiglusHostConfig {
68    pub fn new(project_dir: PathBuf) -> Self {
69        Self {
70            project_dir,
71            scene_name: None,
72            scene_id: None,
73            width: None,
74            height: None,
75        }
76    }
77}
78
79#[derive(Debug, Clone)]
80struct BootConfig {
81    start_scene: String,
82    start_z: i32,
83    menu_scene: String,
84    menu_z: i32,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum ProcType {
89    Script,
90    StartWarning,
91    SyscomWarning,
92    MsgBack,
93    ReturnToMenu,
94    GameEndWipe,
95    Disp,
96    EndGame,
97    GameTimerStart,
98    TimeWait,
99}
100
101#[derive(Debug, Clone)]
102struct ProcFrame {
103    ty: ProcType,
104    option: i32,
105    deadline_frame: Option<u32>,
106}
107
108#[derive(Debug, Default)]
109struct ProcFlow {
110    stack: Vec<ProcFrame>,
111    booted_menu: bool,
112    pending_syscom_proc: Option<SyscomPendingProc>,
113}
114
115impl ProcFlow {
116    fn push(&mut self, ty: ProcType, option: i32) {
117        self.stack.push(ProcFrame {
118            ty,
119            option,
120            deadline_frame: None,
121        });
122    }
123
124    fn pop(&mut self) {
125        let _ = self.stack.pop();
126    }
127
128    fn top(&self) -> Option<&ProcFrame> {
129        self.stack.last()
130    }
131
132    fn top_mut(&mut self) -> Option<&mut ProcFrame> {
133        self.stack.last_mut()
134    }
135}
136
137/// Button values follow SYSTEM.MESSAGEBOX_* VM semantics.
138#[repr(i32)]
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum SiglusNativeMessageBoxKind {
141    Ok = 0,
142    OkCancel = 1,
143    YesNo = 2,
144    YesNoCancel = 3,
145}
146
147impl From<native_ui::NativeMessageBoxKind> for SiglusNativeMessageBoxKind {
148    fn from(value: native_ui::NativeMessageBoxKind) -> Self {
149        match value {
150            native_ui::NativeMessageBoxKind::Ok => Self::Ok,
151            native_ui::NativeMessageBoxKind::OkCancel => Self::OkCancel,
152            native_ui::NativeMessageBoxKind::YesNo => Self::YesNo,
153            native_ui::NativeMessageBoxKind::YesNoCancel => Self::YesNoCancel,
154        }
155    }
156}
157
158/// Callback used by mobile hosts to show a native dialog on the platform UI thread.
159///
160/// All string pointers are valid only for the duration of the callback.  The host must
161/// copy them before returning if it needs to keep them.  The selected button must be
162/// delivered later with the platform-specific `siglus_*_submit_messagebox_result` ABI.
163pub type SiglusNativeMessageBoxCallback = unsafe extern "C" fn(
164    user_data: *mut c_void,
165    request_id: u64,
166    kind: i32,
167    title_utf8: *const c_char,
168    message_utf8: *const c_char,
169);
170
171struct CNativeUiBackend {
172    callback: SiglusNativeMessageBoxCallback,
173    user_data: usize,
174}
175
176unsafe impl Send for CNativeUiBackend {}
177unsafe impl Sync for CNativeUiBackend {}
178
179impl native_ui::NativeUiBackend for CNativeUiBackend {
180    fn show_system_messagebox(&self, request: native_ui::NativeMessageBoxRequest) {
181        let title = CString::new(request.title).unwrap_or_else(|_| CString::new("Siglus").unwrap());
182        let message = CString::new(request.message).unwrap_or_else(|_| CString::new("").unwrap());
183        let kind: SiglusNativeMessageBoxKind = request.kind.into();
184        unsafe {
185            (self.callback)(
186                self.user_data as *mut c_void,
187                request.request_id,
188                kind as i32,
189                title.as_ptr(),
190                message.as_ptr(),
191            );
192        }
193    }
194}
195
196pub struct SiglusHost {
197    config: SiglusHostConfig,
198    boot: BootConfig,
199    flow: ProcFlow,
200    renderer: Rc<RefCell<Renderer>>,
201    vm: SceneVm<'static>,
202    redraw_count: u32,
203    script_needs_pump: bool,
204    script_resume_after_redraw: bool,
205    suppress_render_once: bool,
206    syscom_suspended_waits: Vec<(usize, VmWait, String)>,
207    paused: bool,
208    pending_exit: bool,
209    last_step: Option<Instant>,
210}
211
212fn find_scene_pck_for_host(project_dir: &Path) -> Result<PathBuf> {
213    crate::resource::find_scene_pck_path(project_dir)
214}
215
216fn load_key_toml_config(
217    project_dir: &Path,
218) -> Result<Option<siglus_assets::key_toml::KeyTomlConfig>> {
219    crate::resource::load_project_key_toml(project_dir)
220}
221
222fn load_gameexe_decode_options(project_dir: &Path) -> Result<GameexeDecodeOptions> {
223    crate::resource::load_gameexe_decode_options(project_dir)
224}
225
226fn load_scene_pck_decode_options(project_dir: &Path) -> Result<ScenePckDecodeOptions> {
227    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
228    {
229        let cfg = load_key_toml_config(project_dir)?;
230        let exe = cfg
231            .as_ref()
232            .and_then(|cfg| cfg.exe_key16)
233            .map(|v| v.to_vec());
234        let string_encryption_override = cfg
235            .map(|cfg| cfg.override_string_encryption)
236            .unwrap_or_default();
237        Ok(ScenePckDecodeOptions {
238            exe_angou_element: exe,
239            easy_angou_code: Some(siglus_assets::keys::SCENE_KEY.to_vec()),
240            string_encryption_override,
241        })
242    }
243
244    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
245    {
246        crate::resource::load_scene_pck_decode_options(project_dir)
247    }
248}
249
250impl SiglusHost {
251    pub async fn new_with_renderer(config: SiglusHostConfig, renderer: Renderer) -> Result<Self> {
252        Self::new_with_renderer_sync(config, renderer)
253    }
254
255    /// Synchronous constructor for native hosts which own their event loop and
256    /// do not use a desktop async executor (Horizon/libnx in particular).
257    pub fn new_with_renderer_sync(config: SiglusHostConfig, renderer: Renderer) -> Result<Self> {
258        switch_startup_marker!(b"siglus_switch: host initial-size begin\n\0");
259        let initial_size = Self::resolve_initial_size(&config);
260        switch_startup_marker!(b"siglus_switch: host initial-size complete\n\0");
261        switch_startup_marker!(b"siglus_switch: host boot-config begin\n\0");
262        let boot = Self::resolve_boot_config(&config);
263        switch_startup_marker!(b"siglus_switch: host boot-config complete\n\0");
264        let mut flow = ProcFlow::default();
265        flow.push(ProcType::Script, 0);
266        flow.push(ProcType::StartWarning, 0);
267        let chihaya_display_adapter_name = renderer.adapter_name();
268        let renderer = Rc::new(RefCell::new(renderer));
269        switch_startup_marker!(b"siglus_switch: host vm-init begin\n\0");
270        let mut vm = Self::init_vm(&config, &boot, initial_size)?;
271        switch_startup_marker!(b"siglus_switch: host vm-init complete\n\0");
272        vm.ctx.globals.system.chihaya_display_adapter_name = chihaya_display_adapter_name;
273        let capture_backend: FrameCaptureBackendRef = renderer.clone();
274        vm.ctx.set_frame_capture_backend(Some(capture_backend));
275        Ok(Self {
276            config,
277            boot,
278            flow,
279            renderer,
280            vm,
281            redraw_count: 0,
282            script_needs_pump: true,
283            script_resume_after_redraw: false,
284            suppress_render_once: false,
285            syscom_suspended_waits: Vec::new(),
286            paused: false,
287            pending_exit: false,
288            last_step: None,
289        })
290    }
291
292    pub fn set_native_messagebox_callback(
293        &mut self,
294        callback: Option<SiglusNativeMessageBoxCallback>,
295        user_data: *mut c_void,
296    ) {
297        let backend = callback.map(|cb| {
298            Arc::new(CNativeUiBackend {
299                callback: cb,
300                user_data: user_data as usize,
301            }) as Arc<dyn native_ui::NativeUiBackend>
302        });
303        self.vm.ctx.set_native_ui_backend(backend);
304    }
305
306    pub fn submit_native_messagebox_result(&mut self, request_id: u64, value: i64) {
307        self.vm
308            .ctx
309            .submit_native_messagebox_result(request_id, value);
310        self.script_needs_pump = true;
311    }
312
313    pub fn resize(&mut self, width: u32, height: u32, scale_factor: f32) {
314        self.renderer
315            .borrow_mut()
316            .resize_with_scale(width, height, scale_factor.max(1.0));
317        let logical_w = ((width as f32) / scale_factor.max(1.0)).max(1.0).round() as u32;
318        let logical_h = ((height as f32) / scale_factor.max(1.0)).max(1.0).round() as u32;
319        self.vm.ctx.set_screen_size(logical_w, logical_h);
320        self.script_needs_pump = true;
321    }
322
323    pub fn resize_with_logical_viewport(
324        &mut self,
325        surface_width: u32,
326        surface_height: u32,
327        scale_factor: f32,
328        logical_width: u32,
329        logical_height: u32,
330        viewport_x: u32,
331        viewport_y: u32,
332        viewport_width: u32,
333        viewport_height: u32,
334    ) {
335        self.renderer.borrow_mut().resize_with_logical_viewport(
336            surface_width,
337            surface_height,
338            scale_factor.max(1.0),
339            logical_width.max(1),
340            logical_height.max(1),
341            viewport_x,
342            viewport_y,
343            viewport_width.max(1),
344            viewport_height.max(1),
345        );
346        self.vm
347            .ctx
348            .set_screen_size(logical_width.max(1), logical_height.max(1));
349        self.script_needs_pump = true;
350    }
351
352    pub fn logical_size(&self) -> (u32, u32) {
353        (self.vm.ctx.screen_w.max(1), self.vm.ctx.screen_h.max(1))
354    }
355
356    /// On-demand accounting for movie frames and decoded PCM retained by the VM.
357    pub fn movie_memory_stats(&self) -> crate::movie::MovieMemoryStats {
358        self.vm.ctx.movie.debug_memory_stats()
359    }
360
361    pub fn debug_status_summary(&mut self) -> String {
362        let blocked = self.vm.is_blocked();
363        let movie_playing = self.vm.ctx.globals.mov.playing;
364        let movie_file = self.vm.ctx.globals.mov.file_name.clone();
365        let movie_timer = self.vm.ctx.globals.mov.timer_ms;
366        let movie_frame = self.vm.ctx.globals.mov.last_frame_idx;
367        format!(
368            "scene={:?} line={} pending_exit={} vm_halted={} active_flag={} flow={:?} blocked={} movie_playing={} movie_file={:?} movie_timer={} movie_frame={:?}",
369            self.vm.current_scene_name(),
370            self.vm.current_line_no(),
371            self.pending_exit,
372            self.vm.is_halted(),
373            self.vm.ctx.globals.system.active_flag,
374            self.flow.stack,
375            blocked,
376            movie_playing,
377            movie_file,
378            movie_timer,
379            movie_frame,
380        )
381    }
382
383    fn native_messagebox_pending(&self) -> bool {
384        self.vm
385            .ctx
386            .globals
387            .system
388            .messagebox_modal
389            .as_ref()
390            .map(|modal| modal.native_pending)
391            .unwrap_or(false)
392    }
393
394    /// Step one frame and present when needed. Returns true if the engine requested exit.
395    pub fn step(&mut self, dt_ms: u32) -> Result<bool> {
396        let _ = dt_ms;
397        self.last_step = Some(Instant::now());
398        if self.native_messagebox_pending() {
399            return Ok(false);
400        }
401        if self.script_needs_pump || self.vm.ctx.wait.needs_runtime_poll() {
402            #[cfg(target_os = "vita")]
403            let start = Instant::now();
404            self.pump_vm()?;
405            #[cfg(target_os = "vita")]
406            crate::render::vita_stats::phase(crate::render::vita_stats::PUMP, start);
407        }
408        self.redraw()?;
409        Ok(self.pending_exit || (self.vm.is_halted() && self.flow.stack.is_empty()))
410    }
411
412    pub fn mouse_move(&mut self, x: f64, y: f64) {
413        if self.native_messagebox_pending() {
414            return;
415        }
416        self.vm
417            .ctx
418            .on_mouse_move(x.round() as i32, y.round() as i32);
419        self.script_needs_pump = true;
420    }
421
422    pub fn mouse_down(&mut self, button: VmMouseButton) {
423        if self.native_messagebox_pending() {
424            return;
425        }
426        self.vm.ctx.on_mouse_down(button);
427        self.script_needs_pump = true;
428    }
429
430    pub fn mouse_up(&mut self, button: VmMouseButton) {
431        if self.native_messagebox_pending() {
432            return;
433        }
434        self.vm.ctx.on_mouse_up(button);
435        self.script_needs_pump = true;
436    }
437
438    pub fn mouse_wheel(&mut self, delta_y: i32) {
439        if self.native_messagebox_pending() {
440            return;
441        }
442        self.vm.ctx.on_mouse_wheel(delta_y);
443        self.script_needs_pump = true;
444    }
445
446    pub fn touch(&mut self, phase: i32, x: f64, y: f64) {
447        self.mouse_move(x, y);
448        match phase {
449            0 => self.mouse_down(VmMouseButton::Left),
450            1 => {}
451            2 | 3 => self.mouse_up(VmMouseButton::Left),
452            _ => {}
453        }
454    }
455
456    pub fn key_down(&mut self, key: VmKey) {
457        if self.native_messagebox_pending() {
458            return;
459        }
460        self.vm.ctx.on_key_down(key);
461        self.script_needs_pump = true;
462    }
463
464    pub fn key_down_code(&mut self, code: i32) {
465        if let Some(key) = vm_key_from_platform_code(code) {
466            self.key_down(key);
467        }
468    }
469
470    pub fn key_up(&mut self, key: VmKey) {
471        if self.native_messagebox_pending() {
472            return;
473        }
474        self.vm.ctx.on_key_up(key);
475        self.script_needs_pump = true;
476    }
477
478    /// Record a native controller edge without converting it into keyboard
479    /// activity. Platform backends may additionally send a compatibility key,
480    /// but should call this last so JOYPAD remains the active input family.
481    pub fn joypad_button(&mut self, button: usize, down: bool) {
482        if self.native_messagebox_pending() {
483            return;
484        }
485        if down {
486            self.vm.ctx.script_input.on_joypad_key_down(button);
487        } else {
488            self.vm.ctx.script_input.on_joypad_key_up(button);
489        }
490        self.script_needs_pump = true;
491    }
492
493    pub fn key_up_code(&mut self, code: i32) {
494        if let Some(key) = vm_key_from_platform_code(code) {
495            self.key_up(key);
496        }
497    }
498
499    /// Mobile host key entry point with desktop `KeyboardInput` semantics:
500    /// mapped platform codes go to `on_key_down` (repeat presses of
501    /// Enter/Space/Escape are dropped after a menu reset), unmapped codes fall
502    /// back to the wait-key notification unless an editbox wants raw keyboard
503    /// input, and editboxes that accept direct text also receive the typed text.
504    pub fn key_event(&mut self, code: i32, text: Option<&str>, is_repeat: bool) {
505        if self.native_messagebox_pending() {
506            return;
507        }
508        match vm_key_from_platform_code(code) {
509            Some(key) => {
510                if !is_repeat || !matches!(key, VmKey::Enter | VmKey::Space | VmKey::Escape) {
511                    self.vm.ctx.on_key_down(key);
512                }
513            }
514            None => {
515                if !self.vm.ctx.editbox_accepts_keyboard_input() {
516                    self.vm.ctx.notify_wait_key();
517                }
518            }
519        }
520        if self.vm.ctx.editbox_accepts_direct_text()
521            && let Some(text) = text
522            && !text.is_empty()
523        {
524            self.vm.ctx.on_text_input(text);
525        }
526        self.script_needs_pump = true;
527    }
528
529    pub fn editbox_accepts_direct_text(&self) -> bool {
530        self.vm.ctx.editbox_accepts_direct_text()
531    }
532
533    /// Focused editbox caret area in logical game coordinates, or `None` when
534    /// the current scene does not want a soft keyboard.
535    pub fn focused_editbox_ime_area(&self) -> Option<(i32, i32, i32, i32)> {
536        self.vm.ctx.focused_editbox_ime_area()
537    }
538
539    pub fn notify_wait_key(&mut self) {
540        if self.native_messagebox_pending() {
541            return;
542        }
543        self.vm.ctx.notify_wait_key();
544        self.script_needs_pump = true;
545    }
546
547    pub fn text_input(&mut self, text: &str) {
548        if self.native_messagebox_pending() {
549            return;
550        }
551        self.vm.ctx.on_text_input(text);
552        self.script_needs_pump = true;
553    }
554
555    pub fn ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) {
556        if self.native_messagebox_pending() {
557            return;
558        }
559        self.vm.ctx.on_ime_preedit(text, cursor);
560        self.script_needs_pump = true;
561    }
562
563    pub fn ime_disabled(&mut self) {
564        if self.native_messagebox_pending() {
565            return;
566        }
567        self.vm.ctx.on_ime_disabled();
568        self.script_needs_pump = true;
569    }
570
571    pub fn renderer_mut(&mut self) -> RefMut<'_, Renderer> {
572        self.renderer.borrow_mut()
573    }
574
575    /// Writes the global save if what it keeps has changed since `last`
576    /// (see `global_save_fingerprint`); returns the current fingerprint.
577    pub fn persist_global_if_changed(&mut self, last: Option<u64>) -> u64 {
578        let now = crate::runtime::forms::syscom::global_save_fingerprint(&self.vm.ctx);
579        if last.is_some_and(|last| last != now) {
580            crate::runtime::forms::syscom::write_global_save(&self.vm.ctx);
581        }
582        now
583    }
584
585    pub fn vm_mut(&mut self) -> &mut SceneVm<'static> {
586        &mut self.vm
587    }
588
589    fn resolve_initial_size(config: &SiglusHostConfig) -> (u32, u32) {
590        let cfg_size = Self::try_load_gameexe(&config.project_dir)
591            .as_ref()
592            .and_then(Self::gameexe_screen_size)
593            .unwrap_or((1280, 720));
594        (
595            config.width.unwrap_or(cfg_size.0),
596            config.height.unwrap_or(cfg_size.1),
597        )
598    }
599
600    fn gameexe_screen_size(cfg: &GameexeConfig) -> Option<(u32, u32)> {
601        let entry = cfg.get_entry("SCREEN_SIZE")?;
602        let w = entry.item_unquoted(0)?.trim().parse::<u32>().ok()?;
603        let h = entry.item_unquoted(1)?.trim().parse::<u32>().ok()?;
604        if w == 0 || h == 0 {
605            return None;
606        }
607        Some((w, h))
608    }
609
610    fn gameexe_scene_entry(cfg: &GameexeConfig, key: &str) -> Option<(String, i32)> {
611        let entry = cfg.get_entry(key)?;
612        let scene = entry.item_unquoted(0)?.trim().trim_matches('"').to_string();
613        if scene.is_empty() {
614            return None;
615        }
616        let z = entry
617            .item_unquoted(1)
618            .and_then(|s| s.trim().parse::<i32>().ok())
619            .unwrap_or(0);
620        Some((scene, z))
621    }
622
623    fn resolve_boot_config(config: &SiglusHostConfig) -> BootConfig {
624        let cfg = Self::try_load_gameexe(&config.project_dir);
625        let (default_start, default_start_z) = cfg
626            .as_ref()
627            .and_then(|cfg| Self::gameexe_scene_entry(cfg, "START_SCENE"))
628            .unwrap_or_else(|| ("_start".to_string(), 0));
629        // C_tnm_ini::C_tnm_ini() defaults MENU_SCENE to "_menu" and only
630        // overwrites it when Gameexe provides #MENU_SCENE.
631        let (menu_scene, menu_z) = cfg
632            .as_ref()
633            .and_then(|cfg| Self::gameexe_scene_entry(cfg, "MENU_SCENE"))
634            .unwrap_or_else(|| ("_menu".to_string(), 0));
635        BootConfig {
636            start_scene: config.scene_name.clone().unwrap_or(default_start),
637            start_z: default_start_z,
638            menu_scene,
639            menu_z,
640        }
641    }
642
643    fn try_load_gameexe(project_dir: &Path) -> Option<GameexeConfig> {
644        let path = crate::resource::find_initial_gameexe_path(project_dir).ok()?;
645        let raw = crate::resource::read_file_bytes(&path).ok()?;
646        if path
647            .extension()
648            .and_then(|s| s.to_str())
649            .is_some_and(|ext| ext.eq_ignore_ascii_case("ini"))
650        {
651            let text = String::from_utf8(raw).ok()?;
652            return Some(GameexeConfig::from_text(&text));
653        }
654        let opt = load_gameexe_decode_options(project_dir).ok()?;
655        let (text, _report) = decode_gameexe_dat_bytes(&raw, &opt).ok()?;
656        Some(GameexeConfig::from_text(&text))
657    }
658
659    fn init_vm(
660        config: &SiglusHostConfig,
661        boot: &BootConfig,
662        initial_size: (u32, u32),
663    ) -> Result<SceneVm<'static>> {
664        let project_dir = config.project_dir.clone();
665        switch_startup_marker!(b"siglus_switch: vm scene-pck-path begin\n\0");
666        let scene_pck_path = find_scene_pck_for_host(&project_dir)?;
667        switch_startup_marker!(b"siglus_switch: vm scene-pck-path complete\n\0");
668        let opt = load_scene_pck_decode_options(&project_dir)?;
669        switch_startup_marker!(b"siglus_switch: vm scene-pck-open begin\n\0");
670        let pck = {
671            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
672            {
673                let bytes = crate::resource::read_file_bytes(&scene_pck_path)
674                    .with_context(|| format!("read scene.pck: {}", scene_pck_path.display()))?;
675                ScenePck::load_lazy_from_bytes(bytes, &opt)
676                    .with_context(|| format!("open scene.pck: {}", scene_pck_path.display()))?
677            }
678            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
679            {
680                ScenePck::load_lazy(&scene_pck_path, &opt)
681                    .with_context(|| format!("open scene.pck: {}", scene_pck_path.display()))?
682            }
683        };
684        switch_startup_marker!(b"siglus_switch: vm scene-pck-open complete\n\0");
685
686        let scene_no = if let Some(id) = config.scene_id {
687            id
688        } else if let Some(name) = config.scene_name.as_ref() {
689            pck.find_scene_no(name).unwrap_or(0)
690        } else {
691            pck.find_scene_no(&boot.start_scene).unwrap_or(0)
692        };
693
694        let (owner, range) = pck
695            .scn_data_shared(scene_no)
696            .with_context(|| format!("scene_id out of range: {}", scene_no))?;
697        switch_startup_marker!(b"siglus_switch: vm scene-stream begin\n\0");
698        let mut stream =
699            SceneStream::new_shared_range_with_string_codec(owner, range, pck.string_codec)?;
700        switch_startup_marker!(b"siglus_switch: vm scene-stream complete\n\0");
701        let start_z = if config.scene_id.is_some() || config.scene_name.is_some() {
702            0
703        } else {
704            boot.start_z
705        };
706        stream.jump_to_z_label(start_z.max(0) as usize)?;
707        switch_startup_marker!(b"siglus_switch: vm command-context begin\n\0");
708        let mut ctx = CommandContext::new(project_dir);
709        switch_startup_marker!(b"siglus_switch: vm command-context complete\n\0");
710        let active_append = ctx.globals.append_dir.clone();
711        switch_startup_marker!(b"siglus_switch: vm scene-metadata begin\n\0");
712        ctx.install_scene_metadata(&active_append, &pck)?;
713        switch_startup_marker!(b"siglus_switch: vm scene-metadata complete\n\0");
714        ctx.screen_w = initial_size.0;
715        ctx.screen_h = initial_size.1;
716        switch_startup_marker!(b"siglus_switch: vm create begin\n\0");
717        let mut vm = SceneVm::with_config(VmConfig::from_env(), stream, ctx);
718        vm.install_initial_scene_pck(pck, active_append);
719        switch_startup_marker!(b"siglus_switch: vm create complete\n\0");
720        // C_tnm_eng::init_global() loads global/read/config save data before
721        // start() calls tnm_init_local() and enters the boot scene.
722        if config.scene_id.is_none() && config.scene_name.is_none() {
723            switch_startup_marker!(b"siglus_switch: vm global-save begin\n\0");
724            crate::runtime::forms::syscom::load_global_save(&mut vm.ctx)
725                .context("load global save during engine initialization")?;
726            switch_startup_marker!(b"siglus_switch: vm global-save complete\n\0");
727            if env_is_set!("SG_BOOT_TRACE") {
728                let g1000 = vm
729                    .ctx
730                    .globals
731                    .int_lists
732                    .get(&(crate::runtime::forms::codes::ELM_GLOBAL_G as u32))
733                    .and_then(|g| g.get(1000))
734                    .copied()
735                    .unwrap_or(0);
736                eprintln!("[SG_BOOT] global save initialization complete, g[1000]={g1000}");
737            }
738        }
739        if config.scene_id.is_none() {
740            switch_startup_marker!(b"siglus_switch: vm restart-scene begin\n\0");
741            let scene_name = config
742                .scene_name
743                .clone()
744                .unwrap_or_else(|| boot.start_scene.clone());
745            vm.restart_scene_name(&scene_name, start_z)?;
746            switch_startup_marker!(b"siglus_switch: vm restart-scene complete\n\0");
747        }
748        Ok(vm)
749    }
750
751    fn suspend_wait_for_syscom_excall(&mut self, key: &str) {
752        let flow_depth = self.flow.stack.len();
753        let saved_wait = std::mem::take(&mut self.vm.ctx.wait);
754        self.vm.ctx.input.use_current();
755        self.vm.ctx.script_input.use_current();
756        self.syscom_suspended_waits
757            .push((flow_depth, saved_wait, key.to_string()));
758        if env_is_set!("SG_PROC_FLOW_TRACE") {
759            eprintln!(
760                "[SG_PROC_FLOW] host suspend_wait_for_syscom_excall key={} flow_depth={} saved_count={} scene={:?} line={}",
761                key,
762                flow_depth,
763                self.syscom_suspended_waits.len(),
764                self.vm.current_scene_name(),
765                self.vm.current_line_no()
766            );
767        }
768    }
769
770    fn restore_wait_after_syscom_excall(&mut self, popped_depth: usize) {
771        let should_restore = self
772            .syscom_suspended_waits
773            .last()
774            .map(|(depth, _, _)| *depth == popped_depth)
775            .unwrap_or(false);
776        if !should_restore {
777            return;
778        }
779        let Some((_depth, saved_wait, key)) = self.syscom_suspended_waits.pop() else {
780            return;
781        };
782        self.vm.ctx.wait = saved_wait;
783        self.vm.ctx.input.clear_all();
784        self.vm.ctx.script_input.clear_all();
785        if key == "SAVE_SCENE" {
786            crate::runtime::forms::syscom::free_runtime_save_thumb_capture(
787                &mut self.vm.ctx,
788                crate::runtime::forms::syscom::CAPTURE_PRIOR_SAVE,
789            );
790        }
791        if env_is_set!("SG_PROC_FLOW_TRACE") {
792            eprintln!(
793                "[SG_PROC_FLOW] host restore_wait_after_syscom_excall popped_depth={} remaining={} scene={:?} line={}",
794                popped_depth,
795                self.syscom_suspended_waits.len(),
796                self.vm.current_scene_name(),
797                self.vm.current_line_no()
798            );
799        }
800    }
801
802    fn consume_syscom_pending_proc(&mut self) -> Result<bool> {
803        let Some(proc) = self.vm.ctx.globals.syscom.pending_proc.take() else {
804            return Ok(false);
805        };
806
807        self.vm.ctx.globals.syscom.menu_open = false;
808        self.vm.ctx.globals.syscom.menu_kind = None;
809        if proc.kind != SyscomPendingProcKind::MsgBack {
810            self.vm.ctx.globals.syscom.msg_back_open = false;
811        }
812
813        if env_is_set!("SG_PROC_FLOW_TRACE") {
814            eprintln!(
815                "[SG_PROC_FLOW] host consume_syscom_pending kind={:?} before scene={:?} line={} flow={:?}",
816                proc.kind,
817                self.vm.current_scene_name(),
818                self.vm.current_line_no(),
819                self.flow.stack
820            );
821        }
822
823        match proc.kind {
824            SyscomPendingProcKind::EndGame => {
825                if proc.warning {
826                    self.begin_syscom_warning(proc);
827                } else {
828                    self.queue_end_game_proc(proc);
829                }
830                Ok(true)
831            }
832            SyscomPendingProcKind::ReturnToMenu => {
833                if proc.warning {
834                    self.begin_syscom_warning(proc);
835                } else {
836                    self.queue_return_to_menu_proc(proc);
837                }
838                Ok(true)
839            }
840            SyscomPendingProcKind::RestartScene => {
841                if proc.warning {
842                    self.begin_syscom_warning(proc);
843                } else {
844                    self.perform_restart_from_scene()?;
845                }
846                Ok(true)
847            }
848            SyscomPendingProcKind::ReturnToSel => {
849                if self.vm.restore_last_sel_point() {
850                    self.flow.stack.clear();
851                    self.flow.push(ProcType::GameTimerStart, 0);
852                    self.flow.push(ProcType::Script, 0);
853                    Ok(true)
854                } else {
855                    self.vm.ctx.unknown.record_note(
856                        "SYSCOM.RETURN_TO_SEL requested without an in-memory SELPOINT snapshot",
857                    );
858                    Ok(false)
859                }
860            }
861            SyscomPendingProcKind::Save => {
862                if proc.warning {
863                    self.begin_syscom_warning(proc);
864                } else {
865                    crate::runtime::forms::syscom::menu_save_slot(
866                        &mut self.vm.ctx,
867                        false,
868                        proc.save_id.max(0) as usize,
869                    );
870                    crate::runtime::forms::syscom::write_global_save(&self.vm.ctx);
871                }
872                Ok(true)
873            }
874            SyscomPendingProcKind::Load => {
875                if proc.warning {
876                    self.begin_syscom_warning(proc);
877                } else {
878                    crate::runtime::forms::syscom::menu_load_slot(
879                        &mut self.vm.ctx,
880                        false,
881                        proc.save_id.max(0) as usize,
882                    );
883                }
884                Ok(true)
885            }
886            SyscomPendingProcKind::QuickSave => {
887                if proc.warning {
888                    self.begin_syscom_warning(proc);
889                } else {
890                    crate::runtime::forms::syscom::menu_save_slot(
891                        &mut self.vm.ctx,
892                        true,
893                        proc.save_id.max(0) as usize,
894                    );
895                    crate::runtime::forms::syscom::write_global_save(&self.vm.ctx);
896                }
897                Ok(true)
898            }
899            SyscomPendingProcKind::QuickLoad => {
900                if proc.warning {
901                    self.begin_syscom_warning(proc);
902                } else {
903                    crate::runtime::forms::syscom::menu_load_slot(
904                        &mut self.vm.ctx,
905                        true,
906                        proc.save_id.max(0) as usize,
907                    );
908                }
909                Ok(true)
910            }
911            SyscomPendingProcKind::BacklogLoad => {
912                if self.vm.restore_last_sel_point() {
913                    self.flow.stack.clear();
914                    self.flow.push(ProcType::GameTimerStart, 0);
915                    self.flow.push(ProcType::Script, 0);
916                    Ok(true)
917                } else {
918                    self.vm.ctx.unknown.record_note(&format!(
919                        "SYSCOM.MSG_BACK_LOAD requested but backlog save {} is not materialized without SAVE/LOAD support",
920                        proc.save_id
921                    ));
922                    Ok(false)
923                }
924            }
925            SyscomPendingProcKind::MsgBack => {
926                if self.vm.ctx.globals.syscom.msg_back_open {
927                    self.flow.push(ProcType::MsgBack, 0);
928                    Ok(true)
929                } else {
930                    Ok(false)
931                }
932            }
933            SyscomPendingProcKind::OpenSyscomMenu => {
934                if self.vm.call_syscom_configured_scene("CANCEL_SCENE")? {
935                    self.ensure_requested_script_proc();
936                    self.suspend_wait_for_syscom_excall("CANCEL_SCENE");
937                    Ok(true)
938                } else {
939                    syscom_form::open_fallback_dialog(
940                        &mut self.vm.ctx,
941                        SyscomPendingProcKind::OpenSyscomMenu,
942                    );
943                    Ok(true)
944                }
945            }
946            SyscomPendingProcKind::OpenSave => {
947                if self.vm.call_syscom_configured_scene("SAVE_SCENE")? {
948                    self.ensure_requested_script_proc();
949                    self.suspend_wait_for_syscom_excall("SAVE_SCENE");
950                    Ok(true)
951                } else {
952                    syscom_form::open_fallback_dialog(
953                        &mut self.vm.ctx,
954                        SyscomPendingProcKind::OpenSave,
955                    );
956                    Ok(true)
957                }
958            }
959            SyscomPendingProcKind::OpenLoad => {
960                if self.vm.call_syscom_configured_scene("LOAD_SCENE")? {
961                    self.ensure_requested_script_proc();
962                    self.suspend_wait_for_syscom_excall("LOAD_SCENE");
963                    Ok(true)
964                } else {
965                    syscom_form::open_fallback_dialog(
966                        &mut self.vm.ctx,
967                        SyscomPendingProcKind::OpenLoad,
968                    );
969                    Ok(true)
970                }
971            }
972            SyscomPendingProcKind::OpenConfig | SyscomPendingProcKind::OpenConfigDialog => {
973                if proc.kind == SyscomPendingProcKind::OpenConfig
974                    && self.vm.call_syscom_configured_scene("CONFIG_SCENE")?
975                {
976                    self.ensure_requested_script_proc();
977                    self.suspend_wait_for_syscom_excall("CONFIG_SCENE");
978                    Ok(true)
979                } else {
980                    syscom_form::open_fallback_dialog(
981                        &mut self.vm.ctx,
982                        SyscomPendingProcKind::OpenConfig,
983                    );
984                    Ok(true)
985                }
986            }
987        }
988    }
989
990    fn ensure_requested_script_proc(&mut self) {
991        let requested = self.vm.take_script_proc_request();
992        if requested {
993            if env_is_set!("SG_PROC_FLOW_TRACE") {
994                eprintln!(
995                    "[SG_PROC_FLOW] host ensure_requested_script_proc push before scene={:?} line={} flow={:?}",
996                    self.vm.current_scene_name(),
997                    self.vm.current_line_no(),
998                    self.flow.stack
999                );
1000            }
1001            self.flow.push(ProcType::Script, 0);
1002            if env_is_set!("SG_PROC_FLOW_TRACE") {
1003                eprintln!(
1004                    "[SG_PROC_FLOW] host ensure_requested_script_proc push after flow={:?}",
1005                    self.flow.stack
1006                );
1007            }
1008        }
1009    }
1010
1011    fn begin_syscom_warning(&mut self, mut proc: SyscomPendingProc) {
1012        let kind = proc.kind;
1013        proc.warning = false;
1014        self.flow.pending_syscom_proc = Some(proc);
1015        self.vm.ctx.globals.system.messagebox_modal_result = None;
1016        let request_id = self.vm.ctx.native_ui.next_messagebox_request_id();
1017        let buttons = vec![
1018            SystemMessageBoxButton {
1019                label: "YES".to_string(),
1020                value: 0,
1021            },
1022            SystemMessageBoxButton {
1023                label: "NO".to_string(),
1024                value: 1,
1025            },
1026        ];
1027        let text = self.syscom_warning_text(kind);
1028        let title = self.vm.ctx.game_title();
1029        if let Some(backend) = self.vm.ctx.native_ui_backend.as_ref() {
1030            self.vm.ctx.globals.system.messagebox_modal = Some(SystemMessageBoxModalState {
1031                request_id,
1032                kind: 19,
1033                text: text.clone(),
1034                debug_only: false,
1035                buttons: buttons.clone(),
1036                cursor: 1,
1037                native_pending: true,
1038                complete_wait_with_value: false,
1039            });
1040            backend.show_system_messagebox(native_ui::NativeMessageBoxRequest {
1041                request_id,
1042                kind: native_ui::NativeMessageBoxKind::YesNo,
1043                title,
1044                message: text,
1045                buttons: buttons
1046                    .into_iter()
1047                    .map(|button| native_ui::NativeMessageBoxButton {
1048                        label: button.label,
1049                        value: button.value,
1050                    })
1051                    .collect(),
1052                debug_only: false,
1053            });
1054        } else {
1055            self.vm.ctx.globals.system.messagebox_modal = Some(SystemMessageBoxModalState {
1056                request_id,
1057                kind: 19,
1058                text,
1059                debug_only: false,
1060                buttons,
1061                cursor: 1,
1062                native_pending: false,
1063                complete_wait_with_value: false,
1064            });
1065        }
1066        self.flow.push(ProcType::SyscomWarning, 0);
1067    }
1068
1069    fn syscom_warning_text(&self, kind: SyscomPendingProcKind) -> String {
1070        let keys: &[&str] = match kind {
1071            SyscomPendingProcKind::EndGame => &[
1072                "#WARNINGINFO.GAMEEND_WARNING_STR",
1073                "WARNINGINFO.GAMEEND_WARNING_STR",
1074            ],
1075            SyscomPendingProcKind::ReturnToSel => &[
1076                "#WARNINGINFO.RETURNSEL_WARNING_STR",
1077                "WARNINGINFO.RETURNSEL_WARNING_STR",
1078                "#WARNINGINFO.RETURNMENU_WARNING_STR",
1079                "WARNINGINFO.RETURNMENU_WARNING_STR",
1080            ],
1081            SyscomPendingProcKind::RestartScene => &[
1082                "#WARNINGINFO.SCENESTART_WARNING_STR",
1083                "WARNINGINFO.SCENESTART_WARNING_STR",
1084            ],
1085            SyscomPendingProcKind::Save | SyscomPendingProcKind::QuickSave => &[
1086                "#WARNINGINFO.SAVE_WARNING_STR",
1087                "WARNINGINFO.SAVE_WARNING_STR",
1088            ],
1089            SyscomPendingProcKind::Load | SyscomPendingProcKind::QuickLoad => &[
1090                "#WARNINGINFO.LOAD_WARNING_STR",
1091                "WARNINGINFO.LOAD_WARNING_STR",
1092            ],
1093            _ => &[
1094                "#WARNINGINFO.RETURNMENU_WARNING_STR",
1095                "WARNINGINFO.RETURNMENU_WARNING_STR",
1096            ],
1097        };
1098        let default = match kind {
1099            SyscomPendingProcKind::EndGame => "終了してもよろしいですか?",
1100            SyscomPendingProcKind::ReturnToSel => "前の選択肢に戻ってもよろしいですか?",
1101            SyscomPendingProcKind::RestartScene => "途中から始めてもよろしいですか?",
1102            SyscomPendingProcKind::Save | SyscomPendingProcKind::QuickSave => {
1103                "セーブデータを上書きしてもよろしいですか?"
1104            }
1105            SyscomPendingProcKind::Load | SyscomPendingProcKind::QuickLoad => {
1106                "セーブデータをロードしてもよろしいですか?"
1107            }
1108            _ => "タイトルに戻ってもよろしいですか?",
1109        };
1110        let cfg = self.vm.ctx.tables.gameexe.as_ref();
1111        keys.iter()
1112            .find_map(|key| cfg.and_then(|c| c.get_unquoted(key)))
1113            .filter(|s| !s.is_empty())
1114            .map(str::to_string)
1115            .unwrap_or_else(|| default.to_string())
1116    }
1117
1118    fn return_to_menu_warning_text(&self) -> String {
1119        let cfg = self.vm.ctx.tables.gameexe.as_ref();
1120        [
1121            "#WARNINGINFO.RETURNMENU_WARNING_STR",
1122            "WARNINGINFO.RETURNMENU_WARNING_STR",
1123        ]
1124        .iter()
1125        .find_map(|key| cfg.and_then(|c| c.get_unquoted(key)))
1126        .filter(|s| !s.is_empty())
1127        .map(str::to_string)
1128        .unwrap_or_else(|| "タイトルに戻ってもよろしいですか?".to_string())
1129    }
1130
1131    fn load_wipe_params(&self) -> (i32, i32) {
1132        fn parse_pair(raw: &str) -> Option<(i32, i32)> {
1133            let nums: Vec<i32> = raw
1134                .split(|c: char| !(c == '-' || c.is_ascii_digit()))
1135                .filter(|s| !s.is_empty())
1136                .filter_map(|s| s.parse::<i32>().ok())
1137                .collect();
1138            if nums.len() >= 2 {
1139                Some((nums[0], nums[1]))
1140            } else {
1141                None
1142            }
1143        }
1144        let cfg = self.vm.ctx.tables.gameexe.as_ref();
1145        for key in ["LOAD.WIPE", "LOAD . WIPE", "#LOAD.WIPE", "#LOAD . WIPE"] {
1146            if let Some(pair) = cfg.and_then(|c| c.get_value(key)).and_then(parse_pair) {
1147                return pair;
1148            }
1149        }
1150        (0, 1000)
1151    }
1152
1153    fn queue_end_game_proc(&mut self, proc: SyscomPendingProc) {
1154        self.flow.pending_syscom_proc = None;
1155        self.flow.push(ProcType::EndGame, 0);
1156        if proc.fade_out {
1157            self.flow.push(ProcType::GameEndWipe, 0);
1158            self.flow.push(ProcType::Disp, 0);
1159        } else {
1160            self.flow.push(ProcType::Disp, 0);
1161        }
1162    }
1163
1164    fn queue_return_to_menu_proc(&mut self, proc: SyscomPendingProc) {
1165        // Original tnm_syscom_return_to_menu() persists global data only after
1166        // the warning (if any) has been accepted, and before fade/scene return.
1167        crate::runtime::forms::syscom::write_global_save(&self.vm.ctx);
1168        let option = if proc.leave_msgbk { 1 } else { 0 };
1169        self.flow.pending_syscom_proc = Some(proc.clone());
1170        self.flow.push(ProcType::ReturnToMenu, option);
1171        if proc.fade_out {
1172            self.flow.push(ProcType::GameEndWipe, 0);
1173            self.flow.push(ProcType::Disp, 0);
1174        }
1175    }
1176
1177    fn start_game_end_wipe(&mut self) {
1178        let (wipe_type, wipe_time) = self.load_wipe_params();
1179        if self.vm.ctx.globals.wipe.is_some() {
1180            self.vm.ctx.finish_wipe_runtime();
1181        }
1182        let stage_form_id = if self.vm.ctx.ids.form_global_stage != 0 {
1183            self.vm.ctx.ids.form_global_stage
1184        } else {
1185            crate::runtime::forms::codes::FORM_GLOBAL_STAGE
1186        };
1187        self.vm.ctx.globals.start_wipe(WipeState::new(
1188            stage_form_id,
1189            None,
1190            None,
1191            wipe_type,
1192            wipe_time,
1193            0,
1194            0,
1195            Vec::new(),
1196            i32::MIN,
1197            i32::MAX,
1198            i32::MIN,
1199            i32::MAX,
1200            false,
1201            0,
1202            0,
1203        ));
1204    }
1205
1206    fn finish_runtime_load(&mut self) {
1207        self.renderer.borrow_mut().clear_runtime_image_textures();
1208        self.flow.stack.clear();
1209        self.flow.pending_syscom_proc = None;
1210        self.syscom_suspended_waits.clear();
1211        self.paused = false;
1212        self.script_resume_after_redraw = false;
1213        self.suppress_render_once = true;
1214        self.vm.ctx.globals.syscom.pending_proc = None;
1215        self.vm.ctx.globals.syscom.menu_open = false;
1216        self.vm.ctx.globals.syscom.menu_kind = None;
1217        self.vm.ctx.globals.syscom.msg_back_open = false;
1218        self.flow.push(ProcType::GameTimerStart, 0);
1219        self.flow.push(ProcType::Script, 0);
1220        self.script_needs_pump = true;
1221    }
1222
1223    fn perform_return_to_menu(&mut self, leave_msgbk: bool) -> Result<()> {
1224        let target_scene = self.boot.menu_scene.clone();
1225        let target_z = self.boot.menu_z;
1226        let saved_msgbk = if leave_msgbk {
1227            Some(self.vm.ctx.globals.msgbk_forms.clone())
1228        } else {
1229            None
1230        };
1231        // eng_scene.cpp::tnm_scene_proc_restart_from_menu_scene() always returns
1232        // to the initial Select.ini append, reloads Scene.pck, then resolves the
1233        // configured MENU_SCENE.  SceneVm reloads its package cache when append
1234        // changes, so resetting the append before restart preserves that ordering.
1235        self.vm.ctx.reset_active_append_to_initial();
1236        self.vm.restart_scene_name(&target_scene, target_z)?;
1237        self.renderer.borrow_mut().clear_runtime_image_textures();
1238        if let Some(msgbk) = saved_msgbk {
1239            self.vm.ctx.globals.msgbk_forms = msgbk;
1240        }
1241        self.vm.ctx.globals.finish_wipe();
1242        self.flow.stack.clear();
1243        self.flow.pending_syscom_proc = None;
1244        self.flow.booted_menu = true;
1245        // C++ tnm_scene_proc_restart_func() pushes SCRIPT first, then
1246        // tnm_return_to_menu_proc() pushes GAME_TIMER_START on top of it.
1247        self.flow.push(ProcType::Script, 0);
1248        self.flow.push(ProcType::GameTimerStart, 0);
1249        Ok(())
1250    }
1251
1252    fn perform_restart_from_scene(&mut self) -> Result<()> {
1253        let (target_scene, target_z) = self
1254            .vm
1255            .ctx
1256            .pending_scene_restart
1257            .take()
1258            .ok_or_else(|| anyhow::anyhow!("GLOBAL.RETURNMENU scene restart missing target"))?;
1259
1260        // eng_syscom.cpp::tnm_syscom_restart_from_scene() saves global state only
1261        // after the SCENESTART warning has been accepted, then restarts the named
1262        // scene without resetting the active append.
1263        crate::runtime::forms::syscom::write_global_save(&self.vm.ctx);
1264        self.vm.restart_scene_name(&target_scene, target_z)?;
1265        self.renderer.borrow_mut().clear_runtime_image_textures();
1266        self.vm.ctx.globals.finish_wipe();
1267        self.flow.stack.clear();
1268        self.flow.pending_syscom_proc = None;
1269        self.flow.push(ProcType::Script, 0);
1270        self.script_needs_pump = true;
1271        Ok(())
1272    }
1273
1274    fn pump_vm(&mut self) -> Result<()> {
1275        if env_is_set!("SG_PROC_FLOW_TRACE") {
1276            eprintln!(
1277                "[SG_PROC_FLOW] host pump_vm start paused={} script_needs_pump={} scene={:?} line={} flow={:?} pending_proc={:?}",
1278                self.paused,
1279                self.script_needs_pump,
1280                self.vm.current_scene_name(),
1281                self.vm.current_line_no(),
1282                self.flow.stack,
1283                self.vm.ctx.globals.syscom.pending_proc
1284            );
1285        }
1286        self.script_needs_pump = false;
1287        self.ensure_requested_script_proc();
1288        if self.paused {
1289            return Ok(());
1290        }
1291
1292        self.vm.process_pending_button_actions()?;
1293        if env_is_set!("SG_PROC_FLOW_TRACE") {
1294            eprintln!(
1295                "[SG_PROC_FLOW] host pump_vm after_process_button_actions scene={:?} line={} flow={:?} pending_proc={:?}",
1296                self.vm.current_scene_name(),
1297                self.vm.current_line_no(),
1298                self.flow.stack,
1299                self.vm.ctx.globals.syscom.pending_proc
1300            );
1301        }
1302        if self.vm.ctx.globals.syscom.pending_proc.is_some() {
1303            self.consume_syscom_pending_proc()?;
1304            self.ensure_requested_script_proc();
1305        }
1306
1307        self.vm.begin_script_proc_pump();
1308
1309        loop {
1310            let Some(proc) = self.flow.top().cloned() else {
1311                self.paused = true;
1312                break;
1313            };
1314            if env_is_set!("SG_PROC_FLOW_TRACE") {
1315                eprintln!(
1316                    "[SG_PROC_FLOW] host pump_vm loop top proc={:?} scene={:?} line={} flow={:?}",
1317                    proc,
1318                    self.vm.current_scene_name(),
1319                    self.vm.current_line_no(),
1320                    self.flow.stack
1321                );
1322            }
1323
1324            match proc.ty {
1325                ProcType::Script => {
1326                    let proc_gen_before = self.vm.proc_generation();
1327                    let running = self.vm.run_script_proc_continue()?;
1328                    if self.vm.take_runtime_load_completed() {
1329                        self.finish_runtime_load();
1330                        continue;
1331                    }
1332                    let proc_boundary = self.vm.proc_generation() != proc_gen_before;
1333                    let boundary_kind = self.vm.last_proc_kind();
1334                    let pop_script_proc = self.vm.take_script_proc_pop_request();
1335                    let halted = self.vm.is_halted();
1336                    let cur_scene = self
1337                        .vm
1338                        .current_scene_name()
1339                        .map(|s| s.to_string())
1340                        .unwrap_or_else(|| self.boot.start_scene.clone());
1341                    let pending = self.vm.ctx.globals.syscom.pending_proc.is_some();
1342                    let blocked = if pending { false } else { self.vm.is_blocked() };
1343
1344                    self.ensure_requested_script_proc();
1345                    if pop_script_proc {
1346                        let popped_depth = self.flow.stack.len();
1347                        self.flow.pop();
1348                        self.restore_wait_after_syscom_excall(popped_depth);
1349                        continue;
1350                    }
1351                    if !running || halted {
1352                        self.flow.pop();
1353                        if !self.flow.booted_menu && cur_scene == self.boot.start_scene {
1354                            self.flow.push(ProcType::ReturnToMenu, 0);
1355                        }
1356                        continue;
1357                    }
1358                    if pending {
1359                        if self.consume_syscom_pending_proc()? {
1360                            continue;
1361                        }
1362                        if self.vm.is_blocked() {
1363                            break;
1364                        }
1365                    } else if proc_boundary {
1366                        match boundary_kind {
1367                            ProcKind::Disp => {
1368                                self.script_resume_after_redraw = true;
1369                                break;
1370                            }
1371                            ProcKind::Frame => {
1372                                self.script_resume_after_redraw = true;
1373                                self.suppress_render_once = true;
1374                                break;
1375                            }
1376                            ProcKind::Command
1377                            | ProcKind::MessageBlock
1378                            | ProcKind::MessageWait
1379                            | ProcKind::KeyWait
1380                            | ProcKind::TimeWait
1381                            | ProcKind::MovieWait
1382                            | ProcKind::WipeWait
1383                            | ProcKind::AudioWait
1384                            | ProcKind::EventWait
1385                            | ProcKind::Selection
1386                            | ProcKind::SystemModal
1387                            | ProcKind::Script => {
1388                                if blocked {
1389                                    break;
1390                                }
1391                                continue;
1392                            }
1393                        }
1394                    } else if blocked {
1395                        break;
1396                    }
1397                }
1398                ProcType::StartWarning => {
1399                    let warning_exists = crate::resource::game_file_exists(
1400                        &self
1401                            .vm
1402                            .ctx
1403                            .images
1404                            .project_dir()
1405                            .join("g00")
1406                            .join("___SYSEVE_WARNING.g00"),
1407                    ) || crate::resource::game_file_exists(
1408                        &self
1409                            .vm
1410                            .ctx
1411                            .images
1412                            .project_dir()
1413                            .join("g00")
1414                            .join("___SYSEVE_WARNING.g01"),
1415                    );
1416                    if !warning_exists {
1417                        self.flow.pop();
1418                        continue;
1419                    }
1420                    let cur = self.redraw_count;
1421                    let top = self.flow.top_mut().expect("proc top");
1422                    match top.option {
1423                        0 => {
1424                            top.option = 1;
1425                            self.flow.push(ProcType::TimeWait, 0);
1426                            if let Some(wait) = self.flow.top_mut() {
1427                                wait.deadline_frame = Some(cur.saturating_add(60));
1428                            }
1429                        }
1430                        _ => {
1431                            self.flow.pop();
1432                        }
1433                    }
1434                    break;
1435                }
1436                ProcType::SyscomWarning => {
1437                    if self.vm.ctx.globals.system.messagebox_modal.is_some() {
1438                        break;
1439                    }
1440                    let result = self
1441                        .vm
1442                        .ctx
1443                        .globals
1444                        .system
1445                        .messagebox_modal_result
1446                        .take()
1447                        .unwrap_or(1);
1448                    let pending = self.flow.pending_syscom_proc.take();
1449                    self.flow.pop();
1450                    if result == 0 {
1451                        if let Some(proc) = pending {
1452                            match proc.kind {
1453                                SyscomPendingProcKind::EndGame => {
1454                                    self.queue_end_game_proc(proc);
1455                                }
1456                                SyscomPendingProcKind::ReturnToMenu => {
1457                                    self.queue_return_to_menu_proc(proc);
1458                                }
1459                                SyscomPendingProcKind::RestartScene => {
1460                                    self.perform_restart_from_scene()?;
1461                                }
1462                                SyscomPendingProcKind::Save => {
1463                                    crate::runtime::forms::syscom::menu_save_slot(
1464                                        &mut self.vm.ctx,
1465                                        false,
1466                                        proc.save_id.max(0) as usize,
1467                                    );
1468                                    crate::runtime::forms::syscom::write_global_save(&self.vm.ctx);
1469                                }
1470                                SyscomPendingProcKind::Load => {
1471                                    crate::runtime::forms::syscom::menu_load_slot(
1472                                        &mut self.vm.ctx,
1473                                        false,
1474                                        proc.save_id.max(0) as usize,
1475                                    );
1476                                }
1477                                SyscomPendingProcKind::QuickSave => {
1478                                    crate::runtime::forms::syscom::menu_save_slot(
1479                                        &mut self.vm.ctx,
1480                                        true,
1481                                        proc.save_id.max(0) as usize,
1482                                    );
1483                                    crate::runtime::forms::syscom::write_global_save(&self.vm.ctx);
1484                                }
1485                                SyscomPendingProcKind::QuickLoad => {
1486                                    crate::runtime::forms::syscom::menu_load_slot(
1487                                        &mut self.vm.ctx,
1488                                        true,
1489                                        proc.save_id.max(0) as usize,
1490                                    );
1491                                }
1492                                _ => {}
1493                            }
1494                        }
1495                    } else if matches!(
1496                        pending.as_ref().map(|proc| proc.kind),
1497                        Some(SyscomPendingProcKind::RestartScene)
1498                    ) {
1499                        self.vm.ctx.pending_scene_restart = None;
1500                    } else if matches!(
1501                        pending.as_ref().map(|proc| proc.kind),
1502                        Some(SyscomPendingProcKind::Save)
1503                    ) {
1504                        crate::runtime::forms::syscom::free_runtime_save_thumb_capture(
1505                            &mut self.vm.ctx,
1506                            crate::runtime::forms::syscom::CAPTURE_PRIOR_SAVE,
1507                        );
1508                    }
1509                    continue;
1510                }
1511                ProcType::MsgBack => {
1512                    if !self.vm.ctx.globals.syscom.msg_back_open {
1513                        self.flow.pop();
1514                        continue;
1515                    }
1516                    break;
1517                }
1518                ProcType::Disp => {
1519                    self.flow.pop();
1520                    self.script_resume_after_redraw = true;
1521                    break;
1522                }
1523                ProcType::GameEndWipe => {
1524                    let mut start = false;
1525                    if let Some(top) = self.flow.top_mut()
1526                        && top.option == 0
1527                    {
1528                        top.option = 1;
1529                        start = true;
1530                    }
1531                    if start {
1532                        self.start_game_end_wipe();
1533                        break;
1534                    }
1535                    if self.vm.ctx.globals.wipe_done() {
1536                        self.flow.pop();
1537                        continue;
1538                    }
1539                    break;
1540                }
1541                ProcType::ReturnToMenu => {
1542                    let leave_msgbk = proc.option != 0;
1543                    self.perform_return_to_menu(leave_msgbk)?;
1544                    // Original tnm_return_to_menu_proc() returns false here:
1545                    // leave frame_main_proc and present once before the new
1546                    // GAME_TIMER_START/SCRIPT stack is resumed.
1547                    self.script_resume_after_redraw = true;
1548                    break;
1549                }
1550                ProcType::EndGame => {
1551                    self.flow.pop();
1552                    crate::runtime::forms::syscom::write_global_save(&self.vm.ctx);
1553                    self.vm.ctx.globals.system.active_flag = false;
1554                    continue;
1555                }
1556                ProcType::GameTimerStart => {
1557                    self.flow.pop();
1558                    continue;
1559                }
1560                ProcType::TimeWait => {
1561                    let deadline = proc.deadline_frame.unwrap_or(self.redraw_count);
1562                    if self.redraw_count >= deadline {
1563                        self.flow.pop();
1564                        continue;
1565                    }
1566                    break;
1567                }
1568            }
1569            break;
1570        }
1571        Ok(())
1572    }
1573
1574    fn redraw(&mut self) -> Result<()> {
1575        #[cfg(target_os = "horizon")]
1576        let (switch_trace_frame, switch_trace_enabled) = {
1577            // Per-phase tracing was useful while locating the title-screen
1578            // stall, but it opened and appended to the SD log several times
1579            // per frame. Keep the call sites available for future diagnosis
1580            // without imposing that I/O on release gameplay.
1581            (0_u32, false)
1582        };
1583        #[cfg(target_os = "horizon")]
1584        if switch_trace_enabled {
1585            let scene = self.vm.current_scene_name().map(str::to_owned);
1586            let line = self.vm.current_line_no();
1587            let blocked = self.vm.is_blocked();
1588            crate::switch_host::report_switch_diagnostic(&format!(
1589                "siglus_switch: host-trace frame={} phase=redraw-begin scene={:?} line={} blocked={}\n",
1590                switch_trace_frame, scene, line, blocked,
1591            ));
1592        }
1593        if env_is_set!("SG_PROC_FLOW_TRACE") {
1594            eprintln!(
1595                "[SG_PROC_FLOW] host redraw start scene={:?} line={} flow={:?} pending_proc={:?}",
1596                self.vm.current_scene_name(),
1597                self.vm.current_line_no(),
1598                self.flow.stack,
1599                self.vm.ctx.globals.syscom.pending_proc
1600            );
1601        }
1602        // Match the original C++ frame order: script/input processing runs before
1603        // element frame evaluation and rendering.  If an input event woke the script,
1604        // pump it here before tick_frame(), otherwise the redraw for the same input
1605        // can show stale pre-script object/event state for one frame.
1606        if self.script_needs_pump {
1607            #[cfg(target_os = "horizon")]
1608            if switch_trace_enabled {
1609                crate::switch_host::report_switch_diagnostic(&format!(
1610                    "siglus_switch: host-trace frame={} phase=pump-vm-begin\n",
1611                    switch_trace_frame
1612                ));
1613            }
1614            #[cfg(target_os = "vita")]
1615            let start = Instant::now();
1616            self.pump_vm()?;
1617            #[cfg(target_os = "vita")]
1618            crate::render::vita_stats::phase(crate::render::vita_stats::PUMP, start);
1619            #[cfg(target_os = "horizon")]
1620            if switch_trace_enabled {
1621                crate::switch_host::report_switch_diagnostic(&format!(
1622                    "siglus_switch: host-trace frame={} phase=pump-vm-complete\n",
1623                    switch_trace_frame
1624                ));
1625            }
1626        }
1627        // eng_frame.cpp applies SCRIPT.SET_VSYNC_WAIT_OFF_FLAG after script
1628        // processing and before the frame is presented. Keep the VM flag as the
1629        // script state and let Renderer perform the display-side transition.
1630        self.renderer
1631            .borrow_mut()
1632            .set_wait_display_vsync(!self.vm.ctx.globals.script.wait_display_vsync_off_flag);
1633        let wait_poll_needed = self.vm.ctx.wait.needs_runtime_poll();
1634        #[cfg(target_os = "horizon")]
1635        if switch_trace_enabled {
1636            crate::switch_host::report_switch_diagnostic(&format!(
1637                "siglus_switch: host-trace frame={} phase=tick-frame-begin\n",
1638                switch_trace_frame
1639            ));
1640        }
1641        #[cfg(target_os = "vita")]
1642        let start = Instant::now();
1643        self.vm.tick_frame()?;
1644        #[cfg(target_os = "vita")]
1645        crate::render::vita_stats::phase(crate::render::vita_stats::TICK, start);
1646        #[cfg(target_os = "horizon")]
1647        if switch_trace_enabled {
1648            crate::switch_host::report_switch_diagnostic(&format!(
1649                "siglus_switch: host-trace frame={} phase=tick-frame-complete\n",
1650                switch_trace_frame
1651            ));
1652        }
1653        if self.vm.take_runtime_load_completed() {
1654            self.finish_runtime_load();
1655            return Ok(());
1656        }
1657        if env_is_set!("SG_PROC_FLOW_TRACE") {
1658            eprintln!(
1659                "[SG_PROC_FLOW] host redraw after_tick scene={:?} line={} flow={:?} pending_proc={:?}",
1660                self.vm.current_scene_name(),
1661                self.vm.current_line_no(),
1662                self.flow.stack,
1663                self.vm.ctx.globals.syscom.pending_proc
1664            );
1665        }
1666        if self.vm.ctx.globals.syscom.pending_proc.is_some() {
1667            self.consume_syscom_pending_proc()?;
1668            self.ensure_requested_script_proc();
1669            self.script_needs_pump = true;
1670        }
1671        if wait_poll_needed && !self.vm.is_blocked() {
1672            self.script_needs_pump = true;
1673        }
1674        self.ensure_requested_script_proc();
1675        let render_suppressed = self.suppress_render_once;
1676        self.suppress_render_once = false;
1677        if env_is_set!("SG_PROC_FLOW_TRACE") {
1678            eprintln!(
1679                "[SG_PROC_FLOW] host redraw render_decision render_suppressed={} scene={:?} line={} flow={:?}",
1680                render_suppressed,
1681                self.vm.current_scene_name(),
1682                self.vm.current_line_no(),
1683                self.flow.stack
1684            );
1685        }
1686        if !render_suppressed {
1687            #[cfg(target_os = "horizon")]
1688            if switch_trace_enabled {
1689                crate::switch_host::report_switch_diagnostic(&format!(
1690                    "siglus_switch: host-trace frame={} phase=frame-build-begin\n",
1691                    switch_trace_frame
1692                ));
1693            }
1694            #[cfg(target_os = "vita")]
1695            let start = Instant::now();
1696            let frame = self.vm.ctx.render_frame_with_effects();
1697            #[cfg(target_os = "vita")]
1698            crate::render::vita_stats::phase(crate::render::vita_stats::BUILD, start);
1699            #[cfg(target_os = "horizon")]
1700            if switch_trace_enabled {
1701                let (wipe_type, wipe_progress, emote_count) = if let Some(wipe) = &frame.wipe {
1702                    let emotes = wipe
1703                        .under
1704                        .iter()
1705                        .chain(&wipe.current)
1706                        .chain(&wipe.next)
1707                        .chain(&wipe.over)
1708                        .filter(|item| item.sprite.emote_render.is_some())
1709                        .count();
1710                    (wipe.wipe_type, wipe.progress, emotes)
1711                } else {
1712                    let emotes = frame
1713                        .sprites
1714                        .iter()
1715                        .filter(|item| item.sprite.emote_render.is_some())
1716                        .count();
1717                    (-1, -1.0, emotes)
1718                };
1719                let movie = self.vm.ctx.movie.debug_memory_stats();
1720                crate::switch_host::report_switch_diagnostic(&format!(
1721                    "siglus_switch: host-trace frame={} phase=frame-build-complete sprites={} wipe-type={} wipe-progress={:.4} emotes={} movie-streams={} movie-frames={} movie-bytes={}\n",
1722                    switch_trace_frame,
1723                    frame.submitted_sprite_count(),
1724                    wipe_type,
1725                    wipe_progress,
1726                    emote_count,
1727                    movie.active_streams,
1728                    movie.video_frames,
1729                    movie.video_bytes,
1730                ));
1731                crate::switch_host::report_switch_diagnostic(&format!(
1732                    "siglus_switch: host-trace frame={} phase=cpu-render-begin\n",
1733                    switch_trace_frame
1734                ));
1735            }
1736            self.renderer
1737                .borrow_mut()
1738                .render_frame(&self.vm.ctx.images, &frame)?;
1739            #[cfg(target_os = "horizon")]
1740            if switch_trace_enabled {
1741                crate::switch_host::report_switch_diagnostic(&format!(
1742                    "siglus_switch: host-trace frame={} phase=cpu-render-complete\n",
1743                    switch_trace_frame
1744                ));
1745            }
1746        }
1747        if self.script_resume_after_redraw {
1748            self.script_resume_after_redraw = false;
1749            self.script_needs_pump = true;
1750        }
1751        self.redraw_count = self.redraw_count.saturating_add(1);
1752        Ok(())
1753    }
1754}
1755
1756fn vm_key_from_platform_code(code: i32) -> Option<VmKey> {
1757    match code {
1758        0x1B => Some(VmKey::Escape),
1759        0x0D => Some(VmKey::Enter),
1760        0x20 => Some(VmKey::Space),
1761        0x08 => Some(VmKey::Backspace),
1762        0x2E => Some(VmKey::Delete),
1763        0x09 => Some(VmKey::Tab),
1764        0x10 => Some(VmKey::Shift),
1765        0x11 => Some(VmKey::Control),
1766        0x5B | 0x5C => Some(VmKey::Meta),
1767        0x12 => Some(VmKey::Alt),
1768        0x24 => Some(VmKey::Home),
1769        0x23 => Some(VmKey::End),
1770        0x25 => Some(VmKey::ArrowLeft),
1771        0x26 => Some(VmKey::ArrowUp),
1772        0x27 => Some(VmKey::ArrowRight),
1773        0x28 => Some(VmKey::ArrowDown),
1774        0x30..=0x39 => Some(VmKey::Digit((code - 0x30) as u8)),
1775        0x41..=0x5A => Some(VmKey::Letter((code as u8 as char).to_ascii_uppercase())),
1776        0x61..=0x7A => Some(VmKey::Letter((code as u8 as char).to_ascii_uppercase())),
1777        // Codes 0x70..=0x7A use the lowercase ASCII mapping above.
1778        0x7B => Some(VmKey::F(12)),
1779        _ => None,
1780    }
1781}
1782
1783/// Copy an optional C string, treating null or an empty string as absent.
1784///
1785/// # Safety
1786///
1787/// If non-null, `ptr` must point to a NUL-terminated string in a single
1788/// readable allocation of at most `isize::MAX` bytes, including the terminator.
1789/// The bytes must remain valid and unchanged for this call.
1790pub unsafe fn cstr_opt(ptr: *const c_char) -> Option<String> {
1791    if ptr.is_null() {
1792        return None;
1793    }
1794    let s = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().to_string();
1795    if s.is_empty() { None } else { Some(s) }
1796}
1797
1798/// Copy a UTF-8 C string, returning an error for null or invalid UTF-8.
1799///
1800/// # Safety
1801///
1802/// If non-null, `ptr` must point to a NUL-terminated string in a single
1803/// readable allocation of at most `isize::MAX` bytes, including the terminator.
1804/// The bytes must remain valid and unchanged for this call.
1805pub unsafe fn cstr_required(ptr: *const c_char, what: &str) -> Result<String> {
1806    if ptr.is_null() {
1807        anyhow::bail!("{what} is null");
1808    }
1809    Ok(unsafe { CStr::from_ptr(ptr) }.to_str()?.to_string())
1810}
1811
1812pub fn parse_bool_exit(result: Result<bool>, context: &str) -> i32 {
1813    match result {
1814        Ok(true) => 1,
1815        Ok(false) => 0,
1816        Err(e) => {
1817            log::error!("{context}: {e:?}");
1818            1
1819        }
1820    }
1821}
1822
1823pub fn default_frame_interval_ms(dt_ms: u32) -> u32 {
1824    if dt_ms == 0 { FRAME_INTERVAL_MS } else { dt_ms }
1825}