1use 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
25pub 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 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
136fn 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 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 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 continue;
243 }
244
245 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 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 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}