1use std::collections::BTreeMap;
13use std::env;
14use std::path::Path;
15
16use anyhow::{anyhow, bail, Result};
17use encoding_rs::SHIFT_JIS;
18
19use crate::angou::{xor_cycle_in_place, AngouChain, AngouStep, AngouStepKind};
20use crate::lzss::lzss_unpack_lenient;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum GameexeTextEncoding {
24 Utf16Le,
25 ShiftJis,
26 Utf8,
27}
28
29#[derive(Debug, Clone)]
30pub struct GameexeDecodeOptions {
31 pub exe_key16: Option<[u8; 16]>,
32 pub base_angou_code: Option<Vec<u8>>,
33 pub game_angou_code: Option<Vec<u8>>,
34 pub try_lzss: bool,
35 pub chain_order: Vec<AngouStepKind>,
36}
37
38impl Default for GameexeDecodeOptions {
39 fn default() -> Self {
40 Self {
41 exe_key16: None,
42 base_angou_code: None,
43 game_angou_code: None,
44 try_lzss: true,
45 chain_order: vec![
46 AngouStepKind::ExeKey16,
47 AngouStepKind::BaseCode,
48 AngouStepKind::GameCode,
49 ],
50 }
51 }
52}
53
54impl GameexeDecodeOptions {
55 pub fn from_project_dir(project_dir: &Path) -> Result<Self> {
56 let mut opt = Self::default();
57 opt.game_angou_code = Some(crate::keys::GAMEEXE_KEY.to_vec());
58 if let Some(cfg) = crate::key_toml::load_key_toml_from_project_dir(project_dir)? {
59 opt.exe_key16 = cfg.exe_key16;
60 opt.base_angou_code = cfg.base_angou_code;
61 if cfg.game_angou_code.is_some() {
62 opt.game_angou_code = cfg.game_angou_code;
63 }
64 if let Some(order) = cfg.chain_order {
65 opt.chain_order = order;
66 }
67 } else {
68 opt.exe_key16 = crate::key_toml::load_key16_from_project_dir(project_dir)?;
69 }
70 apply_env_overrides(&mut opt)?;
71 Ok(opt)
72 }
73}
74
75#[derive(Debug, Clone)]
76pub struct GameexeDecodeReport {
77 pub encoding: GameexeTextEncoding,
78 pub applied_xor: Vec<(AngouStepKind, usize)>,
79 pub used_lzss: bool,
80}
81
82#[derive(Debug, Clone)]
83pub struct GameexeEntry {
84 pub line_no: usize,
85 pub raw_key: String,
86 pub key: String,
87 pub key_parts: Vec<String>,
88 pub value: String,
89 pub value_items: Vec<String>,
90}
91
92#[derive(Debug, Clone, Default)]
93pub struct GameexeConfig {
94 pub entries: Vec<GameexeEntry>,
95 pub map: BTreeMap<String, String>,
96}
97
98impl GameexeEntry {
99 pub fn key_index(&self, prefix: &str) -> Option<usize> {
100 let parts = normalized_key_parts(prefix);
101 if self.key_parts.len() < parts.len() + 1 {
102 return None;
103 }
104 if self.key_parts[..parts.len()] != parts[..] {
105 return None;
106 }
107 self.key_parts[parts.len()].parse::<usize>().ok()
108 }
109
110 pub fn key_field_after_index(&self, prefix: &str) -> Option<&str> {
111 let parts = normalized_key_parts(prefix);
112 if self.key_parts.len() < parts.len() + 2 {
113 return None;
114 }
115 if self.key_parts[..parts.len()] != parts[..] {
116 return None;
117 }
118 Some(self.key_parts[parts.len() + 1].as_str())
119 }
120
121 pub fn item(&self, idx: usize) -> Option<&str> {
122 self.value_items.get(idx).map(|s| s.as_str())
123 }
124
125 pub fn item_unquoted(&self, idx: usize) -> Option<&str> {
126 self.item(idx).map(unquote_token)
127 }
128
129 pub fn scalar_unquoted(&self) -> &str {
130 if self.value_items.is_empty() {
131 unquote_token(&self.value)
132 } else {
133 unquote_token(&self.value_items[0])
134 }
135 }
136}
137
138fn unquote_token(s: &str) -> &str {
139 let t = s.trim();
140 if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
141 &t[1..t.len() - 1]
142 } else {
143 t
144 }
145}
146
147impl GameexeConfig {
148 pub fn get(&self, key: &str) -> Option<&str> {
149 self.map.get(&normalize_key(key)).map(|s| s.as_str())
150 }
151
152 pub fn get_value(&self, key: &str) -> Option<&str> {
153 self.get_entry(key).map(|e| e.value.as_str())
154 }
155
156 pub fn get_entry(&self, key: &str) -> Option<&GameexeEntry> {
157 let nk = normalize_key(key);
158 self.entries.iter().rev().find(|e| e.key == nk)
159 }
160
161 pub fn get_entries<'a>(&'a self, key: &str) -> impl Iterator<Item = &'a GameexeEntry> + 'a {
162 let nk = normalize_key(key);
163 self.entries.iter().filter(move |e| e.key == nk)
164 }
165
166 pub fn get_all<'a>(&'a self, key: &str) -> impl Iterator<Item = &'a str> + 'a {
167 self.get_entries(key).map(|e| e.value.as_str())
168 }
169
170 pub fn get_unquoted(&self, key: &str) -> Option<&str> {
171 self.get_entry(key).map(|e| e.scalar_unquoted())
172 }
173
174 pub fn get_item(&self, key: &str, item: usize) -> Option<&str> {
175 self.get_entry(key).and_then(|e| e.item(item))
176 }
177
178 pub fn get_item_unquoted(&self, key: &str, item: usize) -> Option<&str> {
179 self.get_entry(key).and_then(|e| e.item_unquoted(item))
180 }
181
182 pub fn get_i64(&self, key: &str) -> Option<i64> {
183 self.get_unquoted(key).and_then(parse_i64_like)
184 }
185
186 pub fn get_usize(&self, key: &str) -> Option<usize> {
187 self.get_i64(key).and_then(|v| usize::try_from(v).ok())
188 }
189
190 pub fn get_indexed(&self, prefix: &str, index: usize) -> Option<&str> {
191 let key = format!("{}.{}", normalize_key(prefix), index);
192 self.get(&key)
193 }
194
195 pub fn get_indexed_value(&self, prefix: &str, index: usize) -> Option<&str> {
196 self.get_indexed_entry(prefix, index)
197 .map(|e| e.value.as_str())
198 }
199
200 pub fn get_indexed_unquoted(&self, prefix: &str, index: usize) -> Option<&str> {
201 self.get_indexed_entry(prefix, index)
202 .map(|e| e.scalar_unquoted())
203 }
204
205 pub fn get_indexed_item(&self, prefix: &str, index: usize, item: usize) -> Option<&str> {
206 self.get_indexed_entry(prefix, index)
207 .and_then(|e| e.item(item))
208 }
209
210 pub fn get_indexed_item_unquoted(
211 &self,
212 prefix: &str,
213 index: usize,
214 item: usize,
215 ) -> Option<&str> {
216 self.get_indexed_entry(prefix, index)
217 .and_then(|e| e.item_unquoted(item))
218 }
219
220 pub fn get_indexed_entry(&self, prefix: &str, index: usize) -> Option<&GameexeEntry> {
221 self.entries
227 .iter()
228 .rev()
229 .find(|e| e.key_index(prefix) == Some(index))
230 }
231
232 pub fn get_indexed_field(&self, prefix: &str, index: usize, field: &str) -> Option<&str> {
233 let nf = normalize_key(field);
234 self.entries
235 .iter()
236 .rev()
237 .find(|e| {
238 e.key_index(prefix) == Some(index)
239 && e.key_field_after_index(prefix) == Some(nf.as_str())
240 })
241 .map(|e| e.value.as_str())
242 }
243
244 pub fn get_indexed_field_unquoted(
245 &self,
246 prefix: &str,
247 index: usize,
248 field: &str,
249 ) -> Option<&str> {
250 let nf = normalize_key(field);
251 self.entries
252 .iter()
253 .rev()
254 .find(|e| {
255 e.key_index(prefix) == Some(index)
256 && e.key_field_after_index(prefix) == Some(nf.as_str())
257 })
258 .map(|e| e.scalar_unquoted())
259 }
260
261 pub fn get_prefix<'a>(&'a self, prefix: &str) -> impl Iterator<Item = &'a GameexeEntry> + 'a {
262 let prefix_parts = normalized_key_parts(prefix);
263 self.entries.iter().filter(move |e| {
264 e.key_parts.len() >= prefix_parts.len()
265 && e.key_parts[..prefix_parts.len()] == prefix_parts[..]
266 })
267 }
268
269 pub fn indexed_count(&self, prefix: &str) -> usize {
270 if let Some(v) = self.get_usize(&format!("{}.CNT", normalize_key(prefix))) {
271 return v;
272 }
273 let prefix_parts = normalized_key_parts(prefix);
274 let mut max_idx: Option<usize> = None;
275 for e in &self.entries {
276 if e.key_parts.len() < prefix_parts.len() + 1 {
277 continue;
278 }
279 if e.key_parts[..prefix_parts.len()] != prefix_parts[..] {
280 continue;
281 }
282 let Some(idx) = e.key_parts[prefix_parts.len()].parse::<usize>().ok() else {
283 continue;
284 };
285 max_idx = Some(max_idx.map_or(idx, |m| m.max(idx)));
286 }
287 max_idx.map_or(0, |m| m + 1)
288 }
289
290 pub fn from_text(text: &str) -> Self {
291 let mut out = Self::default();
292 for (line_no, raw_line) in text.lines().enumerate() {
293 let mut line = raw_line.trim();
294 if line.is_empty() || line.starts_with(';') {
295 continue;
296 }
297 if let Some(rest) = line.strip_prefix('\u{feff}') {
298 line = rest.trim_start();
299 }
300 if let Some(rest) = line.strip_prefix('#') {
301 line = rest.trim_start();
302 }
303 let Some((k, v)) = line.split_once('=') else {
304 continue;
305 };
306 let key = normalize_key(k);
307 if key.is_empty() {
308 continue;
309 }
310 let value = strip_inline_comment(v.trim()).trim().to_string();
311 let entry = GameexeEntry {
312 line_no: line_no + 1,
313 raw_key: k.trim().to_string(),
314 key: key.clone(),
315 key_parts: split_key_parts(&key),
316 value_items: split_csv_like(&value),
317 value: value.clone(),
318 };
319 out.entries.push(entry);
320 out.map.insert(key, value);
321 }
322 out
323 }
324}
325
326fn normalized_key_parts(k: &str) -> Vec<String> {
327 split_key_parts(&normalize_key(k))
328}
329
330fn split_key_parts(k: &str) -> Vec<String> {
331 k.split('.')
332 .map(str::trim)
333 .filter(|s| !s.is_empty())
334 .map(ToOwned::to_owned)
335 .collect()
336}
337
338pub fn normalize_gameexe_key(k: &str) -> String {
339 normalize_key(k)
340}
341
342fn normalize_key(k: &str) -> String {
343 let mut src = k.trim();
344 if let Some(rest) = src.strip_prefix('\u{feff}') {
345 src = rest.trim_start();
346 }
347 if let Some(rest) = src.strip_prefix('#') {
348 src = rest.trim_start();
349 }
350
351 let mut out = String::new();
352 for ch in src.chars() {
353 if ch.is_ascii_whitespace() {
354 continue;
355 }
356 out.push(ch);
357 }
358 out.make_ascii_uppercase();
359 out
360}
361
362fn split_csv_like(s: &str) -> Vec<String> {
363 let mut out = Vec::new();
364 let mut cur = String::new();
365 let mut in_str = false;
366 let mut escaped = false;
367 for ch in s.chars() {
368 match ch {
369 '"' if !escaped => {
370 in_str = !in_str;
371 cur.push(ch);
372 }
373 ',' if !in_str => {
374 out.push(cur.trim().to_string());
375 cur.clear();
376 }
377 _ => cur.push(ch),
378 }
379 if ch == '\\' {
380 escaped = !escaped;
381 } else {
382 escaped = false;
383 }
384 }
385 if !cur.is_empty() || s.contains(',') {
386 out.push(cur.trim().to_string());
387 }
388 out.retain(|v| !v.is_empty());
389 out
390}
391
392fn parse_i64_like(s: &str) -> Option<i64> {
393 let t = s.trim();
394 if t.is_empty() {
395 return None;
396 }
397 if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
398 return i64::from_str_radix(hex.trim(), 16).ok();
399 }
400 t.parse::<i64>().ok()
401}
402
403fn strip_inline_comment(s: &str) -> &str {
404 let mut in_str = false;
405 let mut escaped = false;
406 for (idx, ch) in s.char_indices() {
407 match ch {
408 '"' if !escaped => in_str = !in_str,
409 ';' if !in_str => return &s[..idx],
410 _ => {}
411 }
412 if ch == '\\' {
413 escaped = !escaped;
414 } else {
415 escaped = false;
416 }
417 }
418 s
419}
420
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn lookup_accepts_hash_and_spaced_keys() {
428 let cfg = GameexeConfig::from_text(
429 "#MSGBK . WINDOW_SIZE = 1280, 720\nMSGBK_ITEM . SLIDER . POS = 1076,70,590\n",
430 );
431 assert_eq!(cfg.get_value("MSGBK.WINDOW_SIZE"), Some("1280, 720"));
432 assert_eq!(cfg.get_value("#MSGBK.WINDOW_SIZE"), Some("1280, 720"));
433 assert_eq!(cfg.get_value("MSGBK_ITEM.SLIDER.POS"), Some("1076,70,590"));
434 }
435
436 #[test]
437 fn scalar_unquoted_and_full_value_are_distinct() {
438 let cfg = GameexeConfig::from_text(
439 "#MSGBK.WINDOW_SIZE = 1280, 720\n#MSGBK.BACK_FILE = \"mn_mw_log00a00\"\n",
440 );
441 assert_eq!(cfg.get_unquoted("MSGBK.WINDOW_SIZE"), Some("1280"));
442 assert_eq!(cfg.get_value("MSGBK.WINDOW_SIZE"), Some("1280, 720"));
443 assert_eq!(cfg.get_unquoted("#MSGBK.BACK_FILE"), Some("mn_mw_log00a00"));
444 }
445
446 #[test]
447 fn indexed_lookup_accepts_zero_padded_source_keys() {
448 let cfg = GameexeConfig::from_text("#WAKU.000.EXTEND_TYPE = 2\n");
449 assert_eq!(cfg.get_indexed_field("WAKU", 0, "EXTEND_TYPE"), Some("2"));
450 }
451
452 #[test]
453 fn indexed_value_preserves_full_rhs_tuple() {
454 let cfg = GameexeConfig::from_text("#COLOR_TABLE.000 = 255, 255, 255\n");
455 assert_eq!(cfg.get_indexed_value("COLOR_TABLE", 0), Some("255, 255, 255"));
456 assert_eq!(cfg.get_indexed_unquoted("COLOR_TABLE", 0), Some("255"));
457 }
458}
459
460pub fn decode_gameexe_dat_bytes(
461 raw: &[u8],
462 opt: &GameexeDecodeOptions,
463) -> Result<(String, GameexeDecodeReport)> {
464 if let Ok(v) = decode_gameexe_with_header(raw, opt) {
465 return Ok(v);
466 }
467
468 if let Ok((s, enc)) = decode_text_guess(raw) {
469 return Ok((
470 s,
471 GameexeDecodeReport {
472 encoding: enc,
473 applied_xor: Vec::new(),
474 used_lzss: false,
475 },
476 ));
477 }
478
479 if opt.try_lzss {
480 if let Ok(unpacked) = lzss_unpack_lenient(raw) {
481 if let Ok((s, enc)) = decode_text_guess(&unpacked) {
482 return Ok((
483 s,
484 GameexeDecodeReport {
485 encoding: enc,
486 applied_xor: Vec::new(),
487 used_lzss: true,
488 },
489 ));
490 }
491 }
492 }
493
494 let (xor_chain, applied) = build_chain(opt)?;
495 if !xor_chain.steps.is_empty() {
496 let mut buf = raw.to_vec();
497 xor_chain.apply_in_place(&mut buf);
498 if let Ok((s, enc)) = decode_text_guess(&buf) {
499 return Ok((
500 s,
501 GameexeDecodeReport {
502 encoding: enc,
503 applied_xor: applied.clone(),
504 used_lzss: false,
505 },
506 ));
507 }
508
509 if opt.try_lzss {
510 if let Ok(unpacked) = lzss_unpack_lenient(&buf) {
511 if let Ok((s, enc)) = decode_text_guess(&unpacked) {
512 return Ok((
513 s,
514 GameexeDecodeReport {
515 encoding: enc,
516 applied_xor: applied,
517 used_lzss: true,
518 },
519 ));
520 }
521 }
522 }
523 }
524
525 bail!("failed to decode Gameexe.dat as plaintext or (xor/lzss) wrapped text")
526}
527
528fn decode_gameexe_with_header(
529 raw: &[u8],
530 opt: &GameexeDecodeOptions,
531) -> Result<(String, GameexeDecodeReport)> {
532 if raw.len() < 8 {
533 bail!("gameexe header: too short");
534 }
535 let version = i32::from_le_bytes(raw[0..4].try_into().unwrap());
536 let exe_angou_mode = i32::from_le_bytes(raw[4..8].try_into().unwrap());
537 let mut buf = raw[8..].to_vec();
538
539 let mut applied = Vec::new();
540
541 if exe_angou_mode != 0 {
542 if let Some(k16) = opt.exe_key16 {
543 let step = AngouStep::new(AngouStepKind::ExeKey16, k16.to_vec())?;
544 xor_cycle_in_place(&mut buf, &step.key);
545 applied.push((AngouStepKind::ExeKey16, step.key.len()));
546 }
547 }
548 if let Some(code) = &opt.game_angou_code {
549 let step = AngouStep::new(AngouStepKind::GameCode, code.clone())?;
550 xor_cycle_in_place(&mut buf, &step.key);
551 applied.push((AngouStepKind::GameCode, step.key.len()));
552 }
553
554 if let Ok(unpacked) = lzss_unpack_lenient(&buf) {
555 if let Ok((s, enc)) = decode_text_guess(&unpacked) {
556 return Ok((
557 s,
558 GameexeDecodeReport {
559 encoding: enc,
560 applied_xor: applied,
561 used_lzss: true,
562 },
563 ));
564 }
565 }
566
567 if let Ok((s, enc)) = decode_text_guess(&buf) {
568 return Ok((
569 s,
570 GameexeDecodeReport {
571 encoding: enc,
572 applied_xor: applied,
573 used_lzss: false,
574 },
575 ));
576 }
577
578 bail!("gameexe header decode failed (version={version})")
579}
580
581fn build_chain(opt: &GameexeDecodeOptions) -> Result<(AngouChain, Vec<(AngouStepKind, usize)>)> {
582 let mut chain = AngouChain::default();
583 for kind in &opt.chain_order {
584 match kind {
585 AngouStepKind::ExeKey16 => {
586 if let Some(k16) = opt.exe_key16 {
587 chain
588 .steps
589 .push(AngouStep::new(AngouStepKind::ExeKey16, k16.to_vec())?);
590 }
591 }
592 AngouStepKind::BaseCode => {
593 if let Some(code) = &opt.base_angou_code {
594 chain
595 .steps
596 .push(AngouStep::new(AngouStepKind::BaseCode, code.clone())?);
597 }
598 }
599 AngouStepKind::GameCode => {
600 if let Some(code) = &opt.game_angou_code {
601 chain
602 .steps
603 .push(AngouStep::new(AngouStepKind::GameCode, code.clone())?);
604 }
605 }
606 }
607 }
608 let applied = chain.describe();
609 Ok((chain, applied))
610}
611
612fn apply_env_overrides(opt: &mut GameexeDecodeOptions) -> Result<()> {
613 if let Ok(hex) = env::var("SIGLUS_EXE_ANGOU_HEX") {
614 let bytes = crate::angou::parse_hex_bytes(&hex)?;
615 if bytes.len() != 16 {
616 bail!("SIGLUS_EXE_ANGOU_HEX must be 16 bytes, got {}", bytes.len());
617 }
618 let mut key16 = [0u8; 16];
619 key16.copy_from_slice(&bytes);
620 opt.exe_key16 = Some(key16);
621 }
622 if let Ok(hex) = env::var("SIGLUS_BASE_ANGOU_CODE_HEX") {
623 opt.base_angou_code = Some(crate::angou::parse_hex_bytes(&hex)?);
624 }
625 if let Ok(hex) = env::var("SIGLUS_GAME_ANGOU_CODE_HEX") {
626 opt.game_angou_code = Some(crate::angou::parse_hex_bytes(&hex)?);
627 }
628 if let Ok(order_raw) = env::var("SIGLUS_ANGOU_CHAIN_ORDER") {
629 let mut order = Vec::new();
630 for part in order_raw.split(',') {
631 let tok = part.trim().to_ascii_lowercase();
632 if tok.is_empty() {
633 continue;
634 }
635 let kind = match tok.as_str() {
636 "exe" | "exe_key16" => AngouStepKind::ExeKey16,
637 "base" | "base_code" => AngouStepKind::BaseCode,
638 "game" | "game_code" => AngouStepKind::GameCode,
639 other => bail!("SIGLUS_ANGOU_CHAIN_ORDER: unknown item {other}"),
640 };
641 order.push(kind);
642 }
643 if !order.is_empty() {
644 opt.chain_order = order;
645 }
646 }
647 Ok(())
648}
649
650fn decode_text_guess(raw: &[u8]) -> Result<(String, GameexeTextEncoding)> {
651 if let Ok(s) = decode_utf16le_text(raw) {
652 if looks_like_gameexe(&s) {
653 return Ok((s, GameexeTextEncoding::Utf16Le));
654 }
655 }
656
657 if let Ok(s) = decode_shift_jis(raw) {
658 if looks_like_gameexe(&s) {
659 return Ok((s, GameexeTextEncoding::ShiftJis));
660 }
661 }
662
663 if let Ok(s) = std::str::from_utf8(raw) {
664 let s = s.to_string();
665 if looks_like_gameexe(&s) {
666 return Ok((s, GameexeTextEncoding::Utf8));
667 }
668 }
669
670 Err(anyhow!("text guess failed"))
671}
672
673fn decode_shift_jis(raw: &[u8]) -> Result<String> {
674 let (cow, _, had_err) = SHIFT_JIS.decode(raw);
675 if had_err {
676 bail!("shift-jis decode error")
677 }
678 Ok(cow.into_owned())
679}
680
681fn decode_utf16le_text(raw: &[u8]) -> Result<String> {
682 if raw.len() < 2 {
683 bail!("too short")
684 }
685
686 let (start, has_bom) = if raw.len() >= 2 && raw[0] == 0xFF && raw[1] == 0xFE {
687 (2usize, true)
688 } else {
689 (0usize, false)
690 };
691
692 if !has_bom {
693 let mut zero_odd = 0usize;
694 let mut total_odd = 0usize;
695 for i in (1..raw.len()).step_by(2) {
696 total_odd += 1;
697 if raw[i] == 0 {
698 zero_odd += 1;
699 }
700 }
701 if total_odd > 0 {
702 let ratio = (zero_odd as f32) / (total_odd as f32);
703 if ratio < 0.30 {
704 bail!("utf16le heuristic ratio too low")
705 }
706 }
707 }
708
709 let mut u16s = Vec::with_capacity((raw.len() - start) / 2);
710 for i in (start..raw.len()).step_by(2) {
711 if i + 1 >= raw.len() {
712 break;
713 }
714 u16s.push(u16::from_le_bytes([raw[i], raw[i + 1]]));
715 }
716 let s = String::from_utf16(&u16s)?;
717 Ok(s)
718}
719
720fn looks_like_gameexe(s: &str) -> bool {
721 let mut cnt = 0usize;
722 for line in s.lines().take(200) {
723 let line = line.trim();
724 if line.starts_with('#') {
725 cnt += 1;
726 if cnt >= 3 {
727 return true;
728 }
729 }
730 }
731 false
732}