Skip to main content

siglus_assets/
thumb_table.rs

1//! Thumb table loader.
2//!
3//! Loads the thumbnail lookup table.
4//!
5//! File layout (on disk):
6//! - `S_tnm_thumbnail_database_header`:
7//!   - `i32 header_size`
8//!   - `i32 version`
9//!   - `i32 data_cnt`
10//! - `lzss_stream[...]` (Siglus LZSS byte-oriented stream)
11//!
12//! Decompressed layout:
13//! - Repeated `data_cnt` times:
14//!   - `TCHAR pct[]` (UTF-16LE) NUL-terminated
15//!   - `TCHAR thumb[]` (UTF-16LE) NUL-terminated
16//!
17//! The engine lowercases both strings (`str_to_lower`) and inserts them into
18//! a map `pct -> thumb`.
19
20use crate::lzss;
21use crate::util::read_i32_le;
22use anyhow::{bail, Result};
23use std::collections::BTreeMap;
24use std::fs;
25use std::path::Path;
26
27#[derive(Debug, Clone)]
28pub struct ThumbTable {
29    header_size: i32,
30    version: i32,
31    map: BTreeMap<String, String>,
32}
33
34impl ThumbTable {
35    /// Load and decode a `thumb_table_file.dat` from disk.
36    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
37        let bytes = fs::read(path)?;
38        Self::from_bytes(&bytes)
39    }
40
41    /// Load and decode a `thumb_table_file.dat` from an in-memory buffer.
42    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
43        let mut off = 0usize;
44        let header_size = read_i32_le(bytes, &mut off)?;
45        let version = read_i32_le(bytes, &mut off)?;
46        let data_cnt = read_i32_le(bytes, &mut off)?;
47        if data_cnt < 0 {
48            bail!("thumb_table: invalid data_cnt={data_cnt}");
49        }
50        if off > bytes.len() {
51            bail!("thumb_table: unexpected EOF in header");
52        }
53
54        // The remaining buffer is an LZSS stream.
55        let unpack = lzss::lzss_unpack(&bytes[off..])?;
56        let mut uoff = 0usize;
57
58        let mut map: BTreeMap<String, String> = BTreeMap::new();
59        for _ in 0..(data_cnt as usize) {
60            let pct = read_tchar_null(&unpack, &mut uoff)?;
61            let thumb = read_tchar_null(&unpack, &mut uoff)?;
62            let pct = to_lowercase_like_engine(&pct);
63            let thumb = to_lowercase_like_engine(&thumb);
64            map.insert(pct, thumb);
65        }
66
67        Ok(Self {
68            header_size,
69            version,
70            map,
71        })
72    }
73
74    pub fn header_size(&self) -> i32 {
75        self.header_size
76    }
77
78    pub fn version(&self) -> i32 {
79        self.version
80    }
81
82    pub fn map(&self) -> &BTreeMap<String, String> {
83        &self.map
84    }
85
86    /// Lookup `pct` after applying the engine's lowercasing.
87    pub fn get(&self, pct: &str) -> Option<&String> {
88        let key = to_lowercase_like_engine(pct);
89        self.map.get(&key)
90    }
91
92    /// Helper matching the `calc_thumb_file_name` lookup behavior:
93    /// provide a file name (with or without extension); the extension is
94    /// stripped and the remaining stem is lowercased before lookup.
95    pub fn get_by_file_stem(&self, name: &str) -> Option<&String> {
96        let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name);
97        self.get(stem)
98    }
99}
100
101fn read_tchar_null(buf: &[u8], off: &mut usize) -> Result<String> {
102    let mut u16s: Vec<u16> = Vec::new();
103    loop {
104        if *off + 2 > buf.len() {
105            bail!("thumb_table: unterminated TCHAR string");
106        }
107        let w = u16::from_le_bytes([buf[*off], buf[*off + 1]]);
108        *off += 2;
109        if w == 0 {
110            break;
111        }
112        u16s.push(w);
113    }
114    Ok(String::from_utf16_lossy(&u16s))
115}
116
117/// Siglus uses `str_to_lower` on `TSTR`. We don't have the exact CRT/Win32
118/// locale behavior here; this implementation applies Unicode simple lowercase
119/// mapping, which matches the expected behavior for ASCII filenames.
120fn to_lowercase_like_engine(s: &str) -> String {
121    s.chars().flat_map(|c| c.to_lowercase()).collect()
122}