Skip to main content

siglus_assets/
gameexe.rs

1//! Gameexe.dat decoding and INI-like parsing.
2//!
3//! In the original Siglus engine, `Gameexe.dat` is treated as a TCHAR text
4//! blob (UTF-16LE). Some titles may store an alternative encoding (e.g.
5//! Shift-JIS) or wrap the text with obfuscation + LZSS.
6//!
7//! This module keeps the decoding pipeline explicit:
8//! - plaintext (UTF-16LE / Shift-JIS / UTF-8)
9//! - optionally XOR with a chain of angou materials
10//! - optional Siglus LZSS unpack
11
12use 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
140// Gameexe is parsed once by the original C_tnm_ini and later subsystems index
141// the resulting arrays directly. Keep Rust lookup semantics identical while
142// avoiding repeated normalization of the same prefix for every candidate row.
143fn 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        // Original Gameexe keys are usually zero-padded (for example BGM.000).
236        // Match by parsed key index instead of formatting the index back as a
237        // non-padded decimal string, otherwise table-backed subsystems silently
238        // miss registered rows. Keep reverse iteration to preserve get_entry
239        // "last definition wins" behavior.
240        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}