Skip to main content

siglus_assets/
scene_pck.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io::{Read, Write};
4use std::ops::Range;
5use std::path::Path;
6use std::sync::Arc;
7
8#[cfg(target_os = "vita")]
9unsafe extern "C" {
10    fn siglus_vita_log_marker(message: *const u8);
11}
12
13use anyhow::{Context, Result, anyhow, bail};
14use flate2::Compression;
15use flate2::write::DeflateEncoder;
16
17use crate::key_toml::StringEncryptionOverride;
18use crate::lzss::lzss_unpack_lenient;
19
20#[derive(Debug, Clone, Copy)]
21pub struct CIndex {
22    pub offset: i32,
23    pub size: i32,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct PackIncProp {
28    pub form: i32,
29    pub size: i32,
30}
31
32impl PackIncProp {
33    pub fn read(buf: &[u8], off: usize) -> Result<Self> {
34        if off + 8 > buf.len() {
35            bail!("scene_pck: PackIncProp out of bounds");
36        }
37        let form = i32::from_le_bytes(buf[off..off + 4].try_into().unwrap());
38        let size = i32::from_le_bytes(buf[off + 4..off + 8].try_into().unwrap());
39        Ok(Self { form, size })
40    }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct PackIncCmd {
45    pub scn_no: i32,
46    pub offset: i32,
47}
48
49impl PackIncCmd {
50    pub fn read(buf: &[u8], off: usize) -> Result<Self> {
51        if off + 8 > buf.len() {
52            bail!("scene_pck: PackIncCmd out of bounds");
53        }
54        let scn_no = i32::from_le_bytes(buf[off..off + 4].try_into().unwrap());
55        let offset = i32::from_le_bytes(buf[off + 4..off + 8].try_into().unwrap());
56        Ok(Self { scn_no, offset })
57    }
58}
59
60impl CIndex {
61    pub fn read(buf: &[u8], off: usize) -> Result<Self> {
62        if off + 8 > buf.len() {
63            bail!("scene_pck: CIndex out of bounds");
64        }
65        let offset = i32::from_le_bytes(buf[off..off + 4].try_into().unwrap());
66        let size = i32::from_le_bytes(buf[off + 4..off + 8].try_into().unwrap());
67        Ok(Self { offset, size })
68    }
69}
70
71/// All fields are little-endian i32.
72#[derive(Debug, Clone, Copy)]
73pub struct PackScnHeader {
74    pub header_size: i32,
75    pub inc_prop_list_ofs: i32,
76    pub inc_prop_cnt: i32,
77    pub inc_prop_name_index_list_ofs: i32,
78    pub inc_prop_name_index_cnt: i32,
79    pub inc_prop_name_list_ofs: i32,
80    pub inc_prop_name_cnt: i32,
81    pub inc_cmd_list_ofs: i32,
82    pub inc_cmd_cnt: i32,
83    pub inc_cmd_name_index_list_ofs: i32,
84    pub inc_cmd_name_index_cnt: i32,
85    pub inc_cmd_name_list_ofs: i32,
86    pub inc_cmd_name_cnt: i32,
87    pub scn_name_index_list_ofs: i32,
88    pub scn_name_index_cnt: i32,
89    pub scn_name_list_ofs: i32,
90    pub scn_name_cnt: i32,
91    pub scn_data_index_list_ofs: i32,
92    pub scn_data_index_cnt: i32,
93    pub scn_data_list_ofs: i32,
94    pub scn_data_cnt: i32,
95    pub scn_data_exe_angou_mod: i32,
96    pub original_source_header_size: i32,
97}
98
99impl PackScnHeader {
100    pub fn read(buf: &[u8], off: usize, has_signature: bool) -> Result<Self> {
101        // header size is stored in the first i32 (no signature in older builds).
102        let min_need = if has_signature { 8 + 4 } else { 4 };
103        if off + min_need > buf.len() {
104            bail!("scene_pck: header out of bounds");
105        }
106        let mut p = off;
107        if has_signature {
108            if &buf[off..off + 8] != b"pack_scn" {
109                bail!("scene_pck: bad signature (expected pack_scn)");
110            }
111            p += 8;
112        }
113        let mut rd = || {
114            let v = i32::from_le_bytes(buf[p..p + 4].try_into().unwrap());
115            p += 4;
116            v
117        };
118        let header_size = rd();
119        let out = Self {
120            header_size,
121            inc_prop_list_ofs: rd(),
122            inc_prop_cnt: rd(),
123            inc_prop_name_index_list_ofs: rd(),
124            inc_prop_name_index_cnt: rd(),
125            inc_prop_name_list_ofs: rd(),
126            inc_prop_name_cnt: rd(),
127            inc_cmd_list_ofs: rd(),
128            inc_cmd_cnt: rd(),
129            inc_cmd_name_index_list_ofs: rd(),
130            inc_cmd_name_index_cnt: rd(),
131            inc_cmd_name_list_ofs: rd(),
132            inc_cmd_name_cnt: rd(),
133            scn_name_index_list_ofs: rd(),
134            scn_name_index_cnt: rd(),
135            scn_name_list_ofs: rd(),
136            scn_name_cnt: rd(),
137            scn_data_index_list_ofs: rd(),
138            scn_data_index_cnt: rd(),
139            scn_data_list_ofs: rd(),
140            scn_data_cnt: rd(),
141            scn_data_exe_angou_mod: rd(),
142            original_source_header_size: rd(),
143        };
144
145        // Optional extra fields in newer headers (ignored for now).
146        let header_bytes = header_size.max(0) as usize;
147        let base_fields_bytes = 23 * 4;
148        let extra_bytes = header_bytes.saturating_sub(base_fields_bytes);
149        let extra_fields = extra_bytes / 4;
150        if extra_fields > 0 {
151            for _ in 0..extra_fields {
152                let _ = rd();
153            }
154        }
155
156        Ok(out)
157    }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum SceneStringCodec {
162    Plain,
163    Xor,
164}
165
166#[derive(Debug, Clone)]
167pub struct ScenePckDecodeOptions {
168    /// Optional 16-byte exe angou element table (`TNM_EXE_ANGOU_ELEMENT_CNT`).
169    pub exe_angou_element: Option<Vec<u8>>,
170    /// Optional easy angou code table (`TNM_EASY_ANGOU_CODE_SIZE`, typically 256).
171    pub easy_angou_code: Option<Vec<u8>>,
172    /// Project override for the historical scene string-table codec.
173    pub string_encryption_override: StringEncryptionOverride,
174}
175
176impl Default for ScenePckDecodeOptions {
177    fn default() -> Self {
178        Self {
179            exe_angou_element: None,
180            easy_angou_code: None,
181            string_encryption_override: StringEncryptionOverride::Xor,
182        }
183    }
184}
185
186impl ScenePckDecodeOptions {
187    pub fn from_project_dir(project_dir: &Path) -> Result<Self> {
188        let cfg = crate::key_toml::load_key_toml_from_project_dir(project_dir)?;
189        let exe = cfg
190            .as_ref()
191            .and_then(|cfg| cfg.exe_key16)
192            .map(|v| v.to_vec());
193        let string_encryption_override = cfg
194            .map(|cfg| cfg.override_string_encryption)
195            .unwrap_or(StringEncryptionOverride::Xor);
196        Ok(Self {
197            exe_angou_element: exe,
198            easy_angou_code: Some(crate::keys::SCENE_KEY.to_vec()),
199            string_encryption_override,
200        })
201    }
202}
203
204#[derive(Debug, Clone)]
205pub struct ScenePck {
206    pub buf: Arc<Vec<u8>>,
207    pub header: PackScnHeader,
208    pub scn_name_map: HashMap<String, usize>,
209    pub inc_prop_name_map: HashMap<u32, String>,
210    pub inc_cmd_name_map: Arc<HashMap<u32, String>>,
211    pub inc_props: Vec<PackIncProp>,
212    pub inc_cmds: Vec<PackIncCmd>,
213    pub string_codec: SceneStringCodec,
214    /// Set by `load_lazy`: `buf` is then the decrypted but still compressed
215    /// pack, and scenes are decompressed when asked for.
216    lazy: Option<Arc<LazyScenes>>,
217}
218
219/// Scenes of a lazily loaded pack: where each compressed chunk lies in
220/// `ScenePck::buf`, and the scenes decompressed so far (kept while any
221/// stream still holds them).
222#[derive(Debug)]
223struct LazyScenes {
224    compressed: bool,
225    ranges: Vec<Range<usize>>,
226    decoded: std::sync::Mutex<HashMap<usize, std::sync::Weak<Vec<u8>>>>,
227}
228
229/// Scene names to numbers, from the pack's name tables (before the scene
230/// data, so the same in a rebuilt and a lazily loaded pack).
231fn read_scene_name_map(buf: &[u8], header: &PackScnHeader) -> Result<HashMap<String, usize>> {
232    let mut scn_name_map = HashMap::new();
233    let name_idx_ofs = header.scn_name_index_list_ofs as usize;
234    let name_cnt = header.scn_name_cnt.max(0) as usize;
235    let name_list_ofs = header.scn_name_list_ofs as usize;
236    if name_idx_ofs + name_cnt * 8 <= buf.len() && name_list_ofs <= buf.len() {
237        for i in 0..name_cnt {
238            let idx = CIndex::read(buf, name_idx_ofs + i * 8)?;
239            let o = idx.offset.max(0) as usize;
240            let n = idx.size.max(0) as usize;
241            let byte_off = name_list_ofs
242                .checked_add(o * 2)
243                .ok_or_else(|| anyhow!("scene_pck: name offset overflow"))?;
244            let byte_end = byte_off
245                .checked_add(n * 2)
246                .ok_or_else(|| anyhow!("scene_pck: name size overflow"))?;
247            if byte_end > buf.len() {
248                continue;
249            }
250            let mut u16s = Vec::with_capacity(n);
251            for j in 0..n {
252                let p = byte_off + j * 2;
253                let w = u16::from_le_bytes([buf[p], buf[p + 1]]);
254                if w == 0 {
255                    break;
256                }
257                u16s.push(w);
258            }
259            let s = String::from_utf16_lossy(&u16s);
260            if !s.is_empty() {
261                scn_name_map.insert(s, i);
262            }
263        }
264    }
265
266    Ok(scn_name_map)
267}
268
269fn read_pack_inc_props(buf: &[u8], list_ofs: usize, count: usize) -> Result<Vec<PackIncProp>> {
270    let mut out = Vec::new();
271    if count == 0 {
272        return Ok(out);
273    }
274    let byte_len = count
275        .checked_mul(8)
276        .ok_or_else(|| anyhow!("scene_pck: inc_prop_list size overflow"))?;
277    let end = list_ofs
278        .checked_add(byte_len)
279        .ok_or_else(|| anyhow!("scene_pck: inc_prop_list offset overflow"))?;
280    if end > buf.len() {
281        bail!("scene_pck: inc_prop_list out of bounds");
282    }
283    out.reserve(count);
284    for i in 0..count {
285        out.push(PackIncProp::read(buf, list_ofs + i * 8)?);
286    }
287    Ok(out)
288}
289
290fn read_pack_inc_cmds(buf: &[u8], list_ofs: usize, count: usize) -> Result<Vec<PackIncCmd>> {
291    let mut out = Vec::new();
292    if count == 0 {
293        return Ok(out);
294    }
295    let byte_len = count
296        .checked_mul(8)
297        .ok_or_else(|| anyhow!("scene_pck: inc_cmd_list size overflow"))?;
298    let end = list_ofs
299        .checked_add(byte_len)
300        .ok_or_else(|| anyhow!("scene_pck: inc_cmd_list offset overflow"))?;
301    if end > buf.len() {
302        bail!("scene_pck: inc_cmd_list out of bounds");
303    }
304    out.reserve(count);
305    for i in 0..count {
306        out.push(PackIncCmd::read(buf, list_ofs + i * 8)?);
307    }
308    Ok(out)
309}
310
311fn read_indexed_utf16_name_map(
312    buf: &[u8],
313    index_list_ofs: usize,
314    count: usize,
315    list_ofs: usize,
316) -> Result<HashMap<u32, String>> {
317    let mut out = HashMap::new();
318    if index_list_ofs + count * 8 > buf.len() || list_ofs > buf.len() {
319        return Ok(out);
320    }
321    for i in 0..count {
322        let idx = CIndex::read(buf, index_list_ofs + i * 8)?;
323        let o = idx.offset.max(0) as usize;
324        let n = idx.size.max(0) as usize;
325        let byte_off = list_ofs
326            .checked_add(o * 2)
327            .ok_or_else(|| anyhow!("scene_pck: name offset overflow"))?;
328        let byte_end = byte_off
329            .checked_add(n * 2)
330            .ok_or_else(|| anyhow!("scene_pck: name size overflow"))?;
331        if byte_end > buf.len() {
332            continue;
333        }
334        let mut u16s = Vec::with_capacity(n);
335        for j in 0..n {
336            let p = byte_off + j * 2;
337            let w = u16::from_le_bytes([buf[p], buf[p + 1]]);
338            if w == 0 {
339                break;
340            }
341            u16s.push(w);
342        }
343        let s = String::from_utf16_lossy(&u16s);
344        if !s.is_empty() {
345            out.insert(i as u32, s);
346        }
347    }
348    Ok(out)
349}
350
351fn read_scene_string_header(chunk: &[u8]) -> Result<(usize, usize, usize)> {
352    if chunk.len() < 28 {
353        bail!("scene_pck: scene chunk too short for string header");
354    }
355    let rd = |off: usize| -> i32 { i32::from_le_bytes(chunk[off..off + 4].try_into().unwrap()) };
356    let str_index_list_ofs = rd(12);
357    let str_index_cnt = rd(16);
358    let str_list_ofs = rd(20);
359    if str_index_list_ofs < 0 || str_index_cnt < 0 || str_list_ofs < 0 {
360        bail!("scene_pck: negative scene string-table field");
361    }
362    Ok((
363        str_index_list_ofs as usize,
364        str_index_cnt as usize,
365        str_list_ofs as usize,
366    ))
367}
368
369fn write_mdl_string_candidate<W: Write>(
370    out: &mut W,
371    chunk: &[u8],
372    index_list_ofs: usize,
373    str_list_ofs: usize,
374    str_id: usize,
375    codec: SceneStringCodec,
376) -> Result<usize> {
377    let idx = CIndex::read(chunk, index_list_ofs + str_id * 8)?;
378    if idx.offset < 0 || idx.size < 0 {
379        bail!("scene_pck: negative scene string index");
380    }
381    let units = idx.size as usize;
382    let byte_off = str_list_ofs
383        .checked_add(
384            (idx.offset as usize)
385                .checked_mul(2)
386                .ok_or_else(|| anyhow!("scene_pck: string offset overflow"))?,
387        )
388        .ok_or_else(|| anyhow!("scene_pck: string offset overflow"))?;
389    let byte_end = byte_off
390        .checked_add(
391            units
392                .checked_mul(2)
393                .ok_or_else(|| anyhow!("scene_pck: string size overflow"))?,
394        )
395        .ok_or_else(|| anyhow!("scene_pck: string size overflow"))?;
396    if byte_end > chunk.len() {
397        bail!("scene_pck: scene string data out of bounds");
398    }
399
400    out.write_all(&(units as u32).to_le_bytes())?;
401    let key = (28807u32).wrapping_mul(str_id as u32) as u16;
402    for unit_no in 0..units {
403        let pos = byte_off + unit_no * 2;
404        let raw = u16::from_le_bytes([chunk[pos], chunk[pos + 1]]);
405        let decoded = match codec {
406            SceneStringCodec::Plain => raw,
407            SceneStringCodec::Xor => raw ^ key,
408        };
409        out.write_all(&decoded.to_le_bytes())?;
410    }
411    Ok(units)
412}
413
414fn detect_scene_string_codec_mdl(buf: &[u8], header: &PackScnHeader) -> Result<SceneStringCodec> {
415    let scn_cnt = header.scn_data_cnt.max(header.scn_data_index_cnt).max(0) as usize;
416    let idx_ofs = header.scn_data_index_list_ofs.max(0) as usize;
417    let data_base = header.scn_data_list_ofs.max(0) as usize;
418    let mut detector = MdlDetector::new();
419    for scn_no in 0..scn_cnt {
420        let idx = CIndex::read(buf, idx_ofs + scn_no * 8)?;
421        if idx.size <= 0 {
422            continue;
423        }
424        let chunk_start = data_base
425            .checked_add(idx.offset.max(0) as usize)
426            .ok_or_else(|| anyhow!("scene_pck: scene offset overflow during MDL detection"))?;
427        let chunk_end = chunk_start
428            .checked_add(idx.size as usize)
429            .ok_or_else(|| anyhow!("scene_pck: scene size overflow during MDL detection"))?;
430        let chunk = buf
431            .get(chunk_start..chunk_end)
432            .ok_or_else(|| anyhow!("scene_pck: scene out of bounds during MDL detection"))?;
433        detector.feed(scn_no, chunk)?;
434    }
435    detector.finish()
436}
437
438/// Compares how well the scene strings compress decoded as plain UTF-16 and
439/// as XORed UTF-16; fed one decoded scene at a time.
440struct MdlDetector {
441    plain: DeflateEncoder<Vec<u8>>,
442    xor: DeflateEncoder<Vec<u8>>,
443    total_units: usize,
444}
445
446impl MdlDetector {
447    fn new() -> Self {
448        Self {
449            plain: DeflateEncoder::new(Vec::new(), Compression::best()),
450            xor: DeflateEncoder::new(Vec::new(), Compression::best()),
451            total_units: 0,
452        }
453    }
454
455    fn feed(&mut self, scn_no: usize, chunk: &[u8]) -> Result<()> {
456        let Self {
457            plain,
458            xor,
459            total_units,
460        } = self;
461        {
462            let (string_index_ofs, string_count, string_list_ofs) =
463                match read_scene_string_header(chunk) {
464                    Ok(v) => v,
465                    Err(_) => return Ok(()),
466                };
467            let index_end = string_index_ofs
468                .checked_add(
469                    string_count
470                        .checked_mul(8)
471                        .ok_or_else(|| anyhow!("scene_pck: string index size overflow"))?,
472                )
473                .ok_or_else(|| anyhow!("scene_pck: string index offset overflow"))?;
474            if index_end > chunk.len() || string_list_ofs > chunk.len() {
475                return Ok(());
476            }
477
478            // Identical scene/string framing is written to both candidates. The
479            // only difference is the candidate decoding of each UTF-16 code unit.
480            plain.write_all(&(scn_no as u32).to_le_bytes())?;
481            xor.write_all(&(scn_no as u32).to_le_bytes())?;
482            for str_id in 0..string_count {
483                plain.write_all(&(str_id as u32).to_le_bytes())?;
484                xor.write_all(&(str_id as u32).to_le_bytes())?;
485                *total_units = total_units.saturating_add(write_mdl_string_candidate(
486                    plain,
487                    chunk,
488                    string_index_ofs,
489                    string_list_ofs,
490                    str_id,
491                    SceneStringCodec::Plain,
492                )?);
493                let _ = write_mdl_string_candidate(
494                    xor,
495                    chunk,
496                    string_index_ofs,
497                    string_list_ofs,
498                    str_id,
499                    SceneStringCodec::Xor,
500                )?;
501            }
502        }
503        Ok(())
504    }
505
506    fn finish(self) -> Result<SceneStringCodec> {
507        let Self {
508            plain,
509            xor,
510            total_units,
511        } = self;
512        if total_units == 0 {
513            return Ok(SceneStringCodec::Xor);
514        }
515        let plain_len = plain.finish()?.len();
516        let xor_len = xor.finish()?.len();
517        Ok(if plain_len < xor_len {
518            SceneStringCodec::Plain
519        } else {
520            // Preserve the historical runtime behavior on ties or weak/noisy
521            // corpora; MDL must strictly prefer Plain before we disable XOR.
522            SceneStringCodec::Xor
523        })
524    }
525}
526
527fn resolve_scene_string_codec(
528    buf: &[u8],
529    header: &PackScnHeader,
530    mode: StringEncryptionOverride,
531) -> Result<SceneStringCodec> {
532    match mode {
533        StringEncryptionOverride::Xor => Ok(SceneStringCodec::Xor),
534        StringEncryptionOverride::None => Ok(SceneStringCodec::Plain),
535        StringEncryptionOverride::Mdl => detect_scene_string_codec_mdl(buf, header),
536    }
537}
538
539fn read_scene_pck_bytes(path: &Path) -> Result<Vec<u8>> {
540    #[cfg(target_os = "horizon")]
541    {
542        // `std::fs::read` reserves metadata().len() up front. fsdev metadata
543        // is not reliable on this custom target, so read the pack in bounded
544        // chunks and grow the Vec from actual bytes received.
545        let mut file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
546        let mut bytes = Vec::new();
547        let mut chunk = [0u8; 64 * 1024];
548        loop {
549            let count = file
550                .read(&mut chunk)
551                .with_context(|| format!("read {}", path.display()))?;
552            if count == 0 {
553                break;
554            }
555            bytes.extend_from_slice(&chunk[..count]);
556        }
557        return Ok(bytes);
558    }
559
560    #[cfg(not(target_os = "horizon"))]
561    {
562        fs::read(path).with_context(|| format!("read {}", path.display()))
563    }
564}
565
566impl ScenePck {
567    pub fn load_and_rebuild(path: &Path, opt: &ScenePckDecodeOptions) -> Result<Self> {
568        #[cfg(target_os = "vita")]
569        unsafe {
570            siglus_vita_log_marker(c"scene-pck: read begin".as_ptr().cast())
571        };
572        let tmp = read_scene_pck_bytes(path)?;
573        #[cfg(target_os = "vita")]
574        unsafe {
575            siglus_vita_log_marker(c"scene-pck: read complete".as_ptr().cast())
576        };
577        Self::load_and_rebuild_from_bytes(tmp, opt)
578    }
579
580    pub fn load_and_rebuild_from_bytes(
581        mut tmp: Vec<u8>,
582        opt: &ScenePckDecodeOptions,
583    ) -> Result<Self> {
584        if tmp.len() < 4 {
585            bail!("scene_pck: file too short");
586        }
587        let has_signature = tmp.len() >= 8 && &tmp[0..8] == b"pack_scn";
588        let header = PackScnHeader::read(&tmp, 0, has_signature)?;
589        let scn_data_list_ofs = header.scn_data_list_ofs as usize;
590        if scn_data_list_ofs > tmp.len() {
591            bail!("scene_pck: scn_data_list_ofs out of bounds");
592        }
593
594        // Rebuild m_scn_data exactly like the original implementation: keep everything before scn_data_list_ofs,
595        // then append decrypted/decompressed scene chunks contiguously.
596        let mut out = tmp[..scn_data_list_ofs].to_vec();
597        #[cfg(target_os = "vita")]
598        unsafe {
599            siglus_vita_log_marker(c"scene-pck: rebuild begin".as_ptr().cast())
600        };
601
602        // Load original index list from the input.
603        let idx_ofs = header.scn_data_index_list_ofs as usize;
604        let scn_cnt = if header.scn_data_cnt > 0 {
605            header.scn_data_cnt as usize
606        } else {
607            header.scn_data_index_cnt.max(0) as usize
608        };
609        if idx_ofs + scn_cnt * 8 > tmp.len() {
610            bail!("scene_pck: scn_data_index_list out of bounds");
611        }
612        let mut idx_list: Vec<CIndex> = Vec::with_capacity(scn_cnt);
613        for i in 0..scn_cnt {
614            idx_list.push(CIndex::read(&tmp, idx_ofs + i * 8)?);
615        }
616
617        // The rebuilt scene pack is much larger than the compressed input.
618        // Read each chunk's LZSS output size before rebuilding so Vec needs
619        // one allocation instead of growing and copying tens of MiB at a time.
620        let estimated_scene_bytes = idx_list.iter().try_fold(0usize, |total, entry| {
621            if entry.size <= 0 {
622                return Some(total);
623            }
624            let size = if header.original_source_header_size <= 0 {
625                entry.size as usize
626            } else {
627                let start = scn_data_list_ofs.checked_add(entry.offset.max(0) as usize)?;
628                let encrypted = tmp.get(start..start.checked_add(8)?)?;
629                let mut lzss_header = [0u8; 8];
630                for (index, byte) in encrypted.iter().enumerate() {
631                    let exe_byte = if header.scn_data_exe_angou_mod != 0 {
632                        opt.exe_angou_element
633                            .as_deref()
634                            .filter(|key| !key.is_empty())
635                            .map_or(0, |key| key[index % key.len()])
636                    } else {
637                        0
638                    };
639                    let easy_byte = opt
640                        .easy_angou_code
641                        .as_deref()
642                        .filter(|key| !key.is_empty())
643                        .map_or(0, |key| key[index % key.len()]);
644                    lzss_header[index] = byte ^ exe_byte ^ easy_byte;
645                }
646                u32::from_le_bytes(lzss_header[4..8].try_into().ok()?) as usize
647            };
648            total.checked_add(size)
649        });
650        if let Some(estimated_scene_bytes) = estimated_scene_bytes {
651            let prefix = scn_data_list_ofs
652                .checked_add(idx_list.first().map_or(0, |x| x.offset.max(0) as usize));
653            if let Some(total_len) = prefix.and_then(|n| n.checked_add(estimated_scene_bytes)) {
654                if total_len > out.len() {
655                    out.try_reserve_exact(total_len - out.len())?;
656                }
657            }
658        }
659
660        let mut offset = idx_list
661            .first()
662            .map(|x| x.offset.max(0) as usize)
663            .unwrap_or(0);
664        if out.len() < scn_data_list_ofs + offset {
665            out.resize(scn_data_list_ofs + offset, 0);
666        }
667
668        for scn_no in 0..scn_cnt {
669            #[cfg(target_os = "vita")]
670            if scn_no % 500 == 0 {
671                unsafe { siglus_vita_log_marker(c"scene-pck: rebuilding scenes".as_ptr().cast()) };
672            }
673            let entry = idx_list[scn_no];
674            let mut new_size = 0usize;
675
676            if entry.size > 0 {
677                let sp_off = scn_data_list_ofs
678                    .checked_add(entry.offset.max(0) as usize)
679                    .ok_or_else(|| anyhow!("scene_pck: offset overflow"))?;
680                let sp_end = sp_off
681                    .checked_add(entry.size as usize)
682                    .ok_or_else(|| anyhow!("scene_pck: size overflow"))?;
683                if sp_end > tmp.len() {
684                    bail!(
685                        "scene_pck: scn chunk out of bounds (scn_no={}, end={}, len={})",
686                        scn_no,
687                        sp_end,
688                        tmp.len()
689                    );
690                }
691
692                let chunk = &mut tmp[sp_off..sp_end];
693
694                let out_chunk: Vec<u8> = if header.original_source_header_size > 0 {
695                    // exe angou element XOR (optional)
696                    if header.scn_data_exe_angou_mod != 0
697                        && let Some(exe_el) = opt.exe_angou_element.as_deref()
698                    {
699                        if exe_el.is_empty() {
700                            // nothing
701                        } else {
702                            let mut eac = 0usize;
703                            for b in chunk.iter_mut() {
704                                *b ^= exe_el[eac];
705                                eac += 1;
706                                if eac >= exe_el.len() {
707                                    eac = 0;
708                                }
709                            }
710                        }
711                    }
712
713                    // easy angou XOR (optional)
714                    if let Some(easy) = opt.easy_angou_code.as_deref()
715                        && !easy.is_empty()
716                    {
717                        let mut eac = 0usize;
718                        for b in chunk.iter_mut() {
719                            *b ^= easy[eac];
720                            eac += 1;
721                            if eac >= easy.len() {
722                                eac = 0;
723                            }
724                        }
725                    }
726
727                    lzss_unpack_lenient(chunk)
728                        .with_context(|| format!("scene_pck: lzss unpack scn_no={}", scn_no))?
729                } else {
730                    // Easy-link mode: keep the chunk bytes as-is.
731                    chunk.to_vec()
732                };
733
734                new_size = out_chunk.len();
735                let dst_off = scn_data_list_ofs + offset;
736                let need_len = dst_off
737                    .checked_add(new_size)
738                    .ok_or_else(|| anyhow!("scene_pck: out size overflow"))?;
739                if out.len() < need_len {
740                    out.resize(need_len, 0);
741                }
742                out[dst_off..dst_off + new_size].copy_from_slice(&out_chunk);
743            }
744
745            // Patch the index list inside the output buffer.
746            let out_idx_ofs = header.scn_data_index_list_ofs as usize;
747            let out_entry_ofs = out_idx_ofs + scn_no * 8;
748            if out_entry_ofs + 8 > out.len() {
749                bail!("scene_pck: output index list out of bounds");
750            }
751            out[out_entry_ofs..out_entry_ofs + 4].copy_from_slice(&(offset as i32).to_le_bytes());
752            out[out_entry_ofs + 4..out_entry_ofs + 8]
753                .copy_from_slice(&(new_size as i32).to_le_bytes());
754
755            offset = offset
756                .checked_add(new_size)
757                .ok_or_else(|| anyhow!("scene_pck: offset overflow"))?;
758        }
759
760        let scn_name_map = read_scene_name_map(&out, &header)?;
761        let inc_prop_name_map = read_indexed_utf16_name_map(
762            &out,
763            header.inc_prop_name_index_list_ofs.max(0) as usize,
764            header.inc_prop_name_cnt.max(0) as usize,
765            header.inc_prop_name_list_ofs.max(0) as usize,
766        )?;
767        let inc_cmd_name_map = read_indexed_utf16_name_map(
768            &out,
769            header.inc_cmd_name_index_list_ofs.max(0) as usize,
770            header.inc_cmd_name_cnt.max(0) as usize,
771            header.inc_cmd_name_list_ofs.max(0) as usize,
772        )?;
773        let inc_props = read_pack_inc_props(
774            &out,
775            header.inc_prop_list_ofs.max(0) as usize,
776            header.inc_prop_cnt.max(0) as usize,
777        )?;
778        let inc_cmds = read_pack_inc_cmds(
779            &out,
780            header.inc_cmd_list_ofs.max(0) as usize,
781            header.inc_cmd_cnt.max(0) as usize,
782        )?;
783        let string_codec =
784            resolve_scene_string_codec(&out, &header, opt.string_encryption_override)?;
785        #[cfg(target_os = "vita")]
786        unsafe {
787            siglus_vita_log_marker(c"scene-pck: rebuild complete".as_ptr().cast())
788        };
789
790        drop(tmp);
791        Ok(Self {
792            // Arc<Vec<u8>> shares the rebuilt allocation directly. Converting
793            // to Arc<[u8]> copies the entire pack, which is tens of MiB for
794            // RewriteHF and can exceed the Vita startup memory budget.
795            buf: Arc::new(out),
796            header,
797            scn_name_map,
798            inc_prop_name_map,
799            inc_cmd_name_map: Arc::new(inc_cmd_name_map),
800            inc_props,
801            inc_cmds,
802            string_codec,
803            lazy: None,
804        })
805    }
806
807    /// Opens a pack without rebuilding it: scenes stay compressed (a
808    /// quarter of the rebuilt size for RewriteHF, 11 MiB instead of 43)
809    /// and are decompressed when a scene is first used.
810    pub fn load_lazy(path: &Path, opt: &ScenePckDecodeOptions) -> Result<Self> {
811        Self::load_lazy_from_bytes(read_scene_pck_bytes(path)?, opt)
812    }
813
814    pub fn load_lazy_from_bytes(mut tmp: Vec<u8>, opt: &ScenePckDecodeOptions) -> Result<Self> {
815        if tmp.len() < 4 {
816            bail!("scene_pck: file too short");
817        }
818        let has_signature = tmp.len() >= 8 && &tmp[0..8] == b"pack_scn";
819        let header = PackScnHeader::read(&tmp, 0, has_signature)?;
820        let scn_data_list_ofs = header.scn_data_list_ofs as usize;
821        if scn_data_list_ofs > tmp.len() {
822            bail!("scene_pck: scn_data_list_ofs out of bounds");
823        }
824        let idx_ofs = header.scn_data_index_list_ofs as usize;
825        let scn_cnt = if header.scn_data_cnt > 0 {
826            header.scn_data_cnt as usize
827        } else {
828            header.scn_data_index_cnt.max(0) as usize
829        };
830        if idx_ofs + scn_cnt * 8 > tmp.len() {
831            bail!("scene_pck: scn_data_index_list out of bounds");
832        }
833        let compressed = header.original_source_header_size > 0;
834        let mut ranges = Vec::with_capacity(scn_cnt);
835        for scn_no in 0..scn_cnt {
836            let entry = CIndex::read(&tmp, idx_ofs + scn_no * 8)?;
837            if entry.size <= 0 {
838                ranges.push(0..0);
839                continue;
840            }
841            let start = scn_data_list_ofs
842                .checked_add(entry.offset.max(0) as usize)
843                .ok_or_else(|| anyhow!("scene_pck: offset overflow"))?;
844            let end = start
845                .checked_add(entry.size as usize)
846                .ok_or_else(|| anyhow!("scene_pck: size overflow"))?;
847            if end > tmp.len() {
848                bail!("scene_pck: scn chunk out of bounds (scn_no={scn_no})");
849            }
850            // Decrypt in place now; only the LZSS stage is left for later.
851            if compressed {
852                let chunk = &mut tmp[start..end];
853                if header.scn_data_exe_angou_mod != 0
854                    && let Some(exe) = opt.exe_angou_element.as_deref().filter(|k| !k.is_empty())
855                {
856                    for (i, b) in chunk.iter_mut().enumerate() {
857                        *b ^= exe[i % exe.len()];
858                    }
859                }
860                if let Some(easy) = opt.easy_angou_code.as_deref().filter(|k| !k.is_empty()) {
861                    for (i, b) in chunk.iter_mut().enumerate() {
862                        *b ^= easy[i % easy.len()];
863                    }
864                }
865            }
866            ranges.push(start..end);
867        }
868        let lazy = Arc::new(LazyScenes {
869            compressed,
870            ranges,
871            decoded: std::sync::Mutex::new(HashMap::new()),
872        });
873
874        let scn_name_map = read_scene_name_map(&tmp, &header)?;
875        let inc_prop_name_map = read_indexed_utf16_name_map(
876            &tmp,
877            header.inc_prop_name_index_list_ofs.max(0) as usize,
878            header.inc_prop_name_cnt.max(0) as usize,
879            header.inc_prop_name_list_ofs.max(0) as usize,
880        )?;
881        let inc_cmd_name_map = read_indexed_utf16_name_map(
882            &tmp,
883            header.inc_cmd_name_index_list_ofs.max(0) as usize,
884            header.inc_cmd_name_cnt.max(0) as usize,
885            header.inc_cmd_name_list_ofs.max(0) as usize,
886        )?;
887        let inc_props = read_pack_inc_props(
888            &tmp,
889            header.inc_prop_list_ofs.max(0) as usize,
890            header.inc_prop_cnt.max(0) as usize,
891        )?;
892        let inc_cmds = read_pack_inc_cmds(
893            &tmp,
894            header.inc_cmd_list_ofs.max(0) as usize,
895            header.inc_cmd_cnt.max(0) as usize,
896        )?;
897        let mut pack = Self {
898            buf: Arc::new(tmp),
899            header,
900            scn_name_map,
901            inc_prop_name_map,
902            inc_cmd_name_map: Arc::new(inc_cmd_name_map),
903            inc_props,
904            inc_cmds,
905            string_codec: SceneStringCodec::Xor,
906            lazy: Some(lazy),
907        };
908        pack.string_codec = match opt.string_encryption_override {
909            StringEncryptionOverride::Xor => SceneStringCodec::Xor,
910            StringEncryptionOverride::None => SceneStringCodec::Plain,
911            StringEncryptionOverride::Mdl => {
912                // One decoded scene at a time.
913                let mut detector = MdlDetector::new();
914                for scn_no in 0..scn_cnt {
915                    let chunk = pack.scn_data_slice(scn_no)?;
916                    if !chunk.is_empty() {
917                        detector.feed(scn_no, &chunk)?;
918                    }
919                }
920                detector.finish()?
921            }
922        };
923        Ok(pack)
924    }
925
926    /// Whether scenes are decompressed on demand (`load_lazy`).
927    pub fn is_lazy(&self) -> bool {
928        self.lazy.is_some()
929    }
930
931    /// A lazily loaded scene, decompressed now or shared with whoever still
932    /// holds it.
933    fn lazy_scene(
934        lazy: &LazyScenes,
935        buf: &Arc<Vec<u8>>,
936        scn_no: usize,
937    ) -> Result<(Arc<Vec<u8>>, Range<usize>)> {
938        let range = lazy
939            .ranges
940            .get(scn_no)
941            .cloned()
942            .ok_or_else(|| anyhow!("scene_pck: scn_no out of range"))?;
943        if range.is_empty() || !lazy.compressed {
944            return Ok((buf.clone(), range));
945        }
946        let mut decoded = lazy.decoded.lock().expect("scene cache lock poisoned");
947        if let Some(scene) = decoded.get(&scn_no).and_then(std::sync::Weak::upgrade) {
948            let len = scene.len();
949            return Ok((scene, 0..len));
950        }
951        let scene = Arc::new(
952            lzss_unpack_lenient(&buf[range])
953                .with_context(|| format!("scene_pck: lzss unpack scn_no={scn_no}"))?,
954        );
955        decoded.retain(|_, weak| weak.strong_count() > 0);
956        decoded.insert(scn_no, Arc::downgrade(&scene));
957        let len = scene.len();
958        Ok((scene, 0..len))
959    }
960
961    fn scn_data_range(&self, scn_no: usize) -> Result<Range<usize>> {
962        let scn_cnt = self.header.scn_data_cnt.max(0) as usize;
963        if scn_no >= scn_cnt {
964            bail!("scene_pck: scn_no out of range");
965        }
966        let idx_ofs = self.header.scn_data_index_list_ofs as usize;
967        let entry = CIndex::read(&self.buf, idx_ofs + scn_no * 8)?;
968        if entry.size <= 0 {
969            return Ok(0..0);
970        }
971        let base = self.header.scn_data_list_ofs as usize;
972        let off = base
973            .checked_add(entry.offset.max(0) as usize)
974            .ok_or_else(|| anyhow!("scene_pck: offset overflow"))?;
975        let end = off
976            .checked_add(entry.size as usize)
977            .ok_or_else(|| anyhow!("scene_pck: size overflow"))?;
978        if end > self.buf.len() {
979            bail!("scene_pck: scn slice out of bounds");
980        }
981        Ok(off..end)
982    }
983
984    /// A scene's bytecode (decompressed first for a lazily loaded pack).
985    pub fn scn_data_slice(&self, scn_no: usize) -> Result<std::borrow::Cow<'_, [u8]>> {
986        if let Some(lazy) = &self.lazy {
987            let (owner, range) = Self::lazy_scene(lazy, &self.buf, scn_no)?;
988            return Ok(std::borrow::Cow::Owned(owner[range].to_vec()));
989        }
990        let range = self.scn_data_range(scn_no)?;
991        Ok(std::borrow::Cow::Borrowed(&self.buf[range]))
992    }
993
994    /// Return shared backing storage plus the scene-local byte range.
995    /// SceneStream uses this to borrow directly from the rebuilt Scene.pck
996    /// (or, lazily loaded, from the scene's own decompressed buffer) without
997    /// copying or leaking each visited scene chunk.
998    pub fn scn_data_shared(&self, scn_no: usize) -> Result<(Arc<Vec<u8>>, Range<usize>)> {
999        if let Some(lazy) = &self.lazy {
1000            return Self::lazy_scene(lazy, &self.buf, scn_no);
1001        }
1002        let range = self.scn_data_range(scn_no)?;
1003        Ok((self.buf.clone(), range))
1004    }
1005
1006    pub fn find_scene_no(&self, name_or_index: &str) -> Option<usize> {
1007        if let Ok(i) = name_or_index.parse::<usize>() {
1008            return Some(i);
1009        }
1010
1011        // Original SiglusCompiler lower-cases every scene name written into
1012        // Scene.pck (`main_proc_link.cpp`), and the runtime lexer lower-cases
1013        // the requested name before looking it up (`tnm_lexer.cpp::get_scn_no`).
1014        // Script source is therefore allowed to spell a scene with arbitrary
1015        // ASCII case, e.g. `_RB_titlemenu` while the pack contains
1016        // `_rb_titlemenu`.  Keep the fast exact lookup first, then mirror the
1017        // original case-insensitive lookup semantics.
1018        self.scn_name_map.get(name_or_index).copied().or_else(|| {
1019            self.scn_name_map.iter().find_map(|(name, scene_no)| {
1020                name.eq_ignore_ascii_case(name_or_index)
1021                    .then_some(*scene_no)
1022            })
1023        })
1024    }
1025
1026    pub fn find_scene_name(&self, scn_no: usize) -> Option<&str> {
1027        self.scn_name_map.iter().find_map(|(name, no)| {
1028            if *no == scn_no {
1029                Some(name.as_str())
1030            } else {
1031                None
1032            }
1033        })
1034    }
1035
1036    pub fn find_inc_cmd_no(&self, cmd_name: &str) -> Option<usize> {
1037        self.inc_cmd_name_map.iter().find_map(|(no, name)| {
1038            if name.eq_ignore_ascii_case(cmd_name) {
1039                Some(*no as usize)
1040            } else {
1041                None
1042            }
1043        })
1044    }
1045}
1046
1047fn find_child_case_insensitive(
1048    parent: &Path,
1049    name: &str,
1050    want_dir: bool,
1051) -> Result<Option<std::path::PathBuf>> {
1052    let exact = parent.join(name);
1053    if (want_dir && exact.is_dir()) || (!want_dir && exact.is_file()) {
1054        return Ok(Some(exact));
1055    }
1056
1057    let entries = match std::fs::read_dir(parent) {
1058        Ok(entries) => entries,
1059        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1060        Err(err) => return Err(err).with_context(|| format!("read_dir {}", parent.display())),
1061    };
1062    let mut matches = Vec::new();
1063    for entry in entries {
1064        let entry = entry?;
1065        if !entry
1066            .file_name()
1067            .to_string_lossy()
1068            .eq_ignore_ascii_case(name)
1069        {
1070            continue;
1071        }
1072        let path = entry.path();
1073        if (want_dir && path.is_dir()) || (!want_dir && path.is_file()) {
1074            matches.push(path);
1075        }
1076    }
1077    matches.sort();
1078    match matches.len() {
1079        0 => Ok(None),
1080        1 => Ok(matches.pop()),
1081        _ => bail!(
1082            "scene_pck: case-insensitive path conflict for {} under {}: {}",
1083            name,
1084            parent.display(),
1085            matches
1086                .iter()
1087                .map(|p| p.display().to_string())
1088                .collect::<Vec<_>>()
1089                .join(", ")
1090        ),
1091    }
1092}
1093
1094/// Helper for typical game directory layout using the Windows-style
1095/// case-insensitive path semantics expected by Siglus.
1096pub fn find_scene_pck_in_project(project_dir: &Path) -> Result<std::path::PathBuf> {
1097    if let Some(path) = find_child_case_insensitive(project_dir, "Scene.pck", false)? {
1098        return Ok(path);
1099    }
1100    if let Some(data_dir) = find_child_case_insensitive(project_dir, "Data", true)?
1101        && let Some(path) = find_child_case_insensitive(&data_dir, "Scene.pck", false)?
1102    {
1103        return Ok(path);
1104    }
1105    bail!(
1106        "scene_pck: Scene.pck not found under {}",
1107        project_dir.display()
1108    );
1109}
1110
1111#[cfg(test)]
1112mod scene_name_lookup_tests {
1113    use super::*;
1114
1115    fn header_for_lookup_test() -> PackScnHeader {
1116        PackScnHeader {
1117            header_size: 0,
1118            inc_prop_list_ofs: 0,
1119            inc_prop_cnt: 0,
1120            inc_prop_name_index_list_ofs: 0,
1121            inc_prop_name_index_cnt: 0,
1122            inc_prop_name_list_ofs: 0,
1123            inc_prop_name_cnt: 0,
1124            inc_cmd_list_ofs: 0,
1125            inc_cmd_cnt: 0,
1126            inc_cmd_name_index_list_ofs: 0,
1127            inc_cmd_name_index_cnt: 0,
1128            inc_cmd_name_list_ofs: 0,
1129            inc_cmd_name_cnt: 0,
1130            scn_name_index_list_ofs: 0,
1131            scn_name_index_cnt: 0,
1132            scn_name_list_ofs: 0,
1133            scn_name_cnt: 0,
1134            scn_data_index_list_ofs: 0,
1135            scn_data_index_cnt: 0,
1136            scn_data_list_ofs: 0,
1137            scn_data_cnt: 0,
1138            scn_data_exe_angou_mod: 0,
1139            original_source_header_size: 0,
1140        }
1141    }
1142
1143    #[test]
1144    fn scene_name_lookup_matches_original_case_insensitive_lexer() {
1145        let mut scn_name_map = HashMap::new();
1146        scn_name_map.insert("_rb_titlemenu".to_string(), 37);
1147        let pck = ScenePck {
1148            buf: Arc::new(Vec::new()),
1149            header: header_for_lookup_test(),
1150            scn_name_map,
1151            inc_prop_name_map: HashMap::new(),
1152            inc_cmd_name_map: Arc::default(),
1153            inc_props: Vec::new(),
1154            inc_cmds: Vec::new(),
1155            string_codec: SceneStringCodec::Xor,
1156            lazy: None,
1157        };
1158
1159        assert_eq!(pck.find_scene_no("_RB_titlemenu"), Some(37));
1160        assert_eq!(pck.find_scene_no("_rb_titlemenu"), Some(37));
1161    }
1162}