Skip to main content

siglus_scene_vm/runtime/
tables.rs

1//! Gameexe-driven table loading.
2//!
3//! This module records asset-table load failures in `UnknownOpRecorder`
4//! and do not crash runtime bring-up.
5//!
6//! NOTE: `TCHAR` in the original engine is UTF-16LE.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use siglus_assets::{
12    cgm::CgTableData,
13    dbs::DbsDatabase,
14    gameexe::{
15        GameexeConfig, GameexeDecodeOptions, GameexeDecodeReport, decode_gameexe_dat_bytes,
16        normalize_gameexe_key,
17    },
18    thumb_table::ThumbTable,
19};
20
21use super::unknown::UnknownOpRecorder;
22const INIDEF_BTN_SE_CNT: usize = 16;
23const INIMAX_BTN_SE_CNT: usize = 256;
24const INIDEF_BTN_ACTION_CNT: usize = 16;
25const INIMAX_BTN_ACTION_CNT: usize = 256;
26const TNM_BTN_STATE_MAX: usize = 5;
27const INIDEF_SE_CNT: usize = 16;
28const INIMIN_SE_CNT: usize = 8;
29const INIMAX_SE_CNT: usize = 256;
30const INIDEF_MWND_CNT: usize = 2;
31const INIMAX_MWND_CNT: usize = 256;
32const INIDEF_WAKU_CNT: usize = 4;
33const INIMAX_WAKU_CNT: usize = 256;
34const INIDEF_MWND_WAKU_BTN_CNT: usize = 8;
35const INIMAX_MWND_WAKU_BTN_CNT: usize = 256;
36const INIDEF_MWND_WAKU_FACE_CNT: usize = 1;
37const INIMAX_MWND_WAKU_FACE_CNT: usize = 16;
38const INIDEF_MWND_WAKU_OBJECT_CNT: usize = 1;
39const INIMAX_MWND_WAKU_OBJECT_CNT: usize = 16;
40const INIDEF_ICON_CNT: usize = 16;
41const INIMAX_ICON_CNT: usize = 256;
42const INIDEF_SEL_BTN_CNT: usize = 16;
43const INIMIN_SEL_BTN_CNT: usize = 0;
44const INIMAX_SEL_BTN_CNT: usize = 256;
45const INIDEF_SHAKE_CNT: usize = 16;
46const INIMAX_SHAKE_CNT: usize = 256;
47
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub struct ShakeStep {
50    pub x: i32,
51    pub y: i32,
52    pub time_ms: i32,
53}
54
55#[derive(Debug, Clone, Copy)]
56pub struct ButtonSeTemplate {
57    pub hit_no: i64,
58    pub push_no: i64,
59    pub decide_no: i64,
60}
61
62impl Default for ButtonSeTemplate {
63    fn default() -> Self {
64        Self {
65            hit_no: 0,
66            push_no: -1,
67            decide_no: 1,
68        }
69    }
70}
71
72#[derive(Debug, Clone, Copy)]
73pub struct ButtonActionPattern {
74    pub rep_pat_no: i64,
75    pub rep_pos_x: i64,
76    pub rep_pos_y: i64,
77    pub rep_tr: i64,
78    pub rep_bright: i64,
79    pub rep_dark: i64,
80}
81
82impl Default for ButtonActionPattern {
83    fn default() -> Self {
84        Self {
85            rep_pat_no: 0,
86            rep_pos_x: 0,
87            rep_pos_y: 0,
88            rep_tr: 255,
89            rep_bright: 0,
90            rep_dark: 0,
91        }
92    }
93}
94
95#[derive(Debug, Clone, Copy)]
96pub struct ButtonActionTemplate {
97    pub state: [ButtonActionPattern; TNM_BTN_STATE_MAX],
98}
99
100impl Default for ButtonActionTemplate {
101    fn default() -> Self {
102        let mut state = [ButtonActionPattern::default(); TNM_BTN_STATE_MAX];
103        state[1].rep_bright = 32;
104        state[2].rep_bright = 32;
105        state[2].rep_pos_x = 1;
106        state[2].rep_pos_y = 1;
107        Self { state }
108    }
109}
110
111#[derive(Debug, Clone)]
112pub struct MwndTemplate {
113    pub novel_mode: i64,
114    pub extend_type: i64,
115    pub window_pos: (i64, i64),
116    pub window_size: (i64, i64),
117    pub message_pos: (i64, i64),
118    pub message_margin: (i64, i64, i64, i64),
119    pub moji_cnt: (i64, i64),
120    pub moji_size: i64,
121    pub moji_space: (i64, i64),
122    pub moji_color: i64,
123    pub shadow_color: i64,
124    pub fuchi_color: i64,
125    pub ruby_size: i64,
126    pub ruby_space: i64,
127    pub waku_no: i64,
128    pub waku_pos: (i64, i64),
129    pub name_disp_mode: i64,
130    pub name_newline: i64,
131    pub name_bracket: i64,
132    pub name_moji_size: i64,
133    pub name_moji_space: (i64, i64),
134    pub name_moji_cnt: i64,
135    pub name_window_pos: (i64, i64),
136    pub name_window_size: (i64, i64),
137    pub name_msg_pos: (i64, i64),
138    pub name_msg_pos_rep: (i64, i64),
139    pub name_msg_margin: (i64, i64, i64, i64),
140    pub name_extend_type: i64,
141    pub name_window_align: i64,
142    pub name_moji_color: i64,
143    pub name_shadow_color: i64,
144    pub name_fuchi_color: i64,
145    pub name_waku_no: i64,
146    pub face_hide_name: i64,
147    pub talk_margin: (i64, i64, i64, i64),
148    pub overflow_check_size: i64,
149    pub msg_back_insert_nl: i64,
150    pub open_anime_type: i64,
151    pub open_anime_time: i64,
152    pub close_anime_type: i64,
153    pub close_anime_time: i64,
154}
155
156impl Default for MwndTemplate {
157    fn default() -> Self {
158        Self {
159            novel_mode: 0,
160            extend_type: 0,
161            window_pos: (50, 400),
162            window_size: (700, 150),
163            message_pos: (20, 20),
164            message_margin: (20, 20, 20, 20),
165            moji_cnt: (26, 3),
166            moji_size: 25,
167            moji_space: (-1, 10),
168            moji_color: -1,
169            shadow_color: -1,
170            fuchi_color: -1,
171            ruby_size: 10,
172            ruby_space: 1,
173            waku_no: 0,
174            waku_pos: (0, 0),
175            name_disp_mode: 0,
176            name_newline: 0,
177            name_bracket: 0,
178            name_moji_size: 16,
179            name_moji_space: (-1, 8),
180            name_moji_cnt: 8,
181            name_window_pos: (0, -100),
182            name_window_size: (300, 100),
183            name_msg_pos: (8, 8),
184            name_msg_pos_rep: (0, 0),
185            name_msg_margin: (8, 8, 8, 8),
186            name_extend_type: 0,
187            name_window_align: 0,
188            name_moji_color: -1,
189            name_shadow_color: -1,
190            name_fuchi_color: -1,
191            name_waku_no: -1,
192            face_hide_name: 0,
193            talk_margin: (0, 0, 0, 0),
194            overflow_check_size: 0,
195            msg_back_insert_nl: 0,
196            open_anime_type: 0,
197            open_anime_time: 0,
198            close_anime_type: 0,
199            close_anime_time: 0,
200        }
201    }
202}
203
204#[derive(Debug, Clone, Copy)]
205pub struct MwndRenderTemplate {
206    /// #TATEYOKO_MODE: 0 = horizontal, 1 = vertical.
207    pub vertical_writing: bool,
208    pub default_mwnd_no: i64,
209    pub default_sel_mwnd_no: i64,
210    pub order: i64,
211    pub filter_layer_rep: i64,
212    pub waku_layer_rep: i64,
213    pub face_layer_rep: i64,
214    pub shadow_layer_rep: i64,
215    pub fuchi_layer_rep: i64,
216    pub moji_layer_rep: i64,
217    pub shadow_color: i64,
218    pub fuchi_color: i64,
219    pub moji_color: i64,
220}
221
222impl Default for MwndRenderTemplate {
223    fn default() -> Self {
224        Self {
225            vertical_writing: false,
226            default_mwnd_no: 0,
227            default_sel_mwnd_no: 1,
228            order: 1,
229            filter_layer_rep: 0,
230            waku_layer_rep: 1,
231            face_layer_rep: 2,
232            shadow_layer_rep: 3,
233            fuchi_layer_rep: 4,
234            moji_layer_rep: 5,
235            shadow_color: 1,
236            fuchi_color: 1,
237            moji_color: 0,
238        }
239    }
240}
241
242#[derive(Debug, Clone, Default)]
243pub struct EmojiTemplate {
244    pub file_name: String,
245    pub font_size: i64,
246}
247
248#[derive(Debug, Clone, Copy)]
249pub struct MessageButtonTemplate {
250    pub color_no: [i64; TNM_BTN_STATE_MAX],
251}
252
253impl Default for MessageButtonTemplate {
254    fn default() -> Self {
255        let mut color_no = [0; TNM_BTN_STATE_MAX];
256        color_no[0] = 0;
257        color_no[1] = 2;
258        color_no[2] = 2;
259        Self { color_no }
260    }
261}
262
263#[derive(Debug, Clone)]
264pub struct IconTemplate {
265    pub file_name: String,
266    pub anime_pat_cnt: i64,
267    pub anime_speed: i64,
268}
269
270impl Default for IconTemplate {
271    fn default() -> Self {
272        Self {
273            file_name: String::new(),
274            anime_pat_cnt: 1,
275            anime_speed: 100,
276        }
277    }
278}
279
280#[derive(Debug, Clone)]
281pub struct NamaeEntry {
282    pub source: String,
283    pub display: String,
284    pub color_mod: i64,
285    pub moji_color_no: i64,
286    pub shadow_color_no: i64,
287    pub fuchi_color_no: i64,
288}
289
290#[derive(Debug, Clone, Copy, Default)]
291pub struct FontConfigDefaults {
292    pub font_type: i64,
293    pub futoku: i64,
294    pub shadow: i64,
295}
296
297#[derive(Debug, Clone, Default)]
298pub struct WakuButtonTemplate {
299    pub file_name: String,
300    pub cut_no: i64,
301    pub pos_base: i64,
302    pub pos: (i64, i64),
303    pub action_no: i64,
304    pub se_no: i64,
305    pub sys_type: i64,
306    pub sys_type_opt: i64,
307    pub btn_mode: i64,
308    pub scn_name: String,
309    pub cmd_name: String,
310    pub z_no: i64,
311    pub frame_action_scn_name: String,
312    pub frame_action_cmd_name: String,
313}
314
315#[derive(Debug, Clone)]
316pub struct WakuTemplate {
317    pub extend_type: i64,
318    pub waku_file: String,
319    pub filter_file: String,
320    pub filter_margin: (i64, i64, i64, i64),
321    pub filter_color: (u8, u8, u8, u8),
322    pub filter_config_color: bool,
323    pub filter_config_tr: bool,
324    pub icon_no: i64,
325    pub page_icon_no: i64,
326    pub icon_pos_type: i64,
327    pub icon_pos_base: i64,
328    pub icon_pos: (i64, i64, i64),
329    pub buttons: Vec<WakuButtonTemplate>,
330    pub face_pos: Vec<(i64, i64)>,
331    pub object_cnt: usize,
332}
333
334impl Default for WakuTemplate {
335    fn default() -> Self {
336        Self {
337            extend_type: 0,
338            waku_file: String::new(),
339            filter_file: String::new(),
340            filter_margin: (0, 0, 0, 0),
341            filter_color: (0, 0, 255, 128),
342            filter_config_color: true,
343            filter_config_tr: true,
344            icon_no: -1,
345            page_icon_no: -1,
346            icon_pos_type: 0,
347            icon_pos_base: 0,
348            icon_pos: (0, 0, 0),
349            buttons: vec![WakuButtonTemplate::default(); INIDEF_MWND_WAKU_BTN_CNT],
350            face_pos: vec![(0, 0); INIDEF_MWND_WAKU_FACE_CNT],
351            object_cnt: INIDEF_MWND_WAKU_OBJECT_CNT,
352        }
353    }
354}
355
356#[derive(Debug, Clone)]
357pub struct SelBtnTemplate {
358    pub base_file: String,
359    pub filter_file: String,
360    pub base_pos: (i64, i64),
361    pub rep_pos: (i64, i64),
362    pub x_align: i64,
363    pub y_align: i64,
364    pub max_y_cnt: i64,
365    pub line_width: i64,
366    pub moji_cnt: i64,
367    pub moji_pos: (i64, i64),
368    pub moji_size: i64,
369    pub moji_space: (i64, i64),
370    pub moji_x_align: i64,
371    pub moji_y_align: i64,
372    pub moji_color: i64,
373    pub moji_hit_color: i64,
374    pub btn_action_no: i64,
375    pub open_anime_type: i64,
376    pub open_anime_time: i64,
377    pub close_anime_type: i64,
378    pub close_anime_time: i64,
379    pub decide_anime_type: i64,
380    pub decide_anime_time: i64,
381}
382
383impl Default for SelBtnTemplate {
384    fn default() -> Self {
385        Self {
386            base_file: String::new(),
387            filter_file: String::new(),
388            base_pos: (0, 0),
389            rep_pos: (0, 0),
390            x_align: 0,
391            y_align: 0,
392            max_y_cnt: 0,
393            line_width: 100,
394            moji_cnt: 0,
395            moji_pos: (0, 0),
396            moji_size: 25,
397            moji_space: (0, 0),
398            moji_x_align: 0,
399            moji_y_align: 0,
400            moji_color: 0,
401            moji_hit_color: 5,
402            btn_action_no: 0,
403            open_anime_type: 1,
404            open_anime_time: 500,
405            close_anime_type: 1,
406            close_anime_time: 500,
407            decide_anime_type: 1,
408            decide_anime_time: 500,
409        }
410    }
411}
412
413#[derive(Debug)]
414pub struct AssetTables {
415    pub gameexe: Option<GameexeConfig>,
416    pub gameexe_report: Option<GameexeDecodeReport>,
417
418    pub button_se_templates: Vec<ButtonSeTemplate>,
419    pub button_action_templates: Vec<ButtonActionTemplate>,
420    pub se_file_names: Vec<Option<String>>,
421    pub mwnd_render: MwndRenderTemplate,
422    pub mwnd_templates: Vec<MwndTemplate>,
423    pub emoji_templates: Vec<EmojiTemplate>,
424    pub message_button_templates: Vec<MessageButtonTemplate>,
425    pub waku_templates: Vec<WakuTemplate>,
426    pub icon_templates: Vec<IconTemplate>,
427    pub sel_btn_templates: Vec<SelBtnTemplate>,
428    pub namae_entries: Vec<NamaeEntry>,
429    pub color_table: Vec<(u8, u8, u8)>,
430    pub font_defaults: FontConfigDefaults,
431    /// #SHAKE.n = (x,y,time)(...) tables.  SCREEN.SHAKE receives the table
432    /// number, not a duration; this mirrors C_tnm_ini::Sshake.
433    pub shake_templates: Vec<Vec<ShakeStep>>,
434
435    pub cgtable: Option<CgTableData>,
436    pub cgtable_flag_cnt: Option<usize>,
437    /// Original C_tnm_cg_table::flag is a fixed C_elm_int_list: every entry is a signed 32-bit int.
438    pub cg_flags: Vec<i32>,
439
440    pub databases: Vec<Option<DbsDatabase>>,
441
442    pub thumb_table: Option<ThumbTable>,
443}
444
445impl Default for AssetTables {
446    fn default() -> Self {
447        Self {
448            gameexe: None,
449            gameexe_report: None,
450            button_se_templates: vec![ButtonSeTemplate::default(); INIDEF_BTN_SE_CNT],
451            button_action_templates: vec![ButtonActionTemplate::default(); INIDEF_BTN_ACTION_CNT],
452            se_file_names: vec![None; INIDEF_SE_CNT],
453            mwnd_render: MwndRenderTemplate::default(),
454            mwnd_templates: vec![MwndTemplate::default(); INIDEF_MWND_CNT],
455            emoji_templates: vec![EmojiTemplate::default(); 16],
456            message_button_templates: vec![MessageButtonTemplate::default(); 16],
457            waku_templates: vec![WakuTemplate::default(); INIDEF_WAKU_CNT],
458            icon_templates: vec![IconTemplate::default(); INIDEF_ICON_CNT],
459            sel_btn_templates: vec![SelBtnTemplate::default(); INIDEF_SEL_BTN_CNT],
460            namae_entries: Vec::new(),
461            color_table: default_color_table(),
462            font_defaults: FontConfigDefaults::default(),
463            shake_templates: vec![Vec::new(); INIDEF_SHAKE_CNT],
464            cgtable: None,
465            cgtable_flag_cnt: None,
466            cg_flags: Vec::new(),
467            databases: Vec::new(),
468            thumb_table: None,
469        }
470    }
471}
472
473impl AssetTables {
474    pub fn load(project_dir: &Path, unknown: &mut UnknownOpRecorder) -> Self {
475        let mut out = Self::default();
476
477        let gameexe_path = match crate::resource::find_initial_gameexe_path(project_dir) {
478            Ok(path) => path,
479            Err(_) => {
480                unknown.record_note("gameexe.missing");
481                return out;
482            }
483        };
484
485        let raw = match crate::resource::read_file_bytes(&gameexe_path) {
486            Ok(b) => b,
487            Err(e) => {
488                unknown.record_note(&format!("gameexe.read.failed:{e}"));
489                return out;
490            }
491        };
492
493        let (text, report): (String, Option<GameexeDecodeReport>) = if gameexe_path
494            .extension()
495            .and_then(|s| s.to_str())
496            .is_some_and(|ext| ext.eq_ignore_ascii_case("ini"))
497        {
498            match String::from_utf8(raw) {
499                Ok(text) => (text, None),
500                Err(e) => {
501                    unknown.record_note(&format!("gameexe.ini.decode.failed:{e}"));
502                    return out;
503                }
504            }
505        } else {
506            let opt = match load_gameexe_decode_options(project_dir) {
507                Ok(v) => v,
508                Err(e) => {
509                    // Keep going with defaults.
510                    unknown.record_note(&format!("gameexe.key_toml.invalid:{e}"));
511                    GameexeDecodeOptions::default()
512                }
513            };
514
515            match decode_gameexe_dat_bytes(&raw, &opt) {
516                Ok((text, report)) => (text, Some(report)),
517                Err(e) => {
518                    unknown.record_note(&format!("gameexe.decode.failed:{e}"));
519                    return out;
520                }
521            }
522        };
523
524        let cfg = GameexeConfig::from_text(&text);
525        out.gameexe_report = report;
526        out.button_se_templates = load_button_se_templates(&cfg);
527        out.button_action_templates = load_button_action_templates(&cfg);
528        out.se_file_names = load_se_file_names(&cfg);
529        out.mwnd_render = load_mwnd_render_template(&cfg);
530        out.mwnd_templates = load_mwnd_templates(&cfg);
531        out.emoji_templates = load_emoji_templates(&cfg);
532        out.message_button_templates = load_message_button_templates(&cfg);
533        out.waku_templates = load_waku_templates(&cfg, Some(&text));
534        out.icon_templates = load_icon_templates(&cfg);
535        out.sel_btn_templates = load_sel_btn_templates(&cfg);
536        out.namae_entries = load_namae_entries(Some(&text));
537        out.color_table = load_color_table(&cfg);
538        out.font_defaults = load_font_config_defaults(&cfg);
539        out.shake_templates = load_shake_templates(&cfg);
540        out.gameexe = Some(cfg);
541
542        // Drive table loading from the parsed config.
543        let dat_dir = project_dir.join("dat");
544        if !path_is_dir(&dat_dir) {
545            unknown.record_note("dat.dir.missing");
546        }
547
548        if let Some(cfg) = out.gameexe.as_ref() {
549            // CGTABLE
550            if let Some(v) = cfg.get_unquoted("CGTABLE_FILE") {
551                if let Some(path) = resolve_table_path(project_dir, &dat_dir, v, Some("cgm")) {
552                    match crate::resource::read_file_bytes(&path)
553                        .and_then(|bytes| CgTableData::from_bytes(&bytes))
554                    {
555                        Ok(t) => out.cgtable = Some(t),
556                        Err(e) => unknown.record_note(&format!("cgtable.load.failed:{path:?}:{e}")),
557                    }
558                } else {
559                    unknown.record_note(&format!("cgtable.path.missing:{v}"));
560                }
561            }
562
563            // CGTABLE_FLAG_CNT
564            if let Some(n) = cfg.get_usize("CGTABLE_FLAG_CNT") {
565                out.cgtable_flag_cnt = Some(n);
566                out.cg_flags = vec![0i32; n.max(32)];
567            }
568
569            // THUMBTABLE
570            if let Some(v) = cfg.get_unquoted("THUMBTABLE_FILE") {
571                if let Some(path) = resolve_table_path(project_dir, &dat_dir, v, Some("dat")) {
572                    match crate::resource::read_file_bytes(&path)
573                        .and_then(|bytes| ThumbTable::from_bytes(&bytes))
574                    {
575                        Ok(t) => out.thumb_table = Some(t),
576                        Err(e) => {
577                            unknown.record_note(&format!("thumb_table.load.failed:{path:?}:{e}"))
578                        }
579                    }
580                } else {
581                    unknown.record_note(&format!("thumb_table.path.missing:{v}"));
582                }
583            }
584
585            // DATABASE
586            let db_cnt = cfg.indexed_count("DATABASE");
587            // The original C_elm_database_list owns a fixed DATABASE[i] slot
588            // for every configured index. A missing/unreadable .dbs leaves
589            // that slot uninitialized; it must not shift following indices.
590            out.databases = vec![None; db_cnt];
591            let database_append = crate::resource::initial_select_ini_append(project_dir);
592            for i in 0..db_cnt {
593                let key = format!("DATABASE.{i}");
594                let Some(name) = cfg
595                    .get_indexed_unquoted("DATABASE", i)
596                    .or_else(|| cfg.get_indexed_item_unquoted("DATABASE", i, 0))
597                    .or_else(|| cfg.get_indexed_field_unquoted("DATABASE", i, "FILE"))
598                    .or_else(|| cfg.get_indexed_field_unquoted("DATABASE", i, "NAME"))
599                else {
600                    unknown.record_note(&format!("database.name.missing:{key}"));
601                    continue;
602                };
603                // Original C_elm_database::load_dbs() does not use a generic
604                // project/dat lookup. It appends ".dbs" to the configured
605                // DATABASE name and resolves the file through tnm_find_dat(),
606                // which searches dat/ from the current Select.ini append to
607                // later append entries in order.
608                let dbs_file_name = format!("{name}.dbs");
609                let path = match crate::resource::resolve_dat_file_path(
610                    project_dir,
611                    &database_append.dir,
612                    &dbs_file_name,
613                ) {
614                    Ok(Some(path)) => path,
615                    Ok(None) => {
616                        unknown
617                            .record_note(&format!("database.path.missing:{key}:{dbs_file_name}"));
618                        continue;
619                    }
620                    Err(e) => {
621                        unknown.record_note(&format!(
622                            "database.path.resolve.failed:{key}:{dbs_file_name}:{e}"
623                        ));
624                        continue;
625                    }
626                };
627                match crate::resource::read_file_bytes(&path)
628                    .and_then(|bytes| DbsDatabase::from_bytes(&bytes))
629                {
630                    Ok(db) => out.databases[i] = Some(db),
631                    Err(e) => unknown.record_note(&format!("dbs.load.failed:{path:?}:{e}")),
632                }
633            }
634        }
635
636        out
637    }
638}
639
640fn parse_i64_tuple(raw: Option<&str>) -> Vec<i64> {
641    raw.map(parse_button_action_numbers).unwrap_or_default()
642}
643
644fn load_mwnd_render_template(cfg: &GameexeConfig) -> MwndRenderTemplate {
645    let mut t = MwndRenderTemplate {
646        vertical_writing: cfg
647            .get_unquoted("TATEYOKO_MODE")
648            .and_then(parse_i64_like_local)
649            .unwrap_or(0)
650            == 1,
651        ..Default::default()
652    };
653
654    if let Some(v) = cfg
655        .get_unquoted("MWND.DEFAULT_MWND_NO")
656        .and_then(parse_i64_like_local)
657    {
658        t.default_mwnd_no = v;
659    }
660    if let Some(v) = cfg
661        .get_unquoted("MWND.DEFAULT_SEL_MWND_NO")
662        .and_then(parse_i64_like_local)
663    {
664        t.default_sel_mwnd_no = v;
665    }
666    if let Some(v) = cfg
667        .get_unquoted("MWND.ORDER")
668        .and_then(parse_i64_like_local)
669    {
670        t.order = v;
671    }
672    if let Some(v) = cfg
673        .get_unquoted("MWND.FILTER_LAYER_REP")
674        .and_then(parse_i64_like_local)
675    {
676        t.filter_layer_rep = v;
677    }
678    if let Some(v) = cfg
679        .get_unquoted("MWND.WAKU_LAYER_REP")
680        .and_then(parse_i64_like_local)
681    {
682        t.waku_layer_rep = v;
683    }
684    if let Some(v) = cfg
685        .get_unquoted("MWND.FACE_LAYER_REP")
686        .and_then(parse_i64_like_local)
687    {
688        t.face_layer_rep = v;
689    }
690    if let Some(v) = cfg
691        .get_unquoted("MWND.SHADOW_LAYER_REP")
692        .and_then(parse_i64_like_local)
693    {
694        t.shadow_layer_rep = v;
695    }
696    if let Some(v) = cfg
697        .get_unquoted("MWND.FUCHI_LAYER_REP")
698        .and_then(parse_i64_like_local)
699    {
700        t.fuchi_layer_rep = v;
701    }
702    if let Some(v) = cfg
703        .get_unquoted("MWND.MOJI_LAYER_REP")
704        .and_then(parse_i64_like_local)
705    {
706        t.moji_layer_rep = v;
707    }
708    if let Some(v) = cfg
709        .get_unquoted("MWND.SHADOW_COLOR")
710        .and_then(parse_i64_like_local)
711    {
712        t.shadow_color = v;
713    }
714    if let Some(v) = cfg
715        .get_unquoted("MWND.FUCHI_COLOR")
716        .and_then(parse_i64_like_local)
717    {
718        t.fuchi_color = v;
719    }
720    if let Some(v) = cfg
721        .get_unquoted("MWND.MOJI_COLOR")
722        .and_then(parse_i64_like_local)
723    {
724        t.moji_color = v;
725    }
726    t
727}
728
729fn load_mwnd_templates(cfg: &GameexeConfig) -> Vec<MwndTemplate> {
730    let cnt = cfg
731        .get_usize("MWND.CNT")
732        .unwrap_or(INIDEF_MWND_CNT)
733        .min(INIMAX_MWND_CNT);
734    let mut out = vec![MwndTemplate::default(); cnt];
735
736    for i in 0..cnt {
737        let mut t = MwndTemplate::default();
738        let get_i64 = |field: &str| {
739            cfg.get_indexed_field("MWND", i, field)
740                .and_then(parse_i64_like_local)
741        };
742        let get_tuple = |field: &str| parse_i64_tuple(cfg.get_indexed_field("MWND", i, field));
743
744        if let Some(v) = get_i64("NOVEL_MODE") {
745            t.novel_mode = v;
746        }
747        if let Some(v) = get_i64("EXTEND_TYPE") {
748            t.extend_type = v;
749        }
750        let window_pos = get_tuple("WINDOW_POS");
751        if window_pos.len() >= 2 {
752            t.window_pos = (window_pos[0], window_pos[1]);
753        }
754        let window_size = get_tuple("WINDOW_SIZE");
755        if window_size.len() >= 2 {
756            t.window_size = (window_size[0], window_size[1]);
757        }
758        let message_pos = get_tuple("MESSAGE_POS");
759        if message_pos.len() >= 2 {
760            t.message_pos = (message_pos[0], message_pos[1]);
761        }
762        let message_margin = get_tuple("MESSAGE_MARGIN");
763        if message_margin.len() >= 4 {
764            t.message_margin = (
765                message_margin[0],
766                message_margin[1],
767                message_margin[2],
768                message_margin[3],
769            );
770        }
771        let moji_cnt = get_tuple("MOJI_CNT");
772        if moji_cnt.len() >= 2 {
773            t.moji_cnt = (moji_cnt[0], moji_cnt[1]);
774        }
775        if let Some(v) = get_i64("MOJI_SIZE") {
776            t.moji_size = v;
777        }
778        let moji_space = get_tuple("MOJI_SPACE");
779        if moji_space.len() >= 2 {
780            t.moji_space = (moji_space[0], moji_space[1]);
781        }
782        if let Some(v) = get_i64("MOJI_COLOR") {
783            t.moji_color = v;
784        }
785        if let Some(v) = get_i64("SHADOW_COLOR") {
786            t.shadow_color = v;
787        }
788        if let Some(v) = get_i64("FUCHI_COLOR") {
789            t.fuchi_color = v;
790        }
791        if let Some(v) = get_i64("RUBY_SIZE") {
792            t.ruby_size = v;
793        }
794        if let Some(v) = get_i64("RUBY_SPACE") {
795            t.ruby_space = v;
796        }
797        if let Some(v) = get_i64("WAKU_NO") {
798            t.waku_no = v;
799        }
800        let waku_pos = get_tuple("WAKU_POS");
801        if waku_pos.len() >= 2 {
802            t.waku_pos = (waku_pos[0], waku_pos[1]);
803        }
804        if let Some(v) = get_i64("NAME_DISP_MODE") {
805            t.name_disp_mode = v;
806        }
807        if let Some(v) = get_i64("NAME_NEWLINE") {
808            t.name_newline = v;
809        }
810        if let Some(v) = get_i64("NAME_BRACKET") {
811            t.name_bracket = v;
812        }
813        if let Some(v) = get_i64("NAME_MOJI_SIZE") {
814            t.name_moji_size = v;
815        }
816        let name_moji_space = get_tuple("NAME_MOJI_SPACE");
817        if name_moji_space.len() >= 2 {
818            t.name_moji_space = (name_moji_space[0], name_moji_space[1]);
819        }
820        if let Some(v) = get_i64("NAME_MOJI_CNT") {
821            t.name_moji_cnt = v;
822        }
823        let name_window_pos = get_tuple("NAME_WINDOW_POS");
824        if name_window_pos.len() >= 2 {
825            t.name_window_pos = (name_window_pos[0], name_window_pos[1]);
826        }
827        let name_window_size = get_tuple("NAME_WINDOW_SIZE");
828        if name_window_size.len() >= 2 {
829            t.name_window_size = (name_window_size[0], name_window_size[1]);
830        }
831        let name_msg_pos = get_tuple("NAME_MESSAGE_POS");
832        if name_msg_pos.len() >= 2 {
833            t.name_msg_pos = (name_msg_pos[0], name_msg_pos[1]);
834        }
835        let name_msg_pos_rep = get_tuple("NAME_MESSAGE_POS_REP");
836        if name_msg_pos_rep.len() >= 2 {
837            t.name_msg_pos_rep = (name_msg_pos_rep[0], name_msg_pos_rep[1]);
838        }
839        let name_msg_margin = get_tuple("NAME_MESSAGE_MARGIN");
840        if name_msg_margin.len() >= 4 {
841            t.name_msg_margin = (
842                name_msg_margin[0],
843                name_msg_margin[1],
844                name_msg_margin[2],
845                name_msg_margin[3],
846            );
847        }
848        if let Some(v) = get_i64("NAME_MOJI_COLOR") {
849            t.name_moji_color = v;
850        }
851        if let Some(v) = get_i64("NAME_SHADOW_COLOR") {
852            t.name_shadow_color = v;
853        }
854        if let Some(v) = get_i64("NAME_FUCHI_COLOR") {
855            t.name_fuchi_color = v;
856        }
857        if let Some(v) = get_i64("NAME_WAKU_NO") {
858            t.name_waku_no = v;
859        }
860        if let Some(v) = get_i64("NAME_EXTEND_TYPE") {
861            t.name_extend_type = v;
862        }
863        if let Some(v) = get_i64("NAME_WINDOW_ALIGN") {
864            t.name_window_align = v;
865        }
866        if let Some(v) = get_i64("FACE_HIDE_NAME") {
867            t.face_hide_name = v;
868        }
869        let talk_margin = get_tuple("TALK_MARGIN");
870        if talk_margin.len() >= 4 {
871            t.talk_margin = (
872                talk_margin[0],
873                talk_margin[1],
874                talk_margin[2],
875                talk_margin[3],
876            );
877        }
878        if let Some(v) = get_i64("OVERFLOW_CHECK_SIZE") {
879            t.overflow_check_size = v;
880        }
881        if let Some(v) = get_i64("MSG_BACK_INSERT_NL") {
882            t.msg_back_insert_nl = v;
883        }
884        if let Some(v) = get_i64("OPEN_ANIME_TYPE") {
885            t.open_anime_type = v;
886        }
887        if let Some(v) = get_i64("OPEN_ANIME_TIME") {
888            t.open_anime_time = v;
889        }
890        if let Some(v) = get_i64("CLOSE_ANIME_TYPE") {
891            t.close_anime_type = v;
892        }
893        if let Some(v) = get_i64("CLOSE_ANIME_TIME") {
894            t.close_anime_time = v;
895        }
896        out[i] = t;
897    }
898
899    out
900}
901
902fn nested_indexed_field<'a>(
903    cfg: &'a GameexeConfig,
904    prefix: &str,
905    index: usize,
906    nested: &str,
907    nested_index: usize,
908    field: &str,
909) -> Option<&'a str> {
910    // Original Gameexe syntax accepts numeric fields, and shipped Gameexe files
911    // conventionally write both levels as zero-padded indices, for example:
912    //   #WAKU.000.BTN.000.FILE = "_mbtn00"
913    // GameexeConfig::get_indexed_field handles the first indexed component, but it
914    // does not reinterpret an already-flattened nested component such as
915    // "BTN.0.FILE" as "BTN.000.FILE". Try the exact C++ textual forms for both
916    // index levels before falling back to the generic indexed helper.
917    for direct in [
918        format!("{prefix}.{index}.{nested}.{nested_index}.{field}"),
919        format!("{prefix}.{index:03}.{nested}.{nested_index}.{field}"),
920        format!("{prefix}.{index}.{nested}.{nested_index:03}.{field}"),
921        format!("{prefix}.{index:03}.{nested}.{nested_index:03}.{field}"),
922    ] {
923        if let Some(v) = cfg.get_value(&direct) {
924            return Some(v);
925        }
926    }
927
928    for nested_prefix in [
929        format!("{prefix}.{index}.{nested}"),
930        format!("{prefix}.{index:03}.{nested}"),
931    ] {
932        if let Some(v) = cfg.get_indexed_field(&nested_prefix, nested_index, field) {
933            return Some(v);
934        }
935    }
936
937    for flat_field in [
938        format!("{nested}.{nested_index}.{field}"),
939        format!("{nested}.{nested_index:03}.{field}"),
940    ] {
941        if let Some(v) = cfg.get_indexed_field(prefix, index, &flat_field) {
942            return Some(v);
943        }
944    }
945    None
946}
947
948fn nested_indexed_field_unquoted<'a>(
949    cfg: &'a GameexeConfig,
950    prefix: &str,
951    index: usize,
952    nested: &str,
953    nested_index: usize,
954    field: &str,
955) -> Option<&'a str> {
956    // See nested_indexed_field(). Keep the unquoted path in lockstep so FILE,
957    // TYPE, CALL, and FRAME_ACTION use the same original Gameexe addressing.
958    for direct in [
959        format!("{prefix}.{index}.{nested}.{nested_index}.{field}"),
960        format!("{prefix}.{index:03}.{nested}.{nested_index}.{field}"),
961        format!("{prefix}.{index}.{nested}.{nested_index:03}.{field}"),
962        format!("{prefix}.{index:03}.{nested}.{nested_index:03}.{field}"),
963    ] {
964        if let Some(v) = cfg.get_unquoted(&direct) {
965            return Some(v);
966        }
967    }
968
969    for nested_prefix in [
970        format!("{prefix}.{index}.{nested}"),
971        format!("{prefix}.{index:03}.{nested}"),
972    ] {
973        if let Some(v) = cfg.get_indexed_field_unquoted(&nested_prefix, nested_index, field) {
974            return Some(v);
975        }
976    }
977
978    for flat_field in [
979        format!("{nested}.{nested_index}.{field}"),
980        format!("{nested}.{nested_index:03}.{field}"),
981    ] {
982        if let Some(v) = cfg.get_indexed_field_unquoted(prefix, index, &flat_field) {
983            return Some(v);
984        }
985    }
986    None
987}
988
989type RawGameexeFields<'a> = HashMap<String, &'a str>;
990
991fn canonical_raw_gameexe_key(raw: &str) -> String {
992    normalize_gameexe_key(raw)
993        .split('.')
994        .filter(|part| !part.is_empty())
995        .map(|part| {
996            part.parse::<usize>()
997                .map(|value| value.to_string())
998                .unwrap_or_else(|_| part.to_string())
999        })
1000        .collect::<Vec<_>>()
1001        .join(".")
1002}
1003
1004fn index_raw_gameexe_fields(raw_text: Option<&str>) -> RawGameexeFields<'_> {
1005    let mut fields = HashMap::new();
1006    for line in raw_text.unwrap_or_default().lines() {
1007        let mut s = line.trim();
1008        if s.is_empty() {
1009            continue;
1010        }
1011        if let Some(rest) = s.strip_prefix('\u{feff}') {
1012            s = rest.trim_start();
1013        }
1014        if let Some(rest) = s.strip_prefix('#') {
1015            s = rest.trim_start();
1016        }
1017        let Some((lhs, rhs)) = s.split_once('=') else {
1018            continue;
1019        };
1020        let value = rhs.trim().trim_end_matches(';').trim();
1021        // C_tnm_ini::analize() processes Gameexe linearly and assigns directly
1022        // into the destination field, so a later duplicate definition wins.
1023        // Use insert (not or_insert) to retain that original behavior.
1024        fields.insert(canonical_raw_gameexe_key(lhs), value);
1025    }
1026    fields
1027}
1028
1029fn raw_gameexe_field(fields: &RawGameexeFields<'_>, key: &str) -> Option<String> {
1030    fields
1031        .get(&canonical_raw_gameexe_key(key))
1032        .map(|value| (*value).to_string())
1033}
1034
1035fn raw_nested_indexed_field(
1036    fields: &RawGameexeFields<'_>,
1037    prefix: &str,
1038    index: usize,
1039    nested: &str,
1040    nested_index: usize,
1041    field: &str,
1042) -> Option<String> {
1043    for key in [
1044        format!("{prefix}.{index}.{nested}.{nested_index}.{field}"),
1045        format!("{prefix}.{index:03}.{nested}.{nested_index}.{field}"),
1046        format!("{prefix}.{index}.{nested}.{nested_index:03}.{field}"),
1047        format!("{prefix}.{index:03}.{nested}.{nested_index:03}.{field}"),
1048    ] {
1049        if let Some(v) = raw_gameexe_field(fields, &key) {
1050            return Some(v);
1051        }
1052    }
1053    None
1054}
1055
1056fn raw_indexed_field(
1057    fields: &RawGameexeFields<'_>,
1058    prefix: &str,
1059    index: usize,
1060    field: &str,
1061) -> Option<String> {
1062    for key in [
1063        format!("{prefix}.{index}.{field}"),
1064        format!("{prefix}.{index:03}.{field}"),
1065    ] {
1066        if let Some(v) = raw_gameexe_field(fields, &key) {
1067            return Some(v);
1068        }
1069    }
1070    None
1071}
1072
1073fn trim_gameexe_scalar(raw: &str) -> &str {
1074    raw.trim().trim_matches('"')
1075}
1076
1077fn parse_waku_button_type(raw: &str, button: &mut WakuButtonTemplate) {
1078    let parts: Vec<&str> = raw.split(',').map(|p| p.trim().trim_matches('"')).collect();
1079    if parts.is_empty() {
1080        return;
1081    }
1082    let ty = parts[0].to_ascii_lowercase();
1083    let n0 = parts
1084        .get(1)
1085        .and_then(|v| parse_i64_like_local(v))
1086        .unwrap_or(0);
1087    let n1 = parts
1088        .get(2)
1089        .and_then(|v| parse_i64_like_local(v))
1090        .unwrap_or(0);
1091    match ty.as_str() {
1092        "none" => button.sys_type = 0,
1093        "save" => button.sys_type = 1,
1094        "load" => button.sys_type = 2,
1095        "read_skip" => {
1096            button.sys_type = 3;
1097            button.btn_mode = n0;
1098        }
1099        "auto_mode" => {
1100            button.sys_type = 4;
1101            button.btn_mode = n0;
1102        }
1103        "return_sel" => button.sys_type = 5,
1104        "close_mwnd" => button.sys_type = 6,
1105        "msg_log" => button.sys_type = 7,
1106        "koe_play" => button.sys_type = 8,
1107        "qsave" => {
1108            button.sys_type = 9;
1109            button.sys_type_opt = n0;
1110        }
1111        "qload" => {
1112            button.sys_type = 10;
1113            button.sys_type_opt = n0;
1114        }
1115        "config" => button.sys_type = 11,
1116        "local_switch" => {
1117            button.sys_type = 12;
1118            button.sys_type_opt = n0;
1119            button.btn_mode = n1;
1120        }
1121        "local_mode" => {
1122            button.sys_type = 13;
1123            button.sys_type_opt = n0;
1124            button.btn_mode = n1;
1125        }
1126        "global_switch" => {
1127            button.sys_type = 14;
1128            button.sys_type_opt = n0;
1129            button.btn_mode = n1;
1130        }
1131        "global_mode" => {
1132            button.sys_type = 15;
1133            button.sys_type_opt = n0;
1134            button.btn_mode = n1;
1135        }
1136        _ => {}
1137    }
1138}
1139
1140fn load_waku_templates(cfg: &GameexeConfig, raw_text: Option<&str>) -> Vec<WakuTemplate> {
1141    // The original parser consumes Gameexe once into C_tnm_ini. Mirror that
1142    // architecture instead of rescanning the full text for every WAKU field.
1143    let raw_fields = index_raw_gameexe_fields(raw_text);
1144    let cnt = cfg
1145        .get_usize("WAKU.CNT")
1146        .unwrap_or(INIDEF_WAKU_CNT)
1147        .min(INIMAX_WAKU_CNT);
1148    let btn_cnt = cfg
1149        .get_usize("WAKU.BTN.CNT")
1150        .unwrap_or(INIDEF_MWND_WAKU_BTN_CNT)
1151        .min(INIMAX_MWND_WAKU_BTN_CNT);
1152    let face_cnt = cfg
1153        .get_usize("WAKU.FACE.CNT")
1154        .unwrap_or(INIDEF_MWND_WAKU_FACE_CNT)
1155        .min(INIMAX_MWND_WAKU_FACE_CNT);
1156    let object_cnt = cfg
1157        .get_usize("WAKU.OBJECT.CNT")
1158        .unwrap_or(INIDEF_MWND_WAKU_OBJECT_CNT)
1159        .min(INIMAX_MWND_WAKU_OBJECT_CNT);
1160    let mut out = vec![WakuTemplate::default(); cnt];
1161
1162    for i in 0..cnt {
1163        let mut t = WakuTemplate {
1164            buttons: vec![WakuButtonTemplate::default(); btn_cnt],
1165            face_pos: vec![(0, 0); face_cnt],
1166            object_cnt,
1167            ..Default::default()
1168        };
1169
1170        let raw_top = |field: &str| raw_indexed_field(&raw_fields, "WAKU", i, field);
1171
1172        let extend_type_raw = raw_top("EXTEND_TYPE");
1173        if let Some(v) = extend_type_raw
1174            .as_deref()
1175            .or_else(|| cfg.get_indexed_field("WAKU", i, "EXTEND_TYPE"))
1176            .and_then(parse_i64_like_local)
1177        {
1178            t.extend_type = v;
1179        }
1180        let waku_file_raw = raw_top("WAKU_FILE");
1181        if let Some(v) = waku_file_raw
1182            .as_deref()
1183            .or_else(|| cfg.get_indexed_field_unquoted("WAKU", i, "WAKU_FILE"))
1184        {
1185            t.waku_file = trim_gameexe_scalar(v).to_string();
1186        }
1187        let filter_file_raw = raw_top("FILTER_FILE");
1188        if let Some(v) = filter_file_raw
1189            .as_deref()
1190            .or_else(|| cfg.get_indexed_field_unquoted("WAKU", i, "FILTER_FILE"))
1191        {
1192            t.filter_file = trim_gameexe_scalar(v).to_string();
1193        }
1194        let filter_margin_raw = raw_top("FILTER_MARGIN");
1195        let filter_margin = parse_i64_tuple(
1196            filter_margin_raw
1197                .as_deref()
1198                .or_else(|| cfg.get_indexed_field("WAKU", i, "FILTER_MARGIN")),
1199        );
1200        if filter_margin.len() >= 4 {
1201            t.filter_margin = (
1202                filter_margin[0],
1203                filter_margin[1],
1204                filter_margin[2],
1205                filter_margin[3],
1206            );
1207        }
1208        let filter_color_raw = raw_top("FILTER_COLOR");
1209        let filter_color = parse_i64_tuple(
1210            filter_color_raw
1211                .as_deref()
1212                .or_else(|| cfg.get_indexed_field("WAKU", i, "FILTER_COLOR")),
1213        );
1214        if filter_color.len() >= 4 {
1215            t.filter_color = (
1216                filter_color[0].clamp(0, 255) as u8,
1217                filter_color[1].clamp(0, 255) as u8,
1218                filter_color[2].clamp(0, 255) as u8,
1219                filter_color[3].clamp(0, 255) as u8,
1220            );
1221        }
1222        let filter_config_color_raw = raw_top("FILTER_CONFIG_COLOR");
1223        if let Some(v) = filter_config_color_raw
1224            .as_deref()
1225            .or_else(|| cfg.get_indexed_field("WAKU", i, "FILTER_CONFIG_COLOR"))
1226            .and_then(parse_i64_like_local)
1227        {
1228            t.filter_config_color = v != 0;
1229        }
1230        let filter_config_tr_raw = raw_top("FILTER_CONFIG_TR");
1231        if let Some(v) = filter_config_tr_raw
1232            .as_deref()
1233            .or_else(|| cfg.get_indexed_field("WAKU", i, "FILTER_CONFIG_TR"))
1234            .and_then(parse_i64_like_local)
1235        {
1236            t.filter_config_tr = v != 0;
1237        }
1238        let icon_no_raw = raw_top("ICON_NO");
1239        if let Some(v) = icon_no_raw
1240            .as_deref()
1241            .or_else(|| cfg.get_indexed_field("WAKU", i, "ICON_NO"))
1242            .and_then(parse_i64_like_local)
1243        {
1244            t.icon_no = v;
1245        }
1246        let page_icon_no_raw = raw_top("PAGE_ICON_NO");
1247        if let Some(v) = page_icon_no_raw
1248            .as_deref()
1249            .or_else(|| cfg.get_indexed_field("WAKU", i, "PAGE_ICON_NO"))
1250            .and_then(parse_i64_like_local)
1251        {
1252            t.page_icon_no = v;
1253        }
1254        let icon_pos_type_raw = raw_top("ICON_POS_TYPE");
1255        if let Some(v) = icon_pos_type_raw
1256            .as_deref()
1257            .or_else(|| cfg.get_indexed_field("WAKU", i, "ICON_POS_TYPE"))
1258            .and_then(parse_i64_like_local)
1259        {
1260            t.icon_pos_type = v;
1261        }
1262        let icon_pos_base_raw = raw_top("ICON_POS_BASE");
1263        if let Some(v) = icon_pos_base_raw
1264            .as_deref()
1265            .or_else(|| cfg.get_indexed_field("WAKU", i, "ICON_POS_BASE"))
1266            .and_then(parse_i64_like_local)
1267        {
1268            t.icon_pos_base = v;
1269        }
1270        let icon_pos_raw = raw_top("ICON_POS");
1271        let icon_pos = parse_i64_tuple(
1272            icon_pos_raw
1273                .as_deref()
1274                .or_else(|| cfg.get_indexed_field("WAKU", i, "ICON_POS")),
1275        );
1276        if icon_pos.len() >= 3 {
1277            // Original tnm_ini parses ICON_POS as: base, x, y.
1278            // Keep base separate and store only the point coordinates in icon_pos.
1279            t.icon_pos_base = icon_pos[0];
1280            t.icon_pos = (icon_pos[1], icon_pos[2], 0);
1281        }
1282
1283        for btn_idx in 0..t.buttons.len() {
1284            let mut b = WakuButtonTemplate::default();
1285
1286            let file_raw = raw_nested_indexed_field(&raw_fields, "WAKU", i, "BTN", btn_idx, "FILE");
1287            if let Some(v) = file_raw
1288                .as_deref()
1289                .or_else(|| nested_indexed_field_unquoted(cfg, "WAKU", i, "BTN", btn_idx, "FILE"))
1290            {
1291                b.file_name = trim_gameexe_scalar(v).to_string();
1292            }
1293
1294            let cut_raw =
1295                raw_nested_indexed_field(&raw_fields, "WAKU", i, "BTN", btn_idx, "CUT_NO");
1296            if let Some(v) = cut_raw
1297                .as_deref()
1298                .or_else(|| nested_indexed_field(cfg, "WAKU", i, "BTN", btn_idx, "CUT_NO"))
1299                .and_then(parse_i64_like_local)
1300            {
1301                b.cut_no = v;
1302            }
1303
1304            let pos_raw = raw_nested_indexed_field(&raw_fields, "WAKU", i, "BTN", btn_idx, "POS");
1305            let pos = parse_i64_tuple(
1306                pos_raw
1307                    .as_deref()
1308                    .or_else(|| nested_indexed_field(cfg, "WAKU", i, "BTN", btn_idx, "POS")),
1309            );
1310            if pos.len() >= 3 {
1311                // Original C_tnm_ini::analize_step_waku() parses:
1312                // WAKU.n.BTN.i.POS = pos_base, x, y
1313                b.pos_base = pos[0];
1314                b.pos = (pos[1], pos[2]);
1315            }
1316
1317            let action_raw =
1318                raw_nested_indexed_field(&raw_fields, "WAKU", i, "BTN", btn_idx, "ACTION");
1319            if let Some(v) = action_raw
1320                .as_deref()
1321                .or_else(|| nested_indexed_field(cfg, "WAKU", i, "BTN", btn_idx, "ACTION"))
1322                .and_then(parse_i64_like_local)
1323            {
1324                b.action_no = v;
1325            }
1326
1327            let se_raw = raw_nested_indexed_field(&raw_fields, "WAKU", i, "BTN", btn_idx, "SE");
1328            if let Some(v) = se_raw
1329                .as_deref()
1330                .or_else(|| nested_indexed_field(cfg, "WAKU", i, "BTN", btn_idx, "SE"))
1331                .and_then(parse_i64_like_local)
1332            {
1333                b.se_no = v;
1334            }
1335
1336            let type_raw = raw_nested_indexed_field(&raw_fields, "WAKU", i, "BTN", btn_idx, "TYPE");
1337            if let Some(v) = type_raw
1338                .as_deref()
1339                .or_else(|| nested_indexed_field_unquoted(cfg, "WAKU", i, "BTN", btn_idx, "TYPE"))
1340            {
1341                parse_waku_button_type(v, &mut b);
1342            }
1343
1344            let call_raw = raw_nested_indexed_field(&raw_fields, "WAKU", i, "BTN", btn_idx, "CALL");
1345            if let Some(v) = call_raw
1346                .as_deref()
1347                .or_else(|| nested_indexed_field_unquoted(cfg, "WAKU", i, "BTN", btn_idx, "CALL"))
1348            {
1349                let parts: Vec<&str> = v.split(',').map(|p| p.trim().trim_matches('"')).collect();
1350                if parts.len() >= 2 {
1351                    b.scn_name = parts[0].to_string();
1352                    if let Some(z) = parse_i64_like_local(parts[1]) {
1353                        b.z_no = z;
1354                    } else {
1355                        b.cmd_name = parts[1].to_string();
1356                    }
1357                }
1358            }
1359
1360            let frame_action_raw =
1361                raw_nested_indexed_field(&raw_fields, "WAKU", i, "BTN", btn_idx, "FRAME_ACTION");
1362            if let Some(v) = frame_action_raw.as_deref().or_else(|| {
1363                nested_indexed_field_unquoted(cfg, "WAKU", i, "BTN", btn_idx, "FRAME_ACTION")
1364            }) {
1365                let parts: Vec<&str> = v.split(',').map(|p| p.trim().trim_matches('"')).collect();
1366                if parts.len() >= 2 {
1367                    b.frame_action_scn_name = parts[0].to_string();
1368                    b.frame_action_cmd_name = parts[1].to_string();
1369                }
1370            }
1371
1372            if !b.file_name.is_empty()
1373                || b.cut_no != 0
1374                || b.pos != (0, 0)
1375                || b.action_no != 0
1376                || b.se_no != 0
1377            {
1378                t.buttons[btn_idx] = b;
1379            }
1380        }
1381
1382        for face_idx in 0..t.face_pos.len() {
1383            let pos_raw = raw_nested_indexed_field(&raw_fields, "WAKU", i, "FACE", face_idx, "POS");
1384            let pos = parse_i64_tuple(
1385                pos_raw
1386                    .as_deref()
1387                    .or_else(|| nested_indexed_field(cfg, "WAKU", i, "FACE", face_idx, "POS")),
1388            );
1389            if pos.len() >= 2 {
1390                t.face_pos[face_idx] = (pos[0], pos[1]);
1391            }
1392        }
1393
1394        out[i] = t;
1395    }
1396
1397    out
1398}
1399
1400fn default_color_table() -> Vec<(u8, u8, u8)> {
1401    let mut out = vec![(255, 255, 255); 256];
1402    out[0] = (255, 255, 255);
1403    out[1] = (0, 0, 0);
1404    out[2] = (255, 0, 0);
1405    out[3] = (0, 255, 0);
1406    out[4] = (0, 0, 255);
1407    out[5] = (255, 255, 0);
1408    out[6] = (255, 0, 255);
1409    out[7] = (0, 255, 255);
1410    out
1411}
1412
1413fn load_color_table(cfg: &GameexeConfig) -> Vec<(u8, u8, u8)> {
1414    let cnt = cfg
1415        .get_usize("COLOR_TABLE.CNT")
1416        .unwrap_or(256)
1417        .clamp(1, 4096);
1418    let mut out = default_color_table();
1419    if out.len() < cnt {
1420        out.resize(cnt, (255, 255, 255));
1421    }
1422
1423    for i in 0..cnt {
1424        let Some(raw) = cfg
1425            .get_indexed_value("COLOR_TABLE", i)
1426            .or_else(|| cfg.get_indexed_field("COLOR_TABLE", i, "RGB"))
1427            .or_else(|| cfg.get_indexed_field("COLOR_TABLE", i, "COLOR"))
1428        else {
1429            continue;
1430        };
1431        let vals = parse_i64_tuple(Some(raw));
1432        if vals.len() >= 3 {
1433            out[i] = (
1434                vals[0].clamp(0, 255) as u8,
1435                vals[1].clamp(0, 255) as u8,
1436                vals[2].clamp(0, 255) as u8,
1437            );
1438        }
1439    }
1440    out
1441}
1442
1443fn load_font_config_defaults(cfg: &GameexeConfig) -> FontConfigDefaults {
1444    FontConfigDefaults {
1445        font_type: cfg
1446            .get_unquoted("CONFIG.FONT.TYPE")
1447            .and_then(parse_i64_like_local)
1448            .unwrap_or(0),
1449        futoku: cfg
1450            .get_unquoted("CONFIG.FONT.FUTOKU")
1451            .and_then(parse_i64_like_local)
1452            .unwrap_or(0),
1453        shadow: cfg
1454            .get_unquoted("CONFIG.FONT.SHADOW")
1455            .and_then(parse_i64_like_local)
1456            .unwrap_or(0),
1457    }
1458}
1459
1460fn load_namae_entries(raw_text: Option<&str>) -> Vec<NamaeEntry> {
1461    let Some(text) = raw_text else {
1462        return Vec::new();
1463    };
1464    let mut out = Vec::new();
1465    for line in text.lines() {
1466        let mut t = line.trim();
1467        if t.is_empty() {
1468            continue;
1469        }
1470        if let Some(rest) = t.strip_prefix('#') {
1471            t = rest.trim_start();
1472        }
1473        let Some(rhs) = t.strip_prefix("NAMAE") else {
1474            continue;
1475        };
1476        let Some(rhs) = rhs.trim_start().strip_prefix('=') else {
1477            continue;
1478        };
1479        let fields = split_gameexe_fields(rhs);
1480        if fields.len() < 5 {
1481            continue;
1482        }
1483        let source = trim_gameexe_scalar(&fields[0]).to_string();
1484        let display = trim_gameexe_scalar(&fields[1]).to_string();
1485        if source.is_empty() {
1486            continue;
1487        }
1488        let color_mod = fields
1489            .get(2)
1490            .and_then(|v| parse_i64_like_local(v))
1491            .unwrap_or(0);
1492        let moji_color_no = fields
1493            .get(3)
1494            .and_then(|v| parse_i64_like_local(v))
1495            .unwrap_or(-1);
1496        let shadow_color_no = fields
1497            .get(4)
1498            .and_then(|v| parse_i64_like_local(v))
1499            .unwrap_or(-1);
1500        let fuchi_color_no = fields
1501            .get(5)
1502            .and_then(|v| parse_i64_like_local(v))
1503            .unwrap_or(-1);
1504        out.push(NamaeEntry {
1505            source,
1506            display,
1507            color_mod,
1508            moji_color_no,
1509            shadow_color_no,
1510            fuchi_color_no,
1511        });
1512    }
1513    out
1514}
1515
1516fn split_gameexe_fields(raw: &str) -> Vec<String> {
1517    let mut fields = Vec::new();
1518    let mut cur = String::new();
1519    let mut in_quote = false;
1520    let mut paren_depth = 0i32;
1521    let mut chars = raw.chars().peekable();
1522    for ch in chars {
1523        match ch {
1524            '"' => {
1525                in_quote = !in_quote;
1526                cur.push(ch);
1527            }
1528            '(' if !in_quote => {
1529                paren_depth += 1;
1530                cur.push(ch);
1531            }
1532            ')' if !in_quote => {
1533                paren_depth = (paren_depth - 1).max(0);
1534                cur.push(ch);
1535            }
1536            ',' if !in_quote && paren_depth == 0 => {
1537                fields.push(cur.trim().to_string());
1538                cur.clear();
1539            }
1540            _ => cur.push(ch),
1541        }
1542    }
1543    if !cur.trim().is_empty() {
1544        fields.push(cur.trim().to_string());
1545    }
1546    fields
1547}
1548
1549fn load_icon_templates(cfg: &GameexeConfig) -> Vec<IconTemplate> {
1550    let cnt = cfg
1551        .get_usize("ICON.CNT")
1552        .unwrap_or(INIDEF_ICON_CNT)
1553        .min(INIMAX_ICON_CNT);
1554    let mut out = vec![IconTemplate::default(); cnt];
1555
1556    for i in 0..cnt {
1557        let mut t = IconTemplate::default();
1558        if let Some(v) = cfg
1559            .get_indexed_field_unquoted("ICON", i, "FILE_NAME")
1560            .or_else(|| cfg.get_indexed_field_unquoted("ICON", i, "FILE"))
1561            .or_else(|| cfg.get_indexed_unquoted("ICON", i))
1562        {
1563            t.file_name = v.to_string();
1564        }
1565        if let Some(v) = cfg
1566            .get_indexed_field("ICON", i, "ANIME_PAT_CNT")
1567            .or_else(|| cfg.get_indexed_field("ICON", i, "CNT"))
1568            .or_else(|| cfg.get_indexed_field("ICON", i, "PAT_CNT"))
1569            .and_then(parse_i64_like_local)
1570        {
1571            t.anime_pat_cnt = v.max(1);
1572        }
1573        if let Some(v) = cfg
1574            .get_indexed_field("ICON", i, "ANIME_SPEED")
1575            .or_else(|| cfg.get_indexed_field("ICON", i, "SPEED"))
1576            .and_then(parse_i64_like_local)
1577        {
1578            t.anime_speed = v.max(1);
1579        }
1580        out[i] = t;
1581    }
1582
1583    out
1584}
1585
1586fn load_sel_btn_templates(cfg: &GameexeConfig) -> Vec<SelBtnTemplate> {
1587    let cnt = cfg
1588        .get_usize("SELBTN.CNT")
1589        .unwrap_or(INIDEF_SEL_BTN_CNT)
1590        .clamp(INIMIN_SEL_BTN_CNT, INIMAX_SEL_BTN_CNT);
1591    let mut out = vec![SelBtnTemplate::default(); cnt];
1592    for i in 0..cnt {
1593        let mut t = SelBtnTemplate::default();
1594        let get_i64 = |field: &str| {
1595            cfg.get_indexed_field("SELBTN", i, field)
1596                .and_then(parse_i64_like_local)
1597        };
1598        let get_tuple = |field: &str| parse_i64_tuple(cfg.get_indexed_field("SELBTN", i, field));
1599
1600        if let Some(v) = cfg.get_indexed_field_unquoted("SELBTN", i, "BASE_FILE") {
1601            t.base_file = v.to_string();
1602        }
1603        if let Some(v) = cfg.get_indexed_field_unquoted("SELBTN", i, "BACK_FILE") {
1604            t.filter_file = v.to_string();
1605        }
1606        let base_pos = get_tuple("BASE_POS");
1607        if base_pos.len() >= 2 {
1608            t.base_pos = (base_pos[0], base_pos[1]);
1609        }
1610        let rep_pos = get_tuple("REP_POS");
1611        if rep_pos.len() >= 2 {
1612            t.rep_pos = (rep_pos[0], rep_pos[1]);
1613        }
1614        let align = get_tuple("ALIGN");
1615        if align.len() >= 2 {
1616            t.x_align = align[0];
1617            t.y_align = align[1];
1618        }
1619        if let Some(v) = get_i64("MAX_Y_CNT") {
1620            t.max_y_cnt = v;
1621        }
1622        if let Some(v) = get_i64("LINE_WIDTH") {
1623            t.line_width = v;
1624        }
1625        let moji_size = get_tuple("MOJI_SIZE");
1626        if moji_size.len() >= 4 {
1627            t.moji_size = moji_size[0];
1628            t.moji_space = (moji_size[1], moji_size[2]);
1629            t.moji_cnt = moji_size[3];
1630        }
1631        let moji_pos = get_tuple("MOJI_POS");
1632        if moji_pos.len() >= 2 {
1633            t.moji_pos = (moji_pos[0], moji_pos[1]);
1634        }
1635        let moji_align = get_tuple("MOJI_ALIGN");
1636        if moji_align.len() >= 2 {
1637            t.moji_x_align = moji_align[0];
1638            t.moji_y_align = moji_align[1];
1639        }
1640        if let Some(v) = get_i64("MOJI_COLOR") {
1641            t.moji_color = v;
1642        }
1643        if let Some(v) = get_i64("MOJI_HIT_COLOR") {
1644            t.moji_hit_color = v;
1645        }
1646        if let Some(v) = get_i64("BTN_ACTION") {
1647            t.btn_action_no = v;
1648        }
1649        let open_anime = get_tuple("OPEN_ANIME");
1650        if open_anime.len() >= 2 {
1651            t.open_anime_type = open_anime[0];
1652            t.open_anime_time = open_anime[1];
1653        }
1654        let close_anime = get_tuple("CLOSE_ANIME");
1655        if close_anime.len() >= 2 {
1656            t.close_anime_type = close_anime[0];
1657            t.close_anime_time = close_anime[1];
1658        }
1659        let decide_anime = get_tuple("DECIDE_ANIME");
1660        if decide_anime.len() >= 2 {
1661            t.decide_anime_type = decide_anime[0];
1662            t.decide_anime_time = decide_anime[1];
1663        }
1664        out[i] = t;
1665    }
1666    out
1667}
1668
1669fn load_emoji_templates(cfg: &GameexeConfig) -> Vec<EmojiTemplate> {
1670    let cnt = cfg.get_usize("EMOJI.CNT").unwrap_or(16).min(256);
1671    let mut out = vec![EmojiTemplate::default(); cnt];
1672    for (i, item) in out.iter_mut().enumerate() {
1673        let raw = cfg.get_unquoted(&format!("EMOJI.{i}"));
1674        let Some(raw) = raw else {
1675            continue;
1676        };
1677        let mut parts = raw.split(',').map(str::trim);
1678        item.file_name = parts.next().unwrap_or("").trim_matches('"').to_string();
1679        item.font_size = parts.next().and_then(parse_i64_like_local).unwrap_or(0);
1680    }
1681    out
1682}
1683
1684fn load_message_button_templates(cfg: &GameexeConfig) -> Vec<MessageButtonTemplate> {
1685    let cnt = cfg.get_usize("MSGBTN.CNT").unwrap_or(16).min(256);
1686    let mut out = vec![MessageButtonTemplate::default(); cnt];
1687    const STATES: [(&str, usize); 3] = [("NORMAL", 0), ("HIT", 1), ("PUSH", 2)];
1688    for (i, item) in out.iter_mut().enumerate() {
1689        for (name, state) in STATES {
1690            let key = format!("MSGBTN.{i}.{name}.COLOR");
1691            if let Some(value) = cfg.get_unquoted(&key).and_then(parse_i64_like_local) {
1692                item.color_no[state] = value;
1693            }
1694        }
1695    }
1696    out
1697}
1698
1699fn load_button_action_templates(cfg: &GameexeConfig) -> Vec<ButtonActionTemplate> {
1700    let cnt = cfg
1701        .get_usize("BUTTON.ACTION.CNT")
1702        .unwrap_or(INIDEF_BTN_ACTION_CNT)
1703        .min(INIMAX_BTN_ACTION_CNT);
1704    let mut out = vec![ButtonActionTemplate::default(); cnt];
1705
1706    const STATES: [(&str, usize); TNM_BTN_STATE_MAX] = [
1707        ("NORMAL", 0),
1708        ("HIT", 1),
1709        ("PUSH", 2),
1710        ("SELECT", 3),
1711        ("DISABLE", 4),
1712    ];
1713
1714    for i in 0..cnt {
1715        for (name, state_idx) in STATES {
1716            let Some(raw) = cfg.get_indexed_field("BUTTON.ACTION", i, name) else {
1717                continue;
1718            };
1719            let nums = parse_button_action_numbers(raw);
1720            if nums.len() >= 6 {
1721                out[i].state[state_idx] = ButtonActionPattern {
1722                    rep_pat_no: nums[0],
1723                    rep_pos_x: nums[1],
1724                    rep_pos_y: nums[2],
1725                    rep_tr: nums[3].clamp(0, 255),
1726                    rep_bright: nums[4].clamp(0, 255),
1727                    rep_dark: nums[5].clamp(0, 255),
1728                };
1729            }
1730        }
1731    }
1732
1733    out
1734}
1735
1736fn parse_button_action_numbers(raw: &str) -> Vec<i64> {
1737    raw.split(|c: char| c == ',' || c.is_ascii_whitespace())
1738        .filter(|s| !s.trim().is_empty())
1739        .filter_map(parse_i64_like_local)
1740        .collect()
1741}
1742
1743fn parse_signed_decimal_numbers(raw: &str) -> Vec<i64> {
1744    let bytes = raw.as_bytes();
1745    let mut out = Vec::new();
1746    let mut i = 0usize;
1747    while i < bytes.len() {
1748        let signed = bytes[i] == b'-' || bytes[i] == b'+';
1749        let digit_at = if signed { i + 1 } else { i };
1750        if digit_at >= bytes.len() || !bytes[digit_at].is_ascii_digit() {
1751            i += 1;
1752            continue;
1753        }
1754        let start = i;
1755        i = digit_at + 1;
1756        while i < bytes.len() && bytes[i].is_ascii_digit() {
1757            i += 1;
1758        }
1759        if let Ok(v) = raw[start..i].parse::<i64>() {
1760            out.push(v);
1761        }
1762    }
1763    out
1764}
1765
1766fn load_shake_templates(cfg: &GameexeConfig) -> Vec<Vec<ShakeStep>> {
1767    let cnt = cfg
1768        .get_usize("SHAKE.CNT")
1769        .unwrap_or(INIDEF_SHAKE_CNT)
1770        .min(INIMAX_SHAKE_CNT);
1771    let mut out = vec![Vec::new(); cnt];
1772    for (i, steps) in out.iter_mut().enumerate() {
1773        let Some(entry) = cfg.get_indexed_entry("SHAKE", i) else {
1774            continue;
1775        };
1776        let nums = parse_signed_decimal_numbers(&entry.value);
1777        for triple in nums.as_chunks::<3>().0.iter() {
1778            steps.push(ShakeStep {
1779                x: triple[0].clamp(i32::MIN as i64, i32::MAX as i64) as i32,
1780                y: triple[1].clamp(i32::MIN as i64, i32::MAX as i64) as i32,
1781                time_ms: triple[2].clamp(0, i32::MAX as i64) as i32,
1782            });
1783        }
1784    }
1785    out
1786}
1787
1788fn load_se_file_names(cfg: &GameexeConfig) -> Vec<Option<String>> {
1789    let cnt = cfg
1790        .get_usize("SE.CNT")
1791        .unwrap_or(INIDEF_SE_CNT)
1792        .clamp(INIMIN_SE_CNT, INIMAX_SE_CNT);
1793    let mut out = vec![None; cnt];
1794
1795    for i in 0..cnt {
1796        if let Some(v) = cfg.get_indexed_unquoted("SE", i) {
1797            let t = v.trim();
1798            if !t.is_empty() {
1799                out[i] = Some(t.to_string());
1800            }
1801        }
1802    }
1803
1804    out
1805}
1806
1807fn load_button_se_templates(cfg: &GameexeConfig) -> Vec<ButtonSeTemplate> {
1808    let cnt = cfg
1809        .get_usize("BUTTON.SE.CNT")
1810        .unwrap_or(INIDEF_BTN_SE_CNT)
1811        .min(INIMAX_BTN_SE_CNT);
1812    let mut out = vec![ButtonSeTemplate::default(); cnt];
1813
1814    for i in 0..cnt {
1815        if let Some(v) = cfg
1816            .get_indexed_field("BUTTON.SE", i, "HIT")
1817            .and_then(parse_i64_like_local)
1818        {
1819            out[i].hit_no = v;
1820        }
1821        if let Some(v) = cfg
1822            .get_indexed_field("BUTTON.SE", i, "PUSH")
1823            .and_then(parse_i64_like_local)
1824        {
1825            out[i].push_no = v;
1826        }
1827        if let Some(v) = cfg
1828            .get_indexed_field("BUTTON.SE", i, "DECIDE")
1829            .and_then(parse_i64_like_local)
1830        {
1831            out[i].decide_no = v;
1832        }
1833    }
1834
1835    out
1836}
1837
1838fn parse_i64_like_local(s: &str) -> Option<i64> {
1839    let t = s.trim().trim_matches('"');
1840    if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
1841        i64::from_str_radix(hex, 16).ok()
1842    } else {
1843        t.parse::<i64>().ok()
1844    }
1845}
1846
1847fn path_is_file(path: &Path) -> bool {
1848    crate::resource::game_file_exists(path)
1849}
1850
1851fn path_is_dir(path: &Path) -> bool {
1852    crate::resource::resolve_game_path(path)
1853        .ok()
1854        .flatten()
1855        .is_some_and(|p| {
1856            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1857            {
1858                crate::resource::wasm_path_is_dir(&p)
1859            }
1860            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1861            {
1862                p.is_dir()
1863            }
1864        })
1865}
1866
1867fn load_key_toml_config(
1868    project_dir: &Path,
1869) -> anyhow::Result<Option<siglus_assets::key_toml::KeyTomlConfig>> {
1870    crate::resource::load_project_key_toml(project_dir)
1871}
1872
1873fn load_gameexe_decode_options(project_dir: &Path) -> anyhow::Result<GameexeDecodeOptions> {
1874    crate::resource::load_gameexe_decode_options(project_dir)
1875}
1876
1877fn resolve_table_path(
1878    project_dir: &Path,
1879    dat_dir: &Path,
1880    raw: &str,
1881    default_ext: Option<&str>,
1882) -> Option<PathBuf> {
1883    let s = raw.trim().trim_matches('"');
1884    if s.is_empty() {
1885        return None;
1886    }
1887
1888    let normalized = s.replace('\\', "/");
1889    let mut candidates = Vec::new();
1890    candidates.push(PathBuf::from(&normalized));
1891
1892    let mut with_ext = PathBuf::from(&normalized);
1893    if with_ext.extension().is_none()
1894        && let Some(ext) = default_ext
1895    {
1896        with_ext.set_extension(ext);
1897    }
1898    if with_ext != normalized {
1899        candidates.push(with_ext.clone());
1900    }
1901
1902    for cand in candidates {
1903        let direct = if cand.is_absolute() {
1904            cand.clone()
1905        } else {
1906            project_dir.join(&cand)
1907        };
1908        if let Some(resolved) = crate::resource::resolve_game_file(&direct).ok().flatten() {
1909            return Some(resolved);
1910        }
1911
1912        let file_name = cand.file_name().map(PathBuf::from);
1913        if let Some(name_only) = file_name {
1914            let p = dat_dir.join(&name_only);
1915            if let Some(resolved) = crate::resource::resolve_game_file(&p).ok().flatten() {
1916                return Some(resolved);
1917            }
1918        }
1919
1920        let p = dat_dir.join(&cand);
1921        if let Some(resolved) = crate::resource::resolve_game_file(&p).ok().flatten() {
1922            return Some(resolved);
1923        }
1924
1925        if let Ok(stripped) = cand.strip_prefix("dat") {
1926            let p = dat_dir.join(stripped);
1927            if let Some(resolved) = crate::resource::resolve_game_file(&p).ok().flatten() {
1928                return Some(resolved);
1929            }
1930        }
1931    }
1932    None
1933}
1934
1935#[cfg(test)]
1936mod raw_gameexe_tests {
1937    use super::*;
1938
1939    #[test]
1940    fn raw_index_matches_original_last_definition_wins_and_keeps_quoting() {
1941        let text = "\u{feff} # waku . 000 . waku_file = \"first\" ;\n\
1942                    #WAKU.000.WAKU_FILE = \"last,b=c\"\n\
1943                    malformed line\n";
1944        let fields = index_raw_gameexe_fields(Some(text));
1945        assert_eq!(
1946            raw_indexed_field(&fields, "WAKU", 0, "WAKU_FILE").as_deref(),
1947            Some("\"last,b=c\"")
1948        );
1949        assert!(raw_indexed_field(&fields, "WAKU", 1, "WAKU_FILE").is_none());
1950        assert!(index_raw_gameexe_fields(None).is_empty());
1951    }
1952
1953    #[test]
1954    fn raw_index_canonicalizes_numeric_components_and_last_definition_wins() {
1955        let fields = index_raw_gameexe_fields(Some(
1956            "#WAKU.1.BTN.2.FILE = \"plain first\"\n\
1957             #WAKU.001.BTN.002.FILE = \"padded last\"\n\
1958             #WAKU.001.BTN.003.FILE = \"only padded\"\n",
1959        ));
1960        assert_eq!(
1961            raw_nested_indexed_field(&fields, "WAKU", 1, "BTN", 2, "FILE").as_deref(),
1962            Some("\"padded last\"")
1963        );
1964        assert_eq!(
1965            raw_nested_indexed_field(&fields, "WAKU", 1, "BTN", 3, "FILE").as_deref(),
1966            Some("\"only padded\"")
1967        );
1968    }
1969}