Skip to main content

siglus_scene_vm/vm/
close.rs

1//! Discover a game's standalone exit action without relying on script names.
2
3use super::*;
4use crate::runtime::forms::codes;
5use std::collections::{BTreeMap, BTreeSet};
6
7fn word(bytes: &[u8], offset: usize) -> Option<i32> {
8    Some(i32::from_le_bytes(
9        bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?,
10    ))
11}
12
13fn pushed_int(stream: &SceneStream<'_>, pc: usize) -> Option<i32> {
14    (stream.scn.get(pc) == Some(&CD_PUSH) && word(stream.scn, pc + 1) == Some(codes::FM_INT))
15        .then(|| word(stream.scn, pc + 5))
16        .flatten()
17}
18
19// Only accept a bounded, parameterless routine with a conditional confirmation
20// path, a RETURN path, and a direct syscom.end_game call. Reject mixed system
21// dispatchers (save/load/return-to-title/etc.) and branches outside the routine.
22// This deliberately leaves unfamiliar or ambiguous scripts to the fallback UI.
23fn is_close_routine(stream: &SceneStream<'_>, start: usize, end: usize, command: bool) -> bool {
24    let inspect = || -> Option<bool> {
25        if command {
26            let mut pc = start;
27            while stream.scn.get(pc) == Some(&CD_NL) {
28                pc = stream.instruction_end(pc)?;
29            }
30            // Parameters are declared with DEC_PROP before ARG.
31            if stream.scn.get(pc) != Some(&CD_ARG) {
32                return Some(false);
33            }
34        }
35        let mut pending = vec![start];
36        let mut visited = BTreeSet::new();
37        let mut exit = false;
38        let mut returns = false;
39        let mut conditional = false;
40        while let Some(pc) = pending.pop() {
41            if pc < start || pc >= end {
42                return Some(false);
43            }
44            if !visited.insert(pc) {
45                continue;
46            }
47            let opcode = *stream.scn.get(pc)?;
48            let next = stream.instruction_end(pc)?;
49            if next > end {
50                return Some(false);
51            }
52            if opcode == CD_ELM_POINT {
53                if let Some(root) = pushed_int(stream, next) {
54                    if root == codes::ELM_GLOBAL_SYSCOM {
55                        let op = pushed_int(stream, next + 9)?;
56                        if op != codes::syscom_op::END_GAME {
57                            return Some(false);
58                        }
59                        // Match a call, not merely a reference to the member.
60                        let mut call_pc = next + 18;
61                        while stream.scn.get(call_pc) == Some(&CD_PUSH) {
62                            call_pc = stream.instruction_end(call_pc)?;
63                        }
64                        if stream.scn.get(call_pc) != Some(&CD_COMMAND)
65                            || stream.instruction_end(call_pc)? > end
66                        {
67                            return Some(false);
68                        }
69                        exit = true;
70                        // END_GAME transfers control to the host; the script
71                        // need not put a RETURN after its final exit branch.
72                        continue;
73                    } else if matches!(root, codes::ELM_GLOBAL_RETURNMENU | codes::ELM_GLOBAL_JUMP)
74                    {
75                        return Some(false);
76                    } else if root == codes::ELM_GLOBAL_EXCALL {
77                        // Discovery runs without an allocated EXCALL menu. A
78                        // cancel-menu callback can look like an exit action
79                        // while still accessing that menu's buttons or locals.
80                        // Only the allocation/call status queries are safe;
81                        // indexed or computed access is not proven standalone.
82                        let op = pushed_int(stream, next + 9)?;
83                        if !matches!(op, codes::excall_op::OP_8 | codes::excall_op::OP_12) {
84                            return Some(false);
85                        }
86                    }
87                }
88            }
89            match opcode {
90                CD_RETURN => {
91                    returns = true;
92                }
93                CD_EOF | CD_TEXT => return Some(false),
94                CD_GOTO | CD_GOTO_TRUE | CD_GOTO_FALSE | CD_GOSUB | CD_GOSUBSTR => {
95                    let label = usize::try_from(word(stream.scn, pc + 1)?).ok()?;
96                    let target =
97                        usize::try_from(word(stream.label_list, label.checked_mul(4)?)?).ok()?;
98                    pending.push(target);
99                    if opcode != CD_GOTO {
100                        pending.push(next);
101                    }
102                    conditional |= matches!(opcode, CD_GOTO_TRUE | CD_GOTO_FALSE);
103                }
104                _ => pending.push(next),
105            }
106        }
107        Some(exit && returns && conditional)
108    };
109    inspect().unwrap_or(false)
110}
111
112#[derive(Clone, Debug)]
113enum CloseEntry {
114    Scene { scene: String, z: i32 },
115    Command(ResolvedUserCommand),
116    Local { offset: usize },
117}
118
119// Include every GOSUB target as a boundary, but only consider targets whose
120// callers pass no arguments. Ordinary branch labels are not routine entries.
121fn local_subroutines(stream: &SceneStream<'_>) -> Option<BTreeMap<usize, bool>> {
122    let mut entries = BTreeMap::new();
123    let mut pc = 0;
124    while pc < stream.scn.len() {
125        if matches!(stream.scn[pc], CD_GOSUB | CD_GOSUBSTR) {
126            let label = usize::try_from(word(stream.scn, pc + 1)?).ok()?;
127            let offset = usize::try_from(word(stream.label_list, label.checked_mul(4)?)?).ok()?;
128            let parameterless = word(stream.scn, pc + 5)? == 0;
129            entries
130                .entry(offset)
131                .and_modify(|value| *value &= parameterless)
132                .or_insert(parameterless);
133        }
134        pc = stream.instruction_end(pc)?;
135    }
136    Some(entries)
137}
138
139impl SceneVm<'_> {
140    /// Use a configured CLOSE_SCENE, or discover a unique standalone exit
141    /// action among the game's cancel-menu labels, shared user commands, and
142    /// the active title menu's local subroutines once its buttons are ready.
143    /// The host must suspend/resume its wait using the SCRIPT proc requests.
144    pub fn call_game_close_scene(&mut self) -> Result<bool> {
145        if self.ctx.excall_state.ready || self.ctx.excall_state.ex_call_flag {
146            return Ok(false);
147        }
148        if self.call_syscom_configured_scene("CLOSE_SCENE")? {
149            self.ctx.excall_state.ex_call_flag = false;
150            return Ok(true);
151        }
152        let Some(entry) = self.discover_close_entry()? else {
153            return Ok(false);
154        };
155        log::debug!("game close action: {entry:?}");
156        let opened = match entry {
157            CloseEntry::Scene { scene, z } => self.call_syscom_scene(&scene, z).map(|()| true),
158            CloseEntry::Command(command) => {
159                self.enter_resolved_user_command(&command, self.cfg.fm_void, &[], true, false)
160            }
161            CloseEntry::Local { offset } => self.enter_current_scene_user_cmd_proc_at_offset(
162                offset,
163                self.cfg.fm_void,
164                &[],
165                true,
166                false,
167            ),
168        }?;
169        // The host needs a separate SCRIPT proc to suspend/resume its wait,
170        // but these actions normally run on the game's regular stage. Setting
171        // EXCALL's input flag without allocated EXCALL objects disables all
172        // normal-stage buttons, including the confirmation's Yes/No buttons.
173        if opened {
174            self.ctx.excall_state.ex_call_flag = false;
175        }
176        Ok(opened)
177    }
178
179    fn discover_close_entry(&mut self) -> Result<Option<CloseEntry>> {
180        self.ensure_scene_pck_cache()?;
181        let pck = self.scene_pck_cache.as_ref().unwrap();
182        let mut entries: BTreeMap<usize, BTreeMap<usize, CloseEntry>> = BTreeMap::new();
183        for (index, target) in pck.inc_cmds.iter().enumerate() {
184            if target.scn_no < 0 || target.offset < 0 {
185                continue;
186            }
187            entries.entry(target.scn_no as usize).or_default().insert(
188                target.offset as usize,
189                CloseEntry::Command(ResolvedUserCommand {
190                    encoded_no: index,
191                    name: pck
192                        .inc_cmd_name_map
193                        .get(&(index as u32))
194                        .cloned()
195                        .unwrap_or_default(),
196                    target_scene_no: target.scn_no as usize,
197                    target_offset: target.offset as usize,
198                    include_command: true,
199                }),
200            );
201        }
202        let cancel_scene = self
203            .ctx
204            .tables
205            .gameexe
206            .as_ref()
207            .and_then(|cfg| cfg.get_entry("CANCEL_SCENE"))
208            .and_then(|entry| entry.item_unquoted(0))
209            .map(str::to_string);
210        if let Some(scene) = cancel_scene {
211            if let Some(scene_no) = Self::find_scene_no_by_name(pck, &scene) {
212                let stream = self.cached_scene_stream(scene_no)?;
213                for (z, offset) in stream.z_label_list.chunks_exact(4).enumerate() {
214                    let offset = i32::from_le_bytes(offset.try_into().unwrap());
215                    if offset < 0 {
216                        continue;
217                    }
218                    entries
219                        .entry(scene_no)
220                        .or_default()
221                        .entry(offset as usize)
222                        .or_insert_with(|| CloseEntry::Scene {
223                            scene: scene.clone(),
224                            z: z as i32,
225                        });
226                }
227            }
228        }
229        // A title button's local callback may use title-owned objects. Never
230        // borrow it from another scene, or during title initialization.
231        let active_title = self.ctx.wait.button_selection_waiting()
232            && self
233                .ctx
234                .tables
235                .gameexe
236                .as_ref()
237                .and_then(|cfg| cfg.get_entry("MENU_SCENE"))
238                .and_then(|entry| entry.item_unquoted(0))
239                .zip(self.current_scene_name())
240                .is_some_and(|(menu, current)| siglus_name_eq(menu, current));
241        let local_entries = if active_title {
242            local_subroutines(&self.stream).unwrap_or_default()
243        } else {
244            BTreeMap::new()
245        };
246        if let Some(scene_no) = self.current_scene_no {
247            for (&offset, &parameterless) in &local_entries {
248                if parameterless {
249                    entries
250                        .entry(scene_no)
251                        .or_default()
252                        .entry(offset)
253                        .or_insert(CloseEntry::Local { offset });
254                }
255            }
256        }
257        let mut found = None;
258        for (scene_no, candidates) in entries {
259            let stream = self.cached_scene_stream(scene_no)?;
260            let mut boundaries: BTreeSet<usize> = candidates.keys().copied().collect();
261            boundaries.insert(stream.scn.len());
262            if self.current_scene_no == Some(scene_no) {
263                boundaries.extend(local_entries.keys().copied());
264            }
265            for z in stream.z_label_list.chunks_exact(4) {
266                if let Ok(offset) = usize::try_from(i32::from_le_bytes(z.try_into().unwrap())) {
267                    boundaries.insert(offset);
268                }
269            }
270            for cmd in 0..stream.header.scn_cmd_cnt.max(0) as usize {
271                if let Ok(offset) = stream.scn_cmd_offset(cmd) {
272                    boundaries.insert(offset);
273                }
274            }
275            for (start, entry) in candidates {
276                let Some(&end) = boundaries
277                    .range((std::ops::Bound::Excluded(start), std::ops::Bound::Unbounded))
278                    .next()
279                else {
280                    continue;
281                };
282                if is_close_routine(&stream, start, end, matches!(entry, CloseEntry::Command(_))) {
283                    if found.is_some() {
284                        return Ok(None);
285                    }
286                    found = Some(entry);
287                }
288            }
289        }
290        Ok(found)
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use std::path::PathBuf;
298
299    fn push_int(code: &mut Vec<u8>, value: i32) {
300        code.push(CD_PUSH);
301        code.extend(codes::FM_INT.to_le_bytes());
302        code.extend(value.to_le_bytes());
303    }
304
305    fn conditional_exit(op: i32, parameters: bool, branch_outside: bool) -> SceneStream<'static> {
306        conditional_exit_with_prefix(op, parameters, branch_outside, &[])
307    }
308
309    fn conditional_exit_with_prefix(
310        op: i32,
311        parameters: bool,
312        branch_outside: bool,
313        prefix: &[u8],
314    ) -> SceneStream<'static> {
315        let mut code = Vec::new();
316        if parameters {
317            code.push(CD_DEC_PROP);
318            code.extend(codes::FM_INT.to_le_bytes());
319            code.extend(0i32.to_le_bytes());
320        }
321        code.push(CD_ARG);
322        code.extend(prefix);
323        push_int(&mut code, 1);
324        code.push(CD_GOTO_FALSE);
325        code.extend(0i32.to_le_bytes());
326        code.push(CD_ELM_POINT);
327        push_int(&mut code, codes::ELM_GLOBAL_SYSCOM);
328        push_int(&mut code, op);
329        push_int(&mut code, 0);
330        code.push(CD_COMMAND);
331        for value in [0i32, 1, codes::FM_INT, 0, codes::FM_VOID] {
332            code.extend(value.to_le_bytes());
333        }
334        let return_pc = code.len();
335        code.push(CD_RETURN);
336        code.extend(0i32.to_le_bytes());
337        let mut header = [0i32; 33];
338        for index in (1..33).step_by(2).chain(std::iter::once(0)) {
339            header[index] = 132;
340        }
341        header[2] = code.len() as i32;
342        header[7] = 132 + header[2];
343        header[8] = 1;
344        let mut chunk: Vec<u8> = header.into_iter().flat_map(i32::to_le_bytes).collect();
345        chunk.extend(code);
346        chunk.extend(
347            (if branch_outside {
348                header[2] + 10
349            } else {
350                return_pc as i32
351            })
352            .to_le_bytes(),
353        );
354        SceneStream::new(Box::leak(chunk.into_boxed_slice())).unwrap()
355    }
356
357    #[test]
358    fn accepts_conditional_parameterless_exit() {
359        let stream = conditional_exit(codes::syscom_op::END_GAME, false, false);
360        assert!(is_close_routine(&stream, 0, stream.scn.len(), true));
361    }
362
363    #[test]
364    fn rejects_parameterized_mixed_or_out_of_bounds_actions() {
365        for (op, parameters, outside) in [
366            (codes::syscom_op::END_GAME, true, false),
367            (codes::syscom_op::RETURN_TO_MENU, false, false),
368            (codes::syscom_op::END_GAME, false, true),
369        ] {
370            let stream = conditional_exit(op, parameters, outside);
371            assert!(!is_close_routine(&stream, 0, stream.scn.len(), true));
372        }
373    }
374
375    #[test]
376    fn rejects_exit_callbacks_that_require_excall_storage() {
377        for chain in [
378            vec![65, 1, 6, -1, 1, 5],        // excall.front.objbtngroup[1].end()
379            vec![65, -1, 1, 1, 6, -1, 1, 5], // explicit excall[1]
380            vec![65, 0, -1, 0],              // excall's local flags
381            vec![65, 4], // callbacks that allocate their own menu are not standalone
382        ] {
383            let mut prefix = vec![CD_ELM_POINT];
384            for value in chain {
385                push_int(&mut prefix, value);
386            }
387            prefix.push(CD_COMMAND);
388            for value in [0i32, 0, 0, codes::FM_VOID] {
389                prefix.extend(value.to_le_bytes());
390            }
391            let stream =
392                conditional_exit_with_prefix(codes::syscom_op::END_GAME, false, false, &prefix);
393            assert!(!is_close_routine(&stream, 0, stream.scn.len(), true));
394        }
395    }
396
397    #[test]
398    fn accepts_excall_status_queries_without_storage() {
399        for op in [codes::excall_op::OP_8, codes::excall_op::OP_12] {
400            let mut prefix = vec![CD_ELM_POINT];
401            push_int(&mut prefix, codes::ELM_GLOBAL_EXCALL);
402            push_int(&mut prefix, op);
403            prefix.push(CD_COMMAND);
404            for value in [0i32, 0, 0, codes::FM_INT] {
405                prefix.extend(value.to_le_bytes());
406            }
407            let stream =
408                conditional_exit_with_prefix(codes::syscom_op::END_GAME, false, false, &prefix);
409            assert!(is_close_routine(&stream, 0, stream.scn.len(), true));
410        }
411    }
412
413    #[test]
414    #[ignore = "requires SIGLUS_CLOSE_TEST_PROJECT with game assets"]
415    fn discovers_game_exit_action() {
416        let project = PathBuf::from(std::env::var("SIGLUS_CLOSE_TEST_PROJECT").unwrap());
417        let path = crate::resource::find_scene_pck_path(&project).unwrap();
418        let options = crate::resource::load_scene_pck_decode_options(&project).unwrap();
419        let pck = ScenePck::load_lazy(&path, &options).unwrap();
420        let ctx = CommandContext::new(project);
421        let menu = ctx
422            .tables
423            .gameexe
424            .as_ref()
425            .unwrap()
426            .get_entry("MENU_SCENE")
427            .unwrap()
428            .item_unquoted(0)
429            .unwrap()
430            .to_string();
431        let scene_no = SceneVm::find_scene_no_by_name(&pck, &menu).unwrap();
432        let (owner, range) = pck.scn_data_shared(scene_no).unwrap();
433        let stream =
434            SceneStream::new_shared_range_with_string_codec(owner, range, pck.string_codec)
435                .unwrap();
436        let mut vm = SceneVm::new(stream, ctx);
437        vm.install_initial_scene_pck(pck, String::new());
438        vm.current_scene_no = Some(scene_no);
439        vm.current_scene_name = Some(menu);
440        vm.ctx.wait.wait_selbtn();
441        let entry = vm.discover_close_entry().unwrap();
442        eprintln!("discovered close entry: {entry:?}");
443        assert!(entry.is_some());
444        assert!(vm.call_game_close_scene().unwrap());
445        assert!(vm.take_script_proc_request());
446        // A cancelled game-owned action returns without requesting EndGame.
447        vm.exec_return(vec![]).unwrap();
448        assert!(vm.take_script_proc_pop_request());
449        assert!(vm.ctx.globals.syscom.pending_proc.is_none());
450    }
451}