1use 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
19fn 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 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 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 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 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
119fn 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 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 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 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, ¶meterless) 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], vec![65, -1, 1, 1, 6, -1, 1, 5], vec![65, 0, -1, 0], vec![65, 4], ] {
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 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}