Skip to main content

siglus_scene_vm/
scene_stream.rs

1use anyhow::{Result, anyhow, bail};
2use std::cmp::Reverse;
3use std::collections::{BinaryHeap, HashMap};
4use std::ops::Range;
5use std::sync::Arc;
6
7use siglus_assets::scene_pck::{CIndex, SceneStringCodec};
8
9/// All fields are little-endian i32 and all offsets are relative to the start of the chunk.
10#[derive(Debug, Clone, Copy)]
11pub struct ScnHeader {
12    pub header_size: i32,
13    pub scn_ofs: i32,
14    pub scn_size: i32,
15    pub str_index_list_ofs: i32,
16    pub str_index_cnt: i32,
17    pub str_list_ofs: i32,
18    pub str_cnt: i32,
19    pub label_list_ofs: i32,
20    pub label_cnt: i32,
21    pub z_label_list_ofs: i32,
22    pub z_label_cnt: i32,
23    pub cmd_label_list_ofs: i32,
24    pub cmd_label_cnt: i32,
25    pub scn_prop_list_ofs: i32,
26    pub scn_prop_cnt: i32,
27    pub scn_prop_name_index_list_ofs: i32,
28    pub scn_prop_name_index_cnt: i32,
29    pub scn_prop_name_list_ofs: i32,
30    pub scn_prop_name_cnt: i32,
31    pub scn_cmd_list_ofs: i32,
32    pub scn_cmd_cnt: i32,
33    pub scn_cmd_name_index_list_ofs: i32,
34    pub scn_cmd_name_index_cnt: i32,
35    pub scn_cmd_name_list_ofs: i32,
36    pub scn_cmd_name_cnt: i32,
37    pub call_prop_name_index_list_ofs: i32,
38    pub call_prop_name_index_cnt: i32,
39    pub call_prop_name_list_ofs: i32,
40    pub call_prop_name_cnt: i32,
41    pub namae_list_ofs: i32,
42    pub namae_cnt: i32,
43    pub read_flag_list_ofs: i32,
44    pub read_flag_cnt: i32,
45}
46
47impl ScnHeader {
48    pub fn read(chunk: &[u8]) -> Result<Self> {
49        // `S_tnm_scn_header` has 33 i32 fields. We only read the early subset we need.
50        let need = 33 * 4;
51        if chunk.len() < need {
52            bail!("scn: chunk too short for header");
53        }
54        let mut p = 0usize;
55        let mut rd = || {
56            let v = i32::from_le_bytes(chunk[p..p + 4].try_into().unwrap());
57            p += 4;
58            v
59        };
60
61        let header_size = rd();
62        let scn_ofs = rd();
63        let scn_size = rd();
64        let str_index_list_ofs = rd();
65        let str_index_cnt = rd();
66        let str_list_ofs = rd();
67        let str_cnt = rd();
68        let label_list_ofs = rd();
69        let label_cnt = rd();
70        let z_label_list_ofs = rd();
71        let z_label_cnt = rd();
72        let cmd_label_list_ofs = rd();
73        let cmd_label_cnt = rd();
74        let scn_prop_list_ofs = rd();
75        let scn_prop_cnt = rd();
76        let scn_prop_name_index_list_ofs = rd();
77        let scn_prop_name_index_cnt = rd();
78        let scn_prop_name_list_ofs = rd();
79        let scn_prop_name_cnt = rd();
80        let scn_cmd_list_ofs = rd();
81        let scn_cmd_cnt = rd();
82        let scn_cmd_name_index_list_ofs = rd();
83        let scn_cmd_name_index_cnt = rd();
84        let scn_cmd_name_list_ofs = rd();
85        let scn_cmd_name_cnt = rd();
86        let call_prop_name_index_list_ofs = rd();
87        let call_prop_name_index_cnt = rd();
88        let call_prop_name_list_ofs = rd();
89        let call_prop_name_cnt = rd();
90        let namae_list_ofs = rd();
91        let namae_cnt = rd();
92        let read_flag_list_ofs = rd();
93        let read_flag_cnt = rd();
94
95        Ok(Self {
96            header_size,
97            scn_ofs,
98            scn_size,
99            str_index_list_ofs,
100            str_index_cnt,
101            str_cnt,
102            str_list_ofs,
103            label_list_ofs,
104            label_cnt,
105            z_label_list_ofs,
106            z_label_cnt,
107            cmd_label_list_ofs,
108            cmd_label_cnt,
109            scn_prop_list_ofs,
110            scn_prop_cnt,
111            scn_prop_name_index_list_ofs,
112            scn_prop_name_index_cnt,
113            scn_prop_name_list_ofs,
114            scn_prop_name_cnt,
115            scn_cmd_list_ofs,
116            scn_cmd_cnt,
117            scn_cmd_name_index_list_ofs,
118            scn_cmd_name_index_cnt,
119            scn_cmd_name_list_ofs,
120            scn_cmd_name_cnt,
121            call_prop_name_index_list_ofs,
122            call_prop_name_index_cnt,
123            call_prop_name_list_ofs,
124            call_prop_name_cnt,
125            namae_list_ofs,
126            namae_cnt,
127            read_flag_list_ofs,
128            read_flag_cnt,
129        })
130    }
131}
132
133fn read_indexed_utf16_name_map(
134    chunk: &[u8],
135    index_list_ofs: usize,
136    count: usize,
137    list_ofs: usize,
138) -> Result<std::collections::HashMap<u32, String>> {
139    let mut out = std::collections::HashMap::new();
140    if index_list_ofs + count * 8 > chunk.len() || list_ofs > chunk.len() {
141        return Ok(out);
142    }
143    for i in 0..count {
144        let idx = CIndex::read(chunk, index_list_ofs + i * 8)?;
145        let o = idx.offset.max(0) as usize;
146        let n = idx.size.max(0) as usize;
147        let byte_off = list_ofs
148            .checked_add(o * 2)
149            .ok_or_else(|| anyhow!("scn: name offset overflow"))?;
150        let byte_end = byte_off
151            .checked_add(n * 2)
152            .ok_or_else(|| anyhow!("scn: name size overflow"))?;
153        if byte_end > chunk.len() {
154            continue;
155        }
156        let mut u16s = Vec::with_capacity(n);
157        for j in 0..n {
158            let p = byte_off + j * 2;
159            let w = u16::from_le_bytes([chunk[p], chunk[p + 1]]);
160            if w == 0 {
161                break;
162            }
163            u16s.push(w);
164        }
165        let s = String::from_utf16_lossy(&u16s);
166        if !s.is_empty() {
167            out.insert(i as u32, s);
168        }
169    }
170    Ok(out)
171}
172
173fn read_i32_at(buf: &[u8], pos: &mut usize) -> Option<i32> {
174    let end = pos.checked_add(4)?;
175    let bytes = buf.get(*pos..end)?;
176    *pos = end;
177    Some(i32::from_le_bytes(bytes.try_into().ok()?))
178}
179
180fn skip_arg_form(buf: &[u8], pos: &mut usize, depth: usize) -> bool {
181    // FM_LIST recursively embeds another argument-form list. Bound nesting so
182    // malformed data cannot recurse indefinitely while resolving the layout.
183    if depth > 64 {
184        return false;
185    }
186    let Some(form) = read_i32_at(buf, pos) else {
187        return false;
188    };
189    if form != crate::runtime::constants::fm::LIST {
190        return true;
191    }
192    skip_arg_form_list(buf, pos, depth + 1)
193}
194
195fn skip_arg_form_list(buf: &[u8], pos: &mut usize, depth: usize) -> bool {
196    let Some(count) = read_i32_at(buf, pos) else {
197        return false;
198    };
199    if count < 0 {
200        return false;
201    }
202    for _ in 0..count as usize {
203        if !skip_arg_form(buf, pos, depth) {
204            return false;
205        }
206    }
207    true
208}
209
210fn read_flag_line_no_from_chunk(
211    chunk: &[u8],
212    header: &ScnHeader,
213    read_flag_no: usize,
214) -> Option<i32> {
215    let count = usize::try_from(header.read_flag_cnt).ok()?;
216    if read_flag_no >= count {
217        return None;
218    }
219    let base = usize::try_from(header.read_flag_list_ofs).ok()?;
220    let start = base.checked_add(read_flag_no.checked_mul(4)?)?;
221    let end = start.checked_add(4)?;
222    let bytes = chunk.get(start..end)?;
223    Some(i32::from_le_bytes(bytes.try_into().ok()?))
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
227struct ReadFlagScanState {
228    pc: usize,
229    line_no: i32,
230    next_read_flag: usize,
231}
232
233#[derive(Debug, Clone, Copy)]
234struct ReadFlagScanPred {
235    prev: ReadFlagScanState,
236    /// `(pc, flag_no)` for a trailing command read-flag operand consumed on
237    /// the edge from `prev` to this state.
238    command_read_flag: Option<(usize, i32)>,
239}
240
241#[derive(Debug, Clone, Copy)]
242struct ReadFlagScanNode {
243    /// Number of distinct parses reaching this state, saturated at two.  We
244    /// only trust a layout when exactly one complete parse exists.
245    path_count: u8,
246    pred: Option<ReadFlagScanPred>,
247}
248
249#[derive(Debug, Clone, Copy)]
250enum ReadFlagScanKind {
251    Other,
252    Text(i32),
253    Command,
254}
255
256fn scan_instruction(scn: &[u8], pc: usize, line_no: i32) -> Option<(usize, i32, ReadFlagScanKind)> {
257    use crate::runtime::constants::cd;
258
259    let opcode = *scn.get(pc)?;
260    let mut pos = pc.checked_add(1)?;
261    let mut next_line = line_no;
262    let kind = match opcode {
263        cd::NONE => return None,
264        cd::NL => {
265            next_line = read_i32_at(scn, &mut pos)?;
266            ReadFlagScanKind::Other
267        }
268        cd::PUSH => {
269            read_i32_at(scn, &mut pos)?;
270            read_i32_at(scn, &mut pos)?;
271            ReadFlagScanKind::Other
272        }
273        cd::POP | cd::COPY => {
274            read_i32_at(scn, &mut pos)?;
275            ReadFlagScanKind::Other
276        }
277        cd::PROPERTY
278        | cd::COPY_ELM
279        | cd::ELM_POINT
280        | cd::ARG
281        | cd::EOF
282        | cd::NAME
283        | cd::SEL_BLOCK_START
284        | cd::SEL_BLOCK_END => ReadFlagScanKind::Other,
285        cd::DEC_PROP => {
286            read_i32_at(scn, &mut pos)?;
287            read_i32_at(scn, &mut pos)?;
288            ReadFlagScanKind::Other
289        }
290        cd::GOTO | cd::GOTO_TRUE | cd::GOTO_FALSE => {
291            read_i32_at(scn, &mut pos)?;
292            ReadFlagScanKind::Other
293        }
294        cd::GOSUB | cd::GOSUBSTR => {
295            read_i32_at(scn, &mut pos)?;
296            if !skip_arg_form_list(scn, &mut pos, 0) {
297                return None;
298            }
299            ReadFlagScanKind::Other
300        }
301        cd::RETURN => {
302            if !skip_arg_form_list(scn, &mut pos, 0) {
303                return None;
304            }
305            ReadFlagScanKind::Other
306        }
307        cd::ASSIGN => {
308            read_i32_at(scn, &mut pos)?;
309            read_i32_at(scn, &mut pos)?;
310            read_i32_at(scn, &mut pos)?;
311            ReadFlagScanKind::Other
312        }
313        cd::OPERATE_1 => {
314            read_i32_at(scn, &mut pos)?;
315            scn.get(pos)?;
316            pos += 1;
317            ReadFlagScanKind::Other
318        }
319        cd::OPERATE_2 => {
320            read_i32_at(scn, &mut pos)?;
321            read_i32_at(scn, &mut pos)?;
322            scn.get(pos)?;
323            pos += 1;
324            ReadFlagScanKind::Other
325        }
326        cd::COMMAND => {
327            // The bytecode embeds arg_list_id followed by the recursively
328            // encoded argument-form list. The argument *values* and element
329            // chain live on the VM stack and therefore do not appear here.
330            read_i32_at(scn, &mut pos)?;
331            if !skip_arg_form_list(scn, &mut pos, 0) {
332                return None;
333            }
334            let named_count = read_i32_at(scn, &mut pos)?;
335            if named_count < 0 {
336                return None;
337            }
338            let named_bytes = (named_count as usize).checked_mul(4)?;
339            pos = pos.checked_add(named_bytes)?;
340            if pos > scn.len() {
341                return None;
342            }
343            // ret_form
344            read_i32_at(scn, &mut pos)?;
345            ReadFlagScanKind::Command
346        }
347        cd::TEXT => {
348            let flag_no = read_i32_at(scn, &mut pos)?;
349            ReadFlagScanKind::Text(flag_no)
350        }
351        _ => return None,
352    };
353    Some((pos, next_line, kind))
354}
355
356fn add_read_flag_scan_state(
357    nodes: &mut HashMap<ReadFlagScanState, ReadFlagScanNode>,
358    queue: &mut BinaryHeap<Reverse<ReadFlagScanState>>,
359    next: ReadFlagScanState,
360    incoming_paths: u8,
361    pred: ReadFlagScanPred,
362) {
363    use std::collections::hash_map::Entry;
364
365    let incoming_paths = incoming_paths.min(2);
366    match nodes.entry(next) {
367        Entry::Vacant(entry) => {
368            entry.insert(ReadFlagScanNode {
369                path_count: incoming_paths,
370                pred: (incoming_paths == 1).then_some(pred),
371            });
372            queue.push(Reverse(next));
373        }
374        Entry::Occupied(mut entry) => {
375            // Every transition advances pc, and the priority queue is ordered
376            // by pc. Therefore all predecessors of `next` are processed before
377            // `next` itself is popped, so no re-queue is required here.
378            let node = entry.get_mut();
379            let merged = node.path_count.saturating_add(incoming_paths).min(2);
380            if merged != node.path_count {
381                node.path_count = merged;
382                if merged != 1 {
383                    node.pred = None;
384                }
385            }
386        }
387    }
388}
389
390/// Resolve, for this exact scene, which CD_COMMAND instructions are followed by
391/// the historical optional `read_flag_no` i32.
392///
393/// Siglus used more than one compiler ABI over its lifetime, and some old
394/// scenes are *mixed*: e.g. KOE continues directly with the next opcode while
395/// SELBTN in the same scene already carries a trailing read flag.  A global
396/// old/new version bit is therefore insufficient.
397///
398/// The compiler's read flags give us a stronger invariant. CD_TEXT and every
399/// command-level read flag consume one monotonically increasing flag number,
400/// and `read_flag_list[n].line_no` records the source line that owns n. We scan
401/// the complete bytecode while allowing each CD_COMMAND boundary to have either
402/// zero or one trailing flag. A branch that consumes a command flag is legal
403/// only when the next i32 equals the next expected flag number and its metadata
404/// line matches the current CD_NL line. We accept the layout only when exactly
405/// one complete parse reaches the end while consuming the entire read-flag
406/// table. Otherwise we return None and the VM keeps its existing modern-ABI
407/// fallback rather than guessing.
408fn resolve_command_read_flags(
409    scn: &[u8],
410    chunk: &[u8],
411    header: &ScnHeader,
412) -> Option<HashMap<usize, i32>> {
413    let read_flag_cnt = usize::try_from(header.read_flag_cnt).ok()?;
414    let mut read_flag_lines = Vec::with_capacity(read_flag_cnt);
415    for flag_no in 0..read_flag_cnt {
416        read_flag_lines.push(read_flag_line_no_from_chunk(chunk, header, flag_no)?);
417    }
418
419    let start = ReadFlagScanState {
420        pc: 0,
421        line_no: 0,
422        next_read_flag: 0,
423    };
424    let mut nodes = HashMap::new();
425    nodes.insert(
426        start,
427        ReadFlagScanNode {
428            path_count: 1,
429            pred: None,
430        },
431    );
432    let mut queue = BinaryHeap::new();
433    queue.push(Reverse(start));
434
435    let mut complete_paths = 0u8;
436    let mut terminal = None;
437
438    while let Some(Reverse(state)) = queue.pop() {
439        let node = *nodes.get(&state)?;
440        if state.pc == scn.len() {
441            if state.next_read_flag == read_flag_cnt {
442                complete_paths = complete_paths.saturating_add(node.path_count).min(2);
443                if complete_paths == 1 && node.path_count == 1 {
444                    terminal = Some(state);
445                } else {
446                    terminal = None;
447                }
448            }
449            continue;
450        }
451
452        let (next_pc, next_line, kind) = match scan_instruction(scn, state.pc, state.line_no) {
453            Some(v) => v,
454            None => continue,
455        };
456
457        match kind {
458            ReadFlagScanKind::Text(flag_no) => {
459                if flag_no < 0
460                    || flag_no as usize != state.next_read_flag
461                    || state.next_read_flag >= read_flag_cnt
462                    || read_flag_lines[state.next_read_flag] != state.line_no
463                {
464                    continue;
465                }
466                let next = ReadFlagScanState {
467                    pc: next_pc,
468                    line_no: next_line,
469                    next_read_flag: state.next_read_flag + 1,
470                };
471                add_read_flag_scan_state(
472                    &mut nodes,
473                    &mut queue,
474                    next,
475                    node.path_count,
476                    ReadFlagScanPred {
477                        prev: state,
478                        command_read_flag: None,
479                    },
480                );
481            }
482            ReadFlagScanKind::Command => {
483                // Historical form: no trailing read flag.
484                add_read_flag_scan_state(
485                    &mut nodes,
486                    &mut queue,
487                    ReadFlagScanState {
488                        pc: next_pc,
489                        line_no: next_line,
490                        next_read_flag: state.next_read_flag,
491                    },
492                    node.path_count,
493                    ReadFlagScanPred {
494                        prev: state,
495                        command_read_flag: None,
496                    },
497                );
498
499                // Newer form: one trailing read_flag_no. Do not infer this from
500                // the bytes alone; require the globally ordered metadata too.
501                if state.next_read_flag < read_flag_cnt
502                    && read_flag_lines[state.next_read_flag] == state.line_no
503                {
504                    let mut after_flag = next_pc;
505                    if read_i32_at(scn, &mut after_flag) == i32::try_from(state.next_read_flag).ok()
506                    {
507                        add_read_flag_scan_state(
508                            &mut nodes,
509                            &mut queue,
510                            ReadFlagScanState {
511                                pc: after_flag,
512                                line_no: next_line,
513                                next_read_flag: state.next_read_flag + 1,
514                            },
515                            node.path_count,
516                            ReadFlagScanPred {
517                                prev: state,
518                                command_read_flag: Some((next_pc, state.next_read_flag as i32)),
519                            },
520                        );
521                    }
522                }
523            }
524            ReadFlagScanKind::Other => {
525                add_read_flag_scan_state(
526                    &mut nodes,
527                    &mut queue,
528                    ReadFlagScanState {
529                        pc: next_pc,
530                        line_no: next_line,
531                        next_read_flag: state.next_read_flag,
532                    },
533                    node.path_count,
534                    ReadFlagScanPred {
535                        prev: state,
536                        command_read_flag: None,
537                    },
538                );
539            }
540        }
541    }
542
543    if complete_paths != 1 {
544        return None;
545    }
546
547    let mut command_read_flags = HashMap::new();
548    let mut state = terminal?;
549    while state != start {
550        let pred = nodes.get(&state)?.pred?;
551        if let Some((pc, flag_no)) = pred.command_read_flag {
552            command_read_flags.insert(pc, flag_no);
553        }
554        state = pred.prev;
555    }
556    Some(command_read_flags)
557}
558
559#[derive(Debug, Clone)]
560pub struct SceneStream<'a> {
561    // Owned streams keep the Scene.pck backing allocation alive while these
562    // slices point into it. Borrowed streams (tests/tools) leave this None.
563    owned_chunk: Option<Arc<[u8]>>,
564    owned_pack: Option<Arc<Vec<u8>>>,
565    pub chunk: &'a [u8],
566    pub header: ScnHeader,
567    pub scn: &'a [u8],
568    pub str_index_list: &'a [u8],
569    pub str_list: &'a [u8],
570    pub label_list: &'a [u8],
571    pub z_label_list: &'a [u8],
572    pub scn_prop_name_map: Arc<std::collections::HashMap<u32, String>>,
573    pub scn_cmd_name_map: Arc<std::collections::HashMap<u32, String>>,
574    pub call_prop_name_map: Arc<std::collections::HashMap<u32, String>>,
575    command_read_flags: Option<Arc<HashMap<usize, i32>>>,
576    pub string_codec: SceneStringCodec,
577    pub pc: usize,
578}
579
580impl<'a> SceneStream<'a> {
581    pub(crate) fn instruction_end(&self, pc: usize) -> Option<usize> {
582        let (mut end, _, kind) = scan_instruction(self.scn, pc, -1)?;
583        if matches!(kind, ReadFlagScanKind::Command)
584            && self.command_read_flags.as_ref()?.contains_key(&end)
585        {
586            end = end.checked_add(4)?;
587        }
588        (end <= self.scn.len()).then_some(end)
589    }
590
591    pub fn new(chunk: &'a [u8]) -> Result<Self> {
592        Self::new_with_string_codec(chunk, SceneStringCodec::Xor)
593    }
594
595    pub fn new_with_string_codec(chunk: &'a [u8], string_codec: SceneStringCodec) -> Result<Self> {
596        let header = ScnHeader::read(chunk)?;
597        let scn_ofs = header.scn_ofs.max(0) as usize;
598        let scn_size = header.scn_size.max(0) as usize;
599        let scn_end = scn_ofs
600            .checked_add(scn_size)
601            .ok_or_else(|| anyhow!("scn: scn_size overflow"))?;
602        if scn_end > chunk.len() {
603            bail!("scn: scn stream out of bounds");
604        }
605        let scn = &chunk[scn_ofs..scn_end];
606
607        let str_index_list_ofs = header.str_index_list_ofs.max(0) as usize;
608        let str_index_cnt = header.str_index_cnt.max(0) as usize;
609        let str_index_list_end = str_index_list_ofs
610            .checked_add(str_index_cnt * 8)
611            .ok_or_else(|| anyhow!("scn: str_index_list overflow"))?;
612        if str_index_list_end > chunk.len() {
613            bail!("scn: str_index_list out of bounds");
614        }
615        let str_index_list = &chunk[str_index_list_ofs..str_index_list_end];
616
617        let str_list_ofs = header.str_list_ofs.max(0) as usize;
618        if str_list_ofs > chunk.len() {
619            bail!("scn: str_list_ofs out of bounds");
620        }
621        let str_list = &chunk[str_list_ofs..];
622
623        let label_list_ofs = header.label_list_ofs.max(0) as usize;
624        let label_cnt = header.label_cnt.max(0) as usize;
625        let label_list_end = label_list_ofs
626            .checked_add(label_cnt * 4)
627            .ok_or_else(|| anyhow!("scn: label_list overflow"))?;
628        if label_list_end > chunk.len() {
629            bail!("scn: label_list out of bounds");
630        }
631        let label_list = &chunk[label_list_ofs..label_list_end];
632
633        let z_label_list_ofs = header.z_label_list_ofs.max(0) as usize;
634        let z_label_cnt = header.z_label_cnt.max(0) as usize;
635        let z_label_list_end = z_label_list_ofs
636            .checked_add(z_label_cnt * 4)
637            .ok_or_else(|| anyhow!("scn: z_label_list overflow"))?;
638        if z_label_list_end > chunk.len() {
639            bail!("scn: z_label_list out of bounds");
640        }
641        let z_label_list = &chunk[z_label_list_ofs..z_label_list_end];
642
643        let scn_prop_name_map = read_indexed_utf16_name_map(
644            chunk,
645            header.scn_prop_name_index_list_ofs.max(0) as usize,
646            header.scn_prop_name_cnt.max(0) as usize,
647            header.scn_prop_name_list_ofs.max(0) as usize,
648        )?;
649        let scn_cmd_name_map = read_indexed_utf16_name_map(
650            chunk,
651            header.scn_cmd_name_index_list_ofs.max(0) as usize,
652            header.scn_cmd_name_cnt.max(0) as usize,
653            header.scn_cmd_name_list_ofs.max(0) as usize,
654        )?;
655        let call_prop_name_map = read_indexed_utf16_name_map(
656            chunk,
657            header.call_prop_name_index_list_ofs.max(0) as usize,
658            header.call_prop_name_cnt.max(0) as usize,
659            header.call_prop_name_list_ofs.max(0) as usize,
660        )?;
661        let command_read_flags = resolve_command_read_flags(scn, chunk, &header).map(Arc::new);
662
663        Ok(Self {
664            owned_chunk: None,
665            owned_pack: None,
666            chunk,
667            header,
668            scn,
669            str_index_list,
670            str_list,
671            label_list,
672            z_label_list,
673            scn_prop_name_map: Arc::new(scn_prop_name_map),
674            scn_cmd_name_map: Arc::new(scn_cmd_name_map),
675            call_prop_name_map: Arc::new(call_prop_name_map),
676            command_read_flags,
677            string_codec,
678            pc: 0,
679        })
680    }
681
682    /// Build a stream backed by an owned ref-counted scene chunk.
683    pub fn new_owned_with_string_codec(
684        owner: Arc<[u8]>,
685        string_codec: SceneStringCodec,
686    ) -> Result<SceneStream<'static>> {
687        let len = owner.len();
688        let chunk: &'static [u8] = unsafe { std::slice::from_raw_parts(owner.as_ptr(), len) };
689        let mut stream = SceneStream::new_with_string_codec(chunk, string_codec)?;
690        stream.owned_chunk = Some(owner);
691        Ok(stream)
692    }
693
694    /// Build a stream that borrows a range from ref-counted backing storage.
695    ///
696    /// The Arc allocation is immovable and is retained by the returned stream.
697    /// Extending the slice lifetime to 'static is therefore safe: clones retain
698    /// the same Arc, and the slice is never exposed after its owning stream is
699    /// dropped. This avoids the previous Box::leak() scene lifetime workaround.
700    pub fn new_shared_range_with_string_codec(
701        owner: Arc<Vec<u8>>,
702        range: Range<usize>,
703        string_codec: SceneStringCodec,
704    ) -> Result<SceneStream<'static>> {
705        if range.start > range.end || range.end > owner.len() {
706            bail!("scn: shared scene range out of bounds");
707        }
708        let ptr = owner.as_ptr();
709        let len = range.end - range.start;
710        // SAFETY: owner is stored in the SceneStream before this function
711        // returns. Arc keeps the allocation alive across moves and clones.
712        let chunk: &'static [u8] = unsafe { std::slice::from_raw_parts(ptr.add(range.start), len) };
713        let mut stream = SceneStream::new_with_string_codec(chunk, string_codec)?;
714        stream.owned_pack = Some(owner);
715        Ok(stream)
716    }
717
718    pub fn eof(&self) -> bool {
719        self.pc >= self.scn.len()
720    }
721
722    pub fn get_prg_cntr(&self) -> usize {
723        self.pc
724    }
725
726    /// Raw scene-stream bytes around `pc`, for diagnostics only.
727    ///
728    /// A `pc` alone cannot be mapped back to a source line when it lands inside
729    /// a long compiled `if/else if` chain, and the port's line table reports the
730    /// *chain's* line rather than the failing instruction's. Seeing the actual
731    /// opcode bytes is the only reliable way to identify the construct.
732    pub fn debug_bytes_around(&self, pc: usize, before: usize, after: usize) -> (usize, Vec<u8>) {
733        let start = pc.saturating_sub(before);
734        let end = (pc + after).min(self.scn.len());
735        if start >= end {
736            return (start, Vec::new());
737        }
738        (start, self.scn[start..end].to_vec())
739    }
740
741    /// Scene-stream length, so a diagnostic can bound its window.
742    pub fn debug_len(&self) -> usize {
743        self.scn.len()
744    }
745
746    pub fn set_prg_cntr(&mut self, prg_cntr: usize) -> Result<()> {
747        if prg_cntr > self.scn.len() {
748            bail!("scn: prg_cntr out of bounds");
749        }
750        self.pc = prg_cntr;
751        Ok(())
752    }
753
754    pub fn jump_to_label(&mut self, label_no: usize) -> Result<()> {
755        let cnt = self.header.label_cnt.max(0) as usize;
756        if label_no >= cnt {
757            bail!("scn: label_no out of range");
758        }
759        let off = label_no * 4;
760        let label_offset = i32::from_le_bytes(self.label_list[off..off + 4].try_into().unwrap());
761        self.set_prg_cntr(label_offset.max(0) as usize)
762    }
763
764    pub fn jump_to_z_label(&mut self, z_no: usize) -> Result<()> {
765        let cnt = self.header.z_label_cnt.max(0) as usize;
766        if z_no >= cnt {
767            bail!("scn: z_label out of range");
768        }
769        let off = z_no * 4;
770        let z_offset = i32::from_le_bytes(self.z_label_list[off..off + 4].try_into().unwrap());
771        self.set_prg_cntr(z_offset.max(0) as usize)
772    }
773
774    pub fn scn_cmd_offset(&self, cmd_no: usize) -> Result<usize> {
775        let cnt = self.header.scn_cmd_cnt.max(0) as usize;
776        if cmd_no >= cnt {
777            bail!("scn: scn_cmd_no out of range");
778        }
779        let ofs = self.header.scn_cmd_list_ofs.max(0) as usize;
780        let byte_ofs = ofs
781            .checked_add(cmd_no * 4)
782            .ok_or_else(|| anyhow!("scn: scn_cmd_list overflow"))?;
783        let byte_end = byte_ofs
784            .checked_add(4)
785            .ok_or_else(|| anyhow!("scn: scn_cmd entry overflow"))?;
786        if byte_end > self.chunk.len() {
787            bail!("scn: scn_cmd_list out of bounds");
788        }
789        let cmd_offset = i32::from_le_bytes(self.chunk[byte_ofs..byte_end].try_into().unwrap());
790        let prg = cmd_offset.max(0) as usize;
791        if prg > self.scn.len() {
792            bail!("scn: scn_cmd offset out of bounds");
793        }
794        Ok(prg)
795    }
796
797    pub fn next_scn_cmd_offset_after(&self, start: usize) -> Result<Option<usize>> {
798        let cnt = self.header.scn_cmd_cnt.max(0) as usize;
799        let mut next: Option<usize> = None;
800        for cmd_no in 0..cnt {
801            let off = self.scn_cmd_offset(cmd_no)?;
802            if off > start {
803                next = Some(match next {
804                    Some(cur) => cur.min(off),
805                    None => off,
806                });
807            }
808        }
809        Ok(next)
810    }
811
812    pub fn pop_u8(&mut self) -> Result<u8> {
813        if self.pc + 1 > self.scn.len() {
814            bail!("scn: pop_u8 past end");
815        }
816        let v = self.scn[self.pc];
817        self.pc += 1;
818        Ok(v)
819    }
820
821    pub fn pop_u16(&mut self) -> Result<u16> {
822        if self.pc + 2 > self.scn.len() {
823            bail!("scn: pop_u16 past end");
824        }
825        let v = u16::from_le_bytes(self.scn[self.pc..self.pc + 2].try_into().unwrap());
826        self.pc += 2;
827        Ok(v)
828    }
829
830    pub fn pop_i32(&mut self) -> Result<i32> {
831        if self.pc + 4 > self.scn.len() {
832            bail!("scn: pop_i32 past end");
833        }
834        let v = i32::from_le_bytes(self.scn[self.pc..self.pc + 4].try_into().unwrap());
835        self.pc += 4;
836        Ok(v)
837    }
838
839    /// True when the complete scene bytecode and read-flag table have a unique
840    /// structural interpretation. If false, callers must preserve the existing
841    /// modern-ABI behavior rather than infer an old layout from local bytes.
842    pub fn has_resolved_command_read_flag_layout(&self) -> bool {
843        self.command_read_flags.is_some()
844    }
845
846    /// Return the compiler read flag structurally stored immediately at the
847    /// current program counter, if the scene layout was uniquely resolved.
848    pub fn command_read_flag_no_at_current_pc(&self) -> Option<i32> {
849        self.command_read_flags
850            .as_ref()
851            .and_then(|flags| flags.get(&self.pc).copied())
852    }
853
854    pub fn pop_str(&mut self) -> Result<String> {
855        let str_id = self.pop_i32()?;
856        self.get_string(str_id as usize)
857    }
858
859    pub fn get_string(&self, str_id: usize) -> Result<String> {
860        let str_cnt = self.header.str_cnt.max(0) as usize;
861        if str_id >= str_cnt {
862            bail!("scn: str_id out of range");
863        }
864        let idx = CIndex::read(self.str_index_list, str_id * 8)?;
865        let o = idx.offset.max(0) as usize;
866        let n = idx.size.max(0) as usize;
867
868        let byte_off = o
869            .checked_mul(2)
870            .ok_or_else(|| anyhow!("scn: str offset overflow"))?;
871        let byte_end = byte_off
872            .checked_add(n * 2)
873            .ok_or_else(|| anyhow!("scn: str size overflow"))?;
874        if byte_end > self.str_list.len() {
875            bail!("scn: str data out of bounds");
876        }
877
878        let key = (28807u32).wrapping_mul(str_id as u32) as u16;
879
880        let mut u16s = Vec::with_capacity(n);
881        for j in 0..n {
882            let p = byte_off + j * 2;
883            let raw = u16::from_le_bytes([self.str_list[p], self.str_list[p + 1]]);
884            let w = match self.string_codec {
885                SceneStringCodec::Plain => raw,
886                SceneStringCodec::Xor => raw ^ key,
887            };
888            if w == 0 {
889                break;
890            }
891            u16s.push(w);
892        }
893        Ok(String::from_utf16_lossy(&u16s))
894    }
895}
896
897#[cfg(test)]
898mod read_flag_compat_tests {
899    use super::{SceneStream, SceneStringCodec};
900    use crate::runtime::constants::{cd, fm};
901    use std::sync::Arc;
902
903    fn push_i32(out: &mut Vec<u8>, value: i32) {
904        out.extend_from_slice(&value.to_le_bytes());
905    }
906
907    fn nl(out: &mut Vec<u8>, line: i32) {
908        out.push(cd::NL);
909        push_i32(out, line);
910    }
911
912    fn text(out: &mut Vec<u8>, flag_no: i32) {
913        out.push(cd::TEXT);
914        push_i32(out, flag_no);
915    }
916
917    fn empty_command(out: &mut Vec<u8>) -> usize {
918        out.push(cd::COMMAND);
919        push_i32(out, 0); // arg_list_id
920        push_i32(out, 0); // arg count
921        push_i32(out, 0); // named arg count
922        push_i32(out, fm::VOID); // ret_form
923        out.len()
924    }
925
926    fn scene_with_code(code: &[u8], read_flag_lines: &[i32]) -> Vec<u8> {
927        const HEADER_WORDS: usize = 33;
928        const HEADER_SIZE: usize = HEADER_WORDS * 4;
929        let read_flag_ofs = HEADER_SIZE + code.len();
930
931        let mut words = [0i32; HEADER_WORDS];
932        words[0] = HEADER_SIZE as i32;
933        words[1] = HEADER_SIZE as i32;
934        words[2] = code.len() as i32;
935        for idx in [3usize, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29] {
936            words[idx] = read_flag_ofs as i32;
937        }
938        words[31] = read_flag_ofs as i32;
939        words[32] = read_flag_lines.len() as i32;
940
941        let mut chunk = Vec::with_capacity(read_flag_ofs + read_flag_lines.len() * 4);
942        for word in words {
943            chunk.extend_from_slice(&word.to_le_bytes());
944        }
945        chunk.extend_from_slice(code);
946        for line in read_flag_lines {
947            chunk.extend_from_slice(&line.to_le_bytes());
948        }
949        chunk
950    }
951
952    #[test]
953    fn legacy_pop_collision_does_not_become_command_read_flag() {
954        let mut code = Vec::new();
955        for (line, flag) in [(10, 0), (20, 1), (30, 2)] {
956            nl(&mut code, line);
957            text(&mut code, flag);
958        }
959        nl(&mut code, 66);
960        let command_end = empty_command(&mut code);
961        // Exact old-game collision: CD_POP/FM_VOID starts 03 00 00 00 00,
962        // while read flag 3 also belongs to line 66. Only the global parse can
963        // prove that flag 3 is actually owned by the following CD_TEXT.
964        code.push(cd::POP);
965        push_i32(&mut code, fm::VOID);
966        text(&mut code, 3);
967
968        let chunk = scene_with_code(&code, &[10, 20, 30, 66]);
969        let mut stream = SceneStream::new(&chunk).expect("scene stream");
970        assert!(stream.has_resolved_command_read_flag_layout());
971        stream.set_prg_cntr(command_end).expect("seek command end");
972        assert_eq!(stream.command_read_flag_no_at_current_pc(), None);
973    }
974
975    #[test]
976    fn modern_command_read_flag_is_resolved_at_exact_boundary() {
977        let mut code = Vec::new();
978        for (line, flag) in [(10, 0), (20, 1), (30, 2)] {
979            nl(&mut code, line);
980            text(&mut code, flag);
981        }
982        nl(&mut code, 66);
983        let command_end = empty_command(&mut code);
984        push_i32(&mut code, 3);
985        code.push(cd::POP);
986        push_i32(&mut code, fm::VOID);
987        nl(&mut code, 67);
988        text(&mut code, 4);
989
990        let chunk = scene_with_code(&code, &[10, 20, 30, 66, 67]);
991        let mut stream = SceneStream::new(&chunk).expect("scene stream");
992        assert!(stream.has_resolved_command_read_flag_layout());
993        stream.set_prg_cntr(command_end).expect("seek command end");
994        assert_eq!(stream.command_read_flag_no_at_current_pc(), Some(3));
995    }
996
997    #[test]
998    fn mixed_scene_resolves_each_command_independently() {
999        let mut code = Vec::new();
1000        for (line, flag) in [(10, 0), (20, 1), (30, 2)] {
1001            nl(&mut code, line);
1002            text(&mut code, flag);
1003        }
1004
1005        nl(&mut code, 66);
1006        let legacy_end = empty_command(&mut code);
1007        code.push(cd::POP);
1008        push_i32(&mut code, fm::VOID);
1009        text(&mut code, 3);
1010
1011        nl(&mut code, 67);
1012        let modern_end = empty_command(&mut code);
1013        push_i32(&mut code, 4);
1014        code.push(cd::POP);
1015        push_i32(&mut code, fm::VOID);
1016        nl(&mut code, 68);
1017        text(&mut code, 5);
1018
1019        let chunk = scene_with_code(&code, &[10, 20, 30, 66, 67, 68]);
1020        let mut stream = SceneStream::new(&chunk).expect("scene stream");
1021        assert!(stream.has_resolved_command_read_flag_layout());
1022
1023        stream
1024            .set_prg_cntr(legacy_end)
1025            .expect("seek legacy command end");
1026        assert_eq!(stream.command_read_flag_no_at_current_pc(), None);
1027
1028        stream
1029            .set_prg_cntr(modern_end)
1030            .expect("seek modern command end");
1031        assert_eq!(stream.command_read_flag_no_at_current_pc(), Some(4));
1032    }
1033
1034    #[test]
1035    fn text_only_scene_resolves_with_no_command_flags() {
1036        let mut code = Vec::new();
1037        nl(&mut code, 7);
1038        text(&mut code, 0);
1039        let chunk = scene_with_code(&code, &[7]);
1040        let stream = SceneStream::new(&chunk).expect("scene stream");
1041        assert!(stream.has_resolved_command_read_flag_layout());
1042        assert_eq!(stream.command_read_flag_no_at_current_pc(), None);
1043    }
1044    #[test]
1045    fn owned_stream_releases_backing_after_last_clone_drops() {
1046        let chunk = scene_with_code(&[], &[]);
1047        let owner: Arc<[u8]> = Arc::from(chunk.into_boxed_slice());
1048        assert_eq!(Arc::strong_count(&owner), 1);
1049        let stream = SceneStream::new_owned_with_string_codec(owner.clone(), SceneStringCodec::Xor)
1050            .expect("owned scene stream");
1051        assert_eq!(Arc::strong_count(&owner), 2);
1052        let cloned = stream.clone();
1053        assert_eq!(Arc::strong_count(&owner), 3);
1054        drop(stream);
1055        assert_eq!(Arc::strong_count(&owner), 2);
1056        drop(cloned);
1057        assert_eq!(Arc::strong_count(&owner), 1);
1058    }
1059}