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