Skip to main content

siglus_scene_vm/
emote_key.rs

1//! Emote PSB key preload policy for native hosts.
2//!
3//! The original engine resolves Emote models through `tnm_find_psb`, which only
4//! searches the `dat` directory of Select.ini append entries.  Keep key discovery
5//! within that same resource scope: never recursively scan the loose game tree,
6//! and never start a DWORD bruteforce from `OBJECT.CREATE_EMOTE` in the live frame
7//! loop.
8
9use std::fs::{self, File};
10use std::io::{Read, Seek, SeekFrom};
11use std::path::{Path, PathBuf};
12
13use eluna::{
14    PsbBruteforceOptions, PsbDecryptionKey, PsbError, PsbFile, PsbHeader, PsbNormalizeOptions,
15    bruteforce_emote_key, normalize_psb_input,
16};
17
18#[derive(Debug)]
19enum ProbeOutcome {
20    NoEncryptedPsb,
21    ValidatedKnownKey { path: PathBuf },
22    NeedsKey { path: PathBuf },
23}
24
25/// Resolve the project Emote DWORD before scene execution starts.
26///
27/// Priority:
28/// 1. `key.toml: emote_key`, validated against an encrypted PSB when one exists;
29/// 2. fallback user-cache key from an earlier run where `key.toml` was not writable;
30/// 3. one Eluna DWORD bruteforce against the smallest encrypted `dat/*.psb` candidate.
31///
32/// A newly discovered key is written back to `key.toml`.  Only if that write fails
33/// is the fallback user cache used.  Failures are logged and do not abort engine
34/// bootstrap; later Emote loading can report the missing/invalid key precisely.
35pub fn preload_emote_key(project_dir: &Path) -> Option<u32> {
36    let configured = match siglus_assets::key_toml::load_emote_key_from_project_dir(project_dir) {
37        Ok(key) => key,
38        Err(err) => {
39            log::warn!(
40                "Emote key preload: failed to read {}/key.toml: {:#}",
41                project_dir.display(),
42                err
43            );
44            None
45        }
46    };
47
48    let cache_path = fallback_cache_path(project_dir);
49    let cached = if configured.is_none() {
50        cache_path.as_deref().and_then(load_cached_key_best_effort)
51    } else {
52        None
53    };
54    let known_key = configured.or(cached);
55
56    let candidates = match crate::resource::find_emote_psb_candidates(project_dir) {
57        Ok(candidates) => candidates,
58        Err(err) => {
59            log::warn!(
60                "Emote key preload: failed to enumerate append dat/*.psb candidates: {:#}",
61                err
62            );
63            return known_key;
64        }
65    };
66
67    if candidates.is_empty() {
68        if let Some(key) = known_key {
69            log::info!(
70                "Emote key preload: no dat/*.psb candidates found; retaining configured/cached key 0x{key:08X}"
71            );
72        }
73        return known_key;
74    }
75
76    let probe = match probe_candidates(&candidates, known_key) {
77        Ok(probe) => probe,
78        Err(err) => {
79            log::warn!("Emote key preload: PSB probe failed: {:#}", err);
80            return known_key;
81        }
82    };
83
84    match probe {
85        ProbeOutcome::NoEncryptedPsb => {
86            if let Some(key) = known_key {
87                log::info!(
88                    "Emote key preload: candidate PSBs are plain/unrelated; retaining known key 0x{key:08X}"
89                );
90            }
91            known_key
92        }
93        ProbeOutcome::ValidatedKnownKey { path } => {
94            let key = known_key.expect("validated key outcome requires a known key");
95            if configured.is_some() {
96                log::info!(
97                    "Emote key preload: key.toml emote_key 0x{key:08X} validated with {}",
98                    path.display()
99                );
100            } else {
101                log::info!(
102                    "Emote key preload: cached emote_key 0x{key:08X} validated with {}",
103                    path.display()
104                );
105                // A fallback cache only exists because an earlier key.toml write
106                // failed.  Retry the preferred project-local persistence on every
107                // validated startup so permission changes heal automatically.
108                persist_recovered_key(project_dir, key, cache_path.as_deref());
109            }
110            Some(key)
111        }
112        ProbeOutcome::NeedsKey { path } => {
113            if let Some(key) = known_key {
114                log::warn!(
115                    "Emote key preload: known emote_key 0x{key:08X} did not validate against encrypted candidates; recovering from {}",
116                    path.display()
117                );
118            } else {
119                log::info!(
120                    "Emote key preload: encrypted Emote PSB detected at {}; starting one-time DWORD recovery before scene execution",
121                    path.display()
122                );
123            }
124            recover_key_from_probe(project_dir, &path, cache_path.as_deref())
125        }
126    }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130enum CandidateEncryption {
131    Plain,
132    NeedsKey,
133    FullProbe,
134}
135
136/// Classify the common raw-PSB case with two tiny reads instead of loading the
137/// whole file.  This mirrors Eluna's current `apply_psb_decryption` boundary:
138/// an explicit encryption flag requires a key, and legacy body-only variants
139/// are treated as encrypted when the root byte is not an OBJECT tag. Wrapped
140/// MDF/LZ4 inputs fall back to Eluna's normalizer because the inner PSB header
141/// is not directly seekable.
142fn classify_candidate_file(path: &Path) -> std::io::Result<CandidateEncryption> {
143    const HEADER_PROBE_LEN: usize = 0x2c;
144    const PSB_OBJECT_ROOT_TAG: u8 = 0x21;
145
146    let mut file = File::open(path)?;
147    let mut header_bytes = [0u8; HEADER_PROBE_LEN];
148    let read = file.read(&mut header_bytes)?;
149    let header_bytes = &header_bytes[..read];
150
151    // Raw PSB signature. MDF/LZ4 and unrelated data are delegated to Eluna so
152    // this host layer never duplicates wrapper decoding.
153    if header_bytes.len() < 4 || &header_bytes[..4] != b"PSB\0" {
154        return Ok(CandidateEncryption::FullProbe);
155    }
156
157    let header = match PsbHeader::read(header_bytes) {
158        Ok(header) => header,
159        Err(_) => return Ok(CandidateEncryption::FullProbe),
160    };
161    if header.has_encryption_flag() {
162        return Ok(CandidateEncryption::NeedsKey);
163    }
164
165    let file_len = file.metadata()?.len();
166    let root_offset = u64::from(header.root_offset);
167    if root_offset >= file_len {
168        return Ok(CandidateEncryption::FullProbe);
169    }
170    file.seek(SeekFrom::Start(root_offset))?;
171    let mut root = [0u8; 1];
172    file.read_exact(&mut root)?;
173    Ok(if root[0] == PSB_OBJECT_ROOT_TAG {
174        CandidateEncryption::Plain
175    } else {
176        CandidateEncryption::NeedsKey
177    })
178}
179
180fn probe_candidates(
181    candidates: &[PathBuf],
182    known_key: Option<u32>,
183) -> anyhow::Result<ProbeOutcome> {
184    let mut first_encrypted: Option<PathBuf> = None;
185
186    for path in candidates {
187        let classification = match classify_candidate_file(path) {
188            Ok(classification) => classification,
189            Err(err) => {
190                log::warn!(
191                    "Emote key preload: cannot inspect candidate {}: {}",
192                    path.display(),
193                    err
194                );
195                continue;
196            }
197        };
198        if classification == CandidateEncryption::Plain {
199            continue;
200        }
201
202        let data = match fs::read(path) {
203            Ok(data) => data,
204            Err(err) => {
205                log::warn!(
206                    "Emote key preload: cannot read candidate {}: {}",
207                    path.display(),
208                    err
209                );
210                continue;
211            }
212        };
213
214        if classification == CandidateEncryption::FullProbe {
215            match normalize_psb_input(&data, &PsbNormalizeOptions::default()) {
216                Ok(_) => continue,
217                Err(PsbError::EncryptedPsbRequiresKey) => {}
218                Err(err) => {
219                    // `dat` can contain PSBs unrelated to Emote. Do not interpret
220                    // an arbitrary parse failure as encryption/bruteforce input.
221                    log::debug!(
222                        "Emote key preload: skipping non-probe PSB {}: {}",
223                        path.display(),
224                        err
225                    );
226                    continue;
227                }
228            }
229        }
230
231        if first_encrypted.is_none() {
232            first_encrypted = Some(path.clone());
233        }
234
235        if let Some(key) = known_key {
236            if validate_key_bytes(&data, key) {
237                return Ok(ProbeOutcome::ValidatedKnownKey { path: path.clone() });
238            }
239            // A corrupt/unrelated encrypted PSB should not invalidate a known
240            // project key by itself. Scan remaining encrypted candidates for one
241            // that validates before deciding the key is stale.
242            continue;
243        }
244
245        // Without a known key, use the first encrypted candidate. Candidates are
246        // already ordered by file size to minimize full-check/decrypt work.
247        return Ok(ProbeOutcome::NeedsKey { path: path.clone() });
248    }
249
250    Ok(match first_encrypted {
251        Some(path) => ProbeOutcome::NeedsKey { path },
252        None => ProbeOutcome::NoEncryptedPsb,
253    })
254}
255
256fn validate_key_bytes(data: &[u8], key: u32) -> bool {
257    let options = PsbNormalizeOptions {
258        decrypt_key: Some(PsbDecryptionKey::emote_key(key)),
259        ..PsbNormalizeOptions::default()
260    };
261    PsbFile::parse_normalized(data, &options).is_ok()
262}
263
264fn recover_key_from_probe(
265    project_dir: &Path,
266    probe_path: &Path,
267    cache_path: Option<&Path>,
268) -> Option<u32> {
269    let data = match fs::read(probe_path) {
270        Ok(data) => data,
271        Err(err) => {
272            log::error!(
273                "Emote key preload: cannot read recovery probe {}: {}",
274                probe_path.display(),
275                err
276            );
277            return None;
278        }
279    };
280
281    let result = match bruteforce_emote_key(&data, PsbBruteforceOptions::default()) {
282        Ok(result) => result,
283        Err(err) => {
284            log::error!(
285                "Emote key preload: Eluna DWORD recovery failed for {}: {}",
286                probe_path.display(),
287                err
288            );
289            return None;
290        }
291    };
292    let Some(result) = result else {
293        log::error!(
294            "Emote key preload: {} was classified as encrypted but Eluna found no DWORD key",
295            probe_path.display()
296        );
297        return None;
298    };
299
300    let key = result.key;
301    if !validate_key_bytes(&data, key) {
302        // Eluna's bruteforce already performs a full PSB parse confirmation. Keep
303        // this host-side check as a hard boundary before persisting the key.
304        log::error!(
305            "Emote key preload: recovered key 0x{key:08X} failed host validation for {}",
306            probe_path.display()
307        );
308        return None;
309    }
310
311    log::info!(
312        "Emote key preload: recovered emote_key=0x{key:08X} from {} after testing {} candidates",
313        probe_path.display(),
314        result.tested_keys
315    );
316    persist_recovered_key(project_dir, key, cache_path);
317    Some(key)
318}
319
320fn persist_recovered_key(project_dir: &Path, key: u32, cache_path: Option<&Path>) {
321    match siglus_assets::key_toml::write_emote_key_to_project_dir(project_dir, key) {
322        Ok(path) => {
323            log::info!(
324                "Emote key preload: persisted emote_key=0x{key:08X} to {}",
325                path.display()
326            );
327            if let Some(cache_path) = cache_path {
328                let _ = fs::remove_file(cache_path);
329            }
330            return;
331        }
332        Err(err) => {
333            log::warn!(
334                "Emote key preload: could not update {}/key.toml: {:#}; using fallback cache",
335                project_dir.display(),
336                err
337            );
338        }
339    }
340
341    let Some(cache_path) = cache_path else {
342        log::warn!(
343            "Emote key preload: no writable platform cache location; recovered key remains in memory for this run only"
344        );
345        return;
346    };
347    let Some(parent) = cache_path.parent() else {
348        return;
349    };
350    if let Err(err) = fs::create_dir_all(parent) {
351        log::warn!(
352            "Emote key preload: cannot create fallback cache {}: {}",
353            parent.display(),
354            err
355        );
356        return;
357    }
358    match siglus_assets::key_toml::write_emote_key_to_file(cache_path, key) {
359        Ok(()) => log::info!(
360            "Emote key preload: cached emote_key=0x{key:08X} at {}",
361            cache_path.display()
362        ),
363        Err(err) => log::warn!(
364            "Emote key preload: cannot persist fallback cache {}: {:#}",
365            cache_path.display(),
366            err
367        ),
368    }
369}
370
371fn load_cached_key_best_effort(path: &Path) -> Option<u32> {
372    if !path.is_file() {
373        return None;
374    }
375    match siglus_assets::key_toml::load_emote_key_from_file(path) {
376        Ok(key) => key,
377        Err(err) => {
378            log::warn!(
379                "Emote key preload: ignoring invalid fallback cache {}: {:#}",
380                path.display(),
381                err
382            );
383            None
384        }
385    }
386}
387
388fn fallback_cache_path(project_dir: &Path) -> Option<PathBuf> {
389    let root = if let Some(path) = std::env::var_os("SIGLUS_CACHE_DIR") {
390        PathBuf::from(path)
391    } else {
392        platform_cache_root()?
393    };
394    Some(
395        root.join("emote_keys")
396            .join(format!("{:016x}.toml", project_cache_id(project_dir))),
397    )
398}
399
400fn project_cache_id(project_dir: &Path) -> u64 {
401    let path = fs::canonicalize(project_dir).unwrap_or_else(|_| project_dir.to_path_buf());
402    // Stable FNV-1a instead of DefaultHasher, whose algorithm is deliberately
403    // unspecified and could move the cache path across Rust releases.
404    let mut hash = 0xcbf2_9ce4_8422_2325u64;
405    for byte in path.to_string_lossy().as_bytes() {
406        hash ^= *byte as u64;
407        hash = hash.wrapping_mul(0x0000_0100_0000_01B3);
408    }
409    hash
410}
411
412#[cfg(target_os = "windows")]
413fn platform_cache_root() -> Option<PathBuf> {
414    std::env::var_os("LOCALAPPDATA")
415        .map(PathBuf::from)
416        .map(|path| path.join("siglus_rs").join("cache"))
417}
418
419#[cfg(target_os = "macos")]
420fn platform_cache_root() -> Option<PathBuf> {
421    std::env::var_os("HOME")
422        .map(PathBuf::from)
423        .map(|path| path.join("Library").join("Caches").join("siglus_rs"))
424}
425
426#[cfg(all(unix, not(target_os = "macos")))]
427fn platform_cache_root() -> Option<PathBuf> {
428    if let Some(path) = std::env::var_os("XDG_CACHE_HOME") {
429        return Some(PathBuf::from(path).join("siglus_rs"));
430    }
431    std::env::var_os("HOME")
432        .map(PathBuf::from)
433        .map(|path| path.join(".cache").join("siglus_rs"))
434}
435
436#[cfg(not(any(windows, unix)))]
437fn platform_cache_root() -> Option<PathBuf> {
438    None
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn project_cache_id_is_stable_for_same_path() {
447        let path = Path::new("/tmp/siglus-emote-key-test");
448        assert_eq!(project_cache_id(path), project_cache_id(path));
449    }
450
451    #[test]
452    fn command_context_preloads_emote_key_from_fallback_cache() {
453        let nonce = std::time::SystemTime::now()
454            .duration_since(std::time::UNIX_EPOCH)
455            .unwrap()
456            .as_nanos();
457        let project = std::env::temp_dir().join(format!(
458            "siglus-emote-bootstrap-{}-{nonce}",
459            std::process::id()
460        ));
461        fs::create_dir_all(&project).unwrap();
462        let Some(cache) = fallback_cache_path(&project) else {
463            fs::remove_dir(&project).unwrap();
464            return;
465        };
466        fs::create_dir_all(cache.parent().unwrap()).unwrap();
467        fs::write(&cache, "emote_key = 0x1234ABCD\n").unwrap();
468        let ctx = crate::runtime::CommandContext::new(project.clone());
469        let loaded = ctx.emote_key;
470        drop(ctx);
471        fs::remove_file(cache).unwrap();
472        fs::remove_dir_all(project).unwrap();
473        assert_eq!(loaded, Some(0x1234ABCD));
474    }
475}