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