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 std::ffi::{c_char, c_void, CStr, CString};
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use crate::platform_time::Instant;
13
14use anyhow::{Context, Result};
15use siglus_assets::gameexe::{decode_gameexe_dat_bytes, GameexeConfig, GameexeDecodeOptions};
16use siglus_assets::scene_pck::{find_scene_pck_in_project, ScenePck, ScenePckDecodeOptions};
17
18use crate::render::Renderer;
19use crate::runtime::globals::{
20    SyscomPendingProc, SyscomPendingProcKind, SystemMessageBoxButton, SystemMessageBoxModalState,
21    WipeState,
22};
23use crate::runtime::input::{VmKey, VmMouseButton};
24use crate::runtime::wait::VmWait;
25use crate::runtime::forms::syscom as syscom_form;
26use crate::runtime::{native_ui, CommandContext, ProcKind};
27use crate::scene_stream::SceneStream;
28use crate::vm::{SceneVm, VmConfig};
29
30const FRAME_INTERVAL_MS: u32 = 16;
31
32
33#[derive(Debug, Clone)]
34pub struct SiglusHostConfig {
35    pub project_dir: PathBuf,
36    pub scene_name: Option<String>,
37    pub scene_id: Option<usize>,
38    pub width: Option<u32>,
39    pub height: Option<u32>,
40}
41
42impl SiglusHostConfig {
43    pub fn new(project_dir: PathBuf) -> Self {
44        Self {
45            project_dir,
46            scene_name: None,
47            scene_id: None,
48            width: None,
49            height: None,
50        }
51    }
52}
53
54#[derive(Debug, Clone)]
55struct BootConfig {
56    start_scene: String,
57    start_z: i32,
58    menu_scene: Option<String>,
59    menu_z: i32,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum ProcType {
64    Script,
65    StartWarning,
66    SyscomWarning,
67    MsgBack,
68    ReturnToMenu,
69    GameEndWipe,
70    Disp,
71    EndGame,
72    GameTimerStart,
73    TimeWait,
74}
75
76#[derive(Debug, Clone)]
77struct ProcFrame {
78    ty: ProcType,
79    option: i32,
80    deadline_frame: Option<u32>,
81}
82
83#[derive(Debug, Default)]
84struct ProcFlow {
85    stack: Vec<ProcFrame>,
86    booted_menu: bool,
87    pending_syscom_proc: Option<SyscomPendingProc>,
88}
89
90impl ProcFlow {
91    fn push(&mut self, ty: ProcType, option: i32) {
92        self.stack.push(ProcFrame {
93            ty,
94            option,
95            deadline_frame: None,
96        });
97    }
98
99    fn pop(&mut self) {
100        let _ = self.stack.pop();
101    }
102
103    fn top(&self) -> Option<&ProcFrame> {
104        self.stack.last()
105    }
106
107    fn top_mut(&mut self) -> Option<&mut ProcFrame> {
108        self.stack.last_mut()
109    }
110}
111
112/// Button values follow SYSTEM.MESSAGEBOX_* VM semantics.
113#[repr(i32)]
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum SiglusNativeMessageBoxKind {
116    Ok = 0,
117    OkCancel = 1,
118    YesNo = 2,
119    YesNoCancel = 3,
120}
121
122impl From<native_ui::NativeMessageBoxKind> for SiglusNativeMessageBoxKind {
123    fn from(value: native_ui::NativeMessageBoxKind) -> Self {
124        match value {
125            native_ui::NativeMessageBoxKind::Ok => Self::Ok,
126            native_ui::NativeMessageBoxKind::OkCancel => Self::OkCancel,
127            native_ui::NativeMessageBoxKind::YesNo => Self::YesNo,
128            native_ui::NativeMessageBoxKind::YesNoCancel => Self::YesNoCancel,
129        }
130    }
131}
132
133/// Callback used by mobile hosts to show a native dialog on the platform UI thread.
134///
135/// All string pointers are valid only for the duration of the callback.  The host must
136/// copy them before returning if it needs to keep them.  The selected button must be
137/// delivered later with the platform-specific `siglus_*_submit_messagebox_result` ABI.
138pub type SiglusNativeMessageBoxCallback = unsafe extern "C" fn(
139    user_data: *mut c_void,
140    request_id: u64,
141    kind: i32,
142    title_utf8: *const c_char,
143    message_utf8: *const c_char,
144);
145
146struct CNativeUiBackend {
147    callback: SiglusNativeMessageBoxCallback,
148    user_data: usize,
149}
150
151unsafe impl Send for CNativeUiBackend {}
152unsafe impl Sync for CNativeUiBackend {}
153
154impl native_ui::NativeUiBackend for CNativeUiBackend {
155    fn show_system_messagebox(&self, request: native_ui::NativeMessageBoxRequest) {
156        let title = CString::new(request.title).unwrap_or_else(|_| CString::new("Siglus").unwrap());
157        let message = CString::new(request.message).unwrap_or_else(|_| CString::new("").unwrap());
158        let kind: SiglusNativeMessageBoxKind = request.kind.into();
159        unsafe {
160            (self.callback)(
161                self.user_data as *mut c_void,
162                request.request_id,
163                kind as i32,
164                title.as_ptr(),
165                message.as_ptr(),
166            );
167        }
168    }
169}
170
171pub struct SiglusHost {
172    config: SiglusHostConfig,
173    boot: BootConfig,
174    flow: ProcFlow,
175    renderer: Renderer,
176    vm: SceneVm<'static>,
177    redraw_count: u32,
178    script_needs_pump: bool,
179    script_resume_after_redraw: bool,
180    suppress_render_once: bool,
181    syscom_suspended_waits: Vec<(usize, VmWait)>,
182    paused: bool,
183    pending_exit: bool,
184    last_step: Option<Instant>,
185}
186
187
188fn find_scene_pck_for_host(project_dir: &Path) -> Result<PathBuf> {
189    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
190    {
191        for name in ["Scene.pck", "scene.pck"] {
192            let p = project_dir.join(name);
193            if crate::resource::wasm_path_is_file(&p) {
194                return Ok(p);
195            }
196        }
197        anyhow::bail!("Scene.pck not found in wasm directory");
198    }
199
200    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
201    {
202        Ok(find_scene_pck_in_project(project_dir)?)
203    }
204}
205
206fn load_key_toml_config(project_dir: &Path) -> Result<Option<siglus_assets::key_toml::KeyTomlConfig>> {
207    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
208    {
209        for name in ["key.toml", "Key.toml"] {
210            let p = project_dir.join(name);
211            if crate::resource::wasm_path_is_file(&p) {
212                let text = crate::resource::read_file_to_string(&p)?;
213                return Ok(Some(siglus_assets::key_toml::parse_key_toml(&text)?));
214            }
215        }
216        Ok(None)
217    }
218
219    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
220    {
221        Ok(siglus_assets::key_toml::load_key_toml_from_project_dir(project_dir)?)
222    }
223}
224
225fn load_gameexe_decode_options(project_dir: &Path) -> Result<GameexeDecodeOptions> {
226    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
227    {
228        let mut opt = GameexeDecodeOptions::default();
229        opt.game_angou_code = Some(siglus_assets::keys::GAMEEXE_KEY.to_vec());
230        if let Some(cfg) = load_key_toml_config(project_dir)? {
231            opt.exe_key16 = cfg.exe_key16;
232            opt.base_angou_code = cfg.base_angou_code;
233            if cfg.game_angou_code.is_some() {
234                opt.game_angou_code = cfg.game_angou_code;
235            }
236            if let Some(order) = cfg.chain_order {
237                opt.chain_order = order;
238            }
239        }
240        Ok(opt)
241    }
242
243    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
244    {
245        GameexeDecodeOptions::from_project_dir(project_dir)
246    }
247}
248
249fn load_scene_pck_decode_options(project_dir: &Path) -> Result<ScenePckDecodeOptions> {
250    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
251    {
252        let exe = load_key_toml_config(project_dir)?
253            .and_then(|cfg| cfg.exe_key16)
254            .map(|v| v.to_vec());
255        Ok(ScenePckDecodeOptions {
256            exe_angou_element: exe,
257            easy_angou_code: Some(siglus_assets::keys::SCENE_KEY.to_vec()),
258        })
259    }
260
261    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
262    {
263        ScenePckDecodeOptions::from_project_dir(project_dir)
264    }
265}
266
267impl SiglusHost {
268    pub async fn new_with_renderer(config: SiglusHostConfig, renderer: Renderer) -> Result<Self> {
269        let initial_size = Self::resolve_initial_size(&config);
270        let boot = Self::resolve_boot_config(&config);
271        let mut flow = ProcFlow::default();
272        flow.push(ProcType::Script, 0);
273        flow.push(ProcType::StartWarning, 0);
274        let vm = Self::init_vm(&config, &boot, initial_size)?;
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.ctx.submit_native_messagebox_result(request_id, value);
308        self.script_needs_pump = true;
309    }
310
311    pub fn resize(&mut self, width: u32, height: u32, scale_factor: f32) {
312        self.renderer.resize_with_scale(width, height, scale_factor.max(1.0));
313        let logical_w = ((width as f32) / scale_factor.max(1.0)).max(1.0).round() as u32;
314        let logical_h = ((height as f32) / scale_factor.max(1.0)).max(1.0).round() as u32;
315        self.vm.ctx.set_screen_size(logical_w, logical_h);
316        self.script_needs_pump = true;
317    }
318
319    pub fn resize_with_logical_viewport(
320        &mut self,
321        surface_width: u32,
322        surface_height: u32,
323        scale_factor: f32,
324        logical_width: u32,
325        logical_height: u32,
326        viewport_x: u32,
327        viewport_y: u32,
328        viewport_width: u32,
329        viewport_height: u32,
330    ) {
331        self.renderer.resize_with_logical_viewport(
332            surface_width,
333            surface_height,
334            scale_factor.max(1.0),
335            logical_width.max(1),
336            logical_height.max(1),
337            viewport_x,
338            viewport_y,
339            viewport_width.max(1),
340            viewport_height.max(1),
341        );
342        self.vm
343            .ctx
344            .set_screen_size(logical_width.max(1), logical_height.max(1));
345        self.script_needs_pump = true;
346    }
347
348    pub fn logical_size(&self) -> (u32, u32) {
349        (
350            self.vm.ctx.screen_w.max(1),
351            self.vm.ctx.screen_h.max(1),
352        )
353    }
354
355    pub fn debug_status_summary(&mut self) -> String {
356        let blocked = self.vm.is_blocked();
357        let movie_playing = self.vm.ctx.globals.mov.playing;
358        let movie_file = self.vm.ctx.globals.mov.file_name.clone();
359        let movie_timer = self.vm.ctx.globals.mov.timer_ms;
360        let movie_frame = self.vm.ctx.globals.mov.last_frame_idx;
361        format!(
362            "scene={:?} line={} pending_exit={} vm_halted={} active_flag={} flow={:?} blocked={} movie_playing={} movie_file={:?} movie_timer={} movie_frame={:?}",
363            self.vm.current_scene_name(),
364            self.vm.current_line_no(),
365            self.pending_exit,
366            self.vm.is_halted(),
367            self.vm.ctx.globals.system.active_flag,
368            self.flow.stack,
369            blocked,
370            movie_playing,
371            movie_file,
372            movie_timer,
373            movie_frame,
374        )
375    }
376
377    /// Step one frame and present when needed. Returns true if the engine requested exit.
378    pub fn step(&mut self, dt_ms: u32) -> Result<bool> {
379        let _ = dt_ms;
380        self.last_step = Some(Instant::now());
381        if self.script_needs_pump || self.vm.ctx.wait.needs_runtime_poll() {
382            self.pump_vm()?;
383        }
384        self.redraw()?;
385        Ok(self.pending_exit || (self.vm.is_halted() && self.flow.stack.is_empty()))
386    }
387
388    pub fn mouse_move(&mut self, x: f64, y: f64) {
389        self.vm.ctx.on_mouse_move(x.round() as i32, y.round() as i32);
390        self.script_needs_pump = true;
391    }
392
393    pub fn mouse_down(&mut self, button: VmMouseButton) {
394        self.vm.ctx.on_mouse_down(button);
395        self.script_needs_pump = true;
396    }
397
398    pub fn mouse_up(&mut self, button: VmMouseButton) {
399        self.vm.ctx.on_mouse_up(button);
400        self.script_needs_pump = true;
401    }
402
403    pub fn mouse_wheel(&mut self, delta_y: i32) {
404        self.vm.ctx.on_mouse_wheel(delta_y);
405        self.script_needs_pump = true;
406    }
407
408    pub fn touch(&mut self, phase: i32, x: f64, y: f64) {
409        self.mouse_move(x, y);
410        match phase {
411            0 => self.mouse_down(VmMouseButton::Left),
412            1 => {}
413            2 | 3 => self.mouse_up(VmMouseButton::Left),
414            _ => {}
415        }
416    }
417
418    pub fn key_down(&mut self, key: VmKey) {
419        self.vm.ctx.on_key_down(key);
420        self.script_needs_pump = true;
421    }
422
423    pub fn key_down_code(&mut self, code: i32) {
424        if let Some(key) = vm_key_from_platform_code(code) {
425            self.key_down(key);
426        }
427    }
428
429    pub fn key_up(&mut self, key: VmKey) {
430        self.vm.ctx.on_key_up(key);
431        self.script_needs_pump = true;
432    }
433
434    pub fn key_up_code(&mut self, code: i32) {
435        if let Some(key) = vm_key_from_platform_code(code) {
436            self.key_up(key);
437        }
438    }
439
440    pub fn text_input(&mut self, text: &str) {
441        self.vm.ctx.on_text_input(text);
442        self.script_needs_pump = true;
443    }
444
445    pub fn renderer_mut(&mut self) -> &mut Renderer {
446        &mut self.renderer
447    }
448
449    pub fn vm_mut(&mut self) -> &mut SceneVm<'static> {
450        &mut self.vm
451    }
452
453    fn resolve_initial_size(config: &SiglusHostConfig) -> (u32, u32) {
454        let cfg_size = Self::try_load_gameexe(&config.project_dir)
455            .as_ref()
456            .and_then(Self::gameexe_screen_size)
457            .unwrap_or((1280, 720));
458        (
459            config.width.unwrap_or(cfg_size.0),
460            config.height.unwrap_or(cfg_size.1),
461        )
462    }
463
464    fn gameexe_screen_size(cfg: &GameexeConfig) -> Option<(u32, u32)> {
465        let entry = cfg.get_entry("SCREEN_SIZE")?;
466        let w = entry.item_unquoted(0)?.trim().parse::<u32>().ok()?;
467        let h = entry.item_unquoted(1)?.trim().parse::<u32>().ok()?;
468        if w == 0 || h == 0 {
469            return None;
470        }
471        Some((w, h))
472    }
473
474    fn gameexe_scene_entry(cfg: &GameexeConfig, key: &str) -> Option<(String, i32)> {
475        let entry = cfg.get_entry(key)?;
476        let scene = entry.item_unquoted(0)?.trim().trim_matches('"').to_string();
477        if scene.is_empty() {
478            return None;
479        }
480        let z = entry
481            .item_unquoted(1)
482            .and_then(|s| s.trim().parse::<i32>().ok())
483            .unwrap_or(0);
484        Some((scene, z))
485    }
486
487    fn resolve_boot_config(config: &SiglusHostConfig) -> BootConfig {
488        let cfg = Self::try_load_gameexe(&config.project_dir);
489        let (default_start, default_start_z) = cfg
490            .as_ref()
491            .and_then(|cfg| Self::gameexe_scene_entry(cfg, "START_SCENE"))
492            .unwrap_or_else(|| ("_start".to_string(), 0));
493        let (menu_scene, menu_z) = cfg
494            .as_ref()
495            .and_then(|cfg| Self::gameexe_scene_entry(cfg, "MENU_SCENE"))
496            .map(|(s, z)| (Some(s), z))
497            .unwrap_or((None, 0));
498        BootConfig {
499            start_scene: config.scene_name.clone().unwrap_or(default_start),
500            start_z: default_start_z,
501            menu_scene,
502            menu_z,
503        }
504    }
505
506    fn find_gameexe_path(project_dir: &Path) -> Option<PathBuf> {
507        let candidates = [
508            "Gameexe.dat", "Gameexe.ini", "gameexe.dat", "gameexe.ini", "GameexeEN.dat",
509            "GameexeEN.ini", "GameexeZH.dat", "GameexeZH.ini", "GameexeZHTW.dat",
510            "GameexeZHTW.ini", "GameexeDE.dat", "GameexeDE.ini", "GameexeES.dat",
511            "GameexeES.ini", "GameexeFR.dat", "GameexeFR.ini", "GameexeID.dat",
512            "GameexeID.ini",
513        ];
514        for name in candidates {
515            let p = project_dir.join(name);
516            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
517            if crate::resource::wasm_path_is_file(&p) {
518                return Some(p);
519            }
520            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
521            if p.is_file() {
522                return Some(p);
523            }
524        }
525        None
526    }
527
528    fn try_load_gameexe(project_dir: &Path) -> Option<GameexeConfig> {
529        let path = Self::find_gameexe_path(project_dir)?;
530        let raw = crate::resource::read_file_bytes(&path).ok()?;
531        if path
532            .extension()
533            .and_then(|s| s.to_str())
534            .is_some_and(|ext| ext.eq_ignore_ascii_case("ini"))
535        {
536            let text = String::from_utf8(raw).ok()?;
537            return Some(GameexeConfig::from_text(&text));
538        }
539        let opt = load_gameexe_decode_options(project_dir).ok()?;
540        let (text, _report) = decode_gameexe_dat_bytes(&raw, &opt).ok()?;
541        Some(GameexeConfig::from_text(&text))
542    }
543
544    fn init_vm(
545        config: &SiglusHostConfig,
546        boot: &BootConfig,
547        initial_size: (u32, u32),
548    ) -> Result<SceneVm<'static>> {
549        let project_dir = config.project_dir.clone();
550        let scene_pck_path = find_scene_pck_for_host(&project_dir)?;
551        let opt = load_scene_pck_decode_options(&project_dir)?;
552        let pck = {
553            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
554            {
555                let bytes = crate::resource::read_file_bytes(&scene_pck_path)
556                    .with_context(|| format!("read scene.pck: {}", scene_pck_path.display()))?;
557                ScenePck::load_and_rebuild_from_bytes(bytes, &opt)
558                    .with_context(|| format!("open scene.pck: {}", scene_pck_path.display()))?
559            }
560            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
561            {
562                ScenePck::load_and_rebuild(&scene_pck_path, &opt)
563                    .with_context(|| format!("open scene.pck: {}", scene_pck_path.display()))?
564            }
565        };
566
567        let scene_no = if let Some(id) = config.scene_id {
568            id
569        } else if let Some(name) = config.scene_name.as_ref() {
570            pck.find_scene_no(name).unwrap_or(0)
571        } else {
572            pck.find_scene_no(&boot.start_scene).unwrap_or(0)
573        };
574
575        let chunk = pck
576            .scn_data_slice(scene_no)
577            .with_context(|| format!("scene_id out of range: {}", scene_no))?;
578        let chunk_leaked: &'static [u8] = Box::leak(chunk.to_vec().into_boxed_slice());
579        let mut stream = SceneStream::new(chunk_leaked)?;
580        let start_z = if config.scene_id.is_some() || config.scene_name.is_some() {
581            0
582        } else {
583            boot.start_z
584        };
585        stream.jump_to_z_label(start_z.max(0) as usize)?;
586        let mut ctx = CommandContext::new(project_dir);
587        ctx.screen_w = initial_size.0;
588        ctx.screen_h = initial_size.1;
589        let mut vm = SceneVm::with_config(VmConfig::from_env(), stream, ctx);
590        if config.scene_id.is_none() {
591            let scene_name = config
592                .scene_name
593                .clone()
594                .unwrap_or_else(|| boot.start_scene.clone());
595            vm.restart_scene_name(&scene_name, start_z)?;
596        }
597        Ok(vm)
598    }
599
600    fn suspend_wait_for_syscom_excall(&mut self, key: &str) {
601        let flow_depth = self.flow.stack.len();
602        let saved_wait = std::mem::take(&mut self.vm.ctx.wait);
603        self.vm.ctx.input.use_current();
604        self.vm.ctx.script_input.use_current();
605        self.syscom_suspended_waits.push((flow_depth, saved_wait));
606        if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
607            eprintln!(
608                "[SG_PROC_FLOW] host suspend_wait_for_syscom_excall key={} flow_depth={} saved_count={} scene={:?} line={}",
609                key,
610                flow_depth,
611                self.syscom_suspended_waits.len(),
612                self.vm.current_scene_name(),
613                self.vm.current_line_no()
614            );
615        }
616    }
617
618    fn restore_wait_after_syscom_excall(&mut self, popped_depth: usize) {
619        let should_restore = self
620            .syscom_suspended_waits
621            .last()
622            .map(|(depth, _)| *depth == popped_depth)
623            .unwrap_or(false);
624        if !should_restore {
625            return;
626        }
627        let Some((_depth, saved_wait)) = self.syscom_suspended_waits.pop() else {
628            return;
629        };
630        self.vm.ctx.wait = saved_wait;
631        self.vm.ctx.input.clear_all();
632        self.vm.ctx.script_input.clear_all();
633        if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
634            eprintln!(
635                "[SG_PROC_FLOW] host restore_wait_after_syscom_excall popped_depth={} remaining={} scene={:?} line={}",
636                popped_depth,
637                self.syscom_suspended_waits.len(),
638                self.vm.current_scene_name(),
639                self.vm.current_line_no()
640            );
641        }
642    }
643
644    fn consume_syscom_pending_proc(&mut self) -> Result<bool> {
645        let Some(proc) = self.vm.ctx.globals.syscom.pending_proc.take() else {
646            return Ok(false);
647        };
648
649        self.vm.ctx.globals.syscom.menu_open = false;
650        self.vm.ctx.globals.syscom.menu_kind = None;
651        if proc.kind != SyscomPendingProcKind::MsgBack {
652            self.vm.ctx.globals.syscom.msg_back_open = false;
653        }
654
655        if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
656            eprintln!(
657                "[SG_PROC_FLOW] host consume_syscom_pending kind={:?} before scene={:?} line={} flow={:?}",
658                proc.kind,
659                self.vm.current_scene_name(),
660                self.vm.current_line_no(),
661                self.flow.stack
662            );
663        }
664
665        match proc.kind {
666            SyscomPendingProcKind::EndGame => {
667                if proc.warning {
668                    self.begin_syscom_warning(proc);
669                } else {
670                    self.queue_end_game_proc(proc);
671                }
672                Ok(true)
673            }
674            SyscomPendingProcKind::ReturnToMenu => {
675                if proc.warning {
676                    self.begin_syscom_warning(proc);
677                } else {
678                    self.queue_return_to_menu_proc(proc);
679                }
680                Ok(true)
681            }
682            SyscomPendingProcKind::ReturnToSel => {
683                if self.vm.restore_last_sel_point() {
684                    self.flow.stack.clear();
685                    self.flow.push(ProcType::GameTimerStart, 0);
686                    self.flow.push(ProcType::Script, 0);
687                    Ok(true)
688                } else {
689                    self.vm.ctx.unknown.record_note(
690                        "SYSCOM.RETURN_TO_SEL requested without an in-memory SELPOINT snapshot",
691                    );
692                    Ok(false)
693                }
694            }
695            SyscomPendingProcKind::Save => {
696                if proc.warning {
697                    self.begin_syscom_warning(proc);
698                } else {
699                    crate::runtime::forms::syscom::menu_save_slot(
700                        &mut self.vm.ctx,
701                        false,
702                        proc.save_id.max(0) as usize,
703                    );
704                    crate::runtime::forms::syscom::write_global_save(&mut self.vm.ctx);
705                }
706                Ok(true)
707            }
708            SyscomPendingProcKind::Load => {
709                if proc.warning {
710                    self.begin_syscom_warning(proc);
711                } else {
712                    crate::runtime::forms::syscom::menu_load_slot(
713                        &mut self.vm.ctx,
714                        false,
715                        proc.save_id.max(0) as usize,
716                    );
717                }
718                Ok(true)
719            }
720            SyscomPendingProcKind::QuickSave => {
721                if proc.warning {
722                    self.begin_syscom_warning(proc);
723                } else {
724                    crate::runtime::forms::syscom::menu_save_slot(
725                        &mut self.vm.ctx,
726                        true,
727                        proc.save_id.max(0) as usize,
728                    );
729                    crate::runtime::forms::syscom::write_global_save(&mut self.vm.ctx);
730                }
731                Ok(true)
732            }
733            SyscomPendingProcKind::QuickLoad => {
734                if proc.warning {
735                    self.begin_syscom_warning(proc);
736                } else {
737                    crate::runtime::forms::syscom::menu_load_slot(
738                        &mut self.vm.ctx,
739                        true,
740                        proc.save_id.max(0) as usize,
741                    );
742                }
743                Ok(true)
744            }
745            SyscomPendingProcKind::BacklogLoad => {
746                if self.vm.restore_last_sel_point() {
747                    self.flow.stack.clear();
748                    self.flow.push(ProcType::GameTimerStart, 0);
749                    self.flow.push(ProcType::Script, 0);
750                    Ok(true)
751                } else {
752                    self.vm.ctx.unknown.record_note(&format!(
753                        "SYSCOM.MSG_BACK_LOAD requested but backlog save {} is not materialized without SAVE/LOAD support",
754                        proc.save_id
755                    ));
756                    Ok(false)
757                }
758            }
759            SyscomPendingProcKind::MsgBack => {
760                if self.vm.ctx.globals.syscom.msg_back_open {
761                    self.flow.push(ProcType::MsgBack, 0);
762                    Ok(true)
763                } else {
764                    Ok(false)
765                }
766            }
767            SyscomPendingProcKind::OpenSyscomMenu => {
768                if self.vm.call_syscom_configured_scene("CANCEL_SCENE")? {
769                    self.ensure_requested_script_proc();
770                    self.suspend_wait_for_syscom_excall("CANCEL_SCENE");
771                    Ok(true)
772                } else {
773                    log::error!("SYSCOM MENU native popup is not implemented and CANCEL_SCENE is not configured");
774                    Ok(false)
775                }
776            }
777            SyscomPendingProcKind::OpenSave => {
778                syscom_form::sync_save_slots_from_disk(&mut self.vm.ctx, false);
779                if self.vm.call_syscom_configured_scene("SAVE_SCENE")? {
780                    self.ensure_requested_script_proc();
781                    self.suspend_wait_for_syscom_excall("SAVE_SCENE");
782                    Ok(true)
783                } else {
784                    log::error!("SYSCOM SAVE native dialog is not implemented and SAVE_SCENE is not configured");
785                    Ok(false)
786                }
787            }
788            SyscomPendingProcKind::OpenLoad => {
789                syscom_form::sync_save_slots_from_disk(&mut self.vm.ctx, false);
790                if self.vm.call_syscom_configured_scene("LOAD_SCENE")? {
791                    self.ensure_requested_script_proc();
792                    self.suspend_wait_for_syscom_excall("LOAD_SCENE");
793                    Ok(true)
794                } else {
795                    log::error!("SYSCOM LOAD native dialog is not implemented and LOAD_SCENE is not configured");
796                    Ok(false)
797                }
798            }
799            SyscomPendingProcKind::OpenConfig => {
800                if self.vm.call_syscom_configured_scene("CONFIG_SCENE")? {
801                    self.ensure_requested_script_proc();
802                    self.suspend_wait_for_syscom_excall("CONFIG_SCENE");
803                    Ok(true)
804                } else {
805                    log::error!("SYSCOM CONFIG native dialog is not implemented and CONFIG_SCENE is not configured");
806                    Ok(false)
807                }
808            }
809        }
810    }
811
812    fn ensure_requested_script_proc(&mut self) {
813        let requested = self.vm.take_script_proc_request();
814        if requested {
815            if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
816                eprintln!(
817                    "[SG_PROC_FLOW] host ensure_requested_script_proc push before scene={:?} line={} flow={:?}",
818                    self.vm.current_scene_name(),
819                    self.vm.current_line_no(),
820                    self.flow.stack
821                );
822            }
823            self.flow.push(ProcType::Script, 0);
824            if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
825                eprintln!("[SG_PROC_FLOW] host ensure_requested_script_proc push after flow={:?}", self.flow.stack);
826            }
827        }
828    }
829
830    fn begin_syscom_warning(&mut self, mut proc: SyscomPendingProc) {
831        let kind = proc.kind;
832        proc.warning = false;
833        self.flow.pending_syscom_proc = Some(proc);
834        self.vm.ctx.globals.system.messagebox_modal_result = None;
835        let request_id = self.vm.ctx.native_ui.next_messagebox_request_id();
836        let buttons = vec![
837            SystemMessageBoxButton {
838                label: "YES".to_string(),
839                value: 0,
840            },
841            SystemMessageBoxButton {
842                label: "NO".to_string(),
843                value: 1,
844            },
845        ];
846        let text = self.syscom_warning_text(kind);
847        let title = self.vm.ctx.game_title();
848        if let Some(backend) = self.vm.ctx.native_ui_backend.as_ref() {
849            self.vm.ctx.globals.system.messagebox_modal = Some(SystemMessageBoxModalState {
850                request_id,
851                kind: 19,
852                text: text.clone(),
853                debug_only: false,
854                buttons: buttons.clone(),
855                cursor: 1,
856                native_pending: true,
857                complete_wait_with_value: false,
858            });
859            backend.show_system_messagebox(native_ui::NativeMessageBoxRequest {
860                request_id,
861                kind: native_ui::NativeMessageBoxKind::YesNo,
862                title,
863                message: text,
864                buttons: buttons
865                    .into_iter()
866                    .map(|button| native_ui::NativeMessageBoxButton {
867                        label: button.label,
868                        value: button.value,
869                    })
870                    .collect(),
871                debug_only: false,
872            });
873        } else {
874            self.vm.ctx.globals.system.messagebox_modal = Some(SystemMessageBoxModalState {
875                request_id,
876                kind: 19,
877                text,
878                debug_only: false,
879                buttons,
880                cursor: 1,
881                native_pending: false,
882                complete_wait_with_value: false,
883            });
884        }
885        self.flow.push(ProcType::SyscomWarning, 0);
886    }
887
888    fn syscom_warning_text(&self, kind: SyscomPendingProcKind) -> String {
889        let keys: &[&str] = match kind {
890            SyscomPendingProcKind::EndGame => &[
891                "#WARNINGINFO.GAMEEND_WARNING_STR",
892                "WARNINGINFO.GAMEEND_WARNING_STR",
893            ],
894            SyscomPendingProcKind::ReturnToSel => &[
895                "#WARNINGINFO.RETURNSEL_WARNING_STR",
896                "WARNINGINFO.RETURNSEL_WARNING_STR",
897                "#WARNINGINFO.RETURNMENU_WARNING_STR",
898                "WARNINGINFO.RETURNMENU_WARNING_STR",
899            ],
900            SyscomPendingProcKind::Save | SyscomPendingProcKind::QuickSave => &[
901                "#WARNINGINFO.SAVE_WARNING_STR",
902                "WARNINGINFO.SAVE_WARNING_STR",
903            ],
904            SyscomPendingProcKind::Load | SyscomPendingProcKind::QuickLoad => &[
905                "#WARNINGINFO.LOAD_WARNING_STR",
906                "WARNINGINFO.LOAD_WARNING_STR",
907            ],
908            _ => &[
909                "#WARNINGINFO.RETURNMENU_WARNING_STR",
910                "WARNINGINFO.RETURNMENU_WARNING_STR",
911            ],
912        };
913        let default = match kind {
914            SyscomPendingProcKind::EndGame => "終了してもよろしいですか?",
915            SyscomPendingProcKind::ReturnToSel => "前の選択肢に戻ってもよろしいですか?",
916            SyscomPendingProcKind::Save | SyscomPendingProcKind::QuickSave => "セーブデータを上書きしてもよろしいですか?",
917            SyscomPendingProcKind::Load | SyscomPendingProcKind::QuickLoad => "セーブデータをロードしてもよろしいですか?",
918            _ => "タイトルに戻ってもよろしいですか?",
919        };
920        let cfg = self.vm.ctx.tables.gameexe.as_ref();
921        keys.iter()
922            .find_map(|key| cfg.and_then(|c| c.get_unquoted(key)))
923            .filter(|s| !s.is_empty())
924            .map(str::to_string)
925            .unwrap_or_else(|| default.to_string())
926    }
927
928    fn return_to_menu_warning_text(&self) -> String {
929        let cfg = self.vm.ctx.tables.gameexe.as_ref();
930        ["#WARNINGINFO.RETURNMENU_WARNING_STR", "WARNINGINFO.RETURNMENU_WARNING_STR"]
931            .iter()
932            .find_map(|key| cfg.and_then(|c| c.get_unquoted(key)))
933            .filter(|s| !s.is_empty())
934            .map(str::to_string)
935            .unwrap_or_else(|| "タイトルに戻ってもよろしいですか?".to_string())
936    }
937
938    fn load_wipe_params(&self) -> (i32, i32) {
939        fn parse_pair(raw: &str) -> Option<(i32, i32)> {
940            let nums: Vec<i32> = raw
941                .split(|c: char| !(c == '-' || c.is_ascii_digit()))
942                .filter(|s| !s.is_empty())
943                .filter_map(|s| s.parse::<i32>().ok())
944                .collect();
945            if nums.len() >= 2 {
946                Some((nums[0], nums[1]))
947            } else {
948                None
949            }
950        }
951        let cfg = self.vm.ctx.tables.gameexe.as_ref();
952        for key in ["LOAD.WIPE", "LOAD . WIPE", "#LOAD.WIPE", "#LOAD . WIPE"] {
953            if let Some(pair) = cfg.and_then(|c| c.get_value(key)).and_then(parse_pair) {
954                return pair;
955            }
956        }
957        (0, 1000)
958    }
959
960    fn queue_end_game_proc(&mut self, proc: SyscomPendingProc) {
961        self.flow.pending_syscom_proc = None;
962        self.flow.push(ProcType::EndGame, 0);
963        if proc.fade_out {
964            self.flow.push(ProcType::GameEndWipe, 0);
965            self.flow.push(ProcType::Disp, 0);
966        } else {
967            self.flow.push(ProcType::Disp, 0);
968        }
969    }
970
971    fn queue_return_to_menu_proc(&mut self, proc: SyscomPendingProc) {
972        let option = if proc.leave_msgbk { 1 } else { 0 };
973        self.flow.pending_syscom_proc = Some(proc.clone());
974        self.flow.push(ProcType::ReturnToMenu, option);
975        if proc.fade_out {
976            self.flow.push(ProcType::GameEndWipe, 0);
977            self.flow.push(ProcType::Disp, 0);
978        }
979    }
980
981    fn start_game_end_wipe(&mut self) {
982        let (wipe_type, wipe_time) = self.load_wipe_params();
983        self.vm.ctx.globals.start_wipe(WipeState::new(
984            None,
985            None,
986            wipe_type,
987            wipe_time,
988            0,
989            0,
990            Vec::new(),
991            i32::MIN,
992            i32::MAX,
993            i32::MIN,
994            i32::MAX,
995            false,
996            0,
997            0,
998        ));
999    }
1000
1001    fn finish_runtime_load(&mut self) {
1002        self.renderer.clear_runtime_image_textures();
1003        self.flow.stack.clear();
1004        self.flow.pending_syscom_proc = None;
1005        self.syscom_suspended_waits.clear();
1006        self.paused = false;
1007        self.script_resume_after_redraw = false;
1008        self.suppress_render_once = true;
1009        self.vm.ctx.globals.syscom.pending_proc = None;
1010        self.vm.ctx.globals.syscom.menu_open = false;
1011        self.vm.ctx.globals.syscom.menu_kind = None;
1012        self.vm.ctx.globals.syscom.msg_back_open = false;
1013        self.flow.push(ProcType::GameTimerStart, 0);
1014        self.flow.push(ProcType::Script, 0);
1015        self.script_needs_pump = true;
1016    }
1017
1018    fn perform_return_to_menu(&mut self, leave_msgbk: bool) -> Result<()> {
1019        let target_scene = self
1020            .boot
1021            .menu_scene
1022            .as_deref()
1023            .unwrap_or(self.boot.start_scene.as_str())
1024            .to_string();
1025        let target_z = if self.boot.menu_scene.is_some() {
1026            self.boot.menu_z
1027        } else {
1028            self.boot.start_z
1029        };
1030        let saved_msgbk = if leave_msgbk {
1031            Some(self.vm.ctx.globals.msgbk_forms.clone())
1032        } else {
1033            None
1034        };
1035        self.vm.restart_scene_name(&target_scene, target_z)?;
1036        self.renderer.clear_runtime_image_textures();
1037        if let Some(msgbk) = saved_msgbk {
1038            self.vm.ctx.globals.msgbk_forms = msgbk;
1039        }
1040        self.vm.ctx.globals.finish_wipe();
1041        self.flow.stack.clear();
1042        self.flow.pending_syscom_proc = None;
1043        self.flow.booted_menu = true;
1044        self.flow.push(ProcType::GameTimerStart, 0);
1045        self.flow.push(ProcType::Script, 0);
1046        Ok(())
1047    }
1048
1049    fn pump_vm(&mut self) -> Result<()> {
1050        if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
1051            eprintln!(
1052                "[SG_PROC_FLOW] host pump_vm start paused={} script_needs_pump={} scene={:?} line={} flow={:?} pending_proc={:?}",
1053                self.paused,
1054                self.script_needs_pump,
1055                self.vm.current_scene_name(),
1056                self.vm.current_line_no(),
1057                self.flow.stack,
1058                self.vm.ctx.globals.syscom.pending_proc
1059            );
1060        }
1061        self.script_needs_pump = false;
1062        self.ensure_requested_script_proc();
1063        if self.paused {
1064            return Ok(());
1065        }
1066
1067        self.vm.process_pending_button_actions()?;
1068        if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
1069            eprintln!(
1070                "[SG_PROC_FLOW] host pump_vm after_process_button_actions scene={:?} line={} flow={:?} pending_proc={:?}",
1071                self.vm.current_scene_name(),
1072                self.vm.current_line_no(),
1073                self.flow.stack,
1074                self.vm.ctx.globals.syscom.pending_proc
1075            );
1076        }
1077        if self.vm.ctx.globals.syscom.pending_proc.is_some() {
1078            self.consume_syscom_pending_proc()?;
1079            self.ensure_requested_script_proc();
1080        }
1081
1082        self.vm.begin_script_proc_pump();
1083
1084        loop {
1085            let Some(proc) = self.flow.top().cloned() else {
1086                self.paused = true;
1087                break;
1088            };
1089            if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
1090                eprintln!(
1091                    "[SG_PROC_FLOW] host pump_vm loop top proc={:?} scene={:?} line={} flow={:?}",
1092                    proc,
1093                    self.vm.current_scene_name(),
1094                    self.vm.current_line_no(),
1095                    self.flow.stack
1096                );
1097            }
1098
1099            match proc.ty {
1100                ProcType::Script => {
1101                    let proc_gen_before = self.vm.proc_generation();
1102                    let running = self.vm.run_script_proc_continue()?;
1103                    if self.vm.take_runtime_load_completed() {
1104                        self.finish_runtime_load();
1105                        continue;
1106                    }
1107                    let proc_boundary = self.vm.proc_generation() != proc_gen_before;
1108                    let boundary_kind = self.vm.last_proc_kind();
1109                    let pop_script_proc = self.vm.take_script_proc_pop_request();
1110                    let halted = self.vm.is_halted();
1111                    let cur_scene = self
1112                        .vm
1113                        .current_scene_name()
1114                        .map(|s| s.to_string())
1115                        .unwrap_or_else(|| self.boot.start_scene.clone());
1116                    let pending = self.vm.ctx.globals.syscom.pending_proc.is_some();
1117                    let blocked = if pending { false } else { self.vm.is_blocked() };
1118
1119                    self.ensure_requested_script_proc();
1120                    if pop_script_proc {
1121                        let popped_depth = self.flow.stack.len();
1122                        self.flow.pop();
1123                        self.restore_wait_after_syscom_excall(popped_depth);
1124                        continue;
1125                    }
1126                    if !running || halted {
1127                        self.flow.pop();
1128                        if !self.flow.booted_menu
1129                            && cur_scene == self.boot.start_scene
1130                            && self.boot.menu_scene.is_some()
1131                        {
1132                            self.flow.push(ProcType::ReturnToMenu, 0);
1133                        }
1134                        continue;
1135                    }
1136                    if pending {
1137                        if self.consume_syscom_pending_proc()? {
1138                            continue;
1139                        }
1140                        if self.vm.is_blocked() {
1141                            break;
1142                        }
1143                    } else if proc_boundary {
1144                        match boundary_kind {
1145                            ProcKind::Disp => {
1146                                self.script_resume_after_redraw = true;
1147                                break;
1148                            }
1149                            ProcKind::Frame => {
1150                                self.script_resume_after_redraw = true;
1151                                self.suppress_render_once = true;
1152                                break;
1153                            }
1154                            ProcKind::Command
1155                            | ProcKind::MessageBlock
1156                            | ProcKind::MessageWait
1157                            | ProcKind::KeyWait
1158                            | ProcKind::TimeWait
1159                            | ProcKind::MovieWait
1160                            | ProcKind::WipeWait
1161                            | ProcKind::AudioWait
1162                            | ProcKind::EventWait
1163                            | ProcKind::Selection
1164                            | ProcKind::SystemModal
1165                            | ProcKind::Script => {
1166                                if blocked {
1167                                    break;
1168                                }
1169                                continue;
1170                            }
1171                        }
1172                    } else if blocked {
1173                        break;
1174                    }
1175                }
1176                ProcType::StartWarning => {
1177                    let warning_exists = self
1178                        .vm
1179                        .ctx
1180                        .images
1181                        .project_dir()
1182                        .join("g00")
1183                        .join("___SYSEVE_WARNING.g00")
1184                        .exists()
1185                        || self
1186                            .vm
1187                            .ctx
1188                            .images
1189                            .project_dir()
1190                            .join("g00")
1191                            .join("___SYSEVE_WARNING.g01")
1192                            .exists();
1193                    if !warning_exists {
1194                        self.flow.pop();
1195                        continue;
1196                    }
1197                    let cur = self.redraw_count;
1198                    let top = self.flow.top_mut().expect("proc top");
1199                    match top.option {
1200                        0 => {
1201                            top.option = 1;
1202                            self.flow.push(ProcType::TimeWait, 0);
1203                            if let Some(wait) = self.flow.top_mut() {
1204                                wait.deadline_frame = Some(cur.saturating_add(60));
1205                            }
1206                        }
1207                        _ => {
1208                            self.flow.pop();
1209                        }
1210                    }
1211                    break;
1212                }
1213                ProcType::SyscomWarning => {
1214                    if self.vm.ctx.globals.system.messagebox_modal.is_some() {
1215                        break;
1216                    }
1217                    let result = self
1218                        .vm
1219                        .ctx
1220                        .globals
1221                        .system
1222                        .messagebox_modal_result
1223                        .take()
1224                        .unwrap_or(1);
1225                    let pending = self.flow.pending_syscom_proc.take();
1226                    self.flow.pop();
1227                    if result == 0 {
1228                        if let Some(proc) = pending {
1229                            match proc.kind {
1230                                SyscomPendingProcKind::EndGame => {
1231                                    self.queue_end_game_proc(proc);
1232                                }
1233                                SyscomPendingProcKind::ReturnToMenu => {
1234                                    self.queue_return_to_menu_proc(proc);
1235                                }
1236                                SyscomPendingProcKind::Save => {
1237                                    crate::runtime::forms::syscom::menu_save_slot(&mut self.vm.ctx, false, proc.save_id.max(0) as usize);
1238                                    crate::runtime::forms::syscom::write_global_save(&mut self.vm.ctx);
1239                                }
1240                                SyscomPendingProcKind::Load => {
1241                                    crate::runtime::forms::syscom::menu_load_slot(&mut self.vm.ctx, false, proc.save_id.max(0) as usize);
1242                                }
1243                                SyscomPendingProcKind::QuickSave => {
1244                                    crate::runtime::forms::syscom::menu_save_slot(&mut self.vm.ctx, true, proc.save_id.max(0) as usize);
1245                                    crate::runtime::forms::syscom::write_global_save(&mut self.vm.ctx);
1246                                }
1247                                SyscomPendingProcKind::QuickLoad => {
1248                                    crate::runtime::forms::syscom::menu_load_slot(&mut self.vm.ctx, true, proc.save_id.max(0) as usize);
1249                                }
1250                                _ => {}
1251                            }
1252                        }
1253                    }
1254                    continue;
1255                }
1256                ProcType::MsgBack => {
1257                    if !self.vm.ctx.globals.syscom.msg_back_open {
1258                        self.flow.pop();
1259                        continue;
1260                    }
1261                    break;
1262                }
1263                ProcType::Disp => {
1264                    self.flow.pop();
1265                    self.script_resume_after_redraw = true;
1266                    break;
1267                }
1268                ProcType::GameEndWipe => {
1269                    let mut start = false;
1270                    if let Some(top) = self.flow.top_mut() {
1271                        if top.option == 0 {
1272                            top.option = 1;
1273                            start = true;
1274                        }
1275                    }
1276                    if start {
1277                        self.start_game_end_wipe();
1278                        break;
1279                    }
1280                    if self.vm.ctx.globals.wipe_done() {
1281                        self.flow.pop();
1282                        continue;
1283                    }
1284                    break;
1285                }
1286                ProcType::ReturnToMenu => {
1287                    let leave_msgbk = proc.option != 0;
1288                    self.perform_return_to_menu(leave_msgbk)?;
1289                    continue;
1290                }
1291                ProcType::EndGame => {
1292                    self.flow.pop();
1293                    self.vm.ctx.globals.system.active_flag = false;
1294                    continue;
1295                }
1296                ProcType::GameTimerStart => {
1297                    self.flow.pop();
1298                    continue;
1299                }
1300                ProcType::TimeWait => {
1301                    let deadline = proc.deadline_frame.unwrap_or(self.redraw_count);
1302                    if self.redraw_count >= deadline {
1303                        self.flow.pop();
1304                        continue;
1305                    }
1306                    break;
1307                }
1308            }
1309            break;
1310        }
1311        Ok(())
1312    }
1313
1314    fn redraw(&mut self) -> Result<()> {
1315        if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
1316            eprintln!(
1317                "[SG_PROC_FLOW] host redraw start scene={:?} line={} flow={:?} pending_proc={:?}",
1318                self.vm.current_scene_name(),
1319                self.vm.current_line_no(),
1320                self.flow.stack,
1321                self.vm.ctx.globals.syscom.pending_proc
1322            );
1323        }
1324        // Match the original C++ frame order: script/input processing runs before
1325        // element frame evaluation and rendering.  If an input event woke the script,
1326        // pump it here before tick_frame(), otherwise the redraw for the same input
1327        // can show stale pre-script object/event state for one frame.
1328        if self.script_needs_pump {
1329            self.pump_vm()?;
1330        }
1331        let wait_poll_needed = self.vm.ctx.wait.needs_runtime_poll();
1332        self.vm.tick_frame()?;
1333        if self.vm.take_runtime_load_completed() {
1334            self.finish_runtime_load();
1335            return Ok(());
1336        }
1337        if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
1338            eprintln!(
1339                "[SG_PROC_FLOW] host redraw after_tick scene={:?} line={} flow={:?} pending_proc={:?}",
1340                self.vm.current_scene_name(),
1341                self.vm.current_line_no(),
1342                self.flow.stack,
1343                self.vm.ctx.globals.syscom.pending_proc
1344            );
1345        }
1346        if self.vm.ctx.globals.syscom.pending_proc.is_some() {
1347            self.consume_syscom_pending_proc()?;
1348            self.ensure_requested_script_proc();
1349            self.script_needs_pump = true;
1350        }
1351        if wait_poll_needed && !self.vm.is_blocked() {
1352            self.script_needs_pump = true;
1353        }
1354        self.ensure_requested_script_proc();
1355        let render_suppressed = self.suppress_render_once;
1356        self.suppress_render_once = false;
1357        if std::env::var_os("SG_PROC_FLOW_TRACE").is_some() {
1358            eprintln!(
1359                "[SG_PROC_FLOW] host redraw render_decision render_suppressed={} scene={:?} line={} flow={:?}",
1360                render_suppressed,
1361                self.vm.current_scene_name(),
1362                self.vm.current_line_no(),
1363                self.flow.stack
1364            );
1365        }
1366        if !render_suppressed {
1367            let list = self.vm.ctx.render_list_with_effects();
1368            self.renderer.render_sprites(&self.vm.ctx.images, &list)?;
1369        }
1370        if self.script_resume_after_redraw {
1371            self.script_resume_after_redraw = false;
1372            self.script_needs_pump = true;
1373        }
1374        self.redraw_count = self.redraw_count.saturating_add(1);
1375        Ok(())
1376    }
1377}
1378
1379fn vm_key_from_platform_code(code: i32) -> Option<VmKey> {
1380    match code {
1381        0x1B => Some(VmKey::Escape),
1382        0x0D => Some(VmKey::Enter),
1383        0x20 => Some(VmKey::Space),
1384        0x08 => Some(VmKey::Backspace),
1385        0x09 => Some(VmKey::Tab),
1386        0x10 => Some(VmKey::Shift),
1387        0x12 => Some(VmKey::Alt),
1388        0x25 => Some(VmKey::ArrowLeft),
1389        0x26 => Some(VmKey::ArrowUp),
1390        0x27 => Some(VmKey::ArrowRight),
1391        0x28 => Some(VmKey::ArrowDown),
1392        0x30..=0x39 => Some(VmKey::Digit((code - 0x30) as u8)),
1393        0x41..=0x5A => Some(VmKey::Letter((code as u8 as char).to_ascii_uppercase())),
1394        0x61..=0x7A => Some(VmKey::Letter((code as u8 as char).to_ascii_uppercase())),
1395        0x70..=0x7B => Some(VmKey::F((code - 0x6F) as u8)),
1396        _ => None,
1397    }
1398}
1399
1400pub unsafe fn cstr_opt(ptr: *const c_char) -> Option<String> {
1401    if ptr.is_null() {
1402        return None;
1403    }
1404    let s = CStr::from_ptr(ptr).to_string_lossy().to_string();
1405    if s.is_empty() { None } else { Some(s) }
1406}
1407
1408pub unsafe fn cstr_required(ptr: *const c_char, what: &str) -> Result<String> {
1409    if ptr.is_null() {
1410        anyhow::bail!("{what} is null");
1411    }
1412    Ok(CStr::from_ptr(ptr).to_str()?.to_string())
1413}
1414
1415pub fn parse_bool_exit(result: Result<bool>, context: &str) -> i32 {
1416    match result {
1417        Ok(true) => 1,
1418        Ok(false) => 0,
1419        Err(e) => {
1420            log::error!("{context}: {e:?}");
1421            1
1422        }
1423    }
1424}
1425
1426pub fn default_frame_interval_ms(dt_ms: u32) -> u32 {
1427    if dt_ms == 0 { FRAME_INTERVAL_MS } else { dt_ms }
1428}