Skip to main content

siglus_scene_vm/runtime/forms/
syscom.rs

1use anyhow::Result;
2
3mod save_versions;
4
5use crate::runtime::globals::{
6    SaveSlotState, SyscomFallbackDialogKind, SyscomFallbackDialogState, SyscomPendingProc,
7    SyscomPendingProcKind, SystemMessageBoxButton, ToggleFeatureState, ValueFeatureState,
8};
9use crate::runtime::{CommandContext, RuntimeSaveKind, Value};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use crate::assets::RgbaImage;
14use crate::original_save::{self, SaveKind};
15
16use super::prop_access;
17
18use super::codes::syscom_op::*;
19
20struct Call<'a> {
21    op: i32,
22    params: &'a [Value],
23}
24
25fn parse_call<'a>(ctx: &CommandContext, form_id: u32, args: &'a [Value]) -> Option<Call<'a>> {
26    let (chain_pos, chain) = prop_access::parse_element_chain_ctx(ctx, form_id, args)?;
27    if chain.len() < 2 {
28        return None;
29    }
30    let params = prop_access::script_args(args, chain_pos);
31    Some(Call {
32        op: chain[1],
33        params,
34    })
35}
36
37fn p_i64(params: &[Value], idx: usize) -> i64 {
38    params.get(idx).and_then(|v| v.as_i64()).unwrap_or(0)
39}
40fn p_bool(params: &[Value], idx: usize) -> bool {
41    p_i64(params, idx) != 0
42}
43
44fn sg_debug_enabled_local() -> bool {
45    env_is_set!("SG_DEBUG")
46}
47
48fn set_syscom_pending_proc(ctx: &mut CommandContext, kind: SyscomPendingProcKind) {
49    ctx.globals.syscom.pending_proc = Some(SyscomPendingProc {
50        kind,
51        warning: false,
52        se_play: false,
53        fade_out: false,
54        leave_msgbk: false,
55        save_id: 0,
56    });
57    ctx.globals.syscom.menu_open = false;
58    ctx.globals.syscom.menu_kind = None;
59    ctx.globals.syscom.menu_result = None;
60}
61
62fn gameexe_unquoted_owned(ctx: &CommandContext, key: &str) -> String {
63    ctx.tables
64        .gameexe
65        .as_ref()
66        .and_then(|cfg| cfg.get_unquoted(key))
67        .unwrap_or("")
68        .to_string()
69}
70
71fn gameexe_value_owned(ctx: &CommandContext, key: &str) -> String {
72    ctx.tables
73        .gameexe
74        .as_ref()
75        .and_then(|cfg| cfg.get_value(key))
76        .unwrap_or("")
77        .to_string()
78}
79
80fn parse_i64_list_local(raw: &str) -> Vec<i64> {
81    raw.split(|c: char| c == ',' || c.is_whitespace())
82        .filter_map(|part| {
83            let t = part.trim();
84            if t.is_empty() {
85                None
86            } else {
87                t.parse::<i64>().ok()
88            }
89        })
90        .collect()
91}
92
93fn parse_first_i64_local(raw: &str) -> Option<i64> {
94    raw.split(|c: char| c == ',' || c.is_whitespace())
95        .find_map(|part| {
96            let t = part.trim();
97            if t.is_empty() {
98                None
99            } else {
100                t.parse::<i64>().ok()
101            }
102        })
103}
104
105fn gameexe_i64_or(ctx: &CommandContext, key: &str, default: i64) -> i64 {
106    ctx.tables
107        .gameexe
108        .as_ref()
109        .and_then(|cfg| cfg.get_i64(key))
110        .unwrap_or(default)
111}
112
113fn gameexe_bool_or(ctx: &CommandContext, key: &str, default: bool) -> bool {
114    gameexe_i64_or(ctx, key, if default { 1 } else { 0 }) != 0
115}
116
117fn config_default_sound_volume(ctx: &CommandContext, sound_type: usize) -> i64 {
118    let key = match sound_type {
119        0 => Some("CONFIG.VOLUME.BGM"),
120        1 => Some("CONFIG.VOLUME.KOE"),
121        2 => Some("CONFIG.VOLUME.PCM"),
122        3 => Some("CONFIG.VOLUME.SE"),
123        4 => Some("CONFIG.VOLUME.MOV"),
124        _ => None,
125    };
126    key.map(|key| gameexe_i64_or(ctx, key, 255))
127        .unwrap_or(255)
128        .clamp(0, 255)
129}
130
131fn config_default_sound_onoff(_ctx: &CommandContext, _sound_type: usize) -> bool {
132    // The original Gameexe parser initializes every sound channel to enabled;
133    // there is no #CONFIG directive that overrides these five checks.
134    true
135}
136
137fn config_default_chrkoe(
138    ctx: &CommandContext,
139    index: usize,
140) -> crate::runtime::globals::ConfigChrKoeState {
141    let Some(entry) = ctx
142        .tables
143        .gameexe
144        .as_ref()
145        .and_then(|cfg| cfg.get_indexed_entry("CHRKOE", index))
146    else {
147        return crate::runtime::globals::ConfigChrKoeState::default();
148    };
149    // #CHRKOE.i = name, check_mode, check_name, onoff, volume, (...)
150    let onoff = entry
151        .item_unquoted(3)
152        .and_then(|v| v.parse::<i64>().ok())
153        .map(|v| v != 0)
154        .unwrap_or(true);
155    let volume = entry
156        .item_unquoted(4)
157        .and_then(|v| v.parse::<i64>().ok())
158        .unwrap_or(255)
159        .clamp(0, 255);
160    crate::runtime::globals::ConfigChrKoeState { onoff, volume }
161}
162
163/// C_tnm_chrkoe masks names until their first named dialogue, unless always visible.
164pub fn config_chrkoe_name_visible(ctx: &CommandContext, index: usize) -> bool {
165    let Some(entry) = ctx
166        .tables
167        .gameexe
168        .as_ref()
169        .and_then(|cfg| cfg.get_indexed_entry("CHRKOE", index))
170    else {
171        return true;
172    };
173    let name = entry.item_unquoted(0).unwrap_or("");
174    ctx.globals
175        .syscom
176        .chrkoe_look_flags
177        .get(name)
178        .copied()
179        .unwrap_or_else(|| !matches!(entry.item_unquoted(1), Some("0" | "2")))
180}
181
182pub fn reveal_config_voice_name(ctx: &mut CommandContext, spoken_name: &str) {
183    let Some(cfg) = ctx.tables.gameexe.as_ref() else {
184        return;
185    };
186    for i in 0..configured_chrkoe_count(ctx) {
187        let Some(entry) = cfg.get_indexed_entry("CHRKOE", i) else {
188            continue;
189        };
190        if entry.item_unquoted(1) == Some("2")
191            && (entry.item_unquoted(0) == Some(spoken_name)
192                || entry.item_unquoted(2) == Some(spoken_name))
193        {
194            let name = entry.item_unquoted(0).unwrap_or("").to_owned();
195            ctx.globals.syscom.chrkoe_look_flags.insert(name, true);
196        }
197    }
198}
199
200fn config_default_indexed_bool(
201    ctx: &CommandContext,
202    prefix: &str,
203    index: usize,
204    default: bool,
205) -> bool {
206    ctx.tables
207        .gameexe
208        .as_ref()
209        .and_then(|cfg| cfg.get_indexed_field_unquoted(prefix, index, "ONOFF"))
210        .and_then(|v| v.parse::<i64>().ok())
211        .map(|v| v != 0)
212        .unwrap_or(default)
213}
214
215fn config_default_indexed_mode(ctx: &CommandContext, index: usize) -> i64 {
216    ctx.tables
217        .gameexe
218        .as_ref()
219        .and_then(|cfg| cfg.get_indexed_field_unquoted("CONFIG.GLOBAL_EXTRA_MODE", index, "MODE"))
220        .and_then(|v| v.parse::<i64>().ok())
221        .unwrap_or(0)
222}
223
224fn config_default_font_name(ctx: &CommandContext) -> String {
225    match gameexe_i64_or(ctx, "CONFIG.FONT.TYPE", 0) {
226        0 => "MS ゴシック".to_string(),
227        1 => "MS 明朝".to_string(),
228        2 => "メイリオ".to_string(),
229        3 => {
230            let name = gameexe_unquoted_owned(ctx, "CONFIG.FONT.NAME");
231            if name.is_empty() {
232                "MS ゴシック".to_string()
233            } else {
234                name
235            }
236        }
237        value => {
238            log::error!(
239                "invalid CONFIG.FONT.TYPE value {value}; using original default MS ゴシック"
240            );
241            "MS ゴシック".to_string()
242        }
243    }
244}
245
246pub fn original_config_defaults(
247    ctx: &CommandContext,
248) -> crate::runtime::globals::OriginalConfigRuntimeState {
249    let mut cfg = crate::runtime::globals::OriginalConfigRuntimeState {
250        screen_size_mode: gameexe_i64_or(ctx, "CONFIG.WINDOW_MODE", 0).clamp(0, 1),
251        ..Default::default()
252    };
253
254    let screen_size = (ctx.screen_w.max(1) as i64, ctx.screen_h.max(1) as i64);
255    cfg.screen_size_free = screen_size;
256    // The current host exposes one active display mode to the VM.  Persist that
257    // actual mode instead of the all-zero placeholder used by the old port.
258    cfg.fullscreen_display_cnt = 1;
259    cfg.fullscreen_display_no = 0;
260    cfg.fullscreen_resolution_cnt = 1;
261    cfg.fullscreen_resolution_no = 0;
262    cfg.fullscreen_resolution = screen_size;
263    cfg.all_sound_user_volume = gameexe_i64_or(ctx, "CONFIG.VOLUME.ALL", 255).clamp(0, 255);
264    for index in 0..cfg.sound_user_volume.len() {
265        cfg.sound_user_volume[index] = config_default_sound_volume(ctx, index);
266        cfg.play_sound_check[index] = config_default_sound_onoff(ctx, index);
267    }
268    cfg.bgmfade_volume = gameexe_i64_or(ctx, "CONFIG.BGMFADE_VOLUME", 192).clamp(0, 255);
269    cfg.bgmfade_use_check = gameexe_bool_or(ctx, "CONFIG.BGMFADE_ONOFF", true);
270    let (r, g, b, a) = config_filter_color_default(ctx);
271    cfg.filter_color_argb = ((a as u32) << 24) | ((r as u32) << 16) | ((g as u32) << 8) | b as u32;
272    cfg.font_name = config_default_font_name(ctx);
273    cfg.font_futoku = gameexe_bool_or(ctx, "CONFIG.FONT.FUTOKU", false);
274    cfg.font_shadow = gameexe_i64_or(ctx, "CONFIG.FONT.SHADOW", 2).clamp(0, 3);
275    cfg.message_speed = gameexe_i64_or(ctx, "CONFIG.MESSAGE_SPEED", 20);
276    cfg.message_speed_nowait = gameexe_bool_or(ctx, "CONFIG.MESSAGE_SPEED_NOWAIT.ONOFF", false);
277    cfg.mouse_cursor_hide_onoff = config_mouse_cursor_hide_onoff_default(ctx) != 0;
278    cfg.mouse_cursor_hide_time = config_mouse_cursor_hide_time_default(ctx);
279    cfg.chrkoe = (0..configured_chrkoe_count(ctx))
280        .map(|index| config_default_chrkoe(ctx, index))
281        .collect();
282    cfg.message_chrcolor_flag = gameexe_bool_or(ctx, "CONFIG.MESSAGE_CHRCOLOR.ONOFF", true);
283    cfg.object_disp_flag = (0..4)
284        .map(|index| config_default_indexed_bool(ctx, "CONFIG.OBJECT_DISP", index, true))
285        .collect();
286    cfg.global_extra_switch_flag = (0..4)
287        .map(|index| config_default_indexed_bool(ctx, "CONFIG.GLOBAL_EXTRA_SWITCH", index, true))
288        .collect();
289    cfg.global_extra_mode_flag = (0..4)
290        .map(|index| config_default_indexed_mode(ctx, index))
291        .collect();
292    cfg.sleep_flag = gameexe_bool_or(ctx, "CONFIG.SLEEP.ONOFF", false);
293    cfg.no_wipe_anime_flag = gameexe_bool_or(ctx, "CONFIG.NO_WIPE_ANIME.ONOFF", false);
294    cfg.skip_wipe_anime_flag = gameexe_bool_or(ctx, "CONFIG.SKIP_WIPE_ANIME.ONOFF", true);
295    cfg.no_mwnd_anime_flag = gameexe_bool_or(ctx, "CONFIG.NO_MWND_ANIME.ONOFF", false);
296    cfg.wheel_next_message_flag = gameexe_bool_or(ctx, "CONFIG.WHEEL_NEXT_MESSAGE.ONOFF", true);
297    cfg.koe_dont_stop_flag = gameexe_bool_or(ctx, "CONFIG.KOE_DONT_STOP.ONOFF", false);
298    cfg.skip_unread_message_flag = gameexe_bool_or(ctx, "CONFIG.SKIP_UNREAD_MESSAGE.ONOFF", false);
299    cfg
300}
301
302fn config_mouse_cursor_hide_onoff_default(ctx: &CommandContext) -> i64 {
303    parse_first_i64_local(&gameexe_value_owned(ctx, "CONFIG.MOUSE_CURSOR_HIDE_ONOFF"))
304        .unwrap_or(0)
305        .clamp(0, 1)
306}
307
308fn config_mouse_cursor_hide_time_default(ctx: &CommandContext) -> i64 {
309    parse_first_i64_local(&gameexe_value_owned(ctx, "CONFIG.MOUSE_CURSOR_HIDE_TIME"))
310        .unwrap_or(5000)
311        .max(0)
312}
313
314fn config_filter_color_default(ctx: &CommandContext) -> (i64, i64, i64, i64) {
315    let raw = gameexe_value_owned(ctx, "CONFIG.FILTER_COLOR");
316    let vals = parse_i64_list_local(&raw);
317    if vals.len() >= 4 {
318        (
319            vals[0].clamp(0, 255),
320            vals[1].clamp(0, 255),
321            vals[2].clamp(0, 255),
322            vals[3].clamp(0, 255),
323        )
324    } else {
325        (0, 0, 0, 128)
326    }
327}
328
329fn local_extra_index(params: &[Value]) -> usize {
330    p_i64(params, 0).clamp(0, 3) as usize
331}
332
333fn local_extra_value_param(params: &[Value]) -> bool {
334    let value_idx = if params.len() >= 2 { 1 } else { 0 };
335    p_bool(params, value_idx)
336}
337
338fn local_extra_i64_param(params: &[Value]) -> i64 {
339    let value_idx = if params.len() >= 2 { 1 } else { 0 };
340    p_i64(params, value_idx)
341}
342
343fn get_local_extra(
344    op: i32,
345    params: &[Value],
346    st: &crate::runtime::globals::SyscomRuntimeState,
347) -> Option<i64> {
348    let idx = local_extra_index(params);
349    let sw = st
350        .local_extra_switches
351        .get(idx)
352        .copied()
353        .unwrap_or(st.local_extra_switch);
354    let mode = st
355        .local_extra_modes
356        .get(idx)
357        .copied()
358        .unwrap_or(st.local_extra_mode);
359    Some(match op {
360        GET_LOCAL_EXTRA_SWITCH_ONOFF_FLAG => {
361            if sw.onoff {
362                1
363            } else {
364                0
365            }
366        }
367        GET_LOCAL_EXTRA_SWITCH_ENABLE_FLAG => {
368            if sw.enable {
369                1
370            } else {
371                0
372            }
373        }
374        GET_LOCAL_EXTRA_SWITCH_EXIST_FLAG => {
375            if sw.exist {
376                1
377            } else {
378                0
379            }
380        }
381        CHECK_LOCAL_EXTRA_SWITCH_ENABLE => sw.check_enabled(),
382        GET_LOCAL_EXTRA_MODE_VALUE => mode.value,
383        GET_LOCAL_EXTRA_MODE_ENABLE_FLAG => {
384            if mode.enable {
385                1
386            } else {
387                0
388            }
389        }
390        GET_LOCAL_EXTRA_MODE_EXIST_FLAG => {
391            if mode.exist {
392                1
393            } else {
394                0
395            }
396        }
397        CHECK_LOCAL_EXTRA_MODE_ENABLE => mode.check_enabled(),
398        _ => return None,
399    })
400}
401
402fn set_local_extra(
403    op: i32,
404    params: &[Value],
405    st: &mut crate::runtime::globals::SyscomRuntimeState,
406) -> bool {
407    let idx = local_extra_index(params);
408    let value = local_extra_value_param(params);
409    match op {
410        SET_LOCAL_EXTRA_SWITCH_ONOFF_FLAG => st.local_extra_switches[idx].onoff = value,
411        SET_LOCAL_EXTRA_SWITCH_ENABLE_FLAG => st.local_extra_switches[idx].enable = value,
412        SET_LOCAL_EXTRA_SWITCH_EXIST_FLAG => st.local_extra_switches[idx].exist = value,
413        SET_LOCAL_EXTRA_MODE_ENABLE_FLAG => st.local_extra_modes[idx].enable = value,
414        SET_LOCAL_EXTRA_MODE_EXIST_FLAG => st.local_extra_modes[idx].exist = value,
415        SET_LOCAL_EXTRA_MODE_VALUE => {
416            st.local_extra_modes[idx].value = local_extra_i64_param(params)
417        }
418        _ => return false,
419    }
420    st.local_extra_switch = st.local_extra_switches[0];
421    st.local_extra_mode = st.local_extra_modes[0];
422    true
423}
424
425fn get_toggle_get(op: i32, st: &crate::runtime::globals::SyscomRuntimeState) -> Option<i64> {
426    Some(match op {
427        GET_READ_SKIP_ONOFF_FLAG => {
428            if st.read_skip.onoff {
429                1
430            } else {
431                0
432            }
433        }
434        GET_READ_SKIP_ENABLE_FLAG => {
435            if st.read_skip.enable {
436                1
437            } else {
438                0
439            }
440        }
441        GET_READ_SKIP_EXIST_FLAG => {
442            if st.read_skip.exist {
443                1
444            } else {
445                0
446            }
447        }
448        CHECK_READ_SKIP_ENABLE => st.read_skip.check_enabled(),
449        GET_AUTO_SKIP_ONOFF_FLAG => {
450            if st.auto_skip.onoff {
451                1
452            } else {
453                0
454            }
455        }
456        GET_AUTO_SKIP_ENABLE_FLAG => {
457            if st.auto_skip.enable {
458                1
459            } else {
460                0
461            }
462        }
463        GET_AUTO_SKIP_EXIST_FLAG => {
464            if st.auto_skip.exist {
465                1
466            } else {
467                0
468            }
469        }
470        CHECK_AUTO_SKIP_ENABLE => st.auto_skip.check_enabled(),
471        GET_AUTO_MODE_ONOFF_FLAG => {
472            if st.auto_mode.onoff {
473                1
474            } else {
475                0
476            }
477        }
478        GET_AUTO_MODE_ENABLE_FLAG => {
479            if st.auto_mode.enable {
480                1
481            } else {
482                0
483            }
484        }
485        GET_AUTO_MODE_EXIST_FLAG => {
486            if st.auto_mode.exist {
487                1
488            } else {
489                0
490            }
491        }
492        CHECK_AUTO_MODE_ENABLE => st.auto_mode.check_enabled(),
493        GET_HIDE_MWND_ONOFF_FLAG => {
494            if st.hide_mwnd.onoff {
495                1
496            } else {
497                0
498            }
499        }
500        GET_HIDE_MWND_ENABLE_FLAG => {
501            if st.hide_mwnd.enable {
502                1
503            } else {
504                0
505            }
506        }
507        GET_HIDE_MWND_EXIST_FLAG => {
508            if st.hide_mwnd.exist {
509                1
510            } else {
511                0
512            }
513        }
514        CHECK_HIDE_MWND_ENABLE => st.hide_mwnd.check_enabled(),
515        GET_MSG_BACK_ENABLE_FLAG => {
516            if st.msg_back.enable {
517                1
518            } else {
519                0
520            }
521        }
522        GET_MSG_BACK_EXIST_FLAG => {
523            if st.msg_back.exist {
524                1
525            } else {
526                0
527            }
528        }
529        CHECK_MSG_BACK_ENABLE => st.msg_back.check_enabled(),
530        CHECK_MSG_BACK_OPEN => {
531            if st.msg_back_open {
532                1
533            } else {
534                0
535            }
536        }
537        GET_RETURN_TO_SEL_ENABLE_FLAG => {
538            if st.return_to_sel.enable {
539                1
540            } else {
541                0
542            }
543        }
544        GET_RETURN_TO_SEL_EXIST_FLAG => {
545            if st.return_to_sel.exist {
546                1
547            } else {
548                0
549            }
550        }
551        CHECK_RETURN_TO_SEL_ENABLE => st.return_to_sel.check_enabled(),
552        GET_RETURN_TO_MENU_ENABLE_FLAG => {
553            if st.return_to_menu.enable {
554                1
555            } else {
556                0
557            }
558        }
559        GET_RETURN_TO_MENU_EXIST_FLAG => {
560            if st.return_to_menu.exist {
561                1
562            } else {
563                0
564            }
565        }
566        CHECK_RETURN_TO_MENU_ENABLE => st.return_to_menu.check_enabled(),
567        GET_END_GAME_ENABLE_FLAG => {
568            if st.end_game.enable {
569                1
570            } else {
571                0
572            }
573        }
574        GET_END_GAME_EXIST_FLAG => {
575            if st.end_game.exist {
576                1
577            } else {
578                0
579            }
580        }
581        CHECK_END_GAME_ENABLE => st.end_game.check_enabled(),
582        GET_SAVE_ENABLE_FLAG => {
583            if st.save_feature.enable {
584                1
585            } else {
586                0
587            }
588        }
589        GET_SAVE_EXIST_FLAG => {
590            if st.save_feature.exist {
591                1
592            } else {
593                0
594            }
595        }
596        CHECK_SAVE_ENABLE => st.save_feature.check_enabled(),
597        GET_LOAD_ENABLE_FLAG => {
598            if st.load_feature.enable {
599                1
600            } else {
601                0
602            }
603        }
604        GET_LOAD_EXIST_FLAG => {
605            if st.load_feature.exist {
606                1
607            } else {
608                0
609            }
610        }
611        CHECK_LOAD_ENABLE => st.load_feature.check_enabled(),
612        _ => return None,
613    })
614}
615
616fn apply_toggle_set(
617    op: i32,
618    v: bool,
619    st: &mut crate::runtime::globals::SyscomRuntimeState,
620) -> bool {
621    match op {
622        SET_READ_SKIP_ONOFF_FLAG => st.read_skip.onoff = v,
623        SET_READ_SKIP_ENABLE_FLAG => st.read_skip.enable = v,
624        SET_READ_SKIP_EXIST_FLAG => st.read_skip.exist = v,
625        SET_AUTO_SKIP_ONOFF_FLAG => st.auto_skip.onoff = v,
626        SET_AUTO_SKIP_ENABLE_FLAG => st.auto_skip.enable = v,
627        SET_AUTO_SKIP_EXIST_FLAG => st.auto_skip.exist = v,
628        SET_AUTO_MODE_ONOFF_FLAG => st.auto_mode.onoff = v,
629        SET_AUTO_MODE_ENABLE_FLAG => st.auto_mode.enable = v,
630        SET_AUTO_MODE_EXIST_FLAG => st.auto_mode.exist = v,
631        SET_HIDE_MWND_ONOFF_FLAG => st.hide_mwnd.onoff = v,
632        SET_HIDE_MWND_ENABLE_FLAG => st.hide_mwnd.enable = v,
633        SET_HIDE_MWND_EXIST_FLAG => st.hide_mwnd.exist = v,
634        SET_MSG_BACK_ENABLE_FLAG => st.msg_back.enable = v,
635        SET_MSG_BACK_EXIST_FLAG => st.msg_back.exist = v,
636        SET_RETURN_TO_SEL_ENABLE_FLAG => st.return_to_sel.enable = v,
637        SET_RETURN_TO_SEL_EXIST_FLAG => st.return_to_sel.exist = v,
638        SET_RETURN_TO_MENU_ENABLE_FLAG => st.return_to_menu.enable = v,
639        SET_RETURN_TO_MENU_EXIST_FLAG => st.return_to_menu.exist = v,
640        SET_END_GAME_ENABLE_FLAG => st.end_game.enable = v,
641        SET_END_GAME_EXIST_FLAG => st.end_game.exist = v,
642        SET_SAVE_ENABLE_FLAG => st.save_feature.enable = v,
643        SET_SAVE_EXIST_FLAG => st.save_feature.exist = v,
644        SET_LOAD_ENABLE_FLAG => st.load_feature.enable = v,
645        SET_LOAD_EXIST_FLAG => st.load_feature.exist = v,
646        _ => return false,
647    }
648    true
649}
650
651fn truncate_to_chars(s: &mut String, max_chars: usize) {
652    if s.chars().count() > max_chars {
653        *s = s.chars().take(max_chars).collect();
654    }
655}
656
657/// Equivalent to C++ `C_tnm_eng::save_local_msg`. Appends to the live
658/// `current_save_message` / `current_save_full_message` and, when a savepoint
659/// snapshot exists, mirrors the append into `m_local_save.save_msg`/`save_full_msg`
660/// without rebuilding the saved stream. Length caps mirror `TNM_SAVE_MESSAGE_MAX_LEN`
661/// / `TNM_SAVE_FULL_MESSAGE_MAX_LEN` (256 TCHARs).
662pub(crate) fn append_current_save_message(ctx: &mut CommandContext, msg: &str) {
663    if msg.is_empty() {
664        return;
665    }
666    ctx.globals.syscom.current_save_message.push_str(msg);
667    truncate_to_chars(&mut ctx.globals.syscom.current_save_message, 256);
668    ctx.globals.syscom.current_save_full_message.push_str(msg);
669    truncate_to_chars(&mut ctx.globals.syscom.current_save_full_message, 256);
670
671    if let Some(snapshot) = ctx.local_save_snapshot.as_mut() {
672        snapshot.save_msg.push_str(msg);
673        truncate_to_chars(&mut snapshot.save_msg, 256);
674        snapshot.save_full_msg.push_str(msg);
675        truncate_to_chars(&mut snapshot.save_full_msg, 256);
676    }
677}
678
679fn save_load_trace_enabled() -> bool {
680    env_is_set!("SG_SAVELOAD_TRACE")
681}
682
683fn trace_save_load_event(
684    ctx: &CommandContext,
685    label: &str,
686    quick: bool,
687    idx: usize,
688    path: Option<&Path>,
689) {
690    if !save_load_trace_enabled() {
691        return;
692    }
693    let kind = if quick { "quick" } else { "normal" };
694    if let Some(path) = path {
695        eprintln!(
696            "[SG_SAVELOAD_TRACE][SYSCOM] {label} kind={kind} idx={idx} path={} exists={}",
697            path.display(),
698            crate::resource::game_path_exists(path)
699        );
700    } else {
701        eprintln!("[SG_SAVELOAD_TRACE][SYSCOM] {label} kind={kind} idx={idx}");
702    }
703}
704
705fn ensure_slot(slots: &mut Vec<SaveSlotState>, idx: usize) -> &mut SaveSlotState {
706    if slots.len() <= idx {
707        slots.resize_with(idx + 1, SaveSlotState::default);
708    }
709    &mut slots[idx]
710}
711
712pub fn menu_save_slot(ctx: &mut CommandContext, quick: bool, idx: usize) {
713    let path = slot_path_with_counts(
714        &ctx.project_dir,
715        quick,
716        idx,
717        configured_save_count(ctx, false),
718        configured_save_count(ctx, true),
719    );
720    trace_save_load_event(ctx, "menu_save_slot", quick, idx, Some(&path));
721    // Do NOT pre-populate the slot from current runtime state here. The VM-level
722    // perform_runtime_save_request consumes the local_save_snapshot built at
723    // SAVEPOINT time to write both the header and the slot fields. Pre-writing
724    // slot data from the currently-open save/load menu (its title/append/etc.)
725    // is exactly the original bug.
726    let kind = if quick {
727        RuntimeSaveKind::Quick
728    } else {
729        RuntimeSaveKind::Normal
730    };
731    ctx.request_runtime_save(kind, idx);
732}
733
734pub fn menu_load_slot(ctx: &mut CommandContext, quick: bool, idx: usize) {
735    let path = slot_path_with_counts(
736        &ctx.project_dir,
737        quick,
738        idx,
739        configured_save_count(ctx, false),
740        configured_save_count(ctx, true),
741    );
742    trace_save_load_event(ctx, "menu_load_slot", quick, idx, Some(&path));
743    let save_cnt = configured_save_count(ctx, false);
744    let quick_cnt = configured_save_count(ctx, true);
745    if quick {
746        ensure_slot_loaded_with_counts(
747            &ctx.project_dir,
748            true,
749            save_cnt,
750            quick_cnt,
751            &mut ctx.globals.syscom.quick_save_slots,
752            idx,
753        );
754        ctx.request_runtime_load(RuntimeSaveKind::Quick, idx);
755    } else {
756        ensure_slot_loaded_with_counts(
757            &ctx.project_dir,
758            false,
759            save_cnt,
760            quick_cnt,
761            &mut ctx.globals.syscom.save_slots,
762            idx,
763        );
764        ctx.request_runtime_load(RuntimeSaveKind::Normal, idx);
765    }
766}
767
768fn saveload_alert_on(ctx: &CommandContext) -> bool {
769    cfg_get_int(&ctx.globals.syscom, GET_SAVELOAD_ALERT_ONOFF, 1) != 0
770}
771
772fn slot_exists_for_menu_action(ctx: &mut CommandContext, quick: bool, idx: usize) -> bool {
773    let save_cnt = configured_save_count(ctx, false);
774    let quick_cnt = configured_save_count(ctx, true);
775    if quick {
776        ensure_slot_loaded_with_counts(
777            &ctx.project_dir,
778            true,
779            save_cnt,
780            quick_cnt,
781            &mut ctx.globals.syscom.quick_save_slots,
782            idx,
783        );
784        ctx.globals
785            .syscom
786            .quick_save_slots
787            .get(idx)
788            .map(|slot| slot.exist)
789            .unwrap_or(false)
790    } else {
791        ensure_slot_loaded_with_counts(
792            &ctx.project_dir,
793            false,
794            save_cnt,
795            quick_cnt,
796            &mut ctx.globals.syscom.save_slots,
797            idx,
798        );
799        ctx.globals
800            .syscom
801            .save_slots
802            .get(idx)
803            .map(|slot| slot.exist)
804            .unwrap_or(false)
805    }
806}
807
808fn request_confirmed_save_or_load(
809    ctx: &mut CommandContext,
810    kind: SyscomPendingProcKind,
811    idx: usize,
812    warning: bool,
813    needs_existing_slot: bool,
814) -> bool {
815    if warning
816        && saveload_alert_on(ctx)
817        && (!needs_existing_slot
818            || slot_exists_for_menu_action(
819                ctx,
820                matches!(
821                    kind,
822                    SyscomPendingProcKind::QuickSave | SyscomPendingProcKind::QuickLoad
823                ),
824                idx,
825            ))
826    {
827        ctx.globals.syscom.pending_proc = Some(SyscomPendingProc {
828            kind,
829            warning: true,
830            se_play: false,
831            fade_out: false,
832            leave_msgbk: false,
833            save_id: idx as i64,
834        });
835        ctx.globals.syscom.menu_open = false;
836        ctx.globals.syscom.menu_kind = None;
837        ctx.globals.syscom.menu_result = None;
838        true
839    } else {
840        false
841    }
842}
843
844fn local_save_available(ctx: &CommandContext) -> bool {
845    ctx.local_save_snapshot
846        .as_ref()
847        .map(|s| !s.local_stream.is_empty())
848        .unwrap_or(false)
849        || ctx.pending_auto_savepoint
850}
851
852fn local_save_file_exists(ctx: &CommandContext, kind: SaveKind, idx: usize) -> bool {
853    let path = original_save::save_file_path_with_counts(
854        &ctx.project_dir,
855        configured_save_count(ctx, false),
856        configured_save_count(ctx, true),
857        kind,
858        idx,
859    );
860    original_save::read_slot_from_path(&path)
861        .map(|slot| slot.exist)
862        .unwrap_or(false)
863}
864
865pub(crate) fn save_dir(project_dir: &Path) -> PathBuf {
866    original_save::save_dir(project_dir)
867}
868
869fn slot_path_with_counts(
870    project_dir: &Path,
871    quick: bool,
872    idx: usize,
873    save_cnt: usize,
874    quick_cnt: usize,
875) -> PathBuf {
876    let kind = if quick {
877        SaveKind::Quick
878    } else {
879        SaveKind::Normal
880    };
881    original_save::save_file_path_with_counts(project_dir, save_cnt, quick_cnt, kind, idx)
882}
883
884#[derive(Clone, Copy, Debug, PartialEq, Eq)]
885enum SaveThumbType {
886    Bmp,
887    Png,
888}
889
890#[derive(Clone, Copy, Debug)]
891struct SaveThumbConfig {
892    enabled: bool,
893    thumb_type: SaveThumbType,
894    width: u32,
895    height: u32,
896}
897
898fn save_thumb_config(ctx: &CommandContext) -> SaveThumbConfig {
899    let mut out = SaveThumbConfig {
900        enabled: false,
901        thumb_type: SaveThumbType::Bmp,
902        width: 200,
903        height: 150,
904    };
905
906    let Some(cfg) = ctx.tables.gameexe.as_ref() else {
907        return out;
908    };
909
910    out.enabled = cfg
911        .get_usize("#SAVE_THUMB.USE")
912        .or_else(|| cfg.get_usize("SAVE_THUMB.USE"))
913        .unwrap_or(0)
914        != 0;
915    out.thumb_type = match cfg
916        .get_usize("#SAVE_THUMB.TYPE")
917        .or_else(|| cfg.get_usize("SAVE_THUMB.TYPE"))
918        .unwrap_or(0)
919    {
920        1 => SaveThumbType::Png,
921        _ => SaveThumbType::Bmp,
922    };
923
924    if let Some(entry) = cfg
925        .get_entry("#SAVE_THUMB.SIZE")
926        .or_else(|| cfg.get_entry("SAVE_THUMB.SIZE"))
927    {
928        let w = entry
929            .item_unquoted(0)
930            .and_then(|v| v.trim().parse::<u32>().ok())
931            .unwrap_or(out.width);
932        let h = entry
933            .item_unquoted(1)
934            .and_then(|v| v.trim().parse::<u32>().ok())
935            .unwrap_or(out.height);
936        if w != 0 && h != 0 {
937            out.width = w;
938            out.height = h;
939        }
940    }
941
942    out
943}
944
945pub(crate) fn thumb_candidate_paths(dir: &Path, idx: i64) -> [PathBuf; 2] {
946    let project_dir = dir.parent().unwrap_or(dir);
947    original_save::thumb_candidate_paths_for_no(project_dir, idx.max(0) as usize)
948}
949
950fn thumb_path_for_no_with_config(
951    project_dir: &Path,
952    config: SaveThumbConfig,
953    save_no: usize,
954) -> PathBuf {
955    let stem = format!("{save_no:04}");
956    let ext = match config.thumb_type {
957        SaveThumbType::Bmp => "bmp",
958        SaveThumbType::Png => "png",
959    };
960    save_dir(project_dir).join(format!("{stem}.{ext}"))
961}
962
963fn pick_thumb_source_name(ctx: &CommandContext) -> Option<String> {
964    let table = ctx.tables.thumb_table.as_ref()?;
965    let mut form_ids: Vec<u32> = ctx.globals.stage_forms.keys().copied().collect();
966    form_ids.sort_unstable();
967    for form_id in form_ids {
968        let Some(stage) = ctx.globals.stage_forms.get(&form_id) else {
969            continue;
970        };
971        let mut stage_ids: Vec<i64> = stage.object_lists.keys().copied().collect();
972        stage_ids.sort_unstable();
973        for stage_idx in stage_ids {
974            let Some(objs) = stage.object_lists.get(&stage_idx) else {
975                continue;
976            };
977            for obj in objs.iter().rev() {
978                if let Some(file) = obj.file_name.as_deref()
979                    && let Some(mapped) = table.get_by_file_stem(file)
980                {
981                    return Some(mapped.clone());
982                }
983            }
984        }
985    }
986    None
987}
988
989fn capture_slot_thumb(
990    ctx: &mut CommandContext,
991    config: SaveThumbConfig,
992) -> anyhow::Result<RgbaImage> {
993    if let Some(name) = pick_thumb_source_name(ctx)
994        && let Ok(img_id) = ctx.images.load_g00(&name, 0)
995        && let Some(img) = ctx.images.get(&img_id)
996    {
997        return Ok(resize_rgba(img.as_ref(), config.width, config.height));
998    }
999
1000    let img = ctx.capture_frame_rgba()?;
1001    Ok(resize_rgba(&img, config.width, config.height))
1002}
1003
1004pub(crate) const CAPTURE_PRIOR_NONE: i32 = 0;
1005pub const CAPTURE_PRIOR_SAVE: i32 = 1;
1006pub(crate) const CAPTURE_PRIOR_END: i32 = 2;
1007pub(crate) const CAPTURE_PRIOR_CAPTURE: i32 = 3;
1008
1009pub(crate) fn prepare_runtime_save_thumb_capture_with_priority(
1010    ctx: &mut CommandContext,
1011    priority: i32,
1012) {
1013    let config = save_thumb_config(ctx);
1014    if !config.enabled {
1015        return;
1016    }
1017    if ctx.globals.save_thumb_capture_image.is_some()
1018        && priority < ctx.globals.save_thumb_capture_prior
1019    {
1020        return;
1021    }
1022
1023    match capture_slot_thumb(ctx, config) {
1024        Ok(image) => {
1025            ctx.globals.save_thumb_capture_image = Some(image);
1026            ctx.globals.save_thumb_capture_prior = priority;
1027        }
1028        Err(err) => {
1029            log::error!("save thumbnail GPU capture failed: {err:#}");
1030        }
1031    }
1032}
1033
1034pub(crate) fn prepare_runtime_save_thumb_capture(ctx: &mut CommandContext) {
1035    prepare_runtime_save_thumb_capture_with_priority(ctx, CAPTURE_PRIOR_SAVE);
1036}
1037
1038pub fn free_runtime_save_thumb_capture(ctx: &mut CommandContext, priority: i32) {
1039    if priority < ctx.globals.save_thumb_capture_prior {
1040        return;
1041    }
1042    ctx.globals.save_thumb_capture_image = None;
1043    ctx.globals.save_thumb_capture_prior = CAPTURE_PRIOR_NONE;
1044}
1045
1046pub(crate) fn prepare_runtime_save_thumb_capture_from_image(
1047    ctx: &mut CommandContext,
1048    img: &RgbaImage,
1049) {
1050    let config = save_thumb_config(ctx);
1051    if !config.enabled {
1052        return;
1053    }
1054
1055    // C++ CAPTURE_FROM_FILE replaces the save-thumbnail texture directly and
1056    // deliberately leaves the current capture-priority owner unchanged.
1057    ctx.globals.save_thumb_capture_image = Some(img.clone());
1058}
1059
1060pub(crate) fn capture_for_local_save(
1061    ctx: &mut CommandContext,
1062    img: &RgbaImage,
1063    width: u32,
1064    height: u32,
1065) -> i64 {
1066    use std::time::{SystemTime, UNIX_EPOCH};
1067
1068    let capture_time = match SystemTime::now().duration_since(UNIX_EPOCH) {
1069        Ok(v) => v.as_secs().min(i64::MAX as u64) as i64,
1070        Err(err) => {
1071            log::error!("GLOBAL.CAPTURE_FOR_LOCAL_SAVE cannot obtain UNIX time: {err}");
1072            return 0;
1073        }
1074    };
1075    let resized = resize_rgba(img, width.max(1), height.max(1));
1076    let path = original_save::save_dir(&ctx.project_dir).join(format!("{capture_time}.png"));
1077    if let Err(err) = write_rgba_png_opaque(&path, &resized) {
1078        log::error!(
1079            "GLOBAL.CAPTURE_FOR_LOCAL_SAVE failed to write {}: {err:#}",
1080            path.display()
1081        );
1082        return 0;
1083    }
1084    capture_time
1085}
1086
1087fn write_slot_thumb_for_save_no(ctx: &mut CommandContext, save_no: usize) {
1088    let config = save_thumb_config(ctx);
1089    if !config.enabled {
1090        return;
1091    }
1092    let Some(img) = ctx.globals.save_thumb_capture_image.clone() else {
1093        ctx.unknown
1094            .record_note(&format!("save_thumb.capture.missing:{save_no}"));
1095        return;
1096    };
1097    let path = thumb_path_for_no_with_config(&ctx.project_dir, config, save_no);
1098    if save_load_trace_enabled() {
1099        eprintln!(
1100            "[SG_SAVELOAD_TRACE][SYSCOM] write_slot_thumb save_no={} path={} size={}x{} type={:?}",
1101            save_no,
1102            path.display(),
1103            img.width,
1104            img.height,
1105            config.thumb_type
1106        );
1107    }
1108    remove_game_file(&path);
1109    let result = match config.thumb_type {
1110        SaveThumbType::Bmp => write_rgba_bmp_top_down(&path, &img),
1111        SaveThumbType::Png => write_rgba_png_opaque(&path, &img),
1112    };
1113    if let Err(err) = result {
1114        eprintln!(
1115            "[SG_SAVE] failed to write save thumb {}: {err:#}",
1116            path.display()
1117        );
1118    }
1119}
1120
1121pub(crate) fn write_runtime_slot_thumb(ctx: &mut CommandContext, save_no: usize) {
1122    write_slot_thumb_for_save_no(ctx, save_no);
1123}
1124
1125fn escape_str(s: &str) -> String {
1126    let mut out = String::with_capacity(s.len());
1127    for ch in s.chars() {
1128        match ch {
1129            '\\' => out.push_str("\\\\"),
1130            '\n' => out.push_str("\\n"),
1131            '\r' => out.push_str("\\r"),
1132            '\t' => out.push_str("\\t"),
1133            _ => out.push(ch),
1134        }
1135    }
1136    out
1137}
1138
1139fn unescape_str(s: &str) -> String {
1140    let mut out = String::with_capacity(s.len());
1141    let mut it = s.chars();
1142    while let Some(ch) = it.next() {
1143        if ch == '\\' {
1144            match it.next() {
1145                Some('n') => out.push('\n'),
1146                Some('r') => out.push('\r'),
1147                Some('t') => out.push('\t'),
1148                Some('\\') => out.push('\\'),
1149                Some(other) => out.push(other),
1150                None => break,
1151            }
1152        } else {
1153            out.push(ch);
1154        }
1155    }
1156    out
1157}
1158
1159fn read_slot(path: &Path) -> Option<SaveSlotState> {
1160    original_save::read_slot_from_path(path)
1161}
1162
1163fn configured_chrkoe_count(ctx: &CommandContext) -> usize {
1164    ctx.tables
1165        .gameexe
1166        .as_ref()
1167        .and_then(|cfg| {
1168            cfg.get_usize("#CHRKOE.CNT")
1169                .or_else(|| cfg.get_usize("CHRKOE.CNT"))
1170        })
1171        .unwrap_or(64)
1172        .min(256)
1173}
1174
1175fn resize_original_config_arrays(
1176    _ctx: &CommandContext,
1177    cfg: &mut crate::runtime::globals::OriginalConfigRuntimeState,
1178) {
1179    cfg.object_disp_flag.resize(4, true);
1180    cfg.object_disp_flag.truncate(4);
1181    cfg.global_extra_switch_flag.resize(4, true);
1182    cfg.global_extra_switch_flag.truncate(4);
1183    cfg.global_extra_mode_flag.resize(4, 0);
1184    cfg.global_extra_mode_flag.truncate(4);
1185}
1186
1187pub fn config_state_for_save(
1188    ctx: &CommandContext,
1189) -> crate::runtime::globals::OriginalConfigRuntimeState {
1190    let mut cfg = ctx.globals.syscom.original_config.clone();
1191    resize_original_config_arrays(ctx, &mut cfg);
1192
1193    cfg.screen_size_mode =
1194        cfg_get_int(&ctx.globals.syscom, GET_WINDOW_MODE, cfg.screen_size_mode).clamp(0, 1);
1195    let window_scale = cfg_get_int(
1196        &ctx.globals.syscom,
1197        GET_WINDOW_MODE_SIZE,
1198        cfg.screen_size_scale.0,
1199    )
1200    .max(1);
1201    cfg.screen_size_scale = (window_scale, window_scale);
1202
1203    cfg.all_sound_user_volume = cfg_get_int(
1204        &ctx.globals.syscom,
1205        GET_ALL_VOLUME,
1206        cfg.all_sound_user_volume,
1207    )
1208    .clamp(0, 255);
1209    for (index, key) in [
1210        GET_BGM_VOLUME,
1211        GET_KOE_VOLUME,
1212        GET_PCM_VOLUME,
1213        GET_SE_VOLUME,
1214        GET_MOV_VOLUME,
1215    ]
1216    .into_iter()
1217    .enumerate()
1218    {
1219        cfg.sound_user_volume[index] =
1220            cfg_get_int(&ctx.globals.syscom, key, cfg.sound_user_volume[index]).clamp(0, 255);
1221    }
1222    cfg.play_all_sound_check = cfg_get_int(
1223        &ctx.globals.syscom,
1224        GET_ALL_ONOFF,
1225        if cfg.play_all_sound_check { 1 } else { 0 },
1226    ) != 0;
1227    for (index, key) in [
1228        GET_BGM_ONOFF,
1229        GET_KOE_ONOFF,
1230        GET_PCM_ONOFF,
1231        GET_SE_ONOFF,
1232        GET_MOV_ONOFF,
1233    ]
1234    .into_iter()
1235    .enumerate()
1236    {
1237        cfg.play_sound_check[index] = cfg_get_int(
1238            &ctx.globals.syscom,
1239            key,
1240            if cfg.play_sound_check[index] { 1 } else { 0 },
1241        ) != 0;
1242    }
1243
1244    cfg.bgmfade_volume =
1245        cfg_get_int(&ctx.globals.syscom, GET_BGMFADE_VOLUME, cfg.bgmfade_volume).clamp(0, 255);
1246    cfg.bgmfade_use_check = cfg_get_int(
1247        &ctx.globals.syscom,
1248        GET_BGMFADE_ONOFF,
1249        if cfg.bgmfade_use_check { 1 } else { 0 },
1250    ) != 0;
1251
1252    let r = cfg_get_int(
1253        &ctx.globals.syscom,
1254        GET_FILTER_COLOR_R,
1255        ((cfg.filter_color_argb >> 16) & 0xff) as i64,
1256    )
1257    .clamp(0, 255) as u32;
1258    let g = cfg_get_int(
1259        &ctx.globals.syscom,
1260        GET_FILTER_COLOR_G,
1261        ((cfg.filter_color_argb >> 8) & 0xff) as i64,
1262    )
1263    .clamp(0, 255) as u32;
1264    let b = cfg_get_int(
1265        &ctx.globals.syscom,
1266        GET_FILTER_COLOR_B,
1267        (cfg.filter_color_argb & 0xff) as i64,
1268    )
1269    .clamp(0, 255) as u32;
1270    let a = cfg_get_int(
1271        &ctx.globals.syscom,
1272        GET_FILTER_COLOR_A,
1273        ((cfg.filter_color_argb >> 24) & 0xff) as i64,
1274    )
1275    .clamp(0, 255) as u32;
1276    cfg.filter_color_argb = (a << 24) | (r << 16) | (g << 8) | b;
1277
1278    cfg.font_name = cfg_get_str(&ctx.globals.syscom, GET_FONT_NAME);
1279    cfg.font_futoku = cfg_get_int(
1280        &ctx.globals.syscom,
1281        GET_FONT_BOLD,
1282        if cfg.font_futoku { 1 } else { 0 },
1283    ) != 0;
1284    cfg.font_shadow = cfg_get_int(&ctx.globals.syscom, GET_FONT_DECORATION, cfg.font_shadow);
1285
1286    cfg.message_speed = cfg_get_int(&ctx.globals.syscom, GET_MESSAGE_SPEED, cfg.message_speed);
1287    cfg.message_speed_nowait = ctx.globals.script.msg_nowait;
1288    cfg.auto_mode_onoff = ctx.globals.syscom.auto_mode.onoff;
1289    cfg.auto_mode_moji_wait = ctx.globals.script.auto_mode_moji_wait;
1290    cfg.auto_mode_min_wait = ctx.globals.script.auto_mode_min_wait;
1291    cfg.mouse_cursor_hide_onoff = cfg_get_int(
1292        &ctx.globals.syscom,
1293        GET_MOUSE_CURSOR_HIDE_ONOFF,
1294        if cfg.mouse_cursor_hide_onoff { 1 } else { 0 },
1295    ) != 0;
1296    cfg.mouse_cursor_hide_time = cfg_get_int(
1297        &ctx.globals.syscom,
1298        GET_MOUSE_CURSOR_HIDE_TIME,
1299        cfg.mouse_cursor_hide_time,
1300    )
1301    .max(0);
1302    cfg.jitan_normal_onoff = cfg_get_int(
1303        &ctx.globals.syscom,
1304        GET_JITAN_NORMAL_ONOFF,
1305        if cfg.jitan_normal_onoff { 1 } else { 0 },
1306    ) != 0;
1307    cfg.jitan_auto_mode_onoff = cfg_get_int(
1308        &ctx.globals.syscom,
1309        GET_JITAN_AUTO_MODE_ONOFF,
1310        if cfg.jitan_auto_mode_onoff { 1 } else { 0 },
1311    ) != 0;
1312    cfg.jitan_msgbk_onoff = cfg_get_int(
1313        &ctx.globals.syscom,
1314        GET_JITAN_KOE_REPLAY_ONOFF,
1315        if cfg.jitan_msgbk_onoff { 1 } else { 0 },
1316    ) != 0;
1317    cfg.jitan_speed = cfg_get_int(&ctx.globals.syscom, GET_JITAN_SPEED, cfg.jitan_speed);
1318    cfg.koe_mode = cfg_get_int(&ctx.globals.syscom, GET_KOEMODE, cfg.koe_mode);
1319
1320    cfg.sleep_flag = cfg_get_int(
1321        &ctx.globals.syscom,
1322        GET_SLEEP_ONOFF,
1323        if cfg.sleep_flag { 1 } else { 0 },
1324    ) != 0;
1325    cfg.no_wipe_anime_flag = cfg_get_int(
1326        &ctx.globals.syscom,
1327        GET_NO_WIPE_ANIME_ONOFF,
1328        if cfg.no_wipe_anime_flag { 1 } else { 0 },
1329    ) != 0;
1330    cfg.skip_wipe_anime_flag = cfg_get_int(
1331        &ctx.globals.syscom,
1332        GET_SKIP_WIPE_ANIME_ONOFF,
1333        if cfg.skip_wipe_anime_flag { 1 } else { 0 },
1334    ) != 0;
1335    cfg.no_mwnd_anime_flag = cfg_get_int(
1336        &ctx.globals.syscom,
1337        GET_NO_MWND_ANIME_ONOFF,
1338        if cfg.no_mwnd_anime_flag { 1 } else { 0 },
1339    ) != 0;
1340    cfg.wheel_next_message_flag = cfg_get_int(
1341        &ctx.globals.syscom,
1342        GET_WHEEL_NEXT_MESSAGE_ONOFF,
1343        if cfg.wheel_next_message_flag { 1 } else { 0 },
1344    ) != 0;
1345    cfg.koe_dont_stop_flag = cfg_get_int(
1346        &ctx.globals.syscom,
1347        GET_KOE_DONT_STOP_ONOFF,
1348        if cfg.koe_dont_stop_flag { 1 } else { 0 },
1349    ) != 0;
1350    cfg.skip_unread_message_flag = cfg_get_int(
1351        &ctx.globals.syscom,
1352        GET_SKIP_UNREAD_MESSAGE_ONOFF,
1353        if cfg.skip_unread_message_flag { 1 } else { 0 },
1354    ) != 0;
1355    cfg.saveload_alert_flag = cfg_get_int(
1356        &ctx.globals.syscom,
1357        GET_SAVELOAD_ALERT_ONOFF,
1358        if cfg.saveload_alert_flag { 1 } else { 0 },
1359    ) != 0;
1360
1361    cfg
1362}
1363
1364fn apply_original_config_to_runtime(ctx: &mut CommandContext) {
1365    let cfg = ctx.globals.syscom.original_config.clone();
1366    cfg_set_int(
1367        &mut ctx.globals.syscom,
1368        GET_WINDOW_MODE,
1369        cfg.screen_size_mode.clamp(0, 1),
1370    );
1371    cfg_set_int(
1372        &mut ctx.globals.syscom,
1373        GET_WINDOW_MODE_SIZE,
1374        cfg.screen_size_scale.0.max(1),
1375    );
1376    cfg_set_int(
1377        &mut ctx.globals.syscom,
1378        GET_ALL_VOLUME,
1379        cfg.all_sound_user_volume.clamp(0, 255),
1380    );
1381    for (index, key) in [
1382        GET_BGM_VOLUME,
1383        GET_KOE_VOLUME,
1384        GET_PCM_VOLUME,
1385        GET_SE_VOLUME,
1386        GET_MOV_VOLUME,
1387    ]
1388    .into_iter()
1389    .enumerate()
1390    {
1391        cfg_set_int(
1392            &mut ctx.globals.syscom,
1393            key,
1394            cfg.sound_user_volume[index].clamp(0, 255),
1395        );
1396    }
1397    cfg_set_int(
1398        &mut ctx.globals.syscom,
1399        GET_ALL_ONOFF,
1400        if cfg.play_all_sound_check { 1 } else { 0 },
1401    );
1402    for (index, key) in [
1403        GET_BGM_ONOFF,
1404        GET_KOE_ONOFF,
1405        GET_PCM_ONOFF,
1406        GET_SE_ONOFF,
1407        GET_MOV_ONOFF,
1408    ]
1409    .into_iter()
1410    .enumerate()
1411    {
1412        cfg_set_int(
1413            &mut ctx.globals.syscom,
1414            key,
1415            if cfg.play_sound_check[index] { 1 } else { 0 },
1416        );
1417    }
1418    cfg_set_int(
1419        &mut ctx.globals.syscom,
1420        GET_BGMFADE_VOLUME,
1421        cfg.bgmfade_volume.clamp(0, 255),
1422    );
1423    cfg_set_int(
1424        &mut ctx.globals.syscom,
1425        GET_BGMFADE_ONOFF,
1426        if cfg.bgmfade_use_check { 1 } else { 0 },
1427    );
1428    cfg_set_int(
1429        &mut ctx.globals.syscom,
1430        GET_FILTER_COLOR_A,
1431        ((cfg.filter_color_argb >> 24) & 0xff) as i64,
1432    );
1433    cfg_set_int(
1434        &mut ctx.globals.syscom,
1435        GET_FILTER_COLOR_R,
1436        ((cfg.filter_color_argb >> 16) & 0xff) as i64,
1437    );
1438    cfg_set_int(
1439        &mut ctx.globals.syscom,
1440        GET_FILTER_COLOR_G,
1441        ((cfg.filter_color_argb >> 8) & 0xff) as i64,
1442    );
1443    cfg_set_int(
1444        &mut ctx.globals.syscom,
1445        GET_FILTER_COLOR_B,
1446        (cfg.filter_color_argb & 0xff) as i64,
1447    );
1448    cfg_set_str(&mut ctx.globals.syscom, GET_FONT_NAME, cfg.font_name);
1449    cfg_set_int(
1450        &mut ctx.globals.syscom,
1451        GET_FONT_BOLD,
1452        if cfg.font_futoku { 1 } else { 0 },
1453    );
1454    cfg_set_int(
1455        &mut ctx.globals.syscom,
1456        GET_FONT_DECORATION,
1457        cfg.font_shadow,
1458    );
1459    cfg_set_int(
1460        &mut ctx.globals.syscom,
1461        GET_MESSAGE_SPEED,
1462        cfg.message_speed,
1463    );
1464    cfg_set_int(
1465        &mut ctx.globals.syscom,
1466        GET_MESSAGE_NOWAIT,
1467        if cfg.message_speed_nowait { 1 } else { 0 },
1468    );
1469    ctx.globals.script.msg_nowait = cfg.message_speed_nowait;
1470    ctx.globals.syscom.auto_mode.onoff = cfg.auto_mode_onoff;
1471    ctx.globals.script.auto_mode_moji_wait = cfg.auto_mode_moji_wait;
1472    ctx.globals.script.auto_mode_min_wait = cfg.auto_mode_min_wait;
1473    cfg_set_int(
1474        &mut ctx.globals.syscom,
1475        GET_AUTO_MODE_MOJI_WAIT,
1476        cfg.auto_mode_moji_wait,
1477    );
1478    cfg_set_int(
1479        &mut ctx.globals.syscom,
1480        GET_AUTO_MODE_MIN_WAIT,
1481        cfg.auto_mode_min_wait,
1482    );
1483    cfg_set_int(
1484        &mut ctx.globals.syscom,
1485        GET_MOUSE_CURSOR_HIDE_ONOFF,
1486        if cfg.mouse_cursor_hide_onoff { 1 } else { 0 },
1487    );
1488    cfg_set_int(
1489        &mut ctx.globals.syscom,
1490        GET_MOUSE_CURSOR_HIDE_TIME,
1491        cfg.mouse_cursor_hide_time,
1492    );
1493    cfg_set_int(
1494        &mut ctx.globals.syscom,
1495        GET_JITAN_NORMAL_ONOFF,
1496        if cfg.jitan_normal_onoff { 1 } else { 0 },
1497    );
1498    cfg_set_int(
1499        &mut ctx.globals.syscom,
1500        GET_JITAN_AUTO_MODE_ONOFF,
1501        if cfg.jitan_auto_mode_onoff { 1 } else { 0 },
1502    );
1503    cfg_set_int(
1504        &mut ctx.globals.syscom,
1505        GET_JITAN_KOE_REPLAY_ONOFF,
1506        if cfg.jitan_msgbk_onoff { 1 } else { 0 },
1507    );
1508    cfg_set_int(&mut ctx.globals.syscom, GET_JITAN_SPEED, cfg.jitan_speed);
1509    cfg_set_int(&mut ctx.globals.syscom, GET_KOEMODE, cfg.koe_mode);
1510    cfg_set_int(
1511        &mut ctx.globals.syscom,
1512        GET_SLEEP_ONOFF,
1513        if cfg.sleep_flag { 1 } else { 0 },
1514    );
1515    cfg_set_int(
1516        &mut ctx.globals.syscom,
1517        GET_NO_WIPE_ANIME_ONOFF,
1518        if cfg.no_wipe_anime_flag { 1 } else { 0 },
1519    );
1520    cfg_set_int(
1521        &mut ctx.globals.syscom,
1522        GET_SKIP_WIPE_ANIME_ONOFF,
1523        if cfg.skip_wipe_anime_flag { 1 } else { 0 },
1524    );
1525    cfg_set_int(
1526        &mut ctx.globals.syscom,
1527        GET_NO_MWND_ANIME_ONOFF,
1528        if cfg.no_mwnd_anime_flag { 1 } else { 0 },
1529    );
1530    cfg_set_int(
1531        &mut ctx.globals.syscom,
1532        GET_WHEEL_NEXT_MESSAGE_ONOFF,
1533        if cfg.wheel_next_message_flag { 1 } else { 0 },
1534    );
1535    cfg_set_int(
1536        &mut ctx.globals.syscom,
1537        GET_KOE_DONT_STOP_ONOFF,
1538        if cfg.koe_dont_stop_flag { 1 } else { 0 },
1539    );
1540    cfg_set_int(
1541        &mut ctx.globals.syscom,
1542        GET_SKIP_UNREAD_MESSAGE_ONOFF,
1543        if cfg.skip_unread_message_flag { 1 } else { 0 },
1544    );
1545    cfg_set_int(
1546        &mut ctx.globals.syscom,
1547        GET_SAVELOAD_ALERT_ONOFF,
1548        if cfg.saveload_alert_flag { 1 } else { 0 },
1549    );
1550    apply_audio_config(ctx);
1551}
1552
1553/// Apply dialog edits through the same state and audio routing used by scripts.
1554pub fn apply_config_dialog_state(
1555    ctx: &mut CommandContext,
1556    mut config: crate::runtime::globals::OriginalConfigRuntimeState,
1557) {
1558    resize_original_config_arrays(ctx, &mut config);
1559    ctx.globals.syscom.original_config = config;
1560    apply_original_config_to_runtime(ctx);
1561}
1562
1563pub fn write_config_save(ctx: &CommandContext) {
1564    let cfg = config_state_for_save(ctx);
1565    let mut stream = original_save::OriginalStreamWriter::new();
1566    stream.push_i32(cfg.screen_size_mode as i32);
1567    stream.push_i32(cfg.screen_size_mode_window as i32);
1568    stream.push_i32(cfg.screen_size_scale.0 as i32);
1569    stream.push_i32(cfg.screen_size_scale.1 as i32);
1570    stream.push_i32(cfg.screen_size_free.0 as i32);
1571    stream.push_i32(cfg.screen_size_free.1 as i32);
1572    stream.push_bool(cfg.fullscreen_change_resolution);
1573    stream.push_i32(cfg.fullscreen_display_cnt as i32);
1574    stream.push_i32(cfg.fullscreen_display_no as i32);
1575    stream.push_i32(cfg.fullscreen_resolution_cnt as i32);
1576    stream.push_i32(cfg.fullscreen_resolution_no as i32);
1577    stream.push_i32(cfg.fullscreen_resolution.0 as i32);
1578    stream.push_i32(cfg.fullscreen_resolution.1 as i32);
1579    stream.push_i32(cfg.fullscreen_mode as i32);
1580    stream.push_i32(cfg.fullscreen_scale.0 as i32);
1581    stream.push_i32(cfg.fullscreen_scale.1 as i32);
1582    stream.push_bool(cfg.fullscreen_scale_sync_switch);
1583    stream.push_i32(cfg.fullscreen_move.0 as i32);
1584    stream.push_i32(cfg.fullscreen_move.1 as i32);
1585    stream.push_i32(cfg.all_sound_user_volume.clamp(0, 255) as i32);
1586    for value in cfg.sound_user_volume {
1587        stream.push_i32(value.clamp(0, 255) as i32);
1588    }
1589    stream.push_bool(cfg.play_all_sound_check);
1590    for value in cfg.play_sound_check {
1591        stream.push_bool(value);
1592    }
1593    stream.push_i32(cfg.bgmfade_volume.clamp(0, 255) as i32);
1594    stream.push_bool(cfg.bgmfade_use_check);
1595    stream.push_u32(cfg.filter_color_argb);
1596    stream.push_bool(cfg.font_proportional);
1597    stream.push_str(&cfg.font_name);
1598    stream.push_i32(cfg.font_shadow as i32);
1599    stream.push_bool(cfg.font_futoku);
1600    stream.push_i32(cfg.message_speed as i32);
1601    stream.push_bool(cfg.message_speed_nowait);
1602    stream.push_bool(cfg.auto_mode_onoff);
1603    stream.push_i32(cfg.auto_mode_moji_wait as i32);
1604    stream.push_i32(cfg.auto_mode_min_wait as i32);
1605    stream.push_bool(cfg.mouse_cursor_hide_onoff);
1606    stream.push_i32(cfg.mouse_cursor_hide_time as i32);
1607    stream.push_bool(cfg.jitan_normal_onoff);
1608    stream.push_bool(cfg.jitan_auto_mode_onoff);
1609    stream.push_bool(cfg.jitan_msgbk_onoff);
1610    stream.push_i32(cfg.jitan_speed as i32);
1611    stream.push_i32(cfg.koe_mode as i32);
1612    stream.push_i32(cfg.chrkoe.len() as i32);
1613    for item in &cfg.chrkoe {
1614        stream.push_bool(item.onoff);
1615        stream.push_padding(3);
1616        stream.push_i32(item.volume.clamp(0, 255) as i32);
1617    }
1618    stream.push_bool(cfg.message_chrcolor_flag);
1619    stream.push_i32(cfg.object_disp_flag.len() as i32);
1620    for value in &cfg.object_disp_flag {
1621        stream.push_bool(*value);
1622    }
1623    stream.push_i32(cfg.global_extra_switch_flag.len() as i32);
1624    for value in &cfg.global_extra_switch_flag {
1625        stream.push_bool(*value);
1626    }
1627    stream.push_i32(cfg.global_extra_mode_flag.len() as i32);
1628    for value in &cfg.global_extra_mode_flag {
1629        stream.push_i32(*value as i32);
1630    }
1631    for value in [
1632        cfg.sleep_flag,
1633        cfg.no_wipe_anime_flag,
1634        cfg.skip_wipe_anime_flag,
1635        cfg.no_mwnd_anime_flag,
1636        cfg.wheel_next_message_flag,
1637        cfg.koe_dont_stop_flag,
1638        cfg.skip_unread_message_flag,
1639        cfg.saveload_alert_flag,
1640        cfg.saveload_dblclick_flag,
1641    ] {
1642        stream.push_bool(value);
1643    }
1644    stream.push_str(&cfg.ss_path);
1645    stream.push_str(&cfg.editor_path);
1646    stream.push_str(&cfg.koe_path);
1647    stream.push_str(&cfg.koe_tool_path);
1648    stream.push_bool(cfg.joypad_swap_ab);
1649    stream.push_bool(cfg.joypad_swap_xy);
1650
1651    if let Err(err) = original_save::write_config_save_file(&ctx.project_dir, &stream.into_inner())
1652    {
1653        log::error!("[SG_SAVE] failed to write config.sav: {err:#}");
1654    }
1655}
1656
1657fn load_config_save(ctx: &mut CommandContext) -> Result<()> {
1658    let mut cfg = original_config_defaults(ctx);
1659    let config_path = original_save::save_dir(&ctx.project_dir).join("config.sav");
1660    if !crate::resource::game_file_exists(&config_path) {
1661        resize_original_config_arrays(ctx, &mut cfg);
1662        ctx.globals.syscom.original_config = cfg;
1663        apply_original_config_to_runtime(ctx);
1664        return Ok(());
1665    }
1666    let (header, payload) = original_save::read_config_save_file(&ctx.project_dir)?;
1667    save_versions::read_config(
1668        header,
1669        &payload,
1670        &mut cfg,
1671        &gameexe_unquoted_owned(ctx, "GAMEID"),
1672    )?;
1673    resize_original_config_arrays(ctx, &mut cfg);
1674    ctx.globals.syscom.original_config = cfg;
1675    apply_original_config_to_runtime(ctx);
1676    Ok(())
1677}
1678
1679fn write_read_flags(ctx: &CommandContext) -> Result<()> {
1680    let metadata = ctx.scene_metadata()?;
1681    let mut rows = Vec::with_capacity(metadata.rows.len());
1682    for (scene_no, (scene_name, flag_count)) in metadata.rows.iter().enumerate() {
1683        let mut flags = ctx
1684            .globals
1685            .read_flags
1686            .get(&(scene_no as i64))
1687            .cloned()
1688            .unwrap_or_default();
1689        flags.resize(*flag_count, 0);
1690        flags.truncate(*flag_count);
1691        rows.push((scene_name.clone(), flags));
1692    }
1693    original_save::write_read_save_file(&ctx.project_dir, &rows)
1694}
1695
1696fn load_read_flags(ctx: &mut CommandContext) -> Result<()> {
1697    let read_path = original_save::save_dir(&ctx.project_dir).join("read.sav");
1698    if crate::resource::resolve_windows_case_insensitive_file(&read_path)?.is_none() {
1699        return Ok(());
1700    }
1701    let metadata = ctx.scene_metadata()?;
1702    ctx.globals.read_flags.clear();
1703    for (scene_no, (_, flag_count)) in metadata.rows.iter().enumerate() {
1704        ctx.globals
1705            .ensure_read_flag_count(scene_no as i64, *flag_count);
1706    }
1707
1708    let rows = match original_save::read_read_save_file(&ctx.project_dir) {
1709        Ok(rows) => rows,
1710        Err(_) => return Ok(()),
1711    };
1712    for (scene_name, saved_flags) in rows {
1713        let Some(scene_no) = metadata.find_scene_no(&scene_name) else {
1714            continue;
1715        };
1716        let Some((_, real_flag_count)) = metadata.rows.get(scene_no) else {
1717            continue;
1718        };
1719        // Original C++ applies a row only when its saved count exactly matches
1720        // Gp_lexer->get_read_flag_cnt(scene_no).
1721        if saved_flags.len() == *real_flag_count {
1722            ctx.globals.read_flags.insert(scene_no as i64, saved_flags);
1723        }
1724    }
1725    Ok(())
1726}
1727
1728/// A fingerprint of what the global save keeps across sessions (global
1729/// flags and names, CG and BGM tables, read flags), leaving out the play
1730/// time. Hosts that can be killed without warning (Vita) write the global
1731/// save when this changes.
1732pub fn global_save_fingerprint(ctx: &CommandContext) -> u64 {
1733    use std::hash::{Hash, Hasher};
1734    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1735    for code in [
1736        crate::runtime::forms::codes::ELM_GLOBAL_G,
1737        crate::runtime::forms::codes::ELM_GLOBAL_Z,
1738    ] {
1739        ctx.globals.int_lists.get(&(code as u32)).hash(&mut hasher);
1740    }
1741    for code in [
1742        crate::runtime::forms::codes::ELM_GLOBAL_M,
1743        crate::runtime::forms::codes::ELM_GLOBAL_NAMAE_GLOBAL,
1744    ] {
1745        ctx.globals.str_lists.get(&(code as u32)).hash(&mut hasher);
1746    }
1747    ctx.tables.cg_flags.hash(&mut hasher);
1748    ctx.globals.bgm_table_flags.hash(&mut hasher);
1749    let mut scenes: Vec<_> = ctx.globals.read_flags.iter().collect();
1750    scenes.sort_by_key(|(scene, _)| **scene);
1751    scenes.hash(&mut hasher);
1752    hasher.finish()
1753}
1754
1755pub fn write_global_save(ctx: &CommandContext) {
1756    write_config_save(ctx);
1757    let mut stream = original_save::OriginalStreamWriter::new();
1758    stream.push_i64(ctx.globals.syscom.total_play_time);
1759
1760    let configured_flag_cnt = ctx.tables.gameexe.as_ref().and_then(|cfg| {
1761        cfg.get_usize("#GLOBAL_FLAG.CNT")
1762            .or_else(|| cfg.get_usize("GLOBAL_FLAG.CNT"))
1763    });
1764    let current_flag_cnt = [
1765        ctx.globals
1766            .int_lists
1767            .get(&(crate::runtime::forms::codes::ELM_GLOBAL_G as u32))
1768            .map_or(0, Vec::len),
1769        ctx.globals
1770            .int_lists
1771            .get(&(crate::runtime::forms::codes::ELM_GLOBAL_Z as u32))
1772            .map_or(0, Vec::len),
1773        ctx.globals
1774            .str_lists
1775            .get(&(crate::runtime::forms::codes::ELM_GLOBAL_M as u32))
1776            .map_or(0, Vec::len),
1777    ]
1778    .into_iter()
1779    .max()
1780    .unwrap_or(0);
1781    let fixed_flag_cnt = configured_flag_cnt
1782        .unwrap_or(current_flag_cnt.max(1000))
1783        .min(10000);
1784    let cg_flag_cnt = ctx
1785        .tables
1786        .cgtable_flag_cnt
1787        .or_else(|| {
1788            ctx.tables.gameexe.as_ref().and_then(|cfg| {
1789                cfg.get_usize("#CGTABLE_FLAG_CNT")
1790                    .or_else(|| cfg.get_usize("CGTABLE_FLAG_CNT"))
1791            })
1792        })
1793        .unwrap_or(ctx.tables.cg_flags.len())
1794        .max(ctx.tables.cg_flags.len());
1795    let bgm_cnt = ctx
1796        .tables
1797        .gameexe
1798        .as_ref()
1799        .and_then(|cfg| {
1800            cfg.get_usize("#BGM.CNT")
1801                .or_else(|| cfg.get_usize("BGM.CNT"))
1802        })
1803        .unwrap_or(32)
1804        .max(ctx.globals.bgm_table_flags.len());
1805    let g = ctx
1806        .globals
1807        .int_lists
1808        .get(&(crate::runtime::forms::codes::ELM_GLOBAL_G as u32))
1809        .map(Vec::as_slice)
1810        .unwrap_or(&[]);
1811    let z = ctx
1812        .globals
1813        .int_lists
1814        .get(&(crate::runtime::forms::codes::ELM_GLOBAL_Z as u32))
1815        .map(Vec::as_slice)
1816        .unwrap_or(&[]);
1817    let m = ctx
1818        .globals
1819        .str_lists
1820        .get(&(crate::runtime::forms::codes::ELM_GLOBAL_M as u32))
1821        .map(Vec::as_slice)
1822        .unwrap_or(&[]);
1823    let namae_global = ctx
1824        .globals
1825        .str_lists
1826        .get(&(crate::runtime::forms::codes::ELM_GLOBAL_NAMAE_GLOBAL as u32))
1827        .map(Vec::as_slice)
1828        .unwrap_or(&[]);
1829
1830    stream.push_fixed_i32_list(g, fixed_flag_cnt);
1831    stream.push_fixed_i32_list(z, fixed_flag_cnt);
1832    stream.push_fixed_str_list(m, fixed_flag_cnt);
1833    stream.push_fixed_str_list(namae_global, 26 + 26 * 26);
1834    stream.push_i32(0);
1835
1836    let cg_flags: Vec<i64> = ctx.tables.cg_flags.iter().map(|v| *v as i64).collect();
1837    // Without a CGTABLE_FILE, native C_tnm_cg_table::init leaves flag in
1838    // its default extendable state. Its empty save contains only a count.
1839    let has_cg_table = ctx.tables.cgtable.is_some()
1840        || ctx
1841            .tables
1842            .gameexe
1843            .as_ref()
1844            .and_then(|cfg| cfg.get_unquoted("CGTABLE_FILE"))
1845            .is_some_and(|name| !name.is_empty());
1846    if !has_cg_table && cg_flags.is_empty() {
1847        stream.push_extend_i32_list(&[]);
1848    } else {
1849        stream.push_fixed_i32_list(&cg_flags, cg_flag_cnt);
1850    }
1851
1852    let bgm_flags: Vec<i64> = ctx
1853        .globals
1854        .bgm_table_flags
1855        .iter()
1856        .map(|v| if *v { 1 } else { 0 })
1857        .collect();
1858    stream.push_fixed_i32_list(&bgm_flags, bgm_cnt);
1859
1860    // C++ twitter_save_state persists OAuth/user state out-of-band in the
1861    // Windows registry and writes nothing to the global stream.  The desktop
1862    // port mirrors that with a sidecar next to the original save files.
1863    #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
1864    if let Err(err) = crate::runtime::twitter::save_state(ctx) {
1865        eprintln!("[SG_SAVE] failed to write Twitter state: {err:#}");
1866    }
1867
1868    let chrkoe_count = configured_chrkoe_count(ctx);
1869    stream.push_i32(chrkoe_count as i32);
1870    for index in 0..chrkoe_count {
1871        let name = ctx
1872            .tables
1873            .gameexe
1874            .as_ref()
1875            .and_then(|cfg| cfg.get_indexed_item_unquoted("CHRKOE", index, 0))
1876            .unwrap_or("");
1877        stream.push_str(name);
1878        stream.push_bool(config_chrkoe_name_visible(ctx, index));
1879    }
1880
1881    let payload = stream.into_inner();
1882    if let Err(err) = original_save::write_global_save_file(&ctx.project_dir, &payload) {
1883        eprintln!("[SG_SAVE] failed to write global.sav: {err:#}");
1884    }
1885    if let Err(err) = write_read_flags(ctx) {
1886        eprintln!("[SG_SAVE] failed to write read.sav: {err:#}");
1887    }
1888}
1889
1890pub fn load_global_save(ctx: &mut CommandContext) -> Result<()> {
1891    let global_path = original_save::save_dir(&ctx.project_dir).join("global.sav");
1892    if crate::resource::resolve_windows_case_insensitive_file(&global_path)?.is_some() {
1893        let (header, payload) = original_save::read_global_save_file(&ctx.project_dir)?;
1894        let fixed_flag_cnt = ctx
1895            .tables
1896            .gameexe
1897            .as_ref()
1898            .and_then(|cfg| {
1899                cfg.get_usize("#GLOBAL_FLAG.CNT")
1900                    .or_else(|| cfg.get_usize("GLOBAL_FLAG.CNT"))
1901            })
1902            .map(|count| count.min(10000));
1903        let save_versions::GlobalSaveData {
1904            total_play_time,
1905            mut g,
1906            mut z,
1907            mut m,
1908            mut namae_global,
1909            cg,
1910            bgm,
1911            chrkoe_look_flags,
1912        } = save_versions::read_global(header, &payload)?;
1913        if let Some(count) = fixed_flag_cnt {
1914            g.resize(count, 0);
1915            z.resize(count, 0);
1916            m.resize_with(count, String::new);
1917        }
1918        namae_global.resize_with(26 + 26 * 26, String::new);
1919        ctx.globals.syscom.chrkoe_look_flags = chrkoe_look_flags.into_iter().collect();
1920
1921        ctx.globals.syscom.total_play_time = total_play_time;
1922        ctx.globals
1923            .int_lists
1924            .insert(crate::runtime::forms::codes::ELM_GLOBAL_G as u32, g);
1925        ctx.globals
1926            .int_lists
1927            .insert(crate::runtime::forms::codes::ELM_GLOBAL_Z as u32, z);
1928        ctx.globals
1929            .str_lists
1930            .insert(crate::runtime::forms::codes::ELM_GLOBAL_M as u32, m);
1931        ctx.globals.str_lists.insert(
1932            crate::runtime::forms::codes::ELM_GLOBAL_NAMAE_GLOBAL as u32,
1933            namae_global,
1934        );
1935        // C_tnm_cg_table::load() restores the fixed C_elm_int_list<int> verbatim.
1936        ctx.tables.cg_flags = cg.into_iter().map(|v| v as i32).collect();
1937        ctx.globals.bgm_table_flags = bgm.into_iter().map(|v| v != 0).collect();
1938        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
1939        crate::runtime::twitter::ensure_state_loaded(ctx);
1940    }
1941    load_read_flags(ctx)?;
1942    load_config_save(ctx)?;
1943    Ok(())
1944}
1945
1946fn ensure_slot_loaded_with_counts(
1947    project_dir: &Path,
1948    quick: bool,
1949    save_cnt: usize,
1950    quick_cnt: usize,
1951    slots: &mut Vec<SaveSlotState>,
1952    idx: usize,
1953) {
1954    // C_tnm_save_cache::load_cache() has a positive cache and a negative
1955    // data_none_flag cache. Once a slot has been queried, metadata commands
1956    // must not reopen the save file until an operation explicitly changes it.
1957    if slots
1958        .get(idx)
1959        .map(|slot| slot.header_cache_valid)
1960        .unwrap_or(false)
1961    {
1962        return;
1963    }
1964
1965    let path = slot_path_with_counts(project_dir, quick, idx, save_cnt, quick_cnt);
1966    if save_load_trace_enabled() {
1967        let before_exist = slots.get(idx).map(|s| s.exist).unwrap_or(false);
1968        eprintln!(
1969            "[SG_SAVELOAD_TRACE][SYSCOM] ensure_slot_loaded kind={} idx={} path={} file_exists={} cached_exist={}",
1970            if quick { "quick" } else { "normal" },
1971            idx,
1972            path.display(),
1973            crate::resource::game_file_exists(&path),
1974            before_exist
1975        );
1976    }
1977
1978    let next = match read_slot(&path) {
1979        Some(slot) => slot,
1980        None => {
1981            // Mirrors C_tnm_save_cache::data_none_flag: a missing/invalid save
1982            // is cached as absent instead of being probed again by every
1983            // GET_SAVE_* command in LOAD_SCENE.
1984
1985            SaveSlotState {
1986                header_cache_valid: true,
1987                ..Default::default()
1988            }
1989        }
1990    };
1991    *ensure_slot(slots, idx) = next;
1992}
1993
1994pub(crate) fn read_save_flag_values(
1995    ctx: &mut CommandContext,
1996    quick: bool,
1997    raw_idx: i64,
1998    flag_cnt: usize,
1999) -> Option<Vec<i64>> {
2000    let project_dir = ctx.project_dir.clone();
2001    let save_cnt = configured_save_count(ctx, false);
2002    let quick_cnt = configured_save_count(ctx, true);
2003    let configured = if quick { quick_cnt } else { save_cnt };
2004    if raw_idx < 0 || raw_idx as usize >= configured {
2005        return None;
2006    }
2007    let idx = raw_idx as usize;
2008    let slots = if quick {
2009        &mut ctx.globals.syscom.quick_save_slots
2010    } else {
2011        &mut ctx.globals.syscom.save_slots
2012    };
2013    ensure_slot_loaded_with_counts(&project_dir, quick, save_cnt, quick_cnt, slots, idx);
2014    let slot = slots.get(idx).filter(|slot| slot.exist)?;
2015    let count = flag_cnt.min(original_save::SAVE_FLAG_MAX_CNT);
2016    Some(
2017        (0..count)
2018            .map(|i| slot.values.get(&(i as i32)).copied().unwrap_or(0))
2019            .collect(),
2020    )
2021}
2022
2023pub(crate) fn write_save_flag_values(
2024    ctx: &mut CommandContext,
2025    quick: bool,
2026    raw_idx: i64,
2027    values: &[i64],
2028) -> bool {
2029    let project_dir = ctx.project_dir.clone();
2030    let save_cnt = configured_save_count(ctx, false);
2031    let quick_cnt = configured_save_count(ctx, true);
2032    let configured = if quick { quick_cnt } else { save_cnt };
2033    if raw_idx < 0 || raw_idx as usize >= configured {
2034        return false;
2035    }
2036    let idx = raw_idx as usize;
2037    let slots = if quick {
2038        &mut ctx.globals.syscom.quick_save_slots
2039    } else {
2040        &mut ctx.globals.syscom.save_slots
2041    };
2042    ensure_slot_loaded_with_counts(&project_dir, quick, save_cnt, quick_cnt, slots, idx);
2043    if !slots.get(idx).map(|slot| slot.exist).unwrap_or(false) {
2044        return false;
2045    }
2046    let count = values.len().min(original_save::SAVE_FLAG_MAX_CNT);
2047    let slot = &mut slots[idx];
2048    for (i, value) in values.iter().copied().take(count).enumerate() {
2049        if value == 0 {
2050            slot.values.remove(&(i as i32));
2051        } else {
2052            slot.values.insert(i as i32, value);
2053        }
2054    }
2055    persist_slot_with_counts(&project_dir, quick, save_cnt, quick_cnt, slots, idx);
2056    true
2057}
2058
2059fn sync_slots_from_disk_with_counts(
2060    project_dir: &Path,
2061    quick: bool,
2062    save_cnt: usize,
2063    quick_cnt: usize,
2064    slots: &mut Vec<SaveSlotState>,
2065    count: usize,
2066) {
2067    if slots.len() < count {
2068        slots.resize_with(count, SaveSlotState::default);
2069    }
2070    for idx in 0..count {
2071        ensure_slot_loaded_with_counts(project_dir, quick, save_cnt, quick_cnt, slots, idx);
2072    }
2073}
2074
2075pub(crate) fn sync_save_slots_from_disk(ctx: &mut CommandContext, quick: bool) {
2076    if save_load_trace_enabled() {
2077        eprintln!(
2078            "[SG_SAVELOAD_TRACE][SYSCOM] sync_save_slots_from_disk kind={}",
2079            if quick { "quick" } else { "normal" }
2080        );
2081    }
2082    let project_dir = ctx.project_dir.clone();
2083    let save_cnt = configured_save_count(ctx, false);
2084    let quick_cnt = configured_save_count(ctx, true);
2085    if quick {
2086        sync_slots_from_disk_with_counts(
2087            &project_dir,
2088            true,
2089            save_cnt,
2090            quick_cnt,
2091            &mut ctx.globals.syscom.quick_save_slots,
2092            quick_cnt,
2093        );
2094    } else {
2095        sync_slots_from_disk_with_counts(
2096            &project_dir,
2097            false,
2098            save_cnt,
2099            quick_cnt,
2100            &mut ctx.globals.syscom.save_slots,
2101            save_cnt,
2102        );
2103    }
2104}
2105
2106fn persist_slot_with_counts(
2107    project_dir: &Path,
2108    quick: bool,
2109    save_cnt: usize,
2110    quick_cnt: usize,
2111    slots: &mut [SaveSlotState],
2112    idx: usize,
2113) {
2114    let Some(slot) = slots.get(idx) else {
2115        return;
2116    };
2117    // SET_SAVE_COMMENT / SET_SAVE_VALUE only rewrite an existing header in the
2118    // original engine. They must never fabricate a local-save payload.
2119    if !slot.header_cache_valid || !slot.exist {
2120        return;
2121    }
2122
2123    let path = slot_path_with_counts(project_dir, quick, idx, save_cnt, quick_cnt);
2124    let Some(existing_path) = crate::resource::resolve_game_file(&path).ok().flatten() else {
2125        return;
2126    };
2127    let header = original_save::OriginalSaveHeader::from_slot(slot, slot.packed_data_size);
2128
2129    // C_tnm_save_cache::save_cache() calls clear_cache(save_no) before opening
2130    // the file and does not repopulate save_header after the write.  Therefore
2131    // the next GET_SAVE_* must reload the header from disk.  Keeping the Rust
2132    // positive cache alive here can make a later LOAD_SCENE observe state that
2133    // the original engine would have discarded.
2134    if let Some(slot) = slots.get_mut(idx) {
2135        slot.header_cache_valid = false;
2136    }
2137
2138    if let Err(err) = original_save::write_header_in_place(&existing_path, &header) {
2139        eprintln!(
2140            "[SG_SAVE] failed to update original save header {}: {err:#}",
2141            existing_path.display()
2142        );
2143    }
2144}
2145
2146fn slot_thumb_save_no(save_cnt: usize, quick_cnt: usize, quick: bool, idx: usize) -> usize {
2147    let kind = if quick {
2148        SaveKind::Quick
2149    } else {
2150        SaveKind::Normal
2151    };
2152    original_save::original_save_no(save_cnt, quick_cnt, kind, idx)
2153}
2154
2155fn remove_thumb_file(
2156    project_dir: &Path,
2157    save_cnt: usize,
2158    quick_cnt: usize,
2159    quick: bool,
2160    config: SaveThumbConfig,
2161    idx: usize,
2162) {
2163    if !config.enabled {
2164        return;
2165    }
2166    let save_no = slot_thumb_save_no(save_cnt, quick_cnt, quick, idx);
2167    remove_game_file(&thumb_path_for_no_with_config(project_dir, config, save_no));
2168}
2169
2170fn copy_thumb_file(
2171    project_dir: &Path,
2172    save_cnt: usize,
2173    quick_cnt: usize,
2174    quick: bool,
2175    config: SaveThumbConfig,
2176    src: usize,
2177    dst: usize,
2178) {
2179    if !config.enabled {
2180        return;
2181    }
2182    let src_no = slot_thumb_save_no(save_cnt, quick_cnt, quick, src);
2183    let dst_no = slot_thumb_save_no(save_cnt, quick_cnt, quick, dst);
2184    let src_path = thumb_path_for_no_with_config(project_dir, config, src_no);
2185    let dst_path = thumb_path_for_no_with_config(project_dir, config, dst_no);
2186    if let Some(src_path) = crate::resource::resolve_game_file(&src_path).ok().flatten() {
2187        if let Some(parent) = dst_path.parent() {
2188            let _ = fs::create_dir_all(parent);
2189        }
2190        copy_game_file(&src_path, &dst_path);
2191    } else {
2192        remove_game_file(&dst_path);
2193    }
2194}
2195
2196fn swap_thumb_file(
2197    project_dir: &Path,
2198    save_cnt: usize,
2199    quick_cnt: usize,
2200    quick: bool,
2201    config: SaveThumbConfig,
2202    a: usize,
2203    b: usize,
2204) {
2205    if !config.enabled || a == b {
2206        return;
2207    }
2208    let a_no = slot_thumb_save_no(save_cnt, quick_cnt, quick, a);
2209    let b_no = slot_thumb_save_no(save_cnt, quick_cnt, quick, b);
2210    let pa = thumb_path_for_no_with_config(project_dir, config, a_no);
2211    let pb = thumb_path_for_no_with_config(project_dir, config, b_no);
2212    let tmp = pa.with_extension(format!(
2213        "{}.swap",
2214        pa.extension().and_then(|v| v.to_str()).unwrap_or("tmp")
2215    ));
2216    let pa_existing = crate::resource::resolve_game_file(&pa).ok().flatten();
2217    let pb_existing = crate::resource::resolve_game_file(&pb).ok().flatten();
2218    let a_exists = pa_existing.is_some();
2219    let b_exists = pb_existing.is_some();
2220    if let Some(existing) = pa_existing.as_ref() {
2221        rename_game_file(existing, &tmp);
2222    }
2223    if let Some(existing) = pb_existing.as_ref() {
2224        if let Some(parent) = pa.parent() {
2225            let _ = fs::create_dir_all(parent);
2226        }
2227        rename_game_file(existing, &pa);
2228    } else {
2229        remove_game_file(&pa);
2230    }
2231    if a_exists {
2232        if let Some(parent) = pb.parent() {
2233            let _ = fs::create_dir_all(parent);
2234        }
2235        rename_game_file(&tmp, &pb);
2236    } else {
2237        remove_game_file(&pb);
2238    }
2239    remove_game_file(&tmp);
2240}
2241
2242fn copy_save_file(
2243    project_dir: &Path,
2244    quick: bool,
2245    save_cnt: usize,
2246    quick_cnt: usize,
2247    src: usize,
2248    dst: usize,
2249) {
2250    let src_path = slot_path_with_counts(project_dir, quick, src, save_cnt, quick_cnt);
2251    let dst_path = slot_path_with_counts(project_dir, quick, dst, save_cnt, quick_cnt);
2252    if let Some(src_path) = crate::resource::resolve_game_file(&src_path).ok().flatten() {
2253        if let Some(parent) = dst_path.parent() {
2254            let _ = fs::create_dir_all(parent);
2255        }
2256        copy_game_file(&src_path, &dst_path);
2257    } else {
2258        remove_game_file(&dst_path);
2259    }
2260}
2261
2262fn swap_save_file(
2263    project_dir: &Path,
2264    quick: bool,
2265    save_cnt: usize,
2266    quick_cnt: usize,
2267    a: usize,
2268    b: usize,
2269) {
2270    if a == b {
2271        return;
2272    }
2273    let pa = slot_path_with_counts(project_dir, quick, a, save_cnt, quick_cnt);
2274    let pb = slot_path_with_counts(project_dir, quick, b, save_cnt, quick_cnt);
2275    let tmp = pa.with_extension("sav.swap");
2276    let pa_existing = crate::resource::resolve_game_file(&pa).ok().flatten();
2277    let pb_existing = crate::resource::resolve_game_file(&pb).ok().flatten();
2278    let a_exists = pa_existing.is_some();
2279    let b_exists = pb_existing.is_some();
2280    if let Some(existing) = pa_existing.as_ref() {
2281        rename_game_file(existing, &tmp);
2282    }
2283    if let Some(existing) = pb_existing.as_ref() {
2284        if let Some(parent) = pa.parent() {
2285            let _ = fs::create_dir_all(parent);
2286        }
2287        rename_game_file(existing, &pa);
2288    } else {
2289        remove_game_file(&pa);
2290    }
2291    if a_exists {
2292        if let Some(parent) = pb.parent() {
2293            let _ = fs::create_dir_all(parent);
2294        }
2295        rename_game_file(&tmp, &pb);
2296    } else {
2297        remove_game_file(&pb);
2298    }
2299    remove_game_file(&tmp);
2300}
2301
2302fn copy_slot(
2303    project_dir: &Path,
2304    quick: bool,
2305    save_cnt: usize,
2306    quick_cnt: usize,
2307    thumb_config: SaveThumbConfig,
2308    slots: &mut Vec<SaveSlotState>,
2309    src: usize,
2310    dst: usize,
2311) -> bool {
2312    ensure_slot_loaded_with_counts(project_dir, quick, save_cnt, quick_cnt, slots, src);
2313    let Some(src_slot) = slots.get(src).cloned() else {
2314        return false;
2315    };
2316    if !src_slot.exist {
2317        return false;
2318    }
2319    *ensure_slot(slots, dst) = src_slot;
2320    copy_save_file(project_dir, quick, save_cnt, quick_cnt, src, dst);
2321    copy_thumb_file(
2322        project_dir,
2323        save_cnt,
2324        quick_cnt,
2325        quick,
2326        thumb_config,
2327        src,
2328        dst,
2329    );
2330    true
2331}
2332
2333fn change_slot(
2334    project_dir: &Path,
2335    quick: bool,
2336    save_cnt: usize,
2337    quick_cnt: usize,
2338    thumb_config: SaveThumbConfig,
2339    slots: &mut Vec<SaveSlotState>,
2340    a: usize,
2341    b: usize,
2342) -> bool {
2343    ensure_slot_loaded_with_counts(project_dir, quick, save_cnt, quick_cnt, slots, a);
2344    ensure_slot_loaded_with_counts(project_dir, quick, save_cnt, quick_cnt, slots, b);
2345    let max_idx = a.max(b);
2346    if slots.len() <= max_idx {
2347        slots.resize_with(max_idx + 1, SaveSlotState::default);
2348    }
2349    slots.swap(a, b);
2350    swap_save_file(project_dir, quick, save_cnt, quick_cnt, a, b);
2351    swap_thumb_file(project_dir, save_cnt, quick_cnt, quick, thumb_config, a, b);
2352    true
2353}
2354
2355fn delete_slot(
2356    project_dir: &Path,
2357    quick: bool,
2358    save_cnt: usize,
2359    quick_cnt: usize,
2360    thumb_config: SaveThumbConfig,
2361    slots: &mut Vec<SaveSlotState>,
2362    idx: usize,
2363) -> bool {
2364    ensure_slot_loaded_with_counts(project_dir, quick, save_cnt, quick_cnt, slots, idx);
2365    let existed = slots.get(idx).map(|s| s.exist).unwrap_or(false);
2366    let mut deleted = SaveSlotState {
2367        header_cache_valid: true,
2368        ..Default::default()
2369    };
2370    // tnm_delete_save_file() clears the cache, then the next existence check
2371    // observes the missing file. We already know the deletion result here, so
2372    // retain the equivalent negative-cache state directly.
2373
2374    *ensure_slot(slots, idx) = deleted;
2375    let path = slot_path_with_counts(project_dir, quick, idx, save_cnt, quick_cnt);
2376    remove_game_file(&path);
2377    remove_thumb_file(project_dir, save_cnt, quick_cnt, quick, thumb_config, idx);
2378    existed
2379}
2380
2381fn capture_flags_path(image_path: &Path) -> PathBuf {
2382    let mut p = image_path.to_path_buf();
2383    let ext = p
2384        .extension()
2385        .and_then(|v| v.to_str())
2386        .map(|v| format!("{v}.siglus_flags"))
2387        .unwrap_or_else(|| "siglus_flags".to_string());
2388    p.set_extension(ext);
2389    p
2390}
2391
2392fn named_i64(params: &[Value], id: i32, default: i64) -> i64 {
2393    params
2394        .iter()
2395        .find_map(|v| match v {
2396            Value::NamedArg { id: nid, value } if *nid == id => value.as_i64(),
2397            _ => None,
2398        })
2399        .unwrap_or(default)
2400}
2401
2402fn named_element(params: &[Value], id: i32) -> Option<Vec<i32>> {
2403    params.iter().find_map(|v| match v {
2404        Value::NamedArg { id: nid, value } if *nid == id => match value.as_ref() {
2405            Value::Element(chain) => Some(chain.clone()),
2406            _ => None,
2407        },
2408        _ => None,
2409    })
2410}
2411
2412fn save_capture_flags_sidecar(ctx: &CommandContext, image_path: &Path, params: &[Value]) {
2413    let Some(flag_chain) = named_element(params, 2) else {
2414        return;
2415    };
2416    let Some(flag_form) = flag_chain.first().copied() else {
2417        return;
2418    };
2419    let flag_index = named_i64(params, 3, 0).max(0) as usize;
2420    let flag_cnt = named_i64(params, 4, 0).max(0) as usize;
2421    let str_chain = named_element(params, 5);
2422    let str_index = named_i64(params, 6, 0).max(0) as usize;
2423    let str_cnt = named_i64(params, 7, 0).max(0) as usize;
2424
2425    let mut out = String::new();
2426    out.push_str("version=1\n");
2427    out.push_str(&format!("flag_cnt={flag_cnt}\n"));
2428    if let Some(list) = ctx.globals.int_lists.get(&(flag_form as u32)) {
2429        for i in 0..flag_cnt {
2430            let v = list.get(flag_index + i).copied().unwrap_or(0);
2431            out.push_str(&format!("flag.{i}={v}\n"));
2432        }
2433    }
2434    if let Some(str_form) = str_chain.and_then(|v| v.first().copied()) {
2435        out.push_str(&format!("str_cnt={str_cnt}\n"));
2436        if let Some(list) = ctx.globals.str_lists.get(&(str_form as u32)) {
2437            for i in 0..str_cnt {
2438                let v = list.get(str_index + i).cloned().unwrap_or_default();
2439                out.push_str(&format!("str.{i}={}\n", escape_str(&v)));
2440            }
2441        }
2442    }
2443    if let Some(parent) = image_path.parent() {
2444        let _ = fs::create_dir_all(parent);
2445    }
2446    let sidecar = capture_flags_path(image_path);
2447    if fs::write(&sidecar, out).is_ok() {
2448        mark_game_file_written(&sidecar);
2449    }
2450}
2451
2452fn load_capture_flags_sidecar(
2453    ctx: &mut CommandContext,
2454    image_path: &Path,
2455    params: &[Value],
2456) -> bool {
2457    let path = capture_flags_path(image_path);
2458    let Ok(data) = crate::resource::read_file_to_string(&path) else {
2459        return crate::resource::game_file_exists(image_path);
2460    };
2461    if let Some(flag_chain) = named_element(params, 2)
2462        && let Some(flag_form) = flag_chain.first().copied()
2463    {
2464        let flag_index = named_i64(params, 3, 0).max(0) as usize;
2465        let flag_cnt = named_i64(params, 4, 0).max(0) as usize;
2466        let mut values = vec![0_i64; flag_cnt];
2467        for line in data.lines() {
2468            if let Some((k, v)) = line.split_once('=')
2469                && let Some(i) = k
2470                    .strip_prefix("flag.")
2471                    .and_then(|x| x.parse::<usize>().ok())
2472                && i < values.len()
2473            {
2474                values[i] = v.trim().parse::<i64>().unwrap_or(0);
2475            }
2476        }
2477        let list = ctx.globals.int_lists.entry(flag_form as u32).or_default();
2478        if list.len() < flag_index + flag_cnt {
2479            list.resize(flag_index + flag_cnt, 0);
2480        }
2481        for (i, v) in values.into_iter().enumerate() {
2482            list[flag_index + i] = v;
2483        }
2484    }
2485    if let Some(str_chain) = named_element(params, 5)
2486        && let Some(str_form) = str_chain.first().copied()
2487    {
2488        let str_index = named_i64(params, 6, 0).max(0) as usize;
2489        let str_cnt = named_i64(params, 7, 0).max(0) as usize;
2490        let mut values = vec![String::new(); str_cnt];
2491        for line in data.lines() {
2492            if let Some((k, v)) = line.split_once('=')
2493                && let Some(i) = k.strip_prefix("str.").and_then(|x| x.parse::<usize>().ok())
2494                && i < values.len()
2495            {
2496                values[i] = unescape_str(v);
2497            }
2498        }
2499        let list = ctx.globals.str_lists.entry(str_form as u32).or_default();
2500        if list.len() < str_index + str_cnt {
2501            list.resize(str_index + str_cnt, String::new());
2502        }
2503        for (i, v) in values.into_iter().enumerate() {
2504            list[str_index + i] = v;
2505        }
2506    }
2507    true
2508}
2509
2510fn write_msg_back(ctx: &CommandContext) {
2511    let form_id = ctx.ids.form_global_msgbk;
2512    if form_id == 0 {
2513        return;
2514    }
2515    let Some(st) = ctx.globals.msgbk_forms.get(&form_id) else {
2516        return;
2517    };
2518    let dir = save_dir(&ctx.project_dir);
2519    let path = dir.join("msg_back.txt");
2520
2521    let mut out = String::new();
2522    for idx in st.ordered_history_indices() {
2523        let Some(entry) = st.history.get(idx) else {
2524            continue;
2525        };
2526        out.push_str(&format!("-- entry {} --\n", idx));
2527        if !entry.original_name.is_empty() || !entry.disp_name.is_empty() {
2528            out.push_str("NAME: ");
2529            out.push_str(&entry.disp_name);
2530            out.push('\n');
2531        }
2532        if !entry.msg_str.is_empty() {
2533            if entry.pct_flag {
2534                out.push_str(&format!(
2535                    "IMG: {} {} {}\n",
2536                    entry.msg_str, entry.pct_pos_x, entry.pct_pos_y
2537                ));
2538            } else {
2539                out.push_str("TEXT: ");
2540                out.push_str(&entry.msg_str);
2541                out.push('\n');
2542            }
2543        }
2544        for (koe_no, chara_no) in entry.koe_no_list.iter().zip(entry.chr_no_list.iter()) {
2545            out.push_str(&format!("KOE: {} {}\n", koe_no, chara_no));
2546        }
2547        if entry.scn_no >= 0 || entry.line_no >= 0 {
2548            out.push_str(&format!("SCENE_LINE: {} {}\n", entry.scn_no, entry.line_no));
2549        }
2550        out.push('\n');
2551    }
2552    if std::fs::write(&path, out).is_ok() {
2553        mark_game_file_written(&path);
2554    }
2555}
2556
2557fn open_msg_back_proc(ctx: &mut CommandContext) -> bool {
2558    if ctx.globals.script.msg_back_disable || ctx.globals.syscom.msg_back.check_enabled() == 0 {
2559        return false;
2560    }
2561    ctx.globals.syscom.read_skip.onoff = false;
2562    ctx.globals.syscom.msg_back_open = true;
2563    ctx.globals.syscom.msg_back_proc_initialized = false;
2564    let form_id = ctx.ids.form_global_msgbk;
2565    let (count, target) = ctx
2566        .globals
2567        .msgbk_forms
2568        .get(&form_id)
2569        .map(|st| {
2570            let indices = st.ordered_history_indices();
2571            let target = if indices.contains(&st.history_last_pos) {
2572                st.history_last_pos as isize
2573            } else {
2574                indices
2575                    .last()
2576                    .copied()
2577                    .map(|idx| idx as isize)
2578                    .unwrap_or(-1)
2579            };
2580            (indices.len(), target)
2581        })
2582        .unwrap_or((0, -1));
2583    ctx.globals.syscom.msg_back_view_pos = count.saturating_sub(1);
2584    ctx.globals.syscom.msg_back_target_no = target;
2585    true
2586}
2587
2588fn configured_save_count(ctx: &CommandContext, quick: bool) -> usize {
2589    let keys: [&str; 2] = if quick {
2590        ["#QUICK_SAVE.CNT", "QUICK_SAVE.CNT"]
2591    } else {
2592        ["#SAVE.CNT", "SAVE.CNT"]
2593    };
2594    let default_count = if quick { 3 } else { 10 };
2595    ctx.tables
2596        .gameexe
2597        .as_ref()
2598        .and_then(|cfg| keys.iter().find_map(|key| cfg.get_usize(key)))
2599        .unwrap_or(default_count)
2600        .min(10000)
2601}
2602
2603const FALLBACK_BACK: i64 = -1;
2604const FALLBACK_PREV: i64 = -2;
2605const FALLBACK_NEXT: i64 = -3;
2606const FALLBACK_CLOSE: i64 = -4;
2607const FALLBACK_SLOTS_PER_PAGE: usize = 5;
2608const FALLBACK_ALL_SOUND: usize = usize::MAX;
2609
2610fn fallback_button(label: impl Into<String>, value: i64) -> SystemMessageBoxButton {
2611    SystemMessageBoxButton {
2612        label: label.into(),
2613        value,
2614    }
2615}
2616
2617fn fallback_onoff(value: bool) -> &'static str {
2618    if value { "ON" } else { "OFF" }
2619}
2620
2621fn fallback_feature_available(feature: &ToggleFeatureState) -> bool {
2622    feature.enable && feature.exist
2623}
2624
2625fn request_fallback_dialog(
2626    ctx: &mut CommandContext,
2627    kind: SyscomFallbackDialogKind,
2628    page: usize,
2629    return_kind: Option<SyscomFallbackDialogKind>,
2630    title: &str,
2631    body: String,
2632    buttons: Vec<SystemMessageBoxButton>,
2633) {
2634    ctx.globals.syscom.menu_open = false;
2635    ctx.globals.syscom.menu_kind = None;
2636    ctx.globals.syscom.menu_result = None;
2637    ctx.globals.syscom.fallback_dialog = Some(SyscomFallbackDialogState {
2638        kind,
2639        page,
2640        awaiting_result: true,
2641        return_kind,
2642    });
2643    let text = if body.is_empty() {
2644        title.to_string()
2645    } else {
2646        format!("{title}\n\n{body}")
2647    };
2648    ctx.request_internal_system_messagebox_no_return(0, false, text, buttons);
2649}
2650
2651fn close_fallback_dialog(ctx: &mut CommandContext, save_config: bool) {
2652    let was_save = matches!(
2653        ctx.globals
2654            .syscom
2655            .fallback_dialog
2656            .as_ref()
2657            .map(|state| state.kind),
2658        Some(SyscomFallbackDialogKind::SaveMenu)
2659    );
2660    ctx.globals.syscom.fallback_dialog = None;
2661    ctx.globals.syscom.fallback_origin = None;
2662    ctx.globals.syscom.menu_open = false;
2663    ctx.globals.syscom.menu_kind = None;
2664    ctx.globals.syscom.menu_result = None;
2665    if save_config {
2666        apply_audio_config(ctx);
2667        write_config_save(ctx);
2668    }
2669    if was_save {
2670        free_runtime_save_thumb_capture(ctx, CAPTURE_PRIOR_SAVE);
2671    }
2672}
2673
2674fn open_fallback_notice(
2675    ctx: &mut CommandContext,
2676    message: impl Into<String>,
2677    return_kind: Option<SyscomFallbackDialogKind>,
2678) {
2679    request_fallback_dialog(
2680        ctx,
2681        SyscomFallbackDialogKind::Notice,
2682        0,
2683        return_kind,
2684        "SYSTEM",
2685        message.into(),
2686        vec![fallback_button("OK", FALLBACK_BACK)],
2687    );
2688}
2689
2690fn open_system_menu_fallback(ctx: &mut CommandContext) {
2691    ctx.globals.syscom.fallback_origin = None;
2692    let mut buttons = Vec::new();
2693    if fallback_feature_available(&ctx.globals.syscom.save_feature) && local_save_available(ctx) {
2694        buttons.push(fallback_button("セーブ / Save", 0));
2695    }
2696    if fallback_feature_available(&ctx.globals.syscom.load_feature) {
2697        buttons.push(fallback_button("ロード / Load", 1));
2698    }
2699    buttons.push(fallback_button("コンフィグ / Config", 2));
2700    if fallback_feature_available(&ctx.globals.syscom.msg_back) {
2701        buttons.push(fallback_button("バックログ / Backlog", 3));
2702    }
2703    if fallback_feature_available(&ctx.globals.syscom.hide_mwnd) {
2704        let label = if ctx.globals.syscom.hide_mwnd.onoff {
2705            "メッセージウィンドウを表示 / Show Message"
2706        } else {
2707            "メッセージウィンドウを隠す / Hide Message"
2708        };
2709        buttons.push(fallback_button(label, 4));
2710    }
2711    if fallback_feature_available(&ctx.globals.syscom.return_to_menu) {
2712        buttons.push(fallback_button("タイトルへ戻る / Return to Title", 5));
2713    }
2714    if fallback_feature_available(&ctx.globals.syscom.end_game) {
2715        buttons.push(fallback_button("ゲーム終了 / Quit", 6));
2716    }
2717    buttons.push(fallback_button("閉じる / Close", FALLBACK_CLOSE));
2718
2719    request_fallback_dialog(
2720        ctx,
2721        SyscomFallbackDialogKind::SystemMenu,
2722        0,
2723        None,
2724        "SYSTEM MENU",
2725        String::new(),
2726        buttons,
2727    );
2728}
2729
2730fn slot_fallback_label(slot_no: usize, slot: Option<&SaveSlotState>) -> String {
2731    let display_no = slot_no + 1;
2732    let Some(slot) = slot.filter(|slot| slot.exist) else {
2733        return format!("{display_no:02}: -- EMPTY --");
2734    };
2735    let title = if slot.title.trim().is_empty() {
2736        if slot.message.trim().is_empty() {
2737            "SAVE DATA"
2738        } else {
2739            slot.message.trim()
2740        }
2741    } else {
2742        slot.title.trim()
2743    };
2744    let title: String = title.chars().take(24).collect();
2745    format!(
2746        "{display_no:02}: {title}  {:04}/{:02}/{:02} {:02}:{:02}",
2747        slot.year, slot.month, slot.day, slot.hour, slot.minute
2748    )
2749}
2750
2751fn open_save_load_fallback(
2752    ctx: &mut CommandContext,
2753    save: bool,
2754    page: usize,
2755    return_kind: Option<SyscomFallbackDialogKind>,
2756) {
2757    sync_save_slots_from_disk(ctx, false);
2758    let count = configured_save_count(ctx, false);
2759    if count == 0 {
2760        open_fallback_notice(ctx, "No save slots are configured.", return_kind);
2761        return;
2762    }
2763    let page_count = count.div_ceil(FALLBACK_SLOTS_PER_PAGE).max(1);
2764    let page = page.min(page_count - 1);
2765    let start = page * FALLBACK_SLOTS_PER_PAGE;
2766    let end = (start + FALLBACK_SLOTS_PER_PAGE).min(count);
2767    let mut buttons = Vec::new();
2768    for idx in start..end {
2769        let slot = ctx.globals.syscom.save_slots.get(idx);
2770        buttons.push(fallback_button(slot_fallback_label(idx, slot), idx as i64));
2771    }
2772    if page > 0 {
2773        buttons.push(fallback_button("◀ Previous", FALLBACK_PREV));
2774    }
2775    if page + 1 < page_count {
2776        buttons.push(fallback_button("Next ▶", FALLBACK_NEXT));
2777    }
2778    buttons.push(fallback_button("戻る / Back", FALLBACK_BACK));
2779
2780    let body = if save && !local_save_available(ctx) {
2781        "A SAVEPOINT snapshot is not available yet.\nSaving is disabled until the script reaches SAVEPOINT."
2782            .to_string()
2783    } else {
2784        format!("Page {}/{}", page + 1, page_count)
2785    };
2786    request_fallback_dialog(
2787        ctx,
2788        if save {
2789            SyscomFallbackDialogKind::SaveMenu
2790        } else {
2791            SyscomFallbackDialogKind::LoadMenu
2792        },
2793        page,
2794        return_kind,
2795        if save { "SAVE" } else { "LOAD" },
2796        body,
2797        buttons,
2798    );
2799}
2800
2801fn open_config_root_fallback(
2802    ctx: &mut CommandContext,
2803    return_kind: Option<SyscomFallbackDialogKind>,
2804) {
2805    request_fallback_dialog(
2806        ctx,
2807        SyscomFallbackDialogKind::ConfigRoot,
2808        0,
2809        return_kind,
2810        "CONFIG",
2811        "Settings are applied immediately and written to config.sav when this menu closes."
2812            .to_string(),
2813        vec![
2814            fallback_button("画面 / Display", 0),
2815            fallback_button("音量 / Volume", 1),
2816            fallback_button("メッセージ / Message", 2),
2817            fallback_button("オートモード / Auto Mode", 3),
2818            fallback_button("フォント / Font", 4),
2819            fallback_button("その他 / Other", 5),
2820            fallback_button("設定を保存して戻る / Save & Back", FALLBACK_BACK),
2821        ],
2822    );
2823}
2824
2825fn open_config_window_fallback(ctx: &mut CommandContext) {
2826    let mode = cfg_get_int(&ctx.globals.syscom, GET_WINDOW_MODE, 0).clamp(0, 1);
2827    let size = cfg_get_int(&ctx.globals.syscom, GET_WINDOW_MODE_SIZE, 100).max(1);
2828    request_fallback_dialog(
2829        ctx,
2830        SyscomFallbackDialogKind::ConfigWindow,
2831        0,
2832        Some(SyscomFallbackDialogKind::ConfigRoot),
2833        "CONFIG - DISPLAY",
2834        format!(
2835            "Window mode: {}\nWindow scale: {}%",
2836            if mode == 0 { "Windowed" } else { "Fullscreen" },
2837            size
2838        ),
2839        vec![
2840            fallback_button("ウィンドウ/フルスクリーン切替", 0),
2841            fallback_button("ウィンドウサイズ変更", 1),
2842            fallback_button("戻る / Back", FALLBACK_BACK),
2843        ],
2844    );
2845}
2846
2847fn fallback_sound_name(sound_type: usize) -> &'static str {
2848    match sound_type {
2849        FALLBACK_ALL_SOUND => "ALL",
2850        0 => "BGM",
2851        1 => "VOICE",
2852        2 => "PCM",
2853        3 => "SE",
2854        4 => "MOVIE",
2855        _ => "SOUND",
2856    }
2857}
2858
2859fn fallback_sound_volume(ctx: &CommandContext, sound_type: usize) -> i64 {
2860    if sound_type == FALLBACK_ALL_SOUND {
2861        cfg_get_int(&ctx.globals.syscom, GET_ALL_VOLUME, 255).clamp(0, 255)
2862    } else {
2863        get_sound_volume_by_type(ctx, sound_type)
2864    }
2865}
2866
2867fn fallback_sound_onoff(ctx: &CommandContext, sound_type: usize) -> bool {
2868    if sound_type == FALLBACK_ALL_SOUND {
2869        cfg_get_int(&ctx.globals.syscom, GET_ALL_ONOFF, 1) != 0
2870    } else {
2871        get_sound_onoff_by_type(ctx, sound_type)
2872    }
2873}
2874
2875fn set_fallback_sound_volume(ctx: &mut CommandContext, sound_type: usize, value: i64) {
2876    if sound_type == FALLBACK_ALL_SOUND {
2877        cfg_set_int(&mut ctx.globals.syscom, GET_ALL_VOLUME, value.clamp(0, 255));
2878    } else {
2879        set_sound_volume_by_type(ctx, sound_type, value);
2880    }
2881    apply_audio_config(ctx);
2882}
2883
2884fn set_fallback_sound_onoff(ctx: &mut CommandContext, sound_type: usize, value: bool) {
2885    if sound_type == FALLBACK_ALL_SOUND {
2886        cfg_set_int(
2887            &mut ctx.globals.syscom,
2888            GET_ALL_ONOFF,
2889            if value { 1 } else { 0 },
2890        );
2891    } else {
2892        set_sound_onoff_by_type(ctx, sound_type, value);
2893    }
2894    apply_audio_config(ctx);
2895}
2896
2897fn open_config_volume_root_fallback(ctx: &mut CommandContext) {
2898    request_fallback_dialog(
2899        ctx,
2900        SyscomFallbackDialogKind::ConfigVolumeRoot,
2901        0,
2902        Some(SyscomFallbackDialogKind::ConfigRoot),
2903        "CONFIG - VOLUME",
2904        String::new(),
2905        vec![
2906            fallback_button("Master / All", 0),
2907            fallback_button("BGM", 1),
2908            fallback_button("Voice", 2),
2909            fallback_button("PCM", 3),
2910            fallback_button("SE", 4),
2911            fallback_button("Movie", 5),
2912            fallback_button("戻る / Back", FALLBACK_BACK),
2913        ],
2914    );
2915}
2916
2917fn open_config_volume_fallback(ctx: &mut CommandContext, sound_type: usize) {
2918    let volume = fallback_sound_volume(ctx, sound_type);
2919    let onoff = fallback_sound_onoff(ctx, sound_type);
2920    let name = fallback_sound_name(sound_type);
2921    request_fallback_dialog(
2922        ctx,
2923        SyscomFallbackDialogKind::ConfigVolume(sound_type),
2924        0,
2925        Some(SyscomFallbackDialogKind::ConfigVolumeRoot),
2926        &format!("CONFIG - {name}"),
2927        format!(
2928            "Output: {}\nVolume: {}% ({volume}/255)",
2929            fallback_onoff(onoff),
2930            raw_volume_percent(volume)
2931        ),
2932        vec![
2933            fallback_button("ON/OFF", 0),
2934            fallback_button("Volume 0%", 1),
2935            fallback_button("Volume 25%", 2),
2936            fallback_button("Volume 50%", 3),
2937            fallback_button("Volume 75%", 4),
2938            fallback_button("Volume 100%", 5),
2939            fallback_button("戻る / Back", FALLBACK_BACK),
2940        ],
2941    );
2942}
2943
2944fn open_config_message_fallback(ctx: &mut CommandContext) {
2945    let speed = cfg_get_int(&ctx.globals.syscom, GET_MESSAGE_SPEED, 20).clamp(0, 100);
2946    let nowait = ctx.globals.script.msg_nowait
2947        || cfg_get_int(&ctx.globals.syscom, GET_MESSAGE_NOWAIT, 0) != 0;
2948    request_fallback_dialog(
2949        ctx,
2950        SyscomFallbackDialogKind::ConfigMessage,
2951        0,
2952        Some(SyscomFallbackDialogKind::ConfigRoot),
2953        "CONFIG - MESSAGE",
2954        format!(
2955            "Message speed: {speed}\nInstant message: {}",
2956            fallback_onoff(nowait)
2957        ),
2958        vec![
2959            fallback_button("メッセージ速度 / Message Speed", 0),
2960            fallback_button("瞬間表示 / Instant", 1),
2961            fallback_button("戻る / Back", FALLBACK_BACK),
2962        ],
2963    );
2964}
2965
2966fn open_config_auto_fallback(ctx: &mut CommandContext) {
2967    let moji_wait = ctx.globals.script.auto_mode_moji_wait.clamp(0, 500);
2968    let min_wait = ctx.globals.script.auto_mode_min_wait.clamp(0, 5000);
2969    request_fallback_dialog(
2970        ctx,
2971        SyscomFallbackDialogKind::ConfigAuto,
2972        0,
2973        Some(SyscomFallbackDialogKind::ConfigRoot),
2974        "CONFIG - AUTO MODE",
2975        format!("Per-character wait: {moji_wait} ms\nMinimum wait: {min_wait} ms"),
2976        vec![
2977            fallback_button("文字待ち時間 / Character Wait", 0),
2978            fallback_button("最低待ち時間 / Minimum Wait", 1),
2979            fallback_button("戻る / Back", FALLBACK_BACK),
2980        ],
2981    );
2982}
2983
2984fn fallback_font_names(ctx: &mut CommandContext) -> Vec<String> {
2985    if ctx.globals.syscom.font_list.is_empty() {
2986        ctx.globals.syscom.font_list = vec![
2987            "MS ゴシック".to_string(),
2988            "MS 明朝".to_string(),
2989            "メイリオ".to_string(),
2990        ];
2991    }
2992    let current = {
2993        let value = cfg_get_str(&ctx.globals.syscom, GET_FONT_NAME);
2994        if value.is_empty() {
2995            config_default_font_name(ctx)
2996        } else {
2997            value
2998        }
2999    };
3000    let mut names = ctx.globals.syscom.font_list.clone();
3001    if !names.iter().any(|name| name.eq_ignore_ascii_case(&current)) {
3002        names.insert(0, current);
3003    }
3004    names
3005}
3006
3007fn open_config_font_fallback(ctx: &mut CommandContext) {
3008    let current = {
3009        let value = cfg_get_str(&ctx.globals.syscom, GET_FONT_NAME);
3010        if value.is_empty() {
3011            config_default_font_name(ctx)
3012        } else {
3013            value
3014        }
3015    };
3016    let bold = cfg_get_int(&ctx.globals.syscom, GET_FONT_BOLD, 0) != 0;
3017    let decoration = cfg_get_int(&ctx.globals.syscom, GET_FONT_DECORATION, 2);
3018    request_fallback_dialog(
3019        ctx,
3020        SyscomFallbackDialogKind::ConfigFont,
3021        0,
3022        Some(SyscomFallbackDialogKind::ConfigRoot),
3023        "CONFIG - FONT",
3024        format!(
3025            "Font face: {current}\nBold: {}\nDecoration: {decoration}",
3026            fallback_onoff(bold)
3027        ),
3028        vec![
3029            fallback_button("フォント切替 / Font Face", 0),
3030            fallback_button("太字 / Bold", 1),
3031            fallback_button("装飾 / Decoration", 2),
3032            fallback_button("戻る / Back", FALLBACK_BACK),
3033        ],
3034    );
3035}
3036
3037fn open_config_other_fallback(ctx: &mut CommandContext) {
3038    let wheel = cfg_get_int(&ctx.globals.syscom, GET_WHEEL_NEXT_MESSAGE_ONOFF, 1) != 0;
3039    let unread = cfg_get_int(&ctx.globals.syscom, GET_SKIP_UNREAD_MESSAGE_ONOFF, 0) != 0;
3040    let mouse_hide = cfg_get_int(&ctx.globals.syscom, GET_MOUSE_CURSOR_HIDE_ONOFF, 0) != 0;
3041    let no_wipe = cfg_get_int(&ctx.globals.syscom, GET_NO_WIPE_ANIME_ONOFF, 0) != 0;
3042    let no_mwnd = cfg_get_int(&ctx.globals.syscom, GET_NO_MWND_ANIME_ONOFF, 0) != 0;
3043    let alert = cfg_get_int(&ctx.globals.syscom, GET_SAVELOAD_ALERT_ONOFF, 1) != 0;
3044    let swap_ab = ctx.globals.syscom.original_config.joypad_swap_ab;
3045    let swap_xy = ctx.globals.syscom.original_config.joypad_swap_xy;
3046    request_fallback_dialog(
3047        ctx,
3048        SyscomFallbackDialogKind::ConfigOther,
3049        0,
3050        Some(SyscomFallbackDialogKind::ConfigRoot),
3051        "CONFIG - OTHER",
3052        format!(
3053            "Wheel advances message: {}\nSkip unread message: {}\nAuto-hide cursor: {}\nDisable wipe animation: {}\nDisable window animation: {}\nSave/load confirmation: {}\nSwap controller A/B: {}\nSwap controller X/Y: {}",
3054            fallback_onoff(wheel),
3055            fallback_onoff(unread),
3056            fallback_onoff(mouse_hide),
3057            fallback_onoff(no_wipe),
3058            fallback_onoff(no_mwnd),
3059            fallback_onoff(alert),
3060            fallback_onoff(swap_ab),
3061            fallback_onoff(swap_xy),
3062        ),
3063        vec![
3064            fallback_button("Mouse wheel message", 0),
3065            fallback_button("Skip unread message", 1),
3066            fallback_button("Auto-hide cursor", 2),
3067            fallback_button("Wipe animation", 3),
3068            fallback_button("Message-window animation", 4),
3069            fallback_button("Save/load confirmation", 5),
3070            fallback_button("Controller A/B swap", 6),
3071            fallback_button("Controller X/Y swap", 7),
3072            fallback_button("戻る / Back", FALLBACK_BACK),
3073        ],
3074    );
3075}
3076
3077fn reopen_fallback_kind(
3078    ctx: &mut CommandContext,
3079    kind: SyscomFallbackDialogKind,
3080    return_kind: Option<SyscomFallbackDialogKind>,
3081) {
3082    match kind {
3083        SyscomFallbackDialogKind::SystemMenu => open_system_menu_fallback(ctx),
3084        SyscomFallbackDialogKind::SaveMenu => open_save_load_fallback(ctx, true, 0, return_kind),
3085        SyscomFallbackDialogKind::LoadMenu => open_save_load_fallback(ctx, false, 0, return_kind),
3086        SyscomFallbackDialogKind::ConfigRoot => open_config_root_fallback(ctx, return_kind),
3087        SyscomFallbackDialogKind::ConfigWindow => open_config_window_fallback(ctx),
3088        SyscomFallbackDialogKind::ConfigVolumeRoot => open_config_volume_root_fallback(ctx),
3089        SyscomFallbackDialogKind::ConfigVolume(sound_type) => {
3090            open_config_volume_fallback(ctx, sound_type)
3091        }
3092        SyscomFallbackDialogKind::ConfigMessage => open_config_message_fallback(ctx),
3093        SyscomFallbackDialogKind::ConfigAuto => open_config_auto_fallback(ctx),
3094        SyscomFallbackDialogKind::ConfigFont => open_config_font_fallback(ctx),
3095        SyscomFallbackDialogKind::ConfigOther => open_config_other_fallback(ctx),
3096        SyscomFallbackDialogKind::Notice => close_fallback_dialog(ctx, false),
3097    }
3098}
3099
3100/// Open the built-in cross-platform Syscom UI when the game does not provide
3101/// its own script scene.  This replaces the original Win32-only popup path
3102/// without changing the script-visible save/load/config state.
3103pub fn open_fallback_dialog(ctx: &mut CommandContext, kind: SyscomPendingProcKind) {
3104    ctx.globals.system.messagebox_modal_result = None;
3105    ctx.globals.syscom.fallback_origin = None;
3106    match kind {
3107        SyscomPendingProcKind::OpenSyscomMenu => open_system_menu_fallback(ctx),
3108        SyscomPendingProcKind::OpenSave => {
3109            prepare_runtime_save_thumb_capture_with_priority(ctx, CAPTURE_PRIOR_SAVE);
3110            open_save_load_fallback(ctx, true, 0, None);
3111        }
3112        SyscomPendingProcKind::OpenLoad => {
3113            open_save_load_fallback(ctx, false, 0, None);
3114        }
3115        SyscomPendingProcKind::OpenConfig | SyscomPendingProcKind::OpenConfigDialog => {
3116            open_config_root_fallback(ctx, None)
3117        }
3118        _ => {
3119            log::error!("unsupported Syscom fallback request: {kind:?}");
3120        }
3121    }
3122}
3123
3124fn queue_fallback_pending_proc(
3125    ctx: &mut CommandContext,
3126    kind: SyscomPendingProcKind,
3127    warning: bool,
3128    save_id: i64,
3129) {
3130    ctx.globals.syscom.pending_proc = Some(SyscomPendingProc {
3131        kind,
3132        warning,
3133        se_play: false,
3134        fade_out: false,
3135        leave_msgbk: false,
3136        save_id,
3137    });
3138    ctx.globals.syscom.fallback_dialog = None;
3139    ctx.globals.syscom.fallback_origin = None;
3140}
3141
3142fn cycle_i64(current: i64, values: &[i64]) -> i64 {
3143    let index = values.iter().position(|value| *value == current);
3144    values[index.map_or(0, |index| (index + 1) % values.len())]
3145}
3146
3147/// Consume a completed internal modal and advance the fallback menu state
3148/// machine.  This is called once per frame from CommandContext::tick_frame.
3149pub(crate) fn poll_fallback_dialog(ctx: &mut CommandContext) {
3150    let Some(result) = ctx.globals.system.messagebox_modal_result.take() else {
3151        return;
3152    };
3153    let Some(state) = ctx.globals.syscom.fallback_dialog.take() else {
3154        // Preserve results belonging to non-Syscom SYSTEM.MESSAGEBOX calls.
3155        ctx.globals.system.messagebox_modal_result = Some(result);
3156        return;
3157    };
3158    if !state.awaiting_result {
3159        ctx.globals.system.messagebox_modal_result = Some(result);
3160        ctx.globals.syscom.fallback_dialog = Some(state);
3161        return;
3162    }
3163
3164    match state.kind {
3165        SyscomFallbackDialogKind::Notice => {
3166            if let Some(kind) = state.return_kind {
3167                let origin = ctx.globals.syscom.fallback_origin;
3168                match kind {
3169                    SyscomFallbackDialogKind::SaveMenu => {
3170                        open_save_load_fallback(ctx, true, 0, origin)
3171                    }
3172                    SyscomFallbackDialogKind::LoadMenu => {
3173                        open_save_load_fallback(ctx, false, 0, origin)
3174                    }
3175                    _ => reopen_fallback_kind(ctx, kind, origin),
3176                }
3177            } else {
3178                close_fallback_dialog(ctx, false);
3179            }
3180        }
3181        SyscomFallbackDialogKind::SystemMenu => match result {
3182            0 => {
3183                ctx.globals.syscom.fallback_origin = Some(SyscomFallbackDialogKind::SystemMenu);
3184                open_save_load_fallback(ctx, true, 0, Some(SyscomFallbackDialogKind::SystemMenu))
3185            }
3186            1 => {
3187                ctx.globals.syscom.fallback_origin = Some(SyscomFallbackDialogKind::SystemMenu);
3188                open_save_load_fallback(ctx, false, 0, Some(SyscomFallbackDialogKind::SystemMenu))
3189            }
3190            2 => {
3191                ctx.globals.syscom.fallback_origin = Some(SyscomFallbackDialogKind::SystemMenu);
3192                open_config_root_fallback(ctx, Some(SyscomFallbackDialogKind::SystemMenu))
3193            }
3194            3 => {
3195                ctx.globals.syscom.msg_back_open = true;
3196                queue_fallback_pending_proc(ctx, SyscomPendingProcKind::MsgBack, false, 0);
3197            }
3198            4 => {
3199                ctx.globals.syscom.hide_mwnd.onoff = !ctx.globals.syscom.hide_mwnd.onoff;
3200                open_system_menu_fallback(ctx);
3201            }
3202            5 => queue_fallback_pending_proc(ctx, SyscomPendingProcKind::ReturnToMenu, true, 0),
3203            6 => queue_fallback_pending_proc(ctx, SyscomPendingProcKind::EndGame, true, 0),
3204            _ => close_fallback_dialog(ctx, false),
3205        },
3206        SyscomFallbackDialogKind::SaveMenu | SyscomFallbackDialogKind::LoadMenu => {
3207            let save = state.kind == SyscomFallbackDialogKind::SaveMenu;
3208            match result {
3209                FALLBACK_PREV => open_save_load_fallback(
3210                    ctx,
3211                    save,
3212                    state.page.saturating_sub(1),
3213                    state.return_kind,
3214                ),
3215                FALLBACK_NEXT => {
3216                    open_save_load_fallback(ctx, save, state.page + 1, state.return_kind)
3217                }
3218                FALLBACK_BACK | FALLBACK_CLOSE => {
3219                    if save {
3220                        free_runtime_save_thumb_capture(ctx, CAPTURE_PRIOR_SAVE);
3221                    }
3222                    if let Some(kind) = state.return_kind.or(ctx.globals.syscom.fallback_origin) {
3223                        reopen_fallback_kind(ctx, kind, None);
3224                    } else {
3225                        close_fallback_dialog(ctx, false);
3226                    }
3227                }
3228                slot @ 0.. => {
3229                    let idx = slot as usize;
3230                    if idx >= configured_save_count(ctx, false) {
3231                        open_save_load_fallback(ctx, save, state.page, state.return_kind);
3232                        return;
3233                    }
3234                    if save {
3235                        if !local_save_available(ctx) {
3236                            open_fallback_notice(
3237                                ctx,
3238                                "SAVEPOINT data is not available, so this slot cannot be saved.",
3239                                Some(SyscomFallbackDialogKind::SaveMenu),
3240                            );
3241                        } else {
3242                            let queued = request_confirmed_save_or_load(
3243                                ctx,
3244                                SyscomPendingProcKind::Save,
3245                                idx,
3246                                true,
3247                                true,
3248                            );
3249                            if queued {
3250                                ctx.globals.syscom.fallback_origin = None;
3251                            } else {
3252                                menu_save_slot(ctx, false, idx);
3253                                write_global_save(ctx);
3254                                ctx.globals.syscom.fallback_dialog = None;
3255                                ctx.globals.syscom.fallback_origin = None;
3256                            }
3257                        }
3258                    } else if local_save_file_exists(ctx, SaveKind::Normal, idx) {
3259                        let queued = request_confirmed_save_or_load(
3260                            ctx,
3261                            SyscomPendingProcKind::Load,
3262                            idx,
3263                            true,
3264                            false,
3265                        );
3266                        if queued {
3267                            ctx.globals.syscom.fallback_origin = None;
3268                        } else {
3269                            menu_load_slot(ctx, false, idx);
3270                            ctx.globals.syscom.fallback_dialog = None;
3271                            ctx.globals.syscom.fallback_origin = None;
3272                        }
3273                    } else {
3274                        open_fallback_notice(
3275                            ctx,
3276                            "The selected save slot is empty.",
3277                            Some(SyscomFallbackDialogKind::LoadMenu),
3278                        );
3279                    }
3280                }
3281                _ => open_save_load_fallback(ctx, save, state.page, state.return_kind),
3282            }
3283        }
3284        SyscomFallbackDialogKind::ConfigRoot => match result {
3285            0 => open_config_window_fallback(ctx),
3286            1 => open_config_volume_root_fallback(ctx),
3287            2 => open_config_message_fallback(ctx),
3288            3 => open_config_auto_fallback(ctx),
3289            4 => open_config_font_fallback(ctx),
3290            5 => open_config_other_fallback(ctx),
3291            _ => {
3292                apply_audio_config(ctx);
3293                write_config_save(ctx);
3294                if let Some(kind) = state.return_kind.or(ctx.globals.syscom.fallback_origin) {
3295                    reopen_fallback_kind(ctx, kind, None);
3296                } else {
3297                    close_fallback_dialog(ctx, false);
3298                }
3299            }
3300        },
3301        SyscomFallbackDialogKind::ConfigWindow => match result {
3302            0 => {
3303                let next = if cfg_get_int(&ctx.globals.syscom, GET_WINDOW_MODE, 0) == 0 {
3304                    1
3305                } else {
3306                    0
3307                };
3308                cfg_set_int(&mut ctx.globals.syscom, GET_WINDOW_MODE, next);
3309                open_config_window_fallback(ctx);
3310            }
3311            1 => {
3312                const SIZES: &[i64] = &[50, 75, 100, 150, 200];
3313                let current = cfg_get_int(&ctx.globals.syscom, GET_WINDOW_MODE_SIZE, 100);
3314                let next = cycle_i64(current, SIZES);
3315                cfg_set_int(&mut ctx.globals.syscom, GET_WINDOW_MODE_SIZE, next);
3316                open_config_window_fallback(ctx);
3317            }
3318            _ => {
3319                let origin = ctx.globals.syscom.fallback_origin;
3320                open_config_root_fallback(ctx, origin)
3321            }
3322        },
3323        SyscomFallbackDialogKind::ConfigVolumeRoot => match result {
3324            0 => open_config_volume_fallback(ctx, FALLBACK_ALL_SOUND),
3325            1 => open_config_volume_fallback(ctx, 0),
3326            2 => open_config_volume_fallback(ctx, 1),
3327            3 => open_config_volume_fallback(ctx, 2),
3328            4 => open_config_volume_fallback(ctx, 3),
3329            5 => open_config_volume_fallback(ctx, 4),
3330            _ => {
3331                let origin = ctx.globals.syscom.fallback_origin;
3332                open_config_root_fallback(ctx, origin)
3333            }
3334        },
3335        SyscomFallbackDialogKind::ConfigVolume(sound_type) => match result {
3336            0 => {
3337                let next = !fallback_sound_onoff(ctx, sound_type);
3338                set_fallback_sound_onoff(ctx, sound_type, next);
3339                open_config_volume_fallback(ctx, sound_type);
3340            }
3341            1..=5 => {
3342                const RAW: &[i64] = &[0, 64, 128, 191, 255];
3343                set_fallback_sound_volume(ctx, sound_type, RAW[(result - 1) as usize]);
3344                open_config_volume_fallback(ctx, sound_type);
3345            }
3346            _ => open_config_volume_root_fallback(ctx),
3347        },
3348        SyscomFallbackDialogKind::ConfigMessage => match result {
3349            0 => {
3350                const SPEEDS: &[i64] = &[0, 10, 20, 40, 60, 80, 100];
3351                let current = cfg_get_int(&ctx.globals.syscom, GET_MESSAGE_SPEED, 20);
3352                let next = cycle_i64(current, SPEEDS);
3353                cfg_set_int(&mut ctx.globals.syscom, GET_MESSAGE_SPEED, next);
3354                open_config_message_fallback(ctx);
3355            }
3356            1 => {
3357                let next = !(ctx.globals.script.msg_nowait
3358                    || cfg_get_int(&ctx.globals.syscom, GET_MESSAGE_NOWAIT, 0) != 0);
3359                ctx.globals.script.msg_nowait = next;
3360                cfg_set_int(
3361                    &mut ctx.globals.syscom,
3362                    GET_MESSAGE_NOWAIT,
3363                    if next { 1 } else { 0 },
3364                );
3365                open_config_message_fallback(ctx);
3366            }
3367            _ => {
3368                let origin = ctx.globals.syscom.fallback_origin;
3369                open_config_root_fallback(ctx, origin)
3370            }
3371        },
3372        SyscomFallbackDialogKind::ConfigAuto => match result {
3373            0 => {
3374                const WAITS: &[i64] = &[0, 20, 40, 70, 100, 150, 250, 500];
3375                let next = cycle_i64(ctx.globals.script.auto_mode_moji_wait, WAITS);
3376                ctx.globals.script.auto_mode_moji_wait = next;
3377                cfg_set_int(&mut ctx.globals.syscom, GET_AUTO_MODE_MOJI_WAIT, next);
3378                open_config_auto_fallback(ctx);
3379            }
3380            1 => {
3381                const WAITS: &[i64] = &[0, 100, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000];
3382                let next = cycle_i64(ctx.globals.script.auto_mode_min_wait, WAITS);
3383                ctx.globals.script.auto_mode_min_wait = next;
3384                cfg_set_int(&mut ctx.globals.syscom, GET_AUTO_MODE_MIN_WAIT, next);
3385                open_config_auto_fallback(ctx);
3386            }
3387            _ => {
3388                let origin = ctx.globals.syscom.fallback_origin;
3389                open_config_root_fallback(ctx, origin)
3390            }
3391        },
3392        SyscomFallbackDialogKind::ConfigFont => match result {
3393            0 => {
3394                let names = fallback_font_names(ctx);
3395                let current = cfg_get_str(&ctx.globals.syscom, GET_FONT_NAME);
3396                let index = names
3397                    .iter()
3398                    .position(|name| name.eq_ignore_ascii_case(&current))
3399                    .unwrap_or(0);
3400                let next = names[(index + 1) % names.len()].clone();
3401                cfg_set_str(&mut ctx.globals.syscom, GET_FONT_NAME, next);
3402                open_config_font_fallback(ctx);
3403            }
3404            1 => {
3405                let next = cfg_get_int(&ctx.globals.syscom, GET_FONT_BOLD, 0) == 0;
3406                cfg_set_int(
3407                    &mut ctx.globals.syscom,
3408                    GET_FONT_BOLD,
3409                    if next { 1 } else { 0 },
3410                );
3411                open_config_font_fallback(ctx);
3412            }
3413            2 => {
3414                let current = cfg_get_int(&ctx.globals.syscom, GET_FONT_DECORATION, 2);
3415                cfg_set_int(
3416                    &mut ctx.globals.syscom,
3417                    GET_FONT_DECORATION,
3418                    (current + 1).rem_euclid(4),
3419                );
3420                open_config_font_fallback(ctx);
3421            }
3422            _ => {
3423                let origin = ctx.globals.syscom.fallback_origin;
3424                open_config_root_fallback(ctx, origin)
3425            }
3426        },
3427        SyscomFallbackDialogKind::ConfigOther => {
3428            let key = match result {
3429                0 => Some(GET_WHEEL_NEXT_MESSAGE_ONOFF),
3430                1 => Some(GET_SKIP_UNREAD_MESSAGE_ONOFF),
3431                2 => Some(GET_MOUSE_CURSOR_HIDE_ONOFF),
3432                3 => Some(GET_NO_WIPE_ANIME_ONOFF),
3433                4 => Some(GET_NO_MWND_ANIME_ONOFF),
3434                5 => Some(GET_SAVELOAD_ALERT_ONOFF),
3435                _ => None,
3436            };
3437            if let Some(key) = key {
3438                let current = cfg_get_int(
3439                    &ctx.globals.syscom,
3440                    key,
3441                    if key == GET_WHEEL_NEXT_MESSAGE_ONOFF || key == GET_SAVELOAD_ALERT_ONOFF {
3442                        1
3443                    } else {
3444                        0
3445                    },
3446                );
3447                cfg_set_int(
3448                    &mut ctx.globals.syscom,
3449                    key,
3450                    if current == 0 { 1 } else { 0 },
3451                );
3452                open_config_other_fallback(ctx);
3453            } else if result == 6 {
3454                ctx.globals.syscom.original_config.joypad_swap_ab =
3455                    !ctx.globals.syscom.original_config.joypad_swap_ab;
3456                open_config_other_fallback(ctx);
3457            } else if result == 7 {
3458                ctx.globals.syscom.original_config.joypad_swap_xy =
3459                    !ctx.globals.syscom.original_config.joypad_swap_xy;
3460                open_config_other_fallback(ctx);
3461            } else {
3462                let origin = ctx.globals.syscom.fallback_origin;
3463                open_config_root_fallback(ctx, origin);
3464            }
3465        }
3466    }
3467}
3468
3469fn save_header_is_older(lhs: &SaveSlotState, rhs: &SaveSlotState) -> bool {
3470    macro_rules! compare_field {
3471        ($field:ident) => {
3472            if lhs.$field < rhs.$field {
3473                return true;
3474            }
3475            if lhs.$field > rhs.$field {
3476                return false;
3477            }
3478        };
3479    }
3480
3481    // S_tnm_save_header::operator< in the original engine compares the
3482    // timestamp fields in this exact order (weekday is intentionally ignored),
3483    // then title.  Equality also returns true, so a later slot wins ties.
3484    compare_field!(year);
3485    compare_field!(month);
3486    compare_field!(day);
3487    compare_field!(hour);
3488    compare_field!(minute);
3489    compare_field!(second);
3490    compare_field!(millisecond);
3491    lhs.title <= rhs.title
3492}
3493
3494fn newest_slot_in_range(
3495    slots: &[SaveSlotState],
3496    start: i64,
3497    cnt: i64,
3498    configured_count: usize,
3499) -> i64 {
3500    if cnt <= 0 || configured_count == 0 {
3501        return -1;
3502    }
3503
3504    // The original loops [start, start + cnt) but only accepts indices inside
3505    // the configured save range.  Intersect first so malformed script input
3506    // cannot force an unbounded loop or grow the Rust slot cache.
3507    let end = start.saturating_add(cnt);
3508    let lo = start.max(0).min(configured_count as i64);
3509    let hi = end.max(0).min(configured_count as i64);
3510    if hi <= lo {
3511        return -1;
3512    }
3513
3514    let mut newest_idx = -1i64;
3515    let mut newest_slot: Option<&SaveSlotState> = None;
3516    for idx in lo as usize..hi as usize {
3517        let Some(slot) = slots.get(idx) else {
3518            continue;
3519        };
3520        if !slot.exist {
3521            continue;
3522        }
3523        if newest_slot
3524            .map(|current| save_header_is_older(current, slot))
3525            .unwrap_or(true)
3526        {
3527            newest_idx = idx as i64;
3528            newest_slot = Some(slot);
3529        }
3530    }
3531    newest_idx
3532}
3533
3534fn slot_i64(slot: &SaveSlotState, op: i32) -> i64 {
3535    match op {
3536        GET_SAVE_EXIST | GET_QUICK_SAVE_EXIST => {
3537            if slot.exist {
3538                1
3539            } else {
3540                0
3541            }
3542        }
3543        GET_SAVE_YEAR | GET_QUICK_SAVE_YEAR => slot.year,
3544        GET_SAVE_MONTH | GET_QUICK_SAVE_MONTH => slot.month,
3545        GET_SAVE_DAY | GET_QUICK_SAVE_DAY => slot.day,
3546        GET_SAVE_WEEKDAY | GET_QUICK_SAVE_WEEKDAY => slot.weekday,
3547        GET_SAVE_HOUR | GET_QUICK_SAVE_HOUR => slot.hour,
3548        GET_SAVE_MINUTE | GET_QUICK_SAVE_MINUTE => slot.minute,
3549        GET_SAVE_SECOND | GET_QUICK_SAVE_SECOND => slot.second,
3550        GET_SAVE_MILLISECOND | GET_QUICK_SAVE_MILLISECOND => slot.millisecond,
3551        _ => 0,
3552    }
3553}
3554
3555fn slot_str(slot: &SaveSlotState, op: i32) -> String {
3556    match op {
3557        GET_SAVE_TITLE | GET_QUICK_SAVE_TITLE => slot.title.clone(),
3558        GET_SAVE_MESSAGE | GET_QUICK_SAVE_MESSAGE => {
3559            // Older Rust saves could lose the short summary when the deferred
3560            // auto-savepoint replaced it after PRINT. Display the retained page
3561            // text in that case, without rewriting the user's save file.
3562            if slot.message.is_empty() {
3563                slot.full_message.clone()
3564            } else {
3565                slot.message.clone()
3566            }
3567        }
3568        GET_SAVE_FULL_MESSAGE | GET_QUICK_SAVE_FULL_MESSAGE => slot.full_message.clone(),
3569        GET_SAVE_COMMENT | GET_QUICK_SAVE_COMMENT => slot.comment.clone(),
3570        GET_SAVE_APPEND_DIR | GET_QUICK_SAVE_APPEND_DIR => slot.append_dir.clone(),
3571        GET_SAVE_APPEND_NAME | GET_QUICK_SAVE_APPEND_NAME => slot.append_name.clone(),
3572        _ => String::new(),
3573    }
3574}
3575
3576fn cfg_get_int(st: &crate::runtime::globals::SyscomRuntimeState, key: i32, default: i64) -> i64 {
3577    st.config_int.get(&key).copied().unwrap_or(default)
3578}
3579
3580fn cfg_set_int(st: &mut crate::runtime::globals::SyscomRuntimeState, key: i32, value: i64) {
3581    st.config_int.insert(key, value);
3582}
3583
3584fn volume_to_raw(v: i64) -> u8 {
3585    v.clamp(0, 255) as u8
3586}
3587
3588fn standard_sound_volume_key(sound_type: usize) -> Option<i32> {
3589    match sound_type {
3590        0 => Some(GET_BGM_VOLUME),
3591        1 => Some(GET_KOE_VOLUME),
3592        2 => Some(GET_PCM_VOLUME),
3593        3 => Some(GET_SE_VOLUME),
3594        4 => Some(GET_MOV_VOLUME),
3595        _ => None,
3596    }
3597}
3598
3599fn standard_sound_onoff_key(sound_type: usize) -> Option<i32> {
3600    match sound_type {
3601        0 => Some(GET_BGM_ONOFF),
3602        1 => Some(GET_KOE_ONOFF),
3603        2 => Some(GET_PCM_ONOFF),
3604        3 => Some(GET_SE_ONOFF),
3605        4 => Some(GET_MOV_ONOFF),
3606        _ => None,
3607    }
3608}
3609
3610fn set_sound_volume_by_type(ctx: &mut CommandContext, sound_type: usize, value: i64) {
3611    if sound_type >= ctx.globals.syscom.original_config.sound_user_volume.len() {
3612        return;
3613    }
3614    let value = value.clamp(0, 255);
3615    ctx.globals.syscom.original_config.sound_user_volume[sound_type] = value;
3616    if let Some(key) = standard_sound_volume_key(sound_type) {
3617        cfg_set_int(&mut ctx.globals.syscom, key, value);
3618    }
3619}
3620
3621fn get_sound_volume_by_type(ctx: &CommandContext, sound_type: usize) -> i64 {
3622    if sound_type >= ctx.globals.syscom.original_config.sound_user_volume.len() {
3623        return 255;
3624    }
3625    standard_sound_volume_key(sound_type)
3626        .map(|key| {
3627            cfg_get_int(
3628                &ctx.globals.syscom,
3629                key,
3630                ctx.globals.syscom.original_config.sound_user_volume[sound_type],
3631            )
3632        })
3633        .unwrap_or(ctx.globals.syscom.original_config.sound_user_volume[sound_type])
3634        .clamp(0, 255)
3635}
3636
3637fn set_sound_onoff_by_type(ctx: &mut CommandContext, sound_type: usize, value: bool) {
3638    if sound_type >= ctx.globals.syscom.original_config.play_sound_check.len() {
3639        return;
3640    }
3641    ctx.globals.syscom.original_config.play_sound_check[sound_type] = value;
3642    if let Some(key) = standard_sound_onoff_key(sound_type) {
3643        cfg_set_int(&mut ctx.globals.syscom, key, if value { 1 } else { 0 });
3644    }
3645}
3646
3647fn get_sound_onoff_by_type(ctx: &CommandContext, sound_type: usize) -> bool {
3648    if sound_type >= ctx.globals.syscom.original_config.play_sound_check.len() {
3649        return true;
3650    }
3651    standard_sound_onoff_key(sound_type)
3652        .map(|key| {
3653            cfg_get_int(
3654                &ctx.globals.syscom,
3655                key,
3656                if ctx.globals.syscom.original_config.play_sound_check[sound_type] {
3657                    1
3658                } else {
3659                    0
3660                },
3661            ) != 0
3662        })
3663        .unwrap_or(ctx.globals.syscom.original_config.play_sound_check[sound_type])
3664}
3665
3666fn raw_volume_percent(value: i64) -> i64 {
3667    (value.clamp(0, 255) * 100 + 127) / 255
3668}
3669
3670fn mul_raw(lhs: i64, rhs: i64) -> i64 {
3671    lhs.clamp(0, 255) * rhs.clamp(0, 255) / 255
3672}
3673
3674fn linear_limit_i64(time: i64, start_time: i64, start: i64, end_time: i64, end: i64) -> i64 {
3675    if end_time <= start_time {
3676        return if time < start_time { start } else { end };
3677    }
3678    if time <= start_time {
3679        start
3680    } else if time >= end_time {
3681        end
3682    } else {
3683        start + (end - start) * (time - start_time) / (end_time - start_time)
3684    }
3685}
3686
3687fn chrkoe_chara_numbers(ctx: &CommandContext, index: usize) -> Vec<i64> {
3688    let Some(entry) = ctx
3689        .tables
3690        .gameexe
3691        .as_ref()
3692        .and_then(|cfg| cfg.get_indexed_entry("CHRKOE", index))
3693    else {
3694        return Vec::new();
3695    };
3696    // #CHRKOE.i = name, check_mode, check_name, onoff, volume, (chara...)
3697    // `value_items` is intentionally a CSV-like view and does not preserve the
3698    // parenthesized list as one token, so recover the list from the raw value.
3699    let Some(open) = entry.value.find('(') else {
3700        return Vec::new();
3701    };
3702    let close = entry.value.rfind(')').unwrap_or(entry.value.len());
3703    if close <= open {
3704        return Vec::new();
3705    }
3706    parse_i64_list_local(&entry.value[open + 1..close])
3707}
3708
3709fn chrkoe_route(
3710    config: &crate::runtime::globals::OriginalConfigRuntimeState,
3711    chara_lists: &[Vec<i64>],
3712    chara_no: i64,
3713) -> (bool, i64) {
3714    if chara_no < 0 {
3715        return (true, 255);
3716    }
3717    let mut onoff = true;
3718    let mut volume = 255;
3719    for (index, state) in config.chrkoe.iter().enumerate() {
3720        if chara_lists
3721            .get(index)
3722            .is_some_and(|numbers| numbers.contains(&chara_no))
3723        {
3724            if !state.onoff {
3725                onoff = false;
3726            }
3727            volume = mul_raw(volume, state.volume);
3728        }
3729    }
3730    (onoff, volume)
3731}
3732
3733fn pcmch_routed_volume(
3734    config: &crate::runtime::globals::OriginalConfigRuntimeState,
3735    chara_lists: &[Vec<i64>],
3736    state: &crate::runtime::globals::PcmChPersistentState,
3737    all_total: i64,
3738    category_total: &[i64; 5],
3739    bgmfade_total: i64,
3740    bgmfade2_total: i64,
3741) -> u8 {
3742    let volume_type = state.volume_type as i32;
3743    let mut volume = match volume_type {
3744        -1 => all_total,
3745        0..=4 => category_total[volume_type as usize],
3746        16..=31 => {
3747            let index = volume_type as usize;
3748            if config.play_sound_check[index] {
3749                mul_raw(all_total, config.sound_user_volume[index])
3750            } else {
3751                0
3752            }
3753        }
3754        _ => 255,
3755    };
3756
3757    if state.bgm_fade_target_flag {
3758        volume = mul_raw(volume, bgmfade_total);
3759    }
3760    if state.bgm_fade2_target_flag {
3761        volume = mul_raw(volume, bgmfade2_total);
3762    }
3763    let (chara_onoff, chara_volume) = chrkoe_route(config, chara_lists, state.chara_no);
3764    volume = mul_raw(volume, chara_volume);
3765    if !chara_onoff {
3766        volume = 0;
3767    }
3768    volume.clamp(0, 255) as u8
3769}
3770
3771/// Recompute the original sound routing graph.
3772///
3773/// PCMCH script volume remains on the individual Kira handle. This function
3774/// supplies the independent C++ `m_system_volume` term selected by
3775/// `volume_type`, character voice settings and the two BGM-fade target flags.
3776pub(crate) fn update_audio_routing(
3777    ctx: &mut CommandContext,
3778    real_delta_ms: i32,
3779    change_force: bool,
3780) {
3781    use crate::audio::TrackKind;
3782
3783    let config = &ctx.globals.syscom.original_config;
3784    let chrkoe_lists = {
3785        if ctx.globals.sound_routing.chrkoe_chara_lists_cache.len() < config.chrkoe.len() {
3786            let lists = (0..config.chrkoe.len())
3787                .map(|index| chrkoe_chara_numbers(ctx, index))
3788                .collect::<Vec<Vec<i64>>>();
3789            ctx.globals.sound_routing.chrkoe_chara_lists_cache = std::sync::Arc::new(lists);
3790        }
3791        std::sync::Arc::clone(&ctx.globals.sound_routing.chrkoe_chara_lists_cache)
3792    };
3793    let all_total = if config.play_all_sound_check {
3794        config.all_sound_user_volume.clamp(0, 255)
3795    } else {
3796        0
3797    };
3798    let mut category_total = [0i64; 5];
3799    for (index, total) in category_total.iter_mut().enumerate() {
3800        *total = if config.play_sound_check[index] {
3801            mul_raw(all_total, config.sound_user_volume[index])
3802        } else {
3803            0
3804        };
3805    }
3806
3807    // Source PCMCH channels are updated before the fade state transition in
3808    // ifc_sound.cpp. Their audible state decides whether both fades are needed.
3809    let old_fade = ctx.globals.sound_routing.bgmfade_total_volume;
3810    let old_fade2 = ctx.globals.sound_routing.bgmfade2_total_volume;
3811    let mut pcmch_bgm_fade_use = false;
3812    let source_channels: Vec<(usize, crate::runtime::globals::PcmChPersistentState)> = ctx
3813        .globals
3814        .pcmch_persistent
3815        .iter()
3816        .cloned()
3817        .enumerate()
3818        .filter(|(_, state)| state.bgm_fade_source_flag)
3819        .collect();
3820    for (channel, state) in source_channels {
3821        let routed = pcmch_routed_volume(
3822            config,
3823            chrkoe_lists.as_slice(),
3824            &state,
3825            all_total,
3826            &category_total,
3827            old_fade,
3828            old_fade2,
3829        );
3830        let _ = ctx.pcm.set_slot_system_volume_raw(channel, routed);
3831        if routed > 0 && ctx.pcm.is_playing_slot(channel) {
3832            pcmch_bgm_fade_use = true;
3833        }
3834    }
3835
3836    // The actual KOE player can contain either a normal message voice or an
3837    // EXKOE voice. EXKOE ignores the KOE on/off checkbox but still uses the KOE
3838    // user-volume slider; both kinds use character-group volume.
3839    let koe_chara_no = ctx.globals.sound_routing.koe_chara_no;
3840    let koe_ex_flag = ctx.globals.sound_routing.koe_ex_flag;
3841    let (koe_chara_onoff, koe_chara_volume) =
3842        chrkoe_route(config, chrkoe_lists.as_slice(), koe_chara_no);
3843    let mut koe_buf_total = if koe_ex_flag {
3844        mul_raw(all_total, config.sound_user_volume[1])
3845    } else {
3846        category_total[1]
3847    };
3848    koe_buf_total = mul_raw(koe_buf_total, koe_chara_volume);
3849    if !koe_ex_flag
3850        && (!koe_chara_onoff
3851            || !config.play_all_sound_check
3852            || !config.play_sound_check[1]
3853            || config.koe_mode == 1)
3854    {
3855        // tnm_update_sound_volume stops a normal voice when its current config
3856        // says it must not play. EXKOE is deliberately not stopped here.
3857        if ctx.koe.is_playing_any() {
3858            let _ = ctx.koe.stop(None);
3859        }
3860        koe_buf_total = 0;
3861    }
3862    let koe_audible = koe_buf_total > 0 && ctx.koe.is_playing_any();
3863    let use_bgmfade = config.bgmfade_use_check
3864        && (ctx.globals.script.bgmfade_flag || koe_audible || pcmch_bgm_fade_use);
3865    let use_bgmfade2 = koe_audible || pcmch_bgm_fade_use;
3866    let bgmfade2_in_start = gameexe_i64_or(ctx, "BGMFADE2.IN_START_TIME", 0);
3867    let bgmfade2_in_len = gameexe_i64_or(ctx, "BGMFADE2.IN_TIME_LEN", 500).max(0);
3868    let bgmfade2_out_start = gameexe_i64_or(ctx, "BGMFADE2.OUT_START_TIME", 0);
3869    let bgmfade2_out_len = gameexe_i64_or(ctx, "BGMFADE2.OUT_TIME_LEN", 500).max(0);
3870    let bgmfade2_volume = gameexe_i64_or(ctx, "BGMFADE2.VOLUME", 0).clamp(0, 255);
3871    let bgmfade2_need = ctx.globals.sound_routing.bgmfade2_need_flag;
3872
3873    // Keep the mutable borrow of the routing sub-state in a narrow scope.
3874    // The volume application below also mutates other CommandContext fields.
3875    let (fade, fade2) = {
3876        let routing = &mut ctx.globals.sound_routing;
3877        routing.bgmfade_cur_time = routing
3878            .bgmfade_cur_time
3879            .saturating_add(real_delta_ms.max(0) as i64);
3880        routing.bgmfade2_cur_time = routing
3881            .bgmfade2_cur_time
3882            .saturating_add(real_delta_ms.max(0) as i64);
3883
3884        if routing.bgmfade_flag != use_bgmfade {
3885            routing.bgmfade_cur_time = 0;
3886            routing.bgmfade_start_value = routing.bgmfade_total_volume;
3887            routing.bgmfade_flag = use_bgmfade;
3888        }
3889        if routing.bgmfade2_flag != use_bgmfade2 {
3890            routing.bgmfade2_cur_time = 0;
3891            routing.bgmfade2_start_value = routing.bgmfade2_total_volume;
3892            routing.bgmfade2_flag = use_bgmfade2;
3893        }
3894
3895        routing.bgmfade_total_volume = if routing.bgmfade_flag {
3896            linear_limit_i64(
3897                routing.bgmfade_cur_time,
3898                0,
3899                routing.bgmfade_start_value,
3900                1000,
3901                config.bgmfade_volume,
3902            )
3903        } else {
3904            linear_limit_i64(
3905                routing.bgmfade_cur_time,
3906                2000,
3907                routing.bgmfade_start_value,
3908                3000,
3909                255,
3910            )
3911        }
3912        .clamp(0, 255);
3913
3914        routing.bgmfade2_total_volume = if routing.bgmfade2_flag {
3915            linear_limit_i64(
3916                routing.bgmfade2_cur_time,
3917                bgmfade2_in_start,
3918                routing.bgmfade2_start_value,
3919                bgmfade2_in_start.saturating_add(bgmfade2_in_len),
3920                bgmfade2_volume,
3921            )
3922        } else if bgmfade2_need {
3923            linear_limit_i64(
3924                routing.bgmfade2_cur_time,
3925                bgmfade2_out_start,
3926                routing.bgmfade2_start_value,
3927                bgmfade2_out_start.saturating_add(bgmfade2_out_len),
3928                255,
3929            )
3930        } else {
3931            // Original one-voice-after-another protection waits 500ms before
3932            // restoring BGM when no message voice is currently attached.
3933            linear_limit_i64(
3934                routing.bgmfade2_cur_time,
3935                500,
3936                routing.bgmfade2_start_value,
3937                500i64.saturating_add(bgmfade2_out_len),
3938                255,
3939            )
3940        }
3941        .clamp(0, 255);
3942
3943        (routing.bgmfade_total_volume, routing.bgmfade2_total_volume)
3944    };
3945
3946    let bgm_total = mul_raw(category_total[0], fade) as u8;
3947    ctx.audio
3948        .set_track_master_volume_raw(TrackKind::Bgm, bgm_total);
3949    ctx.audio
3950        .set_track_master_volume_raw(TrackKind::Koe, koe_buf_total.clamp(0, 255) as u8);
3951    // Global PCM and every PCMCH channel have independent system-volume terms,
3952    // so the shared PCM track itself must remain neutral.
3953    ctx.audio.set_track_master_volume_raw(TrackKind::Pcm, 255);
3954    ctx.audio
3955        .set_track_master_volume_raw(TrackKind::Se, category_total[3] as u8);
3956    ctx.audio
3957        .set_track_master_volume_raw(TrackKind::Mov, category_total[4] as u8);
3958    let _ = ctx
3959        .pcm
3960        .set_global_system_volume_raw(category_total[2] as u8);
3961
3962    let normal_channels: Vec<(usize, crate::runtime::globals::PcmChPersistentState)> = ctx
3963        .globals
3964        .pcmch_persistent
3965        .iter()
3966        .cloned()
3967        .enumerate()
3968        .filter(|(_, state)| !state.bgm_fade_source_flag)
3969        .collect();
3970    for (channel, state) in normal_channels {
3971        let routed = pcmch_routed_volume(
3972            config,
3973            chrkoe_lists.as_slice(),
3974            &state,
3975            all_total,
3976            &category_total,
3977            fade,
3978            fade2,
3979        );
3980        let _ = ctx.pcm.set_slot_system_volume_raw(channel, routed);
3981    }
3982
3983    if change_force {
3984        // Force source channels through the newly calculated target as well.
3985        // The C++ path reaches them on the following sound update; this extra
3986        // pass is required only for immediate config/load application where no
3987        // real-time frame has elapsed yet.
3988        let sources: Vec<(usize, crate::runtime::globals::PcmChPersistentState)> = ctx
3989            .globals
3990            .pcmch_persistent
3991            .iter()
3992            .cloned()
3993            .enumerate()
3994            .filter(|(_, state)| state.bgm_fade_source_flag)
3995            .collect();
3996        for (channel, state) in sources {
3997            let routed = pcmch_routed_volume(
3998                config,
3999                chrkoe_lists.as_slice(),
4000                &state,
4001                all_total,
4002                &category_total,
4003                fade,
4004                fade2,
4005            );
4006            let _ = ctx.pcm.set_slot_system_volume_raw(channel, routed);
4007        }
4008    }
4009}
4010
4011pub(crate) fn apply_audio_config(ctx: &mut CommandContext) {
4012    update_audio_routing(ctx, 0, true);
4013}
4014
4015fn cfg_get_str(st: &crate::runtime::globals::SyscomRuntimeState, key: i32) -> String {
4016    st.config_str.get(&key).cloned().unwrap_or_default()
4017}
4018
4019fn cfg_set_str(st: &mut crate::runtime::globals::SyscomRuntimeState, key: i32, value: String) {
4020    st.config_str.insert(key, value);
4021}
4022
4023fn join_game_path(base: &Path, raw: &str) -> PathBuf {
4024    if raw.is_empty() {
4025        return base.to_path_buf();
4026    }
4027    let norm = raw.replace('\\', "/");
4028    let p = Path::new(&norm);
4029    if p.is_absolute() {
4030        p.to_path_buf()
4031    } else {
4032        base.join(p)
4033    }
4034}
4035
4036fn remove_game_file(path: &Path) {
4037    let _ = fs::remove_file(path);
4038    crate::resource::invalidate_game_path_cache(path);
4039}
4040
4041fn copy_game_file(src: &Path, dst: &Path) {
4042    let _ = fs::copy(src, dst);
4043    crate::resource::invalidate_game_path_cache(dst);
4044}
4045
4046fn rename_game_file(src: &Path, dst: &Path) {
4047    let _ = fs::rename(src, dst);
4048    crate::resource::invalidate_game_path_cache(src);
4049    crate::resource::invalidate_game_path_cache(dst);
4050}
4051
4052fn mark_game_file_written(path: &Path) {
4053    crate::resource::invalidate_game_path_cache(path);
4054}
4055
4056fn opaque_rgba(img: &RgbaImage) -> RgbaImage {
4057    let mut rgba = img.rgba.clone();
4058    for px in rgba.as_chunks_mut::<4>().0.iter_mut() {
4059        px[3] = 255;
4060    }
4061    RgbaImage {
4062        width: img.width,
4063        height: img.height,
4064        center_x: 0,
4065        center_y: 0,
4066        rgba,
4067    }
4068}
4069
4070fn write_rgba_png(path: &Path, img: &RgbaImage) -> Result<()> {
4071    if let Some(parent) = path.parent() {
4072        fs::create_dir_all(parent)?;
4073    }
4074    let Some(buf) = image::RgbaImage::from_raw(img.width, img.height, img.rgba.clone()) else {
4075        anyhow::bail!("invalid rgba buffer for {}x{} image", img.width, img.height);
4076    };
4077    buf.save(path)?;
4078    mark_game_file_written(path);
4079    Ok(())
4080}
4081
4082fn write_rgba_png_opaque(path: &Path, img: &RgbaImage) -> Result<()> {
4083    let opaque = opaque_rgba(img);
4084    write_rgba_png(path, &opaque)
4085}
4086
4087fn push_u16_le(out: &mut Vec<u8>, value: u16) {
4088    out.extend_from_slice(&value.to_le_bytes());
4089}
4090
4091fn push_u32_le(out: &mut Vec<u8>, value: u32) {
4092    out.extend_from_slice(&value.to_le_bytes());
4093}
4094
4095fn push_i32_le(out: &mut Vec<u8>, value: i32) {
4096    out.extend_from_slice(&value.to_le_bytes());
4097}
4098
4099fn write_rgba_bmp_top_down(path: &Path, img: &RgbaImage) -> Result<()> {
4100    if let Some(parent) = path.parent() {
4101        fs::create_dir_all(parent)?;
4102    }
4103    let width = img.width;
4104    let height = img.height;
4105    if width == 0 || height == 0 {
4106        anyhow::bail!("invalid zero-sized bmp image {}x{}", width, height);
4107    }
4108    let pixel_size = width.saturating_mul(height).saturating_mul(4);
4109    let file_size = 14u32.saturating_add(40).saturating_add(pixel_size);
4110    let mut out = Vec::with_capacity(file_size as usize);
4111
4112    out.extend_from_slice(b"BM");
4113    push_u32_le(&mut out, file_size);
4114    push_u16_le(&mut out, 0);
4115    push_u16_le(&mut out, 0);
4116    push_u32_le(&mut out, 14 + 40);
4117
4118    push_u32_le(&mut out, 40);
4119    push_i32_le(&mut out, width as i32);
4120    push_i32_le(&mut out, -(height as i32));
4121    push_u16_le(&mut out, 1);
4122    push_u16_le(&mut out, 32);
4123    push_u32_le(&mut out, 0);
4124    push_u32_le(&mut out, 0);
4125    push_i32_le(&mut out, 0);
4126    push_i32_le(&mut out, 0);
4127    push_u32_le(&mut out, 0);
4128    push_u32_le(&mut out, 0);
4129
4130    for px in img.rgba.as_chunks::<4>().0.iter() {
4131        out.push(px[2]);
4132        out.push(px[1]);
4133        out.push(px[0]);
4134        out.push(px[3]);
4135    }
4136    fs::write(path, out)?;
4137    mark_game_file_written(path);
4138    Ok(())
4139}
4140
4141fn resize_rgba(img: &RgbaImage, w: u32, h: u32) -> RgbaImage {
4142    if img.width == 0 || img.height == 0 || w == 0 || h == 0 {
4143        return img.clone();
4144    }
4145    if img.width == w && img.height == h {
4146        return img.clone();
4147    }
4148    let mut out = vec![0u8; (w * h * 4) as usize];
4149    for y in 0..h {
4150        let src_y = (y as u64 * img.height as u64 / h as u64) as u32;
4151        for x in 0..w {
4152            let src_x = (x as u64 * img.width as u64 / w as u64) as u32;
4153            let si = ((src_y * img.width + src_x) * 4) as usize;
4154            let di = ((y * w + x) * 4) as usize;
4155            out[di..di + 4].copy_from_slice(&img.rgba[si..si + 4]);
4156        }
4157    }
4158    RgbaImage {
4159        width: w,
4160        height: h,
4161        center_x: 0,
4162        center_y: 0,
4163        rgba: out,
4164    }
4165}
4166
4167pub(crate) fn resize_capture_rgba_nearest(img: &RgbaImage, w: u32, h: u32) -> RgbaImage {
4168    resize_rgba(img, w, h)
4169}
4170
4171fn blend_tweet_overlay_fullscreen(base: &mut RgbaImage, overlay: &RgbaImage) {
4172    if base.width == 0 || base.height == 0 || overlay.width == 0 || overlay.height == 0 {
4173        return;
4174    }
4175
4176    // Original C_tnm_wnd::disp_proc_capture_for_tweet renders the configured
4177    // TWITTER.OVERLAP_IMAGE as a screen-sized D2 sprite with alpha test/blend.
4178    // Stretch the source over the whole capture target, then apply the normal
4179    // source-alpha / inverse-source-alpha blend into the X8R8G8B8-equivalent
4180    // opaque capture buffer.
4181    let scaled = resize_rgba(overlay, base.width, base.height);
4182    for (dst, src) in base
4183        .rgba
4184        .as_chunks_mut::<4>()
4185        .0
4186        .iter_mut()
4187        .zip(scaled.rgba.as_chunks::<4>().0.iter())
4188    {
4189        let alpha = u32::from(src[3]);
4190        if alpha == 0 {
4191            dst[3] = 255;
4192            continue;
4193        }
4194        let inv_alpha = 255 - alpha;
4195        for channel in 0..3 {
4196            let value = u32::from(src[channel]) * alpha + u32::from(dst[channel]) * inv_alpha + 127;
4197            dst[channel] = (value / 255) as u8;
4198        }
4199        dst[3] = 255;
4200    }
4201}
4202
4203/// Create the image consumed by SYSCOM.OPEN_TWEET_DIALOG.
4204///
4205/// This mirrors C_tnm_wnd::disp_proc_capture_for_tweet: capture the game at
4206/// the logical screen size and, when #TWITTER.OVERLAP_IMAGE is configured and
4207/// resolvable, render cut 0 over the full capture using alpha blending.
4208pub fn capture_for_tweet(ctx: &mut CommandContext) -> Result<RgbaImage> {
4209    let mut capture = ctx.capture_frame_rgba()?;
4210    let overlap_name = gameexe_unquoted_owned(ctx, "TWITTER.OVERLAP_IMAGE");
4211    if overlap_name.is_empty() {
4212        return Ok(capture);
4213    }
4214
4215    match ctx.images.load_g00(&overlap_name, 0) {
4216        Ok(image_id) => {
4217            if let Some(overlay) = ctx.images.get(&image_id).map(|image| (*image).clone()) {
4218                blend_tweet_overlay_fullscreen(&mut capture, &overlay);
4219            }
4220        }
4221        Err(err) => {
4222            // Original tnm_check_pct(..., false) simply skips a missing overlap
4223            // image. Keep that non-fatal behavior while making diagnostics
4224            // available in debug builds.
4225            log::debug!(
4226                "TWITTER.OVERLAP_IMAGE {:?} is not available; capturing without overlay: {err:#}",
4227                overlap_name
4228            );
4229        }
4230    }
4231
4232    Ok(capture)
4233}
4234
4235#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
4236fn open_tweet_dialog(ctx: &mut CommandContext) -> Result<()> {
4237    let Some(capture) = ctx.globals.capture_image.clone() else {
4238        log::error!("SYSCOM.OPEN_TWEET_DIALOG called without GLOBAL.CAPTURE_FOR_TWEET capture");
4239        return Ok(());
4240    };
4241
4242    let out_path = save_dir(&ctx.project_dir).join("tweet.png");
4243    write_rgba_png_opaque(&out_path, &capture)?;
4244    crate::runtime::twitter::ensure_state_loaded(ctx);
4245    let initial_text = gameexe_unquoted_owned(ctx, "TWITTER.INITIAL_TWEET_TEXT");
4246    ctx.globals.twitter_dialog_request = Some(crate::runtime::twitter::TwitterDialogRequest {
4247        image_path: out_path.clone(),
4248        image_rgba: capture.rgba.clone(),
4249        image_width: capture.width,
4250        image_height: capture.height,
4251        initial_text,
4252    });
4253    ctx.globals
4254        .system
4255        .debug_logs
4256        .push(format!("open_tweet_dialog:{}", out_path.display()));
4257    Ok(())
4258}
4259
4260#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
4261fn open_tweet_dialog(_ctx: &mut CommandContext) -> Result<()> {
4262    log::error!("SYSCOM.OPEN_TWEET_DIALOG is not implemented on this platform");
4263    Ok(())
4264}
4265
4266fn font_exists(project_dir: &Path, name: &str) -> bool {
4267    if name.is_empty() {
4268        return false;
4269    }
4270    if crate::text_render::font_name_matches_embedded_default(name) {
4271        return true;
4272    }
4273
4274    let name_lower = name.to_ascii_lowercase();
4275    for font_dir in [project_dir.join("font"), project_dir.join("fonts")] {
4276        let Some(font_dir) = crate::resource::resolve_game_path(&font_dir).ok().flatten() else {
4277            continue;
4278        };
4279        let Ok(entries) = fs::read_dir(font_dir) else {
4280            continue;
4281        };
4282        for entry in entries.flatten() {
4283            let path = entry.path();
4284            if !path.is_file() {
4285                continue;
4286            }
4287            let ext = path
4288                .extension()
4289                .and_then(|s| s.to_str())
4290                .unwrap_or("")
4291                .to_ascii_lowercase();
4292            if ext != "ttf" && ext != "otf" && ext != "ttc" {
4293                continue;
4294            }
4295            let stem = path
4296                .file_stem()
4297                .and_then(|s| s.to_str())
4298                .unwrap_or("")
4299                .to_ascii_lowercase();
4300            if stem == name_lower {
4301                return true;
4302            }
4303        }
4304    }
4305    false
4306}
4307
4308pub fn dispatch(ctx: &mut CommandContext, form_id: u32, args: &[Value]) -> Result<bool> {
4309    let Some(call) = parse_call(ctx, form_id, args) else {
4310        return Ok(false);
4311    };
4312    let op = call.op;
4313    let params = call.params;
4314
4315    // eng_syscom.cpp::tnm_syscom_read_skip_is_enable() also gates read skip
4316    // on the current message's committed read flag unless unread-skip is on.
4317    if op == CHECK_READ_SKIP_ENABLE {
4318        ctx.push(Value::Int(if ctx.runtime_read_skip_is_enable() {
4319            1
4320        } else {
4321            0
4322        }));
4323        return Ok(true);
4324    }
4325    if matches!(
4326        op,
4327        SET_READ_SKIP_ONOFF_FLAG | SET_READ_SKIP_ENABLE_FLAG | SET_READ_SKIP_EXIST_FLAG
4328    ) {
4329        let value = p_bool(params, 0);
4330        match op {
4331            SET_READ_SKIP_ONOFF_FLAG => ctx.globals.syscom.read_skip.onoff = value,
4332            SET_READ_SKIP_ENABLE_FLAG => ctx.globals.syscom.read_skip.enable = value,
4333            SET_READ_SKIP_EXIST_FLAG => ctx.globals.syscom.read_skip.exist = value,
4334            _ => unreachable!(),
4335        }
4336        ctx.runtime_update_read_skip_menu();
4337        ctx.push(Value::Int(0));
4338        return Ok(true);
4339    }
4340
4341    {
4342        let st = &ctx.globals.syscom;
4343        if let Some(v) = get_local_extra(op, params, st) {
4344            ctx.push(Value::Int(v));
4345            return Ok(true);
4346        }
4347        if let Some(v) = get_toggle_get(op, st) {
4348            ctx.push(Value::Int(v));
4349            return Ok(true);
4350        }
4351    }
4352    {
4353        let st = &mut ctx.globals.syscom;
4354        if set_local_extra(op, params, st) {
4355            ctx.push(Value::Int(0));
4356            return Ok(true);
4357        }
4358        if apply_toggle_set(op, p_bool(params, 0), st) {
4359            ctx.push(Value::Int(0));
4360            return Ok(true);
4361        }
4362    }
4363
4364    match op {
4365        CALL_EX => {
4366            // C++ routes SYSCOM.CALL_EX to tnm_command_proc_farcall_ex(..., FM_VOID).
4367            // That transfer is implemented in the VM before generic form dispatch,
4368            // because only the VM owns the script call stack and proc boundary.
4369            // Do not fake a return value here.
4370            return Ok(false);
4371        }
4372        CALL_SYSCOM_MENU => {
4373            ctx.globals.syscom.menu_open = false;
4374            ctx.globals.syscom.menu_kind = None;
4375            ctx.globals.syscom.menu_result = None;
4376            ctx.globals.syscom.read_skip.onoff = false;
4377            ctx.globals.syscom.pending_proc = Some(SyscomPendingProc {
4378                kind: SyscomPendingProcKind::OpenSyscomMenu,
4379                warning: false,
4380                se_play: false,
4381                fade_out: false,
4382                leave_msgbk: false,
4383                save_id: 0,
4384            });
4385            ctx.globals.syscom.last_menu_call = CALL_SYSCOM_MENU;
4386            return Ok(true);
4387        }
4388        SET_SYSCOM_MENU_ENABLE => ctx.globals.syscom.syscom_menu_disable = false,
4389        SET_SYSCOM_MENU_DISABLE => ctx.globals.syscom.syscom_menu_disable = true,
4390        SET_MWND_BTN_ENABLE => {
4391            if params.is_empty() {
4392                ctx.globals.syscom.mwnd_btn_disable_all = false;
4393                if sg_debug_enabled_local() {
4394                    eprintln!(
4395                        "[SG_DEBUG][BUTTON_TRACE][SYSCOM] SET_MWND_BTN_ENABLE all disable_all=false"
4396                    );
4397                }
4398            } else {
4399                let idx = p_i64(params, 0);
4400                ctx.globals.syscom.mwnd_btn_disable.insert(idx, false);
4401                if sg_debug_enabled_local() {
4402                    eprintln!(
4403                        "[SG_DEBUG][BUTTON_TRACE][SYSCOM] SET_MWND_BTN_ENABLE idx={} disabled=false",
4404                        idx
4405                    );
4406                }
4407            }
4408        }
4409        SET_MWND_BTN_DISABLE => {
4410            if params.is_empty() {
4411                ctx.globals.syscom.mwnd_btn_disable_all = true;
4412                if sg_debug_enabled_local() {
4413                    eprintln!(
4414                        "[SG_DEBUG][BUTTON_TRACE][SYSCOM] SET_MWND_BTN_DISABLE all disable_all=true"
4415                    );
4416                }
4417            } else {
4418                let idx = p_i64(params, 0);
4419                ctx.globals.syscom.mwnd_btn_disable.insert(idx, true);
4420                if sg_debug_enabled_local() {
4421                    eprintln!(
4422                        "[SG_DEBUG][BUTTON_TRACE][SYSCOM] SET_MWND_BTN_DISABLE idx={} disabled=true",
4423                        idx
4424                    );
4425                }
4426            }
4427        }
4428        SET_MWND_BTN_TOUCH_ENABLE => {
4429            ctx.globals.syscom.mwnd_btn_touch_disable = false;
4430            if sg_debug_enabled_local() {
4431                eprintln!(
4432                    "[SG_DEBUG][BUTTON_TRACE][SYSCOM] SET_MWND_BTN_TOUCH_ENABLE touch_disable=false"
4433                );
4434            }
4435        }
4436        SET_MWND_BTN_TOUCH_DISABLE => {
4437            ctx.globals.syscom.mwnd_btn_touch_disable = true;
4438            if sg_debug_enabled_local() {
4439                eprintln!(
4440                    "[SG_DEBUG][BUTTON_TRACE][SYSCOM] SET_MWND_BTN_TOUCH_DISABLE touch_disable=true"
4441                );
4442            }
4443        }
4444        INIT_SYSCOM_FLAG => {
4445            let enabled = ToggleFeatureState {
4446                onoff: false,
4447                enable: true,
4448                exist: true,
4449            };
4450            ctx.globals.syscom.read_skip = enabled;
4451            ctx.globals.syscom.auto_skip = enabled;
4452            ctx.globals.syscom.auto_mode = enabled;
4453            ctx.globals.syscom.hide_mwnd = enabled;
4454            ctx.globals.syscom.local_extra_switch = enabled;
4455            ctx.globals.syscom.local_extra_mode = ValueFeatureState {
4456                value: 0,
4457                enable: true,
4458                exist: true,
4459            };
4460            ctx.globals.syscom.local_extra_switches = [enabled; 4];
4461            ctx.globals.syscom.local_extra_modes = [ValueFeatureState {
4462                value: 0,
4463                enable: true,
4464                exist: true,
4465            }; 4];
4466            ctx.globals.syscom.msg_back = enabled;
4467            ctx.globals.syscom.return_to_sel = enabled;
4468            ctx.globals.syscom.return_to_menu = enabled;
4469            ctx.globals.syscom.end_game = enabled;
4470            ctx.globals.syscom.save_feature = enabled;
4471            ctx.globals.syscom.load_feature = enabled;
4472            ctx.globals.syscom.msg_back_open = false;
4473            ctx.runtime_update_read_skip_menu();
4474        }
4475        OPEN_MSG_BACK => {
4476            if open_msg_back_proc(ctx) {
4477                ctx.globals.syscom.pending_proc = Some(SyscomPendingProc {
4478                    kind: SyscomPendingProcKind::MsgBack,
4479                    warning: false,
4480                    se_play: false,
4481                    fade_out: false,
4482                    leave_msgbk: false,
4483                    save_id: 0,
4484                });
4485            }
4486            ctx.globals.syscom.last_menu_call = OPEN_MSG_BACK;
4487        }
4488        CLOSE_MSG_BACK => {
4489            ctx.globals.syscom.msg_back_open = false;
4490            ctx.globals.syscom.msg_back_proc_initialized = false;
4491            ctx.globals.syscom.last_menu_call = CLOSE_MSG_BACK;
4492        }
4493        RETURN_TO_SEL => {
4494            ctx.globals.syscom.pending_proc = Some(SyscomPendingProc {
4495                kind: SyscomPendingProcKind::ReturnToSel,
4496                warning: p_bool(params, 0),
4497                se_play: p_bool(params, 1),
4498                fade_out: p_bool(params, 2),
4499                leave_msgbk: false,
4500                save_id: 0,
4501            });
4502            ctx.globals.syscom.last_menu_call = RETURN_TO_SEL;
4503            ctx.globals.syscom.menu_open = false;
4504        }
4505        RETURN_TO_MENU => {
4506            let leave_msgbk = params
4507                .iter()
4508                .find(|v| v.named_id() == Some(0))
4509                .and_then(Value::as_i64)
4510                .unwrap_or(0)
4511                != 0;
4512            ctx.globals.syscom.pending_proc = Some(SyscomPendingProc {
4513                kind: SyscomPendingProcKind::ReturnToMenu,
4514                warning: p_bool(params, 0),
4515                se_play: p_bool(params, 1),
4516                fade_out: p_bool(params, 2),
4517                leave_msgbk,
4518                save_id: 0,
4519            });
4520            ctx.globals.syscom.last_menu_call = RETURN_TO_MENU;
4521            ctx.globals.syscom.menu_open = false;
4522        }
4523        END_GAME => {
4524            ctx.globals.syscom.pending_proc = Some(SyscomPendingProc {
4525                kind: SyscomPendingProcKind::EndGame,
4526                warning: p_bool(params, 0),
4527                se_play: p_bool(params, 1),
4528                fade_out: p_bool(params, 2),
4529                leave_msgbk: false,
4530                save_id: 0,
4531            });
4532            ctx.globals.syscom.last_menu_call = END_GAME;
4533            ctx.globals.syscom.menu_open = false;
4534        }
4535        REPLAY_KOE => {
4536            let koe_no = ctx.globals.script.cur_koe_no;
4537            let chara_no = ctx.globals.script.cur_chr_no;
4538            if koe_no >= 0 {
4539                // Replay is a new normal C_elm_koe playback. Re-establish the
4540                // actual buffer metadata so character routing and BGMFADE2 do
4541                // not inherit a preceding EXKOE/other-character voice.
4542                crate::runtime::forms::global::remember_global_koe(ctx, koe_no, chara_no, false);
4543                let append_dir = ctx.globals.append_dir.clone();
4544                let jitan_rate = ctx.koe_jitan_rate(None, true);
4545                if let Err(err) = {
4546                    let (koe, audio) = (&mut ctx.koe, &mut ctx.audio);
4547                    koe.play_koe_no_with_rate(audio, koe_no, &append_dir, jitan_rate)
4548                } {
4549                    log::error!(
4550                        "SYSCOM.REPLAY_KOE failed koe_no={koe_no} chara_no={chara_no}: {err:#}"
4551                    );
4552                }
4553                ctx.globals.syscom.replay_koe = Some((koe_no, chara_no));
4554            }
4555        }
4556        CHECK_REPLAY_KOE => {
4557            let v = if ctx.globals.script.cur_koe_no >= 0 {
4558                1
4559            } else {
4560                0
4561            };
4562            ctx.push(Value::Int(v));
4563            return Ok(true);
4564        }
4565        GET_REPLAY_KOE_KOE_NO => {
4566            let v = ctx.globals.script.cur_koe_no;
4567            ctx.push(Value::Int(v));
4568            return Ok(true);
4569        }
4570        GET_REPLAY_KOE_CHARA_NO => {
4571            let v = ctx.globals.script.cur_chr_no;
4572            ctx.push(Value::Int(v));
4573            return Ok(true);
4574        }
4575        CLEAR_REPLAY_KOE => {
4576            ctx.globals.script.cur_koe_no = -1;
4577            ctx.globals.script.cur_chr_no = -1;
4578            ctx.globals.syscom.replay_koe = None;
4579        }
4580        GET_CURRENT_SAVE_SCENE_TITLE => {
4581            let v = ctx.globals.syscom.current_save_scene_title.clone();
4582            ctx.push(Value::Str(v));
4583            return Ok(true);
4584        }
4585        GET_CURRENT_SAVE_MESSAGE => {
4586            let v = ctx.globals.syscom.current_save_message.clone();
4587            ctx.push(Value::Str(v));
4588            return Ok(true);
4589        }
4590        GET_TOTAL_PLAY_TIME => {
4591            let v = ctx.globals.syscom.total_play_time;
4592            ctx.push(Value::Int(v));
4593            return Ok(true);
4594        }
4595        SET_TOTAL_PLAY_TIME => {
4596            ctx.globals.syscom.total_play_time = p_i64(params, 0);
4597            write_global_save(ctx);
4598        }
4599        CALL_SAVE_MENU => {
4600            prepare_runtime_save_thumb_capture_with_priority(ctx, CAPTURE_PRIOR_SAVE);
4601            set_syscom_pending_proc(ctx, SyscomPendingProcKind::OpenSave);
4602            ctx.globals.syscom.last_menu_call = CALL_SAVE_MENU;
4603            return Ok(true);
4604        }
4605        CALL_LOAD_MENU => {
4606            set_syscom_pending_proc(ctx, SyscomPendingProcKind::OpenLoad);
4607            ctx.globals.syscom.last_menu_call = CALL_LOAD_MENU;
4608            return Ok(true);
4609        }
4610        SAVE => {
4611            let idx = p_i64(params, 0).max(0) as usize;
4612            let warning = p_bool(params, 1);
4613            let ok = idx < configured_save_count(ctx, false) && local_save_available(ctx);
4614            if ok
4615                && !request_confirmed_save_or_load(
4616                    ctx,
4617                    SyscomPendingProcKind::Save,
4618                    idx,
4619                    warning,
4620                    true,
4621                )
4622                && ok
4623            {
4624                menu_save_slot(ctx, false, idx);
4625            }
4626            ctx.globals.syscom.last_menu_call = SAVE;
4627            ctx.push(Value::Int(if ok { 1 } else { 0 }));
4628            return Ok(true);
4629        }
4630        LOAD => {
4631            let idx = p_i64(params, 0).max(0) as usize;
4632            let warning = p_bool(params, 1);
4633            let ok = idx < configured_save_count(ctx, false)
4634                && local_save_file_exists(ctx, SaveKind::Normal, idx);
4635            if ok
4636                && !request_confirmed_save_or_load(
4637                    ctx,
4638                    SyscomPendingProcKind::Load,
4639                    idx,
4640                    warning,
4641                    false,
4642                )
4643            {
4644                menu_load_slot(ctx, false, idx);
4645            }
4646            ctx.globals.syscom.last_menu_call = LOAD;
4647        }
4648        QUICK_SAVE => {
4649            let idx = p_i64(params, 0).max(0) as usize;
4650            let warning = p_bool(params, 1);
4651            let ok = idx < configured_save_count(ctx, true) && local_save_available(ctx);
4652            if ok
4653                && !request_confirmed_save_or_load(
4654                    ctx,
4655                    SyscomPendingProcKind::QuickSave,
4656                    idx,
4657                    warning,
4658                    true,
4659                )
4660                && ok
4661            {
4662                menu_save_slot(ctx, true, idx);
4663            }
4664            ctx.globals.syscom.last_menu_call = QUICK_SAVE;
4665            ctx.push(Value::Int(if ok { 1 } else { 0 }));
4666            return Ok(true);
4667        }
4668        QUICK_LOAD => {
4669            let idx = p_i64(params, 0).max(0) as usize;
4670            let warning = p_bool(params, 1);
4671            let ok = idx < configured_save_count(ctx, true)
4672                && local_save_file_exists(ctx, SaveKind::Quick, idx);
4673            if ok
4674                && !request_confirmed_save_or_load(
4675                    ctx,
4676                    SyscomPendingProcKind::QuickLoad,
4677                    idx,
4678                    warning,
4679                    false,
4680                )
4681            {
4682                menu_load_slot(ctx, true, idx);
4683            }
4684            ctx.globals.syscom.last_menu_call = QUICK_LOAD;
4685        }
4686        END_SAVE => {
4687            // C++ `END_SAVE(warning, se_play)` always targets
4688            // save_cnt + quick_save_cnt; it has no script-supplied index.
4689            let _warning = p_bool(params, 0);
4690            let ok = local_save_available(ctx);
4691            if ok {
4692                prepare_runtime_save_thumb_capture_with_priority(ctx, CAPTURE_PRIOR_END);
4693                ctx.request_runtime_save(RuntimeSaveKind::End, 0);
4694            }
4695            ctx.push(Value::Int(if ok { 1 } else { 0 }));
4696            return Ok(true);
4697        }
4698        END_LOAD => {
4699            // C++ `END_LOAD(warning, se_play, fade_out)` loads the single end-save
4700            // slot at save_cnt + quick_save_cnt.
4701            if local_save_file_exists(ctx, SaveKind::End, 0) {
4702                ctx.request_runtime_load(RuntimeSaveKind::End, 0);
4703            }
4704            ctx.globals.syscom.last_menu_call = END_LOAD;
4705        }
4706        INNER_SAVE => {
4707            let idx = p_i64(params, 0).max(0) as usize;
4708            let ok = local_save_available(ctx);
4709            if ok {
4710                ctx.request_runtime_save(RuntimeSaveKind::Inner, idx);
4711            }
4712            ctx.push(Value::Int(if ok { 1 } else { 0 }));
4713            return Ok(true);
4714        }
4715        INNER_LOAD => {
4716            let idx = p_i64(params, 0).max(0) as usize;
4717            ctx.globals.syscom.last_menu_call = INNER_LOAD;
4718            let exists = ctx
4719                .globals
4720                .syscom
4721                .inner_save_streams
4722                .get(idx)
4723                .map(|s| !s.is_empty())
4724                .unwrap_or(false);
4725            if exists {
4726                ctx.request_runtime_load(RuntimeSaveKind::Inner, idx);
4727            }
4728            ctx.push(Value::Int(if exists { 1 } else { 0 }));
4729            return Ok(true);
4730        }
4731        CLEAR_INNER_SAVE => {
4732            let idx = p_i64(params, 0).max(0) as usize;
4733            let existed = ctx
4734                .globals
4735                .syscom
4736                .inner_save_streams
4737                .get(idx)
4738                .map(|s| !s.is_empty())
4739                .unwrap_or(false);
4740            if let Some(slot) = ctx.globals.syscom.inner_save_streams.get_mut(idx) {
4741                slot.clear();
4742            }
4743            ctx.globals.syscom.inner_save_exists = ctx
4744                .globals
4745                .syscom
4746                .inner_save_streams
4747                .iter()
4748                .any(|s| !s.is_empty());
4749            ctx.push(Value::Int(if existed { 1 } else { 0 }));
4750            return Ok(true);
4751        }
4752        COPY_INNER_SAVE => {
4753            let from = p_i64(params, 0).max(0) as usize;
4754            let to = p_i64(params, 1).max(0) as usize;
4755            let Some(stream) = ctx
4756                .globals
4757                .syscom
4758                .inner_save_streams
4759                .get(from)
4760                .cloned()
4761                .filter(|s| !s.is_empty())
4762            else {
4763                ctx.push(Value::Int(0));
4764                return Ok(true);
4765            };
4766            if ctx.globals.syscom.inner_save_streams.len() <= to {
4767                ctx.globals
4768                    .syscom
4769                    .inner_save_streams
4770                    .resize_with(to + 1, Vec::new);
4771            }
4772            ctx.globals.syscom.inner_save_streams[to] = stream;
4773            ctx.globals.syscom.inner_save_exists = true;
4774            ctx.push(Value::Int(1));
4775            return Ok(true);
4776        }
4777        CHECK_INNER_SAVE => {
4778            let idx = p_i64(params, 0).max(0) as usize;
4779            let v = if ctx
4780                .globals
4781                .syscom
4782                .inner_save_streams
4783                .get(idx)
4784                .map(|s| !s.is_empty())
4785                .unwrap_or(false)
4786            {
4787                1
4788            } else {
4789                0
4790            };
4791            ctx.push(Value::Int(v));
4792            return Ok(true);
4793        }
4794        MSG_BACK_LOAD => {
4795            ctx.globals.syscom.pending_proc = Some(SyscomPendingProc {
4796                kind: SyscomPendingProcKind::BacklogLoad,
4797                warning: p_bool(params, 0),
4798                se_play: p_bool(params, 1),
4799                fade_out: p_bool(params, 2),
4800                leave_msgbk: false,
4801                save_id: ctx.globals.syscom.msg_back_load_tid,
4802            });
4803            ctx.globals.syscom.last_menu_call = MSG_BACK_LOAD;
4804            ctx.globals.syscom.msg_back_open = false;
4805            write_msg_back(ctx);
4806        }
4807        GET_SAVE_CNT => {
4808            let v = configured_save_count(ctx, false) as i64;
4809            ctx.push(Value::Int(v));
4810            return Ok(true);
4811        }
4812        GET_QUICK_SAVE_CNT => {
4813            let v = configured_save_count(ctx, true) as i64;
4814            ctx.push(Value::Int(v));
4815            return Ok(true);
4816        }
4817        GET_SAVE_NEW_NO => {
4818            sync_save_slots_from_disk(ctx, false);
4819            let save_cnt = configured_save_count(ctx, false);
4820            let v = match params.len() {
4821                0 => newest_slot_in_range(
4822                    &ctx.globals.syscom.save_slots,
4823                    0,
4824                    save_cnt as i64,
4825                    save_cnt,
4826                ),
4827                2 => newest_slot_in_range(
4828                    &ctx.globals.syscom.save_slots,
4829                    p_i64(params, 0),
4830                    p_i64(params, 1),
4831                    save_cnt,
4832                ),
4833                _ => -1,
4834            };
4835            ctx.push(Value::Int(v));
4836            return Ok(true);
4837        }
4838        GET_QUICK_SAVE_NEW_NO => {
4839            sync_save_slots_from_disk(ctx, true);
4840            let quick_cnt = configured_save_count(ctx, true);
4841            let v = match params.len() {
4842                0 => newest_slot_in_range(
4843                    &ctx.globals.syscom.quick_save_slots,
4844                    0,
4845                    quick_cnt as i64,
4846                    quick_cnt,
4847                ),
4848                2 => newest_slot_in_range(
4849                    &ctx.globals.syscom.quick_save_slots,
4850                    p_i64(params, 0),
4851                    p_i64(params, 1),
4852                    quick_cnt,
4853                ),
4854                _ => -1,
4855            };
4856            ctx.push(Value::Int(v));
4857            return Ok(true);
4858        }
4859        GET_SAVE_EXIST | GET_SAVE_YEAR | GET_SAVE_MONTH | GET_SAVE_DAY | GET_SAVE_WEEKDAY
4860        | GET_SAVE_HOUR | GET_SAVE_MINUTE | GET_SAVE_SECOND | GET_SAVE_MILLISECOND => {
4861            let raw_idx = p_i64(params, 0);
4862            let save_cnt = configured_save_count(ctx, false);
4863            if raw_idx < 0 || raw_idx as usize >= save_cnt {
4864                ctx.push(Value::Int(0));
4865                return Ok(true);
4866            }
4867            let idx = raw_idx as usize;
4868            {
4869                let quick_cnt = configured_save_count(ctx, true);
4870                ensure_slot_loaded_with_counts(
4871                    &ctx.project_dir,
4872                    false,
4873                    save_cnt,
4874                    quick_cnt,
4875                    &mut ctx.globals.syscom.save_slots,
4876                    idx,
4877                );
4878            }
4879            let v = ctx
4880                .globals
4881                .syscom
4882                .save_slots
4883                .get(idx)
4884                .map(|s| slot_i64(s, op))
4885                .unwrap_or(0);
4886            ctx.push(Value::Int(v));
4887            return Ok(true);
4888        }
4889        GET_SAVE_TITLE
4890        | GET_SAVE_MESSAGE
4891        | GET_SAVE_FULL_MESSAGE
4892        | GET_SAVE_COMMENT
4893        | GET_SAVE_APPEND_DIR
4894        | GET_SAVE_APPEND_NAME => {
4895            let raw_idx = p_i64(params, 0);
4896            let save_cnt = configured_save_count(ctx, false);
4897            if raw_idx < 0 || raw_idx as usize >= save_cnt {
4898                ctx.push(Value::Str(String::new()));
4899                return Ok(true);
4900            }
4901            let idx = raw_idx as usize;
4902            {
4903                let quick_cnt = configured_save_count(ctx, true);
4904                ensure_slot_loaded_with_counts(
4905                    &ctx.project_dir,
4906                    false,
4907                    save_cnt,
4908                    quick_cnt,
4909                    &mut ctx.globals.syscom.save_slots,
4910                    idx,
4911                );
4912            }
4913            let v = ctx
4914                .globals
4915                .syscom
4916                .save_slots
4917                .get(idx)
4918                .map(|s| slot_str(s, op))
4919                .unwrap_or_default();
4920            ctx.push(Value::Str(v));
4921            return Ok(true);
4922        }
4923        SET_SAVE_COMMENT => {
4924            let raw_idx = p_i64(params, 0);
4925            let save_cnt = configured_save_count(ctx, false);
4926            if raw_idx < 0 || raw_idx as usize >= save_cnt {
4927                return Ok(true);
4928            }
4929            let idx = raw_idx as usize;
4930            let quick_cnt = configured_save_count(ctx, true);
4931            ensure_slot_loaded_with_counts(
4932                &ctx.project_dir,
4933                false,
4934                save_cnt,
4935                quick_cnt,
4936                &mut ctx.globals.syscom.save_slots,
4937                idx,
4938            );
4939            // Original SET_SAVE_COMMENT first calls tnm_load_save_header(); if
4940            // the slot does not exist it is a no-op and must not create a save.
4941            if !ctx
4942                .globals
4943                .syscom
4944                .save_slots
4945                .get(idx)
4946                .map(|slot| slot.exist)
4947                .unwrap_or(false)
4948            {
4949                return Ok(true);
4950            }
4951            let comment = params
4952                .get(1)
4953                .and_then(|v| v.as_str())
4954                .unwrap_or("")
4955                .to_string();
4956            ctx.globals.syscom.save_slots[idx].comment = comment;
4957            persist_slot_with_counts(
4958                &ctx.project_dir,
4959                false,
4960                save_cnt,
4961                quick_cnt,
4962                &mut ctx.globals.syscom.save_slots,
4963                idx,
4964            );
4965            return Ok(true);
4966        }
4967        GET_SAVE_VALUE => {
4968            let raw_idx = p_i64(params, 0);
4969            let save_cnt = configured_save_count(ctx, false);
4970            if raw_idx < 0 || raw_idx as usize >= save_cnt {
4971                return Ok(true);
4972            }
4973            let idx = raw_idx as usize;
4974            let quick_cnt = configured_save_count(ctx, true);
4975            ensure_slot_loaded_with_counts(
4976                &ctx.project_dir,
4977                false,
4978                save_cnt,
4979                quick_cnt,
4980                &mut ctx.globals.syscom.save_slots,
4981                idx,
4982            );
4983            let Some(slot) = ctx
4984                .globals
4985                .syscom
4986                .save_slots
4987                .get(idx)
4988                .filter(|slot| slot.exist)
4989            else {
4990                return Ok(true);
4991            };
4992            let Some(Value::Element(chain)) = params.get(1).map(|v| v.unwrap_named()) else {
4993                return Ok(true);
4994            };
4995            let Some(form_id) = chain.first().copied() else {
4996                return Ok(true);
4997            };
4998            let flag_index = p_i64(params, 2).max(0) as usize;
4999            let flag_cnt = (p_i64(params, 3).max(0) as usize).min(original_save::SAVE_FLAG_MAX_CNT);
5000            let values: Vec<i64> = (0..flag_cnt)
5001                .map(|i| slot.values.get(&(i as i32)).copied().unwrap_or(0))
5002                .collect();
5003            let list = ctx.globals.int_lists.entry(form_id as u32).or_default();
5004            if list.len() < flag_index + flag_cnt {
5005                list.resize(flag_index + flag_cnt, 0);
5006            }
5007            for (i, value) in values.into_iter().enumerate() {
5008                list[flag_index + i] = value;
5009            }
5010            // GET_SAVE_VALUE is FM_VOID in def_element_Siglus.h. It writes the
5011            // destination int-list and does not push a return value.
5012            return Ok(true);
5013        }
5014        SET_SAVE_VALUE => {
5015            let raw_idx = p_i64(params, 0);
5016            let save_cnt = configured_save_count(ctx, false);
5017            if raw_idx < 0 || raw_idx as usize >= save_cnt {
5018                return Ok(true);
5019            }
5020            let idx = raw_idx as usize;
5021            let quick_cnt = configured_save_count(ctx, true);
5022            ensure_slot_loaded_with_counts(
5023                &ctx.project_dir,
5024                false,
5025                save_cnt,
5026                quick_cnt,
5027                &mut ctx.globals.syscom.save_slots,
5028                idx,
5029            );
5030            if !ctx
5031                .globals
5032                .syscom
5033                .save_slots
5034                .get(idx)
5035                .map(|slot| slot.exist)
5036                .unwrap_or(false)
5037            {
5038                return Ok(true);
5039            }
5040            let Some(Value::Element(chain)) = params.get(1).map(|v| v.unwrap_named()) else {
5041                return Ok(true);
5042            };
5043            let Some(form_id) = chain.first().copied() else {
5044                return Ok(true);
5045            };
5046            let flag_index = p_i64(params, 2).max(0) as usize;
5047            let flag_cnt = (p_i64(params, 3).max(0) as usize).min(original_save::SAVE_FLAG_MAX_CNT);
5048            let values: Vec<i64> = (0..flag_cnt)
5049                .map(|i| {
5050                    ctx.globals
5051                        .int_lists
5052                        .get(&(form_id as u32))
5053                        .and_then(|list| list.get(flag_index + i).copied())
5054                        .unwrap_or(0)
5055                })
5056                .collect();
5057            let slot = &mut ctx.globals.syscom.save_slots[idx];
5058            for (i, value) in values.into_iter().enumerate() {
5059                if value == 0 {
5060                    slot.values.remove(&(i as i32));
5061                } else {
5062                    slot.values.insert(i as i32, value);
5063                }
5064            }
5065            persist_slot_with_counts(
5066                &ctx.project_dir,
5067                false,
5068                save_cnt,
5069                quick_cnt,
5070                &mut ctx.globals.syscom.save_slots,
5071                idx,
5072            );
5073            return Ok(true);
5074        }
5075        GET_QUICK_SAVE_EXIST
5076        | GET_QUICK_SAVE_YEAR
5077        | GET_QUICK_SAVE_MONTH
5078        | GET_QUICK_SAVE_DAY
5079        | GET_QUICK_SAVE_WEEKDAY
5080        | GET_QUICK_SAVE_HOUR
5081        | GET_QUICK_SAVE_MINUTE
5082        | GET_QUICK_SAVE_SECOND
5083        | GET_QUICK_SAVE_MILLISECOND => {
5084            let raw_idx = p_i64(params, 0);
5085            let quick_cnt = configured_save_count(ctx, true);
5086            if raw_idx < 0 || raw_idx as usize >= quick_cnt {
5087                ctx.push(Value::Int(0));
5088                return Ok(true);
5089            }
5090            let idx = raw_idx as usize;
5091            {
5092                let save_cnt = configured_save_count(ctx, false);
5093                ensure_slot_loaded_with_counts(
5094                    &ctx.project_dir,
5095                    true,
5096                    save_cnt,
5097                    quick_cnt,
5098                    &mut ctx.globals.syscom.quick_save_slots,
5099                    idx,
5100                );
5101            }
5102            let v = ctx
5103                .globals
5104                .syscom
5105                .quick_save_slots
5106                .get(idx)
5107                .map(|s| slot_i64(s, op))
5108                .unwrap_or(0);
5109            ctx.push(Value::Int(v));
5110            return Ok(true);
5111        }
5112        GET_QUICK_SAVE_TITLE
5113        | GET_QUICK_SAVE_MESSAGE
5114        | GET_QUICK_SAVE_FULL_MESSAGE
5115        | GET_QUICK_SAVE_COMMENT
5116        | GET_QUICK_SAVE_APPEND_DIR
5117        | GET_QUICK_SAVE_APPEND_NAME => {
5118            let raw_idx = p_i64(params, 0);
5119            let quick_cnt = configured_save_count(ctx, true);
5120            if raw_idx < 0 || raw_idx as usize >= quick_cnt {
5121                ctx.push(Value::Str(String::new()));
5122                return Ok(true);
5123            }
5124            let idx = raw_idx as usize;
5125            {
5126                let save_cnt = configured_save_count(ctx, false);
5127                ensure_slot_loaded_with_counts(
5128                    &ctx.project_dir,
5129                    true,
5130                    save_cnt,
5131                    quick_cnt,
5132                    &mut ctx.globals.syscom.quick_save_slots,
5133                    idx,
5134                );
5135            }
5136            let v = ctx
5137                .globals
5138                .syscom
5139                .quick_save_slots
5140                .get(idx)
5141                .map(|s| slot_str(s, op))
5142                .unwrap_or_default();
5143            ctx.push(Value::Str(v));
5144            return Ok(true);
5145        }
5146        SET_QUICK_SAVE_COMMENT => {
5147            let raw_idx = p_i64(params, 0);
5148            let quick_cnt = configured_save_count(ctx, true);
5149            if raw_idx < 0 || raw_idx as usize >= quick_cnt {
5150                return Ok(true);
5151            }
5152            let idx = raw_idx as usize;
5153            let save_cnt = configured_save_count(ctx, false);
5154            ensure_slot_loaded_with_counts(
5155                &ctx.project_dir,
5156                true,
5157                save_cnt,
5158                quick_cnt,
5159                &mut ctx.globals.syscom.quick_save_slots,
5160                idx,
5161            );
5162            if !ctx
5163                .globals
5164                .syscom
5165                .quick_save_slots
5166                .get(idx)
5167                .map(|slot| slot.exist)
5168                .unwrap_or(false)
5169            {
5170                return Ok(true);
5171            }
5172            let comment = params
5173                .get(1)
5174                .and_then(|v| v.as_str())
5175                .unwrap_or("")
5176                .to_string();
5177            ctx.globals.syscom.quick_save_slots[idx].comment = comment;
5178            persist_slot_with_counts(
5179                &ctx.project_dir,
5180                true,
5181                save_cnt,
5182                quick_cnt,
5183                &mut ctx.globals.syscom.quick_save_slots,
5184                idx,
5185            );
5186            return Ok(true);
5187        }
5188        GET_QUICK_SAVE_VALUE => {
5189            let raw_idx = p_i64(params, 0);
5190            let quick_cnt = configured_save_count(ctx, true);
5191            if raw_idx < 0 || raw_idx as usize >= quick_cnt {
5192                return Ok(true);
5193            }
5194            let idx = raw_idx as usize;
5195            let save_cnt = configured_save_count(ctx, false);
5196            ensure_slot_loaded_with_counts(
5197                &ctx.project_dir,
5198                true,
5199                save_cnt,
5200                quick_cnt,
5201                &mut ctx.globals.syscom.quick_save_slots,
5202                idx,
5203            );
5204            let Some(slot) = ctx
5205                .globals
5206                .syscom
5207                .quick_save_slots
5208                .get(idx)
5209                .filter(|slot| slot.exist)
5210            else {
5211                return Ok(true);
5212            };
5213            let Some(Value::Element(chain)) = params.get(1).map(|v| v.unwrap_named()) else {
5214                return Ok(true);
5215            };
5216            let Some(form_id) = chain.first().copied() else {
5217                return Ok(true);
5218            };
5219            let flag_index = p_i64(params, 2).max(0) as usize;
5220            let flag_cnt = (p_i64(params, 3).max(0) as usize).min(original_save::SAVE_FLAG_MAX_CNT);
5221            let values: Vec<i64> = (0..flag_cnt)
5222                .map(|i| slot.values.get(&(i as i32)).copied().unwrap_or(0))
5223                .collect();
5224            let list = ctx.globals.int_lists.entry(form_id as u32).or_default();
5225            if list.len() < flag_index + flag_cnt {
5226                list.resize(flag_index + flag_cnt, 0);
5227            }
5228            for (i, value) in values.into_iter().enumerate() {
5229                list[flag_index + i] = value;
5230            }
5231            return Ok(true);
5232        }
5233        SET_QUICK_SAVE_VALUE => {
5234            let raw_idx = p_i64(params, 0);
5235            let quick_cnt = configured_save_count(ctx, true);
5236            if raw_idx < 0 || raw_idx as usize >= quick_cnt {
5237                return Ok(true);
5238            }
5239            let idx = raw_idx as usize;
5240            let save_cnt = configured_save_count(ctx, false);
5241            ensure_slot_loaded_with_counts(
5242                &ctx.project_dir,
5243                true,
5244                save_cnt,
5245                quick_cnt,
5246                &mut ctx.globals.syscom.quick_save_slots,
5247                idx,
5248            );
5249            if !ctx
5250                .globals
5251                .syscom
5252                .quick_save_slots
5253                .get(idx)
5254                .map(|slot| slot.exist)
5255                .unwrap_or(false)
5256            {
5257                return Ok(true);
5258            }
5259            let Some(Value::Element(chain)) = params.get(1).map(|v| v.unwrap_named()) else {
5260                return Ok(true);
5261            };
5262            let Some(form_id) = chain.first().copied() else {
5263                return Ok(true);
5264            };
5265            let flag_index = p_i64(params, 2).max(0) as usize;
5266            let flag_cnt = (p_i64(params, 3).max(0) as usize).min(original_save::SAVE_FLAG_MAX_CNT);
5267            let values: Vec<i64> = (0..flag_cnt)
5268                .map(|i| {
5269                    ctx.globals
5270                        .int_lists
5271                        .get(&(form_id as u32))
5272                        .and_then(|list| list.get(flag_index + i).copied())
5273                        .unwrap_or(0)
5274                })
5275                .collect();
5276            let slot = &mut ctx.globals.syscom.quick_save_slots[idx];
5277            for (i, value) in values.into_iter().enumerate() {
5278                if value == 0 {
5279                    slot.values.remove(&(i as i32));
5280                } else {
5281                    slot.values.insert(i as i32, value);
5282                }
5283            }
5284            persist_slot_with_counts(
5285                &ctx.project_dir,
5286                true,
5287                save_cnt,
5288                quick_cnt,
5289                &mut ctx.globals.syscom.quick_save_slots,
5290                idx,
5291            );
5292            return Ok(true);
5293        }
5294        GET_END_SAVE_EXIST => {
5295            let save_cnt = configured_save_count(ctx, false);
5296            let quick_cnt = configured_save_count(ctx, true);
5297            let end_path = original_save::save_file_path_with_counts(
5298                &ctx.project_dir,
5299                save_cnt,
5300                quick_cnt,
5301                SaveKind::End,
5302                0,
5303            );
5304            let v = if ctx.globals.syscom.end_save_exists
5305                || crate::resource::game_file_exists(&end_path)
5306            {
5307                1
5308            } else {
5309                0
5310            };
5311            ctx.push(Value::Int(v));
5312            return Ok(true);
5313        }
5314        COPY_SAVE => {
5315            let src = p_i64(params, 0).max(0) as usize;
5316            let dst = p_i64(params, 1).max(0) as usize;
5317            let save_cnt = configured_save_count(ctx, false);
5318            let quick_cnt = configured_save_count(ctx, true);
5319            let thumb_config = save_thumb_config(ctx);
5320            let ok = copy_slot(
5321                &ctx.project_dir,
5322                false,
5323                save_cnt,
5324                quick_cnt,
5325                thumb_config,
5326                &mut ctx.globals.syscom.save_slots,
5327                src,
5328                dst,
5329            );
5330            ctx.globals.syscom.last_menu_call = op;
5331            ctx.push(Value::Int(if ok { 1 } else { 0 }));
5332            return Ok(true);
5333        }
5334        COPY_QUICK_SAVE => {
5335            let src = p_i64(params, 0).max(0) as usize;
5336            let dst = p_i64(params, 1).max(0) as usize;
5337            let save_cnt = configured_save_count(ctx, false);
5338            let quick_cnt = configured_save_count(ctx, true);
5339            let thumb_config = save_thumb_config(ctx);
5340            let ok = copy_slot(
5341                &ctx.project_dir,
5342                true,
5343                save_cnt,
5344                quick_cnt,
5345                thumb_config,
5346                &mut ctx.globals.syscom.quick_save_slots,
5347                src,
5348                dst,
5349            );
5350            ctx.globals.syscom.last_menu_call = op;
5351            ctx.push(Value::Int(if ok { 1 } else { 0 }));
5352            return Ok(true);
5353        }
5354        CHANGE_SAVE => {
5355            let a = p_i64(params, 0).max(0) as usize;
5356            let b = p_i64(params, 1).max(0) as usize;
5357            let save_cnt = configured_save_count(ctx, false);
5358            let quick_cnt = configured_save_count(ctx, true);
5359            let thumb_config = save_thumb_config(ctx);
5360            let ok = change_slot(
5361                &ctx.project_dir,
5362                false,
5363                save_cnt,
5364                quick_cnt,
5365                thumb_config,
5366                &mut ctx.globals.syscom.save_slots,
5367                a,
5368                b,
5369            );
5370            ctx.globals.syscom.last_menu_call = op;
5371            ctx.push(Value::Int(if ok { 1 } else { 0 }));
5372            return Ok(true);
5373        }
5374        CHANGE_QUICK_SAVE => {
5375            let a = p_i64(params, 0).max(0) as usize;
5376            let b = p_i64(params, 1).max(0) as usize;
5377            let save_cnt = configured_save_count(ctx, false);
5378            let quick_cnt = configured_save_count(ctx, true);
5379            let thumb_config = save_thumb_config(ctx);
5380            let ok = change_slot(
5381                &ctx.project_dir,
5382                true,
5383                save_cnt,
5384                quick_cnt,
5385                thumb_config,
5386                &mut ctx.globals.syscom.quick_save_slots,
5387                a,
5388                b,
5389            );
5390            ctx.globals.syscom.last_menu_call = op;
5391            ctx.push(Value::Int(if ok { 1 } else { 0 }));
5392            return Ok(true);
5393        }
5394        DELETE_SAVE => {
5395            let idx = p_i64(params, 0).max(0) as usize;
5396            let save_cnt = configured_save_count(ctx, false);
5397            let quick_cnt = configured_save_count(ctx, true);
5398            let thumb_config = save_thumb_config(ctx);
5399            let ok = delete_slot(
5400                &ctx.project_dir,
5401                false,
5402                save_cnt,
5403                quick_cnt,
5404                thumb_config,
5405                &mut ctx.globals.syscom.save_slots,
5406                idx,
5407            );
5408            ctx.globals.syscom.last_menu_call = op;
5409            ctx.push(Value::Int(if ok { 1 } else { 0 }));
5410            return Ok(true);
5411        }
5412        DELETE_QUICK_SAVE => {
5413            let idx = p_i64(params, 0).max(0) as usize;
5414            let save_cnt = configured_save_count(ctx, false);
5415            let quick_cnt = configured_save_count(ctx, true);
5416            let thumb_config = save_thumb_config(ctx);
5417            let ok = delete_slot(
5418                &ctx.project_dir,
5419                true,
5420                save_cnt,
5421                quick_cnt,
5422                thumb_config,
5423                &mut ctx.globals.syscom.quick_save_slots,
5424                idx,
5425            );
5426            ctx.globals.syscom.last_menu_call = op;
5427            ctx.push(Value::Int(if ok { 1 } else { 0 }));
5428            return Ok(true);
5429        }
5430        CALL_CONFIG_MENU => {
5431            set_syscom_pending_proc(ctx, SyscomPendingProcKind::OpenConfig);
5432            ctx.globals.syscom.last_menu_call = op;
5433        }
5434        CALL_CONFIG_WINDOW_MODE_MENU
5435        | CALL_CONFIG_VOLUME_MENU
5436        | CALL_CONFIG_BGMFADE_MENU
5437        | CALL_CONFIG_KOEMODE_MENU
5438        | CALL_CONFIG_CHARAKOE_MENU
5439        | CALL_CONFIG_JITAN_MENU
5440        | CALL_CONFIG_MESSAGE_SPEED_MENU
5441        | CALL_CONFIG_FILTER_COLOR_MENU
5442        | CALL_CONFIG_AUTO_MODE_MENU
5443        | CALL_CONFIG_FONT_MENU
5444        | CALL_CONFIG_SYSTEM_MENU
5445        | CALL_CONFIG_MOVIE_MENU => {
5446            // C++ cmd_syscom opens cfg_wnd_solo_* directly for these calls.
5447            // Re-entering CONFIG_SCENE would share the active EXCALL storage;
5448            // returning from the inner menu frees the outer menu's objects.
5449            set_syscom_pending_proc(ctx, SyscomPendingProcKind::OpenConfigDialog);
5450            ctx.globals.syscom.last_menu_call = op;
5451        }
5452        SET_WINDOW_MODE => cfg_set_int(&mut ctx.globals.syscom, GET_WINDOW_MODE, p_i64(params, 0)),
5453        SET_WINDOW_MODE_DEFAULT => {
5454            let value = gameexe_i64_or(ctx, "CONFIG.WINDOW_MODE", 0).clamp(0, 1);
5455            cfg_set_int(&mut ctx.globals.syscom, GET_WINDOW_MODE, value);
5456        }
5457        GET_WINDOW_MODE => {
5458            let v = cfg_get_int(&ctx.globals.syscom, GET_WINDOW_MODE, 0);
5459            ctx.push(Value::Int(v));
5460            return Ok(true);
5461        }
5462        SET_WINDOW_MODE_SIZE => cfg_set_int(
5463            &mut ctx.globals.syscom,
5464            GET_WINDOW_MODE_SIZE,
5465            p_i64(params, 0),
5466        ),
5467        SET_WINDOW_MODE_SIZE_DEFAULT => {
5468            let value = ctx
5469                .globals
5470                .syscom
5471                .original_config
5472                .screen_size_scale
5473                .0
5474                .max(1);
5475            cfg_set_int(&mut ctx.globals.syscom, GET_WINDOW_MODE_SIZE, value);
5476        }
5477        GET_WINDOW_MODE_SIZE => {
5478            let v = cfg_get_int(&ctx.globals.syscom, GET_WINDOW_MODE_SIZE, 100);
5479            ctx.push(Value::Int(v));
5480            return Ok(true);
5481        }
5482        CHECK_WINDOW_MODE_SIZE_ENABLE => {
5483            ctx.push(Value::Int(1));
5484            return Ok(true);
5485        }
5486        SET_ALL_VOLUME => {
5487            let value = p_i64(params, 0).clamp(0, 255);
5488            ctx.globals.syscom.original_config.all_sound_user_volume = value;
5489            cfg_set_int(&mut ctx.globals.syscom, GET_ALL_VOLUME, value);
5490            apply_audio_config(ctx);
5491        }
5492        SET_BGM_VOLUME => {
5493            set_sound_volume_by_type(ctx, 0, p_i64(params, 0));
5494            apply_audio_config(ctx);
5495        }
5496        SET_KOE_VOLUME => {
5497            set_sound_volume_by_type(ctx, 1, p_i64(params, 0));
5498            apply_audio_config(ctx);
5499        }
5500        SET_PCM_VOLUME => {
5501            set_sound_volume_by_type(ctx, 2, p_i64(params, 0));
5502            apply_audio_config(ctx);
5503        }
5504        SET_SE_VOLUME => {
5505            set_sound_volume_by_type(ctx, 3, p_i64(params, 0));
5506            apply_audio_config(ctx);
5507        }
5508        SET_MOV_VOLUME => {
5509            set_sound_volume_by_type(ctx, 4, p_i64(params, 0));
5510            apply_audio_config(ctx);
5511        }
5512        SET_SOUND_VOLUME => {
5513            let sound_type = p_i64(params, 0);
5514            if (0..32).contains(&sound_type) {
5515                set_sound_volume_by_type(ctx, sound_type as usize, p_i64(params, 1));
5516                apply_audio_config(ctx);
5517            }
5518        }
5519        SET_ALL_VOLUME_DEFAULT => {
5520            let value = gameexe_i64_or(ctx, "CONFIG.VOLUME.ALL", 255).clamp(0, 255);
5521            ctx.globals.syscom.original_config.all_sound_user_volume = value;
5522            cfg_set_int(&mut ctx.globals.syscom, GET_ALL_VOLUME, value);
5523            apply_audio_config(ctx);
5524        }
5525        SET_BGM_VOLUME_DEFAULT => {
5526            let value = config_default_sound_volume(ctx, 0);
5527            set_sound_volume_by_type(ctx, 0, value);
5528            apply_audio_config(ctx);
5529        }
5530        SET_KOE_VOLUME_DEFAULT => {
5531            let value = config_default_sound_volume(ctx, 1);
5532            set_sound_volume_by_type(ctx, 1, value);
5533            apply_audio_config(ctx);
5534        }
5535        SET_PCM_VOLUME_DEFAULT => {
5536            let value = config_default_sound_volume(ctx, 2);
5537            set_sound_volume_by_type(ctx, 2, value);
5538            apply_audio_config(ctx);
5539        }
5540        SET_SE_VOLUME_DEFAULT => {
5541            let value = config_default_sound_volume(ctx, 3);
5542            set_sound_volume_by_type(ctx, 3, value);
5543            apply_audio_config(ctx);
5544        }
5545        SET_MOV_VOLUME_DEFAULT => {
5546            let value = config_default_sound_volume(ctx, 4);
5547            set_sound_volume_by_type(ctx, 4, value);
5548            apply_audio_config(ctx);
5549        }
5550        SET_SOUND_VOLUME_DEFAULT => {
5551            let sound_type = p_i64(params, 0);
5552            if (0..32).contains(&sound_type) {
5553                let value = config_default_sound_volume(ctx, sound_type as usize);
5554                set_sound_volume_by_type(ctx, sound_type as usize, value);
5555                apply_audio_config(ctx);
5556            }
5557        }
5558        GET_ALL_VOLUME | GET_BGM_VOLUME | GET_KOE_VOLUME | GET_PCM_VOLUME | GET_SE_VOLUME
5559        | GET_MOV_VOLUME => {
5560            let v = cfg_get_int(&ctx.globals.syscom, op, 255).clamp(0, 255);
5561            ctx.push(Value::Int(v));
5562            return Ok(true);
5563        }
5564        GET_SOUND_VOLUME => {
5565            let sound_type = p_i64(params, 0);
5566            let value = if (0..32).contains(&sound_type) {
5567                get_sound_volume_by_type(ctx, sound_type as usize)
5568            } else {
5569                255
5570            };
5571            ctx.push(Value::Int(value));
5572            return Ok(true);
5573        }
5574        SET_ALL_ONOFF => {
5575            let value = p_bool(params, 0);
5576            ctx.globals.syscom.original_config.play_all_sound_check = value;
5577            cfg_set_int(
5578                &mut ctx.globals.syscom,
5579                GET_ALL_ONOFF,
5580                if value { 1 } else { 0 },
5581            );
5582            apply_audio_config(ctx);
5583        }
5584        SET_BGM_ONOFF => {
5585            set_sound_onoff_by_type(ctx, 0, p_bool(params, 0));
5586            apply_audio_config(ctx);
5587        }
5588        SET_KOE_ONOFF => {
5589            set_sound_onoff_by_type(ctx, 1, p_bool(params, 0));
5590            apply_audio_config(ctx);
5591        }
5592        SET_PCM_ONOFF => {
5593            set_sound_onoff_by_type(ctx, 2, p_bool(params, 0));
5594            apply_audio_config(ctx);
5595        }
5596        SET_SE_ONOFF => {
5597            set_sound_onoff_by_type(ctx, 3, p_bool(params, 0));
5598            apply_audio_config(ctx);
5599        }
5600        SET_MOV_ONOFF => {
5601            set_sound_onoff_by_type(ctx, 4, p_bool(params, 0));
5602            apply_audio_config(ctx);
5603        }
5604        SET_SOUND_ONOFF => {
5605            let sound_type = p_i64(params, 0);
5606            if (0..32).contains(&sound_type) {
5607                set_sound_onoff_by_type(ctx, sound_type as usize, p_bool(params, 1));
5608                apply_audio_config(ctx);
5609            }
5610        }
5611        SET_ALL_ONOFF_DEFAULT => {
5612            ctx.globals.syscom.original_config.play_all_sound_check = true;
5613            cfg_set_int(&mut ctx.globals.syscom, GET_ALL_ONOFF, 1);
5614            apply_audio_config(ctx);
5615        }
5616        SET_BGM_ONOFF_DEFAULT => {
5617            let value = config_default_sound_onoff(ctx, 0);
5618            set_sound_onoff_by_type(ctx, 0, value);
5619            apply_audio_config(ctx);
5620        }
5621        SET_KOE_ONOFF_DEFAULT => {
5622            let value = config_default_sound_onoff(ctx, 1);
5623            set_sound_onoff_by_type(ctx, 1, value);
5624            apply_audio_config(ctx);
5625        }
5626        SET_PCM_ONOFF_DEFAULT => {
5627            let value = config_default_sound_onoff(ctx, 2);
5628            set_sound_onoff_by_type(ctx, 2, value);
5629            apply_audio_config(ctx);
5630        }
5631        SET_SE_ONOFF_DEFAULT => {
5632            let value = config_default_sound_onoff(ctx, 3);
5633            set_sound_onoff_by_type(ctx, 3, value);
5634            apply_audio_config(ctx);
5635        }
5636        SET_MOV_ONOFF_DEFAULT => {
5637            let value = config_default_sound_onoff(ctx, 4);
5638            set_sound_onoff_by_type(ctx, 4, value);
5639            apply_audio_config(ctx);
5640        }
5641        SET_SOUND_ONOFF_DEFAULT => {
5642            let sound_type = p_i64(params, 0);
5643            if (0..32).contains(&sound_type) {
5644                let value = config_default_sound_onoff(ctx, sound_type as usize);
5645                set_sound_onoff_by_type(ctx, sound_type as usize, value);
5646                apply_audio_config(ctx);
5647            }
5648        }
5649        GET_ALL_ONOFF | GET_BGM_ONOFF | GET_KOE_ONOFF | GET_PCM_ONOFF | GET_SE_ONOFF
5650        | GET_MOV_ONOFF => {
5651            let v = cfg_get_int(&ctx.globals.syscom, op, 1);
5652            ctx.push(Value::Int(if v != 0 { 1 } else { 0 }));
5653            return Ok(true);
5654        }
5655        GET_SOUND_ONOFF => {
5656            let sound_type = p_i64(params, 0);
5657            let value = if (0..32).contains(&sound_type) {
5658                get_sound_onoff_by_type(ctx, sound_type as usize)
5659            } else {
5660                true
5661            };
5662            ctx.push(Value::Int(if value { 1 } else { 0 }));
5663            return Ok(true);
5664        }
5665        SET_BGMFADE_VOLUME => {
5666            let value = p_i64(params, 0).clamp(0, 255);
5667            ctx.globals.syscom.original_config.bgmfade_volume = value;
5668            cfg_set_int(&mut ctx.globals.syscom, GET_BGMFADE_VOLUME, value);
5669            apply_audio_config(ctx);
5670        }
5671        SET_BGMFADE_ONOFF => {
5672            let value = p_bool(params, 0);
5673            ctx.globals.syscom.original_config.bgmfade_use_check = value;
5674            cfg_set_int(
5675                &mut ctx.globals.syscom,
5676                GET_BGMFADE_ONOFF,
5677                if value { 1 } else { 0 },
5678            );
5679            apply_audio_config(ctx);
5680        }
5681        SET_BGMFADE_VOLUME_DEFAULT => {
5682            let value = gameexe_i64_or(ctx, "CONFIG.BGMFADE_VOLUME", 192).clamp(0, 255);
5683            ctx.globals.syscom.original_config.bgmfade_volume = value;
5684            cfg_set_int(&mut ctx.globals.syscom, GET_BGMFADE_VOLUME, value);
5685            apply_audio_config(ctx);
5686        }
5687        SET_BGMFADE_ONOFF_DEFAULT => {
5688            let value = gameexe_bool_or(ctx, "CONFIG.BGMFADE_ONOFF", true);
5689            ctx.globals.syscom.original_config.bgmfade_use_check = value;
5690            cfg_set_int(
5691                &mut ctx.globals.syscom,
5692                GET_BGMFADE_ONOFF,
5693                if value { 1 } else { 0 },
5694            );
5695            apply_audio_config(ctx);
5696        }
5697        GET_BGMFADE_VOLUME | GET_BGMFADE_ONOFF => {
5698            let default = if op == GET_BGMFADE_ONOFF { 1 } else { 192 };
5699            let v = cfg_get_int(&ctx.globals.syscom, op, default);
5700            ctx.push(Value::Int(v));
5701            return Ok(true);
5702        }
5703        SET_KOEMODE => {
5704            let value = match p_i64(params, 0) {
5705                0..=2 => p_i64(params, 0),
5706                _ => 0,
5707            };
5708            ctx.globals.syscom.original_config.koe_mode = value;
5709            cfg_set_int(&mut ctx.globals.syscom, GET_KOEMODE, value);
5710            apply_audio_config(ctx);
5711        }
5712        SET_KOEMODE_DEFAULT => {
5713            ctx.globals.syscom.original_config.koe_mode = 0;
5714            cfg_set_int(&mut ctx.globals.syscom, GET_KOEMODE, 0);
5715            apply_audio_config(ctx);
5716        }
5717        GET_KOEMODE => {
5718            let v = cfg_get_int(&ctx.globals.syscom, GET_KOEMODE, 0);
5719            ctx.push(Value::Int(v));
5720            return Ok(true);
5721        }
5722        SET_CHARAKOE_ONOFF => {
5723            let index = p_i64(params, 0);
5724            let count = configured_chrkoe_count(ctx);
5725            ctx.globals
5726                .syscom
5727                .original_config
5728                .chrkoe
5729                .resize_with(count, crate::runtime::globals::ConfigChrKoeState::default);
5730            if index >= 0
5731                && let Some(item) = ctx
5732                    .globals
5733                    .syscom
5734                    .original_config
5735                    .chrkoe
5736                    .get_mut(index as usize)
5737            {
5738                item.onoff = p_bool(params, 1);
5739            }
5740            apply_audio_config(ctx);
5741        }
5742        SET_CHARAKOE_ONOFF_DEFAULT => {
5743            let index = p_i64(params, 0);
5744            let count = configured_chrkoe_count(ctx);
5745            ctx.globals
5746                .syscom
5747                .original_config
5748                .chrkoe
5749                .resize_with(count, crate::runtime::globals::ConfigChrKoeState::default);
5750            if index >= 0 && (index as usize) < count {
5751                let default = config_default_chrkoe(ctx, index as usize);
5752                if let Some(item) = ctx
5753                    .globals
5754                    .syscom
5755                    .original_config
5756                    .chrkoe
5757                    .get_mut(index as usize)
5758                {
5759                    item.onoff = default.onoff;
5760                }
5761            }
5762            apply_audio_config(ctx);
5763        }
5764        GET_CHARAKOE_ONOFF => {
5765            let index = p_i64(params, 0);
5766            let value = if index >= 0 {
5767                ctx.globals
5768                    .syscom
5769                    .original_config
5770                    .chrkoe
5771                    .get(index as usize)
5772                    .map(|item| item.onoff)
5773                    .unwrap_or(false)
5774            } else {
5775                false
5776            };
5777            ctx.push(Value::Int(if value { 1 } else { 0 }));
5778            return Ok(true);
5779        }
5780        SET_CHARAKOE_VOLUME => {
5781            let index = p_i64(params, 0);
5782            let count = configured_chrkoe_count(ctx);
5783            ctx.globals
5784                .syscom
5785                .original_config
5786                .chrkoe
5787                .resize_with(count, crate::runtime::globals::ConfigChrKoeState::default);
5788            if index >= 0
5789                && let Some(item) = ctx
5790                    .globals
5791                    .syscom
5792                    .original_config
5793                    .chrkoe
5794                    .get_mut(index as usize)
5795            {
5796                item.volume = p_i64(params, 1).clamp(0, 255);
5797            }
5798            apply_audio_config(ctx);
5799        }
5800        SET_CHARAKOE_VOLUME_DEFAULT => {
5801            let index = p_i64(params, 0);
5802            let count = configured_chrkoe_count(ctx);
5803            ctx.globals
5804                .syscom
5805                .original_config
5806                .chrkoe
5807                .resize_with(count, crate::runtime::globals::ConfigChrKoeState::default);
5808            if index >= 0 && (index as usize) < count {
5809                let default = config_default_chrkoe(ctx, index as usize);
5810                if let Some(item) = ctx
5811                    .globals
5812                    .syscom
5813                    .original_config
5814                    .chrkoe
5815                    .get_mut(index as usize)
5816                {
5817                    item.volume = default.volume;
5818                }
5819            }
5820            apply_audio_config(ctx);
5821        }
5822        GET_CHARAKOE_VOLUME => {
5823            let index = p_i64(params, 0);
5824            let value = if index >= 0 {
5825                ctx.globals
5826                    .syscom
5827                    .original_config
5828                    .chrkoe
5829                    .get(index as usize)
5830                    .map(|item| item.volume)
5831                    .unwrap_or(0)
5832            } else {
5833                0
5834            };
5835            ctx.push(Value::Int(value.clamp(0, 255)));
5836            return Ok(true);
5837        }
5838        SET_JITAN_NORMAL_ONOFF => cfg_set_int(
5839            &mut ctx.globals.syscom,
5840            GET_JITAN_NORMAL_ONOFF,
5841            if p_bool(params, 0) { 1 } else { 0 },
5842        ),
5843        SET_JITAN_NORMAL_ONOFF_DEFAULT => {
5844            cfg_set_int(&mut ctx.globals.syscom, GET_JITAN_NORMAL_ONOFF, 0)
5845        }
5846        GET_JITAN_NORMAL_ONOFF => {
5847            let v = cfg_get_int(&ctx.globals.syscom, GET_JITAN_NORMAL_ONOFF, 0);
5848            ctx.push(Value::Int(v));
5849            return Ok(true);
5850        }
5851        SET_JITAN_AUTO_MODE_ONOFF => cfg_set_int(
5852            &mut ctx.globals.syscom,
5853            GET_JITAN_AUTO_MODE_ONOFF,
5854            if p_bool(params, 0) { 1 } else { 0 },
5855        ),
5856        SET_JITAN_AUTO_MODE_ONOFF_DEFAULT => {
5857            cfg_set_int(&mut ctx.globals.syscom, GET_JITAN_AUTO_MODE_ONOFF, 0)
5858        }
5859        GET_JITAN_AUTO_MODE_ONOFF => {
5860            let v = cfg_get_int(&ctx.globals.syscom, GET_JITAN_AUTO_MODE_ONOFF, 0);
5861            ctx.push(Value::Int(v));
5862            return Ok(true);
5863        }
5864        SET_JITAN_KOE_REPLAY_ONOFF => cfg_set_int(
5865            &mut ctx.globals.syscom,
5866            GET_JITAN_KOE_REPLAY_ONOFF,
5867            if p_bool(params, 0) { 1 } else { 0 },
5868        ),
5869        SET_JITAN_KOE_REPLAY_ONOFF_DEFAULT => {
5870            cfg_set_int(&mut ctx.globals.syscom, GET_JITAN_KOE_REPLAY_ONOFF, 0)
5871        }
5872        GET_JITAN_KOE_REPLAY_ONOFF => {
5873            let v = cfg_get_int(&ctx.globals.syscom, GET_JITAN_KOE_REPLAY_ONOFF, 0);
5874            ctx.push(Value::Int(v));
5875            return Ok(true);
5876        }
5877        SET_JITAN_SPEED => cfg_set_int(
5878            &mut ctx.globals.syscom,
5879            GET_JITAN_SPEED,
5880            p_i64(params, 0).clamp(100, 300),
5881        ),
5882        SET_JITAN_SPEED_DEFAULT => cfg_set_int(&mut ctx.globals.syscom, GET_JITAN_SPEED, 100),
5883        GET_JITAN_SPEED => {
5884            let v = cfg_get_int(&ctx.globals.syscom, GET_JITAN_SPEED, 100);
5885            ctx.push(Value::Int(v));
5886            return Ok(true);
5887        }
5888        SET_MESSAGE_SPEED => cfg_set_int(
5889            &mut ctx.globals.syscom,
5890            GET_MESSAGE_SPEED,
5891            p_i64(params, 0).clamp(0, 100),
5892        ),
5893        SET_MESSAGE_SPEED_DEFAULT => {
5894            let value = gameexe_i64_or(ctx, "CONFIG.MESSAGE_SPEED", 20);
5895            cfg_set_int(&mut ctx.globals.syscom, GET_MESSAGE_SPEED, value);
5896        }
5897        GET_MESSAGE_SPEED => {
5898            let v = cfg_get_int(&ctx.globals.syscom, GET_MESSAGE_SPEED, 20);
5899            ctx.push(Value::Int(v));
5900            return Ok(true);
5901        }
5902        SET_MESSAGE_NOWAIT => {
5903            let v = p_bool(params, 0);
5904            ctx.globals.script.msg_nowait = v;
5905            cfg_set_int(
5906                &mut ctx.globals.syscom,
5907                GET_MESSAGE_NOWAIT,
5908                if v { 1 } else { 0 },
5909            );
5910        }
5911        SET_MESSAGE_NOWAIT_DEFAULT => {
5912            let value = gameexe_bool_or(ctx, "CONFIG.MESSAGE_SPEED_NOWAIT.ONOFF", false);
5913            ctx.globals.script.msg_nowait = value;
5914            cfg_set_int(
5915                &mut ctx.globals.syscom,
5916                GET_MESSAGE_NOWAIT,
5917                if value { 1 } else { 0 },
5918            );
5919        }
5920        GET_MESSAGE_NOWAIT => {
5921            let v = if ctx.globals.script.msg_nowait {
5922                1
5923            } else {
5924                cfg_get_int(&ctx.globals.syscom, GET_MESSAGE_NOWAIT, 0)
5925            };
5926            ctx.push(Value::Int(v));
5927            return Ok(true);
5928        }
5929        SET_AUTO_MODE_MOJI_WAIT => {
5930            let v = p_i64(params, 0).clamp(0, 500);
5931            ctx.globals.script.auto_mode_moji_wait = v;
5932            cfg_set_int(&mut ctx.globals.syscom, GET_AUTO_MODE_MOJI_WAIT, v);
5933        }
5934        SET_AUTO_MODE_MOJI_WAIT_DEFAULT => {
5935            let value = 70;
5936            ctx.globals.script.auto_mode_moji_wait = value;
5937            cfg_set_int(&mut ctx.globals.syscom, GET_AUTO_MODE_MOJI_WAIT, value);
5938        }
5939        GET_AUTO_MODE_MOJI_WAIT => {
5940            let v = ctx.globals.script.auto_mode_moji_wait;
5941            ctx.push(Value::Int(v));
5942            return Ok(true);
5943        }
5944        SET_AUTO_MODE_MIN_WAIT => {
5945            let v = p_i64(params, 0).clamp(0, 5000);
5946            ctx.globals.script.auto_mode_min_wait = v;
5947            cfg_set_int(&mut ctx.globals.syscom, GET_AUTO_MODE_MIN_WAIT, v);
5948        }
5949        SET_AUTO_MODE_MIN_WAIT_DEFAULT => {
5950            let value = 300;
5951            ctx.globals.script.auto_mode_min_wait = value;
5952            cfg_set_int(&mut ctx.globals.syscom, GET_AUTO_MODE_MIN_WAIT, value);
5953        }
5954        GET_AUTO_MODE_MIN_WAIT => {
5955            let v = ctx.globals.script.auto_mode_min_wait;
5956            ctx.push(Value::Int(v));
5957            return Ok(true);
5958        }
5959        SET_MOUSE_CURSOR_HIDE_ONOFF => cfg_set_int(
5960            &mut ctx.globals.syscom,
5961            GET_MOUSE_CURSOR_HIDE_ONOFF,
5962            if p_bool(params, 0) { 1 } else { 0 },
5963        ),
5964        SET_MOUSE_CURSOR_HIDE_ONOFF_DEFAULT => {
5965            let v = config_mouse_cursor_hide_onoff_default(ctx);
5966            cfg_set_int(&mut ctx.globals.syscom, GET_MOUSE_CURSOR_HIDE_ONOFF, v)
5967        }
5968        GET_MOUSE_CURSOR_HIDE_ONOFF => {
5969            let v = cfg_get_int(
5970                &ctx.globals.syscom,
5971                GET_MOUSE_CURSOR_HIDE_ONOFF,
5972                config_mouse_cursor_hide_onoff_default(ctx),
5973            );
5974            ctx.push(Value::Int(v));
5975            return Ok(true);
5976        }
5977        SET_MOUSE_CURSOR_HIDE_TIME => cfg_set_int(
5978            &mut ctx.globals.syscom,
5979            GET_MOUSE_CURSOR_HIDE_TIME,
5980            p_i64(params, 0),
5981        ),
5982        SET_MOUSE_CURSOR_HIDE_TIME_DEFAULT => {
5983            let v = config_mouse_cursor_hide_time_default(ctx);
5984            cfg_set_int(&mut ctx.globals.syscom, GET_MOUSE_CURSOR_HIDE_TIME, v)
5985        }
5986        GET_MOUSE_CURSOR_HIDE_TIME => {
5987            let v = cfg_get_int(
5988                &ctx.globals.syscom,
5989                GET_MOUSE_CURSOR_HIDE_TIME,
5990                config_mouse_cursor_hide_time_default(ctx),
5991            );
5992            ctx.push(Value::Int(v));
5993            return Ok(true);
5994        }
5995        SET_FILTER_COLOR_R => cfg_set_int(
5996            &mut ctx.globals.syscom,
5997            GET_FILTER_COLOR_R,
5998            p_i64(params, 0),
5999        ),
6000        SET_FILTER_COLOR_G => cfg_set_int(
6001            &mut ctx.globals.syscom,
6002            GET_FILTER_COLOR_G,
6003            p_i64(params, 0),
6004        ),
6005        SET_FILTER_COLOR_B => cfg_set_int(
6006            &mut ctx.globals.syscom,
6007            GET_FILTER_COLOR_B,
6008            p_i64(params, 0),
6009        ),
6010        SET_FILTER_COLOR_A => cfg_set_int(
6011            &mut ctx.globals.syscom,
6012            GET_FILTER_COLOR_A,
6013            p_i64(params, 0),
6014        ),
6015        SET_FILTER_COLOR_R_DEFAULT => {
6016            let (r, _, _, _) = config_filter_color_default(ctx);
6017            cfg_set_int(&mut ctx.globals.syscom, GET_FILTER_COLOR_R, r)
6018        }
6019        SET_FILTER_COLOR_G_DEFAULT => {
6020            let (_, g, _, _) = config_filter_color_default(ctx);
6021            cfg_set_int(&mut ctx.globals.syscom, GET_FILTER_COLOR_G, g)
6022        }
6023        SET_FILTER_COLOR_B_DEFAULT => {
6024            let (_, _, b, _) = config_filter_color_default(ctx);
6025            cfg_set_int(&mut ctx.globals.syscom, GET_FILTER_COLOR_B, b)
6026        }
6027        SET_FILTER_COLOR_A_DEFAULT => {
6028            let (_, _, _, a) = config_filter_color_default(ctx);
6029            cfg_set_int(&mut ctx.globals.syscom, GET_FILTER_COLOR_A, a)
6030        }
6031        GET_FILTER_COLOR_R | GET_FILTER_COLOR_G | GET_FILTER_COLOR_B | GET_FILTER_COLOR_A => {
6032            let (r, g, b, a) = config_filter_color_default(ctx);
6033            let default = match op {
6034                GET_FILTER_COLOR_R => r,
6035                GET_FILTER_COLOR_G => g,
6036                GET_FILTER_COLOR_B => b,
6037                GET_FILTER_COLOR_A => a,
6038                _ => 0,
6039            };
6040            let v = cfg_get_int(&ctx.globals.syscom, op, default);
6041            ctx.push(Value::Int(v));
6042            return Ok(true);
6043        }
6044        SET_OBJECT_DISP_ONOFF => {
6045            let index = p_i64(params, 0);
6046            if (0..4).contains(&index) {
6047                ctx.globals.syscom.original_config.object_disp_flag[index as usize] =
6048                    p_bool(params, 1);
6049            }
6050        }
6051        SET_OBJECT_DISP_ONOFF_DEFAULT => {
6052            let index = p_i64(params, 0);
6053            if (0..4).contains(&index) {
6054                // The original command reads the GLOBAL_EXTRA_SWITCH default here.
6055                let value = config_default_indexed_bool(
6056                    ctx,
6057                    "CONFIG.GLOBAL_EXTRA_SWITCH",
6058                    index as usize,
6059                    true,
6060                );
6061                ctx.globals.syscom.original_config.object_disp_flag[index as usize] = value;
6062            }
6063        }
6064        GET_OBJECT_DISP_ONOFF => {
6065            let index = p_i64(params, 0);
6066            let value = if (0..4).contains(&index) {
6067                ctx.globals.syscom.original_config.object_disp_flag[index as usize]
6068            } else {
6069                false
6070            };
6071            ctx.push(Value::Int(if value { 1 } else { 0 }));
6072            return Ok(true);
6073        }
6074        SET_GLOBAL_EXTRA_SWITCH_ONOFF => {
6075            let index = p_i64(params, 0);
6076            if (0..4).contains(&index) {
6077                ctx.globals.syscom.original_config.global_extra_switch_flag[index as usize] =
6078                    p_bool(params, 1);
6079            }
6080        }
6081        SET_GLOBAL_EXTRA_SWITCH_ONOFF_DEFAULT => {
6082            let index = p_i64(params, 0);
6083            if (0..4).contains(&index) {
6084                let value = config_default_indexed_bool(
6085                    ctx,
6086                    "CONFIG.GLOBAL_EXTRA_SWITCH",
6087                    index as usize,
6088                    true,
6089                );
6090                ctx.globals.syscom.original_config.global_extra_switch_flag[index as usize] = value;
6091            }
6092        }
6093        GET_GLOBAL_EXTRA_SWITCH_ONOFF => {
6094            let index = p_i64(params, 0);
6095            let value = if (0..4).contains(&index) {
6096                ctx.globals.syscom.original_config.global_extra_switch_flag[index as usize]
6097            } else {
6098                false
6099            };
6100            ctx.push(Value::Int(if value { 1 } else { 0 }));
6101            return Ok(true);
6102        }
6103        SET_GLOBAL_EXTRA_MODE_VALUE => {
6104            let index = p_i64(params, 0);
6105            if (0..4).contains(&index) {
6106                ctx.globals.syscom.original_config.global_extra_mode_flag[index as usize] =
6107                    p_i64(params, 1);
6108            }
6109        }
6110        SET_GLOBAL_EXTRA_MODE_VALUE_DEFAULT => {
6111            let index = p_i64(params, 0);
6112            if (0..4).contains(&index) {
6113                let value = config_default_indexed_mode(ctx, index as usize);
6114                ctx.globals.syscom.original_config.global_extra_mode_flag[index as usize] = value;
6115            }
6116        }
6117        GET_GLOBAL_EXTRA_MODE_VALUE => {
6118            let index = p_i64(params, 0);
6119            let value = if (0..4).contains(&index) {
6120                ctx.globals.syscom.original_config.global_extra_mode_flag[index as usize]
6121            } else {
6122                0
6123            };
6124            ctx.push(Value::Int(value));
6125            return Ok(true);
6126        }
6127        SET_SAVELOAD_ALERT_ONOFF => cfg_set_int(
6128            &mut ctx.globals.syscom,
6129            GET_SAVELOAD_ALERT_ONOFF,
6130            if p_bool(params, 0) { 1 } else { 0 },
6131        ),
6132        SET_SAVELOAD_ALERT_ONOFF_DEFAULT => {
6133            cfg_set_int(&mut ctx.globals.syscom, GET_SAVELOAD_ALERT_ONOFF, 1)
6134        }
6135        GET_SAVELOAD_ALERT_ONOFF => {
6136            let v = cfg_get_int(&ctx.globals.syscom, GET_SAVELOAD_ALERT_ONOFF, 1);
6137            ctx.push(Value::Int(v));
6138            return Ok(true);
6139        }
6140        SET_SLEEP_ONOFF => cfg_set_int(
6141            &mut ctx.globals.syscom,
6142            GET_SLEEP_ONOFF,
6143            if p_bool(params, 0) { 1 } else { 0 },
6144        ),
6145        SET_SLEEP_ONOFF_DEFAULT => {
6146            let value = gameexe_bool_or(ctx, "CONFIG.SLEEP.ONOFF", false);
6147            cfg_set_int(
6148                &mut ctx.globals.syscom,
6149                GET_SLEEP_ONOFF,
6150                if value { 1 } else { 0 },
6151            );
6152        }
6153        GET_SLEEP_ONOFF => {
6154            let v = cfg_get_int(&ctx.globals.syscom, GET_SLEEP_ONOFF, 0);
6155            ctx.push(Value::Int(v));
6156            return Ok(true);
6157        }
6158        SET_NO_WIPE_ANIME_ONOFF => cfg_set_int(
6159            &mut ctx.globals.syscom,
6160            GET_NO_WIPE_ANIME_ONOFF,
6161            if p_bool(params, 0) { 1 } else { 0 },
6162        ),
6163        SET_NO_WIPE_ANIME_ONOFF_DEFAULT => {
6164            let value = gameexe_bool_or(ctx, "CONFIG.NO_WIPE_ANIME.ONOFF", false);
6165            cfg_set_int(
6166                &mut ctx.globals.syscom,
6167                GET_NO_WIPE_ANIME_ONOFF,
6168                if value { 1 } else { 0 },
6169            );
6170        }
6171        GET_NO_WIPE_ANIME_ONOFF => {
6172            let v = cfg_get_int(&ctx.globals.syscom, GET_NO_WIPE_ANIME_ONOFF, 0);
6173            ctx.push(Value::Int(v));
6174            return Ok(true);
6175        }
6176        SET_SKIP_WIPE_ANIME_ONOFF => cfg_set_int(
6177            &mut ctx.globals.syscom,
6178            GET_SKIP_WIPE_ANIME_ONOFF,
6179            if p_bool(params, 0) { 1 } else { 0 },
6180        ),
6181        SET_SKIP_WIPE_ANIME_ONOFF_DEFAULT => {
6182            let value = gameexe_bool_or(ctx, "CONFIG.SKIP_WIPE_ANIME.ONOFF", true);
6183            cfg_set_int(
6184                &mut ctx.globals.syscom,
6185                GET_SKIP_WIPE_ANIME_ONOFF,
6186                if value { 1 } else { 0 },
6187            );
6188        }
6189        GET_SKIP_WIPE_ANIME_ONOFF => {
6190            let v = cfg_get_int(&ctx.globals.syscom, GET_SKIP_WIPE_ANIME_ONOFF, 1);
6191            ctx.push(Value::Int(v));
6192            return Ok(true);
6193        }
6194        SET_NO_MWND_ANIME_ONOFF => cfg_set_int(
6195            &mut ctx.globals.syscom,
6196            GET_NO_MWND_ANIME_ONOFF,
6197            if p_bool(params, 0) { 1 } else { 0 },
6198        ),
6199        SET_NO_MWND_ANIME_ONOFF_DEFAULT => {
6200            let value = gameexe_bool_or(ctx, "CONFIG.NO_MWND_ANIME.ONOFF", false);
6201            cfg_set_int(
6202                &mut ctx.globals.syscom,
6203                GET_NO_MWND_ANIME_ONOFF,
6204                if value { 1 } else { 0 },
6205            );
6206        }
6207        GET_NO_MWND_ANIME_ONOFF => {
6208            let v = cfg_get_int(&ctx.globals.syscom, GET_NO_MWND_ANIME_ONOFF, 0);
6209            ctx.push(Value::Int(v));
6210            return Ok(true);
6211        }
6212        SET_WHEEL_NEXT_MESSAGE_ONOFF => cfg_set_int(
6213            &mut ctx.globals.syscom,
6214            GET_WHEEL_NEXT_MESSAGE_ONOFF,
6215            if p_bool(params, 0) { 1 } else { 0 },
6216        ),
6217        SET_WHEEL_NEXT_MESSAGE_ONOFF_DEFAULT => {
6218            let value = gameexe_bool_or(ctx, "CONFIG.WHEEL_NEXT_MESSAGE.ONOFF", true);
6219            cfg_set_int(
6220                &mut ctx.globals.syscom,
6221                GET_WHEEL_NEXT_MESSAGE_ONOFF,
6222                if value { 1 } else { 0 },
6223            );
6224        }
6225        GET_WHEEL_NEXT_MESSAGE_ONOFF => {
6226            let v = cfg_get_int(&ctx.globals.syscom, GET_WHEEL_NEXT_MESSAGE_ONOFF, 1);
6227            ctx.push(Value::Int(v));
6228            return Ok(true);
6229        }
6230        SET_KOE_DONT_STOP_ONOFF => cfg_set_int(
6231            &mut ctx.globals.syscom,
6232            GET_KOE_DONT_STOP_ONOFF,
6233            if p_bool(params, 0) { 1 } else { 0 },
6234        ),
6235        SET_KOE_DONT_STOP_ONOFF_DEFAULT => {
6236            let value = gameexe_bool_or(ctx, "CONFIG.KOE_DONT_STOP.ONOFF", false);
6237            cfg_set_int(
6238                &mut ctx.globals.syscom,
6239                GET_KOE_DONT_STOP_ONOFF,
6240                if value { 1 } else { 0 },
6241            );
6242        }
6243        GET_KOE_DONT_STOP_ONOFF => {
6244            let v = cfg_get_int(&ctx.globals.syscom, GET_KOE_DONT_STOP_ONOFF, 0);
6245            ctx.push(Value::Int(v));
6246            return Ok(true);
6247        }
6248        SET_SKIP_UNREAD_MESSAGE_ONOFF => cfg_set_int(
6249            &mut ctx.globals.syscom,
6250            GET_SKIP_UNREAD_MESSAGE_ONOFF,
6251            if p_bool(params, 0) { 1 } else { 0 },
6252        ),
6253        SET_SKIP_UNREAD_MESSAGE_ONOFF_DEFAULT => {
6254            let value = gameexe_bool_or(ctx, "CONFIG.SKIP_UNREAD_MESSAGE.ONOFF", false);
6255            cfg_set_int(
6256                &mut ctx.globals.syscom,
6257                GET_SKIP_UNREAD_MESSAGE_ONOFF,
6258                if value { 1 } else { 0 },
6259            );
6260        }
6261        GET_SKIP_UNREAD_MESSAGE_ONOFF => {
6262            let v = cfg_get_int(&ctx.globals.syscom, GET_SKIP_UNREAD_MESSAGE_ONOFF, 0);
6263            ctx.push(Value::Int(v));
6264            return Ok(true);
6265        }
6266        SET_PLAY_SILENT_SOUND_ONOFF => cfg_set_int(
6267            &mut ctx.globals.syscom,
6268            GET_PLAY_SILENT_SOUND_ONOFF,
6269            if p_bool(params, 0) { 1 } else { 0 },
6270        ),
6271        SET_PLAY_SILENT_SOUND_ONOFF_DEFAULT => {
6272            cfg_set_int(&mut ctx.globals.syscom, GET_PLAY_SILENT_SOUND_ONOFF, 0)
6273        }
6274        GET_PLAY_SILENT_SOUND_ONOFF => {
6275            let v = cfg_get_int(&ctx.globals.syscom, GET_PLAY_SILENT_SOUND_ONOFF, 0);
6276            ctx.push(Value::Int(v));
6277            return Ok(true);
6278        }
6279        SET_FONT_NAME => {
6280            let v = params
6281                .first()
6282                .and_then(|v| v.as_str())
6283                .unwrap_or("")
6284                .to_string();
6285            ctx.globals.syscom.original_config.font_name = v.clone();
6286            cfg_set_str(&mut ctx.globals.syscom, GET_FONT_NAME, v);
6287        }
6288        SET_FONT_NAME_DEFAULT => {
6289            let value = config_default_font_name(ctx);
6290            ctx.globals.syscom.original_config.font_name = value.clone();
6291            cfg_set_str(&mut ctx.globals.syscom, GET_FONT_NAME, value);
6292        }
6293        GET_FONT_NAME => {
6294            let mut v = cfg_get_str(&ctx.globals.syscom, GET_FONT_NAME);
6295            if v.is_empty() {
6296                v = config_default_font_name(ctx);
6297            }
6298            ctx.push(Value::Str(v));
6299            return Ok(true);
6300        }
6301        IS_FONT_EXIST => {
6302            let name = params.first().and_then(|v| v.as_str()).unwrap_or("");
6303            let exists = font_exists(&ctx.project_dir, name);
6304            ctx.push(Value::Int(if exists { 1 } else { 0 }));
6305            return Ok(true);
6306        }
6307        SET_FONT_BOLD => {
6308            let value = p_bool(params, 0);
6309            ctx.globals.syscom.original_config.font_futoku = value;
6310            cfg_set_int(
6311                &mut ctx.globals.syscom,
6312                GET_FONT_BOLD,
6313                if value { 1 } else { 0 },
6314            );
6315        }
6316        SET_FONT_BOLD_DEFAULT => {
6317            let value = gameexe_bool_or(ctx, "CONFIG.FONT.FUTOKU", false);
6318            ctx.globals.syscom.original_config.font_futoku = value;
6319            cfg_set_int(
6320                &mut ctx.globals.syscom,
6321                GET_FONT_BOLD,
6322                if value { 1 } else { 0 },
6323            );
6324        }
6325        GET_FONT_BOLD => {
6326            let v = cfg_get_int(&ctx.globals.syscom, GET_FONT_BOLD, 0);
6327            ctx.push(Value::Int(v));
6328            return Ok(true);
6329        }
6330        SET_FONT_DECORATION => {
6331            let value = p_i64(params, 0).clamp(0, 3);
6332            ctx.globals.syscom.original_config.font_shadow = value;
6333            cfg_set_int(&mut ctx.globals.syscom, GET_FONT_DECORATION, value);
6334        }
6335        SET_FONT_DECORATION_DEFAULT => {
6336            let value = gameexe_i64_or(ctx, "CONFIG.FONT.SHADOW", 2).clamp(0, 3);
6337            ctx.globals.syscom.original_config.font_shadow = value;
6338            cfg_set_int(&mut ctx.globals.syscom, GET_FONT_DECORATION, value);
6339        }
6340        GET_FONT_DECORATION => {
6341            let v = cfg_get_int(&ctx.globals.syscom, GET_FONT_DECORATION, 2);
6342            ctx.push(Value::Int(v));
6343            return Ok(true);
6344        }
6345        CREATE_CAPTURE_BUFFER => {
6346            let w = p_i64(params, 0).max(1) as u32;
6347            let h = p_i64(params, 1).max(1) as u32;
6348            ctx.globals.syscom.capture_size = Some((w, h));
6349            ctx.globals.syscom.capture_buffer = None;
6350        }
6351        DESTROY_CAPTURE_BUFFER => {
6352            ctx.globals.syscom.capture_buffer = None;
6353            ctx.globals.syscom.capture_size = None;
6354        }
6355        CAPTURE_TO_CAPTURE_BUFFER => {
6356            let mut img = ctx.capture_frame_rgba()?;
6357            if let Some((w, h)) = ctx.globals.syscom.capture_size {
6358                img = resize_rgba(&img, w, h);
6359            }
6360            ctx.globals.syscom.capture_buffer = Some(img);
6361        }
6362        SAVE_CAPTURE_BUFFER_TO_FILE => {
6363            let file_name = params.first().and_then(|v| v.as_str()).unwrap_or("");
6364            let extension = params.get(1).and_then(|v| v.as_str()).unwrap_or("");
6365            let mut name = file_name.to_string();
6366            if !extension.is_empty()
6367                && !name
6368                    .to_ascii_lowercase()
6369                    .ends_with(&format!(".{}", extension.to_ascii_lowercase()))
6370            {
6371                name.push('.');
6372                name.push_str(extension);
6373            }
6374            let path = join_game_path(&ctx.project_dir, &name);
6375            if ctx.globals.syscom.capture_buffer.is_none() {
6376                let mut img = ctx.capture_frame_rgba()?;
6377                if let Some((w, h)) = ctx.globals.syscom.capture_size {
6378                    img = resize_rgba(&img, w, h);
6379                }
6380                ctx.globals.syscom.capture_buffer = Some(img);
6381            }
6382            if let Some(img) = ctx.globals.syscom.capture_buffer.as_ref() {
6383                write_rgba_png(&path, img);
6384                save_capture_flags_sidecar(ctx, &path, params);
6385                ctx.push(Value::Int(1));
6386            } else {
6387                ctx.push(Value::Int(0));
6388            }
6389            return Ok(true);
6390        }
6391        LOAD_FLAG_FROM_CAPTURE_FILE => {
6392            let file_name = params.first().and_then(|v| v.as_str()).unwrap_or("");
6393            let extension = params.get(1).and_then(|v| v.as_str()).unwrap_or("");
6394            let mut name = file_name.to_string();
6395            if !extension.is_empty()
6396                && !name
6397                    .to_ascii_lowercase()
6398                    .ends_with(&format!(".{}", extension.to_ascii_lowercase()))
6399            {
6400                name.push('.');
6401                name.push_str(extension);
6402            }
6403            let path = join_game_path(&ctx.project_dir, &name);
6404            let ok = load_capture_flags_sidecar(ctx, &path, params);
6405            ctx.push(Value::Int(if ok { 1 } else { 0 }));
6406            return Ok(true);
6407        }
6408        CAPTURE_AND_SAVE_BUFFER_TO_PNG => {
6409            let file_name = params.get(2).and_then(|v| v.as_str()).unwrap_or("");
6410            let path = join_game_path(&ctx.project_dir, file_name);
6411            let mut img = ctx.capture_frame_rgba()?;
6412            if let Some((w, h)) = ctx.globals.syscom.capture_size {
6413                img = resize_rgba(&img, w, h);
6414            }
6415            write_rgba_png(&path, &img);
6416        }
6417        OPEN_TWEET_DIALOG => {
6418            open_tweet_dialog(ctx)?;
6419        }
6420        SET_RETURN_SCENE_ONCE => {
6421            let name = params
6422                .first()
6423                .and_then(|v| v.as_str())
6424                .unwrap_or("")
6425                .to_string();
6426            let z_no = p_i64(params, 1);
6427            ctx.globals.syscom.return_scene_once = Some((name, z_no));
6428        }
6429        GET_SYSTEM_EXTRA_INT_VALUE => {
6430            let v = ctx.globals.syscom.system_extra_int_value;
6431            ctx.push(Value::Int(v));
6432            return Ok(true);
6433        }
6434        GET_SYSTEM_EXTRA_STR_VALUE => {
6435            let v = ctx.globals.syscom.system_extra_str_value.clone();
6436            ctx.push(Value::Str(v));
6437            return Ok(true);
6438        }
6439        JOYPAD_MODE_ACTIVE => {
6440            // Newer engine: SCRIPT.98/99 may override Gameexe joypad permission,
6441            // while this query still reports joypad mode only when the active
6442            // input family is actually the joypad.
6443            let joypad_mode_override = if ctx.excall_state.ex_call_flag {
6444                ctx.excall_state.joypad_mode_override
6445            } else {
6446                ctx.globals.script.joypad_mode_override
6447            };
6448            let allow = match joypad_mode_override {
6449                0 => false,
6450                1 => true,
6451                _ => gameexe_bool_or(ctx, "JOYPAD.ALLOW_JOYPAD_MODE", true),
6452            };
6453            ctx.push(Value::Int(if allow && ctx.input.joypad_mode_active() {
6454                1
6455            } else {
6456                0
6457            }));
6458            return Ok(true);
6459        }
6460        OPEN_JOYPAD_CONFIG => {
6461            // Recovered newer-engine behavior: this opens the engine's native
6462            // joypad configuration window.  The cross-platform port does not
6463            // implement that native window; unlike the old placeholder, this is
6464            // not an input-resync opcode.
6465            log::error!("SYSCOM.334 native Joypad configuration window is not implemented");
6466        }
6467        _ => {
6468            return Ok(false);
6469        }
6470    }
6471
6472    ctx.push(Value::Int(0));
6473    Ok(true)
6474}
6475
6476#[cfg(test)]
6477mod tweet_compat_tests {
6478    use super::*;
6479
6480    #[test]
6481    fn tweet_overlap_is_stretched_and_source_alpha_blended() {
6482        let mut base = RgbaImage {
6483            width: 2,
6484            height: 2,
6485            center_x: 0,
6486            center_y: 0,
6487            rgba: [0, 0, 0, 255].repeat(4),
6488        };
6489        let overlay = RgbaImage {
6490            width: 1,
6491            height: 1,
6492            center_x: 0,
6493            center_y: 0,
6494            rgba: vec![255, 64, 0, 128],
6495        };
6496
6497        blend_tweet_overlay_fullscreen(&mut base, &overlay);
6498        for pixel in base.rgba.as_chunks::<4>().0.iter() {
6499            assert_eq!(pixel, &[128, 32, 0, 255]);
6500        }
6501    }
6502}
6503
6504#[cfg(test)]
6505mod global_save_init_tests {
6506    use super::*;
6507    use crate::runtime::forms::codes;
6508    use std::fs;
6509    use std::sync::atomic::{AtomicU64, Ordering};
6510
6511    #[test]
6512    fn save_summary_uses_retained_page_only_when_short_summary_is_empty() {
6513        let mut slot = SaveSlotState::default();
6514        for op in [GET_SAVE_MESSAGE, GET_QUICK_SAVE_MESSAGE] {
6515            assert_eq!(slot_str(&slot, op), "");
6516        }
6517        slot.full_message = "retained page".into();
6518        for op in [GET_SAVE_MESSAGE, GET_QUICK_SAVE_MESSAGE] {
6519            assert_eq!(slot_str(&slot, op), "retained page");
6520        }
6521        slot.message = "current block".into();
6522        for op in [GET_SAVE_MESSAGE, GET_QUICK_SAVE_MESSAGE] {
6523            assert_eq!(slot_str(&slot, op), "current block");
6524        }
6525        assert_eq!(slot_str(&slot, GET_SAVE_FULL_MESSAGE), "retained page");
6526    }
6527
6528    fn test_project_dir() -> std::path::PathBuf {
6529        static NEXT: AtomicU64 = AtomicU64::new(0);
6530        std::env::temp_dir().join(format!(
6531            "siglus-global-save-test-{}-{}",
6532            std::process::id(),
6533            NEXT.fetch_add(1, Ordering::Relaxed)
6534        ))
6535    }
6536
6537    #[test]
6538    fn config_save_versions_preserve_settings_after_optional_fields() {
6539        for (minor_version, planetarian, sound_count) in [
6540            (0, false, 4),
6541            (0, false, 5),
6542            (1, false, 32),
6543            (2, false, 32),
6544            (2, true, 32),
6545            (3, false, 32),
6546            (4, false, 32),
6547        ] {
6548            let project_dir = test_project_dir();
6549            let mut stream = original_save::OriginalStreamWriter::new();
6550            stream.push_i32(0); // screen mode
6551            if minor_version >= 3 || planetarian {
6552                stream.push_i32(1); // window mode
6553            }
6554            stream.push_i32(80);
6555            stream.push_i32(90);
6556            if minor_version >= 3 || planetarian {
6557                stream.push_i32(1280);
6558                stream.push_i32(720);
6559            }
6560            stream.push_bool(false); // fullscreen resolution change
6561            for value in [1, 0, 28, 27, 1920, 1080, 1, 100, 100] {
6562                stream.push_i32(value);
6563            }
6564            stream.push_bool(true); // fullscreen scale sync
6565            stream.push_i32(0);
6566            stream.push_i32(0);
6567            stream.push_i32(180); // master volume
6568            for _ in 0..sound_count {
6569                stream.push_i32(200);
6570            }
6571            for _ in 0..=sound_count {
6572                stream.push_bool(true);
6573            }
6574            stream.push_i32(175); // BGM fade
6575            stream.push_bool(true);
6576            stream.push_u32(0xe61d0818); // filter
6577            stream.push_bool(false); // proportional font
6578            stream.push_str("Test Font");
6579            stream.push_i32(2); // font shadow
6580            stream.push_bool(false); // bold
6581            stream.push_i32(20); // message speed
6582            stream.push_bool(false); // no wait
6583            stream.push_bool(false); // auto mode
6584            stream.push_i32(70);
6585            stream.push_i32(300);
6586            if minor_version >= 2 {
6587                stream.push_bool(true); // cursor auto-hide
6588                stream.push_i32(2468);
6589            }
6590            stream.push_bool(true); // jitan normal
6591            stream.push_bool(false); // jitan auto
6592            stream.push_bool(true); // jitan backlog
6593            stream.push_i32(125); // jitan speed
6594            stream.push_i32(1); // voice mode
6595            stream.push_i32(1); // character voices
6596            stream.push_bool(false);
6597            stream.push_padding(3);
6598            stream.push_i32(170);
6599            stream.push_bool(true); // character text colors
6600            for _ in 0..2 {
6601                // object and extra-switch flags
6602                stream.push_i32(4);
6603                for flag in [true, false, true, false] {
6604                    stream.push_bool(flag);
6605                }
6606            }
6607            stream.push_i32(4); // extra modes
6608            for value in [0, 1, 2, 3] {
6609                stream.push_i32(value);
6610            }
6611            for flag in [false, false, false, false, true, false, false] {
6612                stream.push_bool(flag);
6613            }
6614            if minor_version == 0 {
6615                if sound_count == 5 {
6616                    stream.push_bool(true);
6617                }
6618                stream.push_i32(0x12345678);
6619            }
6620            stream.push_bool(true); // save/load alert
6621            stream.push_bool(false); // save/load double click
6622            for path in ["screenshots", "editor"] {
6623                stream.push_str(path);
6624            }
6625            if minor_version >= 1 {
6626                stream.push_str("voices");
6627                stream.push_str("voice-tool");
6628            }
6629            if minor_version >= 4 {
6630                stream.push_bool(true); // swap controller A/B
6631                stream.push_bool(false); // keep controller X/Y
6632            }
6633            let payload = stream.into_inner();
6634            let packed = original_save::pack_buffer(&payload);
6635            let mut data = original_save::OriginalConfigSaveHeader {
6636                major_version: 1,
6637                minor_version,
6638                config_data_size: packed.len() as i32,
6639            }
6640            .to_bytes();
6641            data.extend_from_slice(&packed);
6642            fs::create_dir_all(project_dir.join("savedata")).unwrap();
6643            fs::write(project_dir.join("savedata/config.sav"), data).unwrap();
6644            let mut ctx = CommandContext::new(project_dir.clone());
6645            if planetarian {
6646                ctx.tables.gameexe = Some(crate::formats::gameexe::GameexeConfig::from_text(
6647                    "#GAMEID=\"planetarian [HD Edition]\"\n",
6648                ));
6649            }
6650            let defaults = original_config_defaults(&ctx);
6651
6652            load_config_save(&mut ctx).unwrap();
6653            let cfg = &ctx.globals.syscom.original_config;
6654            assert_eq!(cfg.screen_size_scale, (80, 90));
6655            if minor_version >= 3 || planetarian {
6656                assert_eq!(cfg.screen_size_mode_window, 1);
6657                assert_eq!(cfg.screen_size_free, (1280, 720));
6658            }
6659            assert_eq!(cfg.all_sound_user_volume, 180);
6660            assert!(
6661                cfg.sound_user_volume[..sound_count]
6662                    .iter()
6663                    .all(|&value| value == 200)
6664            );
6665            if minor_version == 0 {
6666                assert_eq!(
6667                    &cfg.sound_user_volume[sound_count..],
6668                    &defaults.sound_user_volume[sound_count..]
6669                );
6670                assert_eq!(
6671                    &cfg.play_sound_check[sound_count..],
6672                    &defaults.play_sound_check[sound_count..]
6673                );
6674            }
6675            assert_eq!(cfg.font_name, "Test Font");
6676            assert_eq!(cfg.auto_mode_min_wait, 300);
6677            assert_eq!(
6678                cfg.mouse_cursor_hide_onoff,
6679                if minor_version < 2 {
6680                    defaults.mouse_cursor_hide_onoff
6681                } else {
6682                    true
6683                }
6684            );
6685            assert_eq!(
6686                cfg.mouse_cursor_hide_time,
6687                if minor_version < 2 {
6688                    defaults.mouse_cursor_hide_time
6689                } else {
6690                    2468
6691                }
6692            );
6693            assert!(cfg.jitan_normal_onoff && cfg.jitan_msgbk_onoff);
6694            assert!(!cfg.jitan_auto_mode_onoff);
6695            assert_eq!(cfg.jitan_speed, 125);
6696            assert_eq!(cfg.koe_mode, 1);
6697            assert!(!cfg.chrkoe[0].onoff);
6698            assert_eq!(cfg.chrkoe[0].volume, 170);
6699            assert_eq!(cfg.global_extra_mode_flag, [0, 1, 2, 3]);
6700            assert!(cfg.saveload_alert_flag);
6701            assert!(!cfg.saveload_dblclick_flag);
6702            assert_eq!(cfg.ss_path, "screenshots");
6703            assert_eq!(cfg.editor_path, "editor");
6704            assert_eq!(
6705                cfg.koe_path,
6706                if minor_version == 0 {
6707                    defaults.koe_path.as_str()
6708                } else {
6709                    "voices"
6710                }
6711            );
6712            assert_eq!(
6713                cfg.koe_tool_path,
6714                if minor_version == 0 {
6715                    defaults.koe_tool_path.as_str()
6716                } else {
6717                    "voice-tool"
6718                }
6719            );
6720            assert_eq!(cfg.joypad_swap_ab, minor_version >= 4);
6721            assert!(!cfg.joypad_swap_xy);
6722            let header = original_save::OriginalConfigSaveHeader {
6723                major_version: 1,
6724                minor_version,
6725                config_data_size: 0,
6726            };
6727            let game_id = gameexe_unquoted_owned(&ctx, "GAMEID");
6728            assert!(
6729                save_versions::read_config(
6730                    header,
6731                    &payload[..payload.len() - 1],
6732                    &mut defaults.clone(),
6733                    &game_id
6734                )
6735                .is_err()
6736            );
6737            let mut trailing = payload.clone();
6738            trailing.push(0);
6739            assert!(
6740                save_versions::read_config(header, &trailing, &mut defaults.clone(), &game_id)
6741                    .is_err()
6742            );
6743            let unknown = original_save::OriginalConfigSaveHeader {
6744                minor_version: 99,
6745                ..header
6746            };
6747            assert!(
6748                save_versions::read_config(unknown, &payload, &mut defaults.clone(), &game_id)
6749                    .unwrap_err()
6750                    .to_string()
6751                    .contains("unsupported config save version")
6752            );
6753            fs::remove_dir_all(project_dir).unwrap();
6754        }
6755    }
6756
6757    #[test]
6758    fn config_dialog_edits_update_script_state_and_survive_reopening() {
6759        let project_dir = test_project_dir();
6760        fs::create_dir_all(&project_dir).unwrap();
6761        let mut ctx = CommandContext::new(project_dir.clone());
6762        let gameexe = crate::formats::gameexe::GameexeConfig::from_text(
6763            "#CONFIG.VOLUME.BGM=165\n#CHRKOE.000=\"Hidden\",2,\"Alias\",1,235,(0)\n",
6764        );
6765        ctx.tables.gameexe = Some(gameexe.clone());
6766        load_global_save(&mut ctx).unwrap();
6767        let mut config = config_state_for_save(&ctx);
6768        config.sound_user_volume[0] = 87;
6769        config.play_sound_check[3] = false;
6770        config.message_speed = 31;
6771        config.auto_mode_moji_wait = 90;
6772        config.chrkoe[0].volume = 170;
6773        config.global_extra_switch_flag[0] = false;
6774        config.skip_unread_message_flag = true;
6775        config.joypad_swap_ab = true;
6776        config.joypad_swap_xy = true;
6777        apply_config_dialog_state(&mut ctx, config.clone());
6778        assert_eq!(cfg_get_int(&ctx.globals.syscom, GET_BGM_VOLUME, -1), 87);
6779        assert_eq!(ctx.globals.script.auto_mode_moji_wait, 90);
6780        assert_eq!(config_state_for_save(&ctx), config);
6781        write_config_save(&ctx);
6782        reveal_config_voice_name(&mut ctx, "Alias");
6783        write_global_save(&ctx);
6784
6785        let mut reopened = CommandContext::new(project_dir.clone());
6786        reopened.tables.gameexe = Some(gameexe);
6787        load_global_save(&mut reopened).unwrap();
6788        assert_eq!(config_state_for_save(&reopened), config);
6789        assert!(config_chrkoe_name_visible(&reopened, 0));
6790        fs::remove_dir_all(project_dir).unwrap();
6791    }
6792
6793    #[test]
6794    fn missing_global_save_keeps_zero_initialized_flags() {
6795        let project_dir = test_project_dir();
6796        fs::create_dir_all(&project_dir).expect("test project dir");
6797        let mut ctx = CommandContext::new(project_dir.clone());
6798
6799        load_global_save(&mut ctx).expect("missing global save is valid");
6800
6801        let g = &ctx.globals.int_lists[&(codes::ELM_GLOBAL_G as u32)];
6802        let z = &ctx.globals.int_lists[&(codes::ELM_GLOBAL_Z as u32)];
6803        let m = &ctx.globals.str_lists[&(codes::ELM_GLOBAL_M as u32)];
6804        assert!(!g.is_empty() && g.iter().all(|value| *value == 0));
6805        assert!(!z.is_empty() && z.iter().all(|value| *value == 0));
6806        assert!(!m.is_empty() && m.iter().all(String::is_empty));
6807
6808        let _ = fs::remove_dir_all(project_dir);
6809    }
6810
6811    #[test]
6812    fn corrupt_existing_global_save_is_an_error() {
6813        let project_dir = test_project_dir();
6814        let save_dir = project_dir.join("savedata");
6815        fs::create_dir_all(&save_dir).expect("test save dir");
6816        fs::write(save_dir.join("global.sav"), b"invalid").expect("corrupt global save");
6817        let mut ctx = CommandContext::new(project_dir.clone());
6818
6819        assert!(load_global_save(&mut ctx).is_err());
6820
6821        let _ = fs::remove_dir_all(project_dir);
6822    }
6823
6824    #[test]
6825    fn global_save_with_uninitialized_cg_table_preserves_following_fields() {
6826        let project_dir = test_project_dir();
6827        let mut stream = original_save::OriginalStreamWriter::new();
6828        stream.push_i64(61151);
6829        stream.push_fixed_i32_list(&[42], 10000);
6830        stream.push_fixed_i32_list(&[7], 10000);
6831        stream.push_fixed_str_list(&["global".to_string()], 10000);
6832        stream.push_fixed_str_list(&[], 702);
6833        stream.push_i32(0);
6834        stream.push_extend_i32_list(&[]);
6835        stream.push_fixed_i32_list(&[1, 0, 1], 20);
6836        stream.push_i32(2);
6837        stream.push_str("first");
6838        stream.push_bool(true);
6839        stream.push_str("second");
6840        stream.push_bool(false);
6841        let payload = stream.into_inner();
6842        original_save::write_global_save_file(&project_dir, &payload).unwrap();
6843
6844        let mut ctx = CommandContext::new(project_dir.clone());
6845        load_global_save(&mut ctx).expect("native empty extendable CG table");
6846        assert_eq!(ctx.globals.syscom.total_play_time, 61151);
6847        assert_eq!(ctx.globals.int_lists[&(codes::ELM_GLOBAL_G as u32)][0], 42);
6848        assert_eq!(ctx.globals.int_lists[&(codes::ELM_GLOBAL_Z as u32)][0], 7);
6849        assert_eq!(
6850            ctx.globals.str_lists[&(codes::ELM_GLOBAL_M as u32)][0],
6851            "global"
6852        );
6853        assert!(ctx.tables.cg_flags.is_empty());
6854        assert_eq!(ctx.globals.bgm_table_flags.len(), 20);
6855        assert_eq!(&ctx.globals.bgm_table_flags[..3], &[true, false, true]);
6856        assert_eq!(
6857            ctx.globals.syscom.chrkoe_look_flags.get("first"),
6858            Some(&true)
6859        );
6860        assert_eq!(
6861            ctx.globals.syscom.chrkoe_look_flags.get("second"),
6862            Some(&false)
6863        );
6864
6865        // The alternate CG layout must not hide a truncated later field.
6866        original_save::write_global_save_file(&project_dir, &payload[..payload.len() - 1]).unwrap();
6867        assert!(load_global_save(&mut ctx).is_err());
6868        fs::remove_dir_all(project_dir).unwrap();
6869    }
6870
6871    #[test]
6872    fn global_save_writer_uses_native_empty_cg_layout() {
6873        let project_dir = test_project_dir();
6874        fs::create_dir_all(&project_dir).unwrap();
6875        let mut ctx = CommandContext::new(project_dir.clone());
6876        ctx.tables.gameexe = Some(crate::formats::gameexe::GameexeConfig::from_text(
6877            "#CGTABLE_FILE=\"\"\n#CGTABLE_FLAG_CNT=1000\n",
6878        ));
6879        ctx.globals.bgm_table_flags = vec![true, false, true];
6880        write_global_save(&ctx);
6881        let (_, payload) = original_save::read_global_save_file(&project_dir).unwrap();
6882        let mut rd = original_save::OriginalStreamReader::new(&payload);
6883        rd.i64().unwrap();
6884        rd.fixed_i32_list().unwrap();
6885        rd.fixed_i32_list().unwrap();
6886        rd.fixed_str_list().unwrap();
6887        rd.fixed_str_list().unwrap();
6888        rd.i32().unwrap();
6889        assert_eq!(rd.i32().unwrap(), 0, "CG count has no preceding jump");
6890        assert_eq!(&rd.fixed_i32_list().unwrap()[..3], &[1, 0, 1]);
6891        fs::remove_dir_all(project_dir).unwrap();
6892    }
6893
6894    #[test]
6895    fn original_global_save_uses_one_byte_character_voice_flags() {
6896        for (major, minor) in [(1, 2), (1, 5), (2, 0)] {
6897            for empty_cg in [false, true] {
6898                check_original_global_save(major, minor, empty_cg);
6899            }
6900        }
6901    }
6902
6903    fn check_original_global_save(major_version: i32, minor_version: i32, empty_cg: bool) {
6904        let project_dir = test_project_dir();
6905        fs::create_dir_all(&project_dir).expect("test project dir");
6906
6907        let mut stream = original_save::OriginalStreamWriter::new();
6908        stream.push_i64(1234);
6909        stream.push_fixed_i32_list(&[42], 1);
6910        stream.push_fixed_i32_list(&[7], 1);
6911        stream.push_fixed_str_list(&["global".to_string()], 1);
6912        stream.push_fixed_str_list(&["名前".to_string()], 702);
6913        stream.push_i32(1359557559);
6914        if empty_cg {
6915            stream.push_extend_i32_list(&[]);
6916        } else {
6917            stream.push_fixed_i32_list(&[1, 0, 1], 1000);
6918        }
6919        stream.push_fixed_i32_list(&[0, 1], 52);
6920        stream.push_i32(2);
6921        stream.push_str("桜");
6922        stream.push_bool(true);
6923        stream.push_str("second");
6924        stream.push_bool(false);
6925        let payload = stream.into_inner();
6926        let packed = original_save::pack_buffer(&payload);
6927        let mut data = original_save::OriginalGlobalSaveHeader {
6928            major_version,
6929            minor_version,
6930            global_data_size: packed.len() as i32,
6931        }
6932        .to_bytes();
6933        data.extend_from_slice(&packed);
6934        let path = project_dir.join("savedata/global.sav");
6935        fs::create_dir_all(path.parent().unwrap()).unwrap();
6936        fs::write(&path, &data).expect("synthetic global save");
6937
6938        let mut ctx = CommandContext::new(project_dir.clone());
6939        load_global_save(&mut ctx).expect("one-byte chrkoe flags");
6940        assert_eq!(ctx.globals.syscom.total_play_time, 1234);
6941        assert_eq!(ctx.globals.int_lists[&(codes::ELM_GLOBAL_G as u32)][0], 42);
6942        assert_eq!(ctx.globals.int_lists[&(codes::ELM_GLOBAL_Z as u32)][0], 7);
6943        assert_eq!(
6944            ctx.globals.str_lists[&(codes::ELM_GLOBAL_M as u32)][0],
6945            "global"
6946        );
6947        assert_eq!(
6948            ctx.globals.str_lists[&(codes::ELM_GLOBAL_NAMAE_GLOBAL as u32)][0],
6949            "名前"
6950        );
6951        if empty_cg {
6952            assert!(ctx.tables.cg_flags.is_empty());
6953        } else {
6954            assert_eq!(&ctx.tables.cg_flags[..3], &[1, 0, 1]);
6955        }
6956        assert_eq!(&ctx.globals.bgm_table_flags[..2], &[false, true]);
6957        assert_eq!(ctx.globals.syscom.chrkoe_look_flags.get("桜"), Some(&true));
6958        assert_eq!(
6959            ctx.globals.syscom.chrkoe_look_flags.get("second"),
6960            Some(&false)
6961        );
6962
6963        let header = original_save::OriginalGlobalSaveHeader {
6964            major_version,
6965            minor_version,
6966            global_data_size: 0,
6967        };
6968        assert!(save_versions::read_global(header, &payload[..payload.len() - 1]).is_err());
6969        let mut trailing = payload;
6970        trailing.push(0);
6971        assert!(save_versions::read_global(header, &trailing).is_err());
6972
6973        fs::write(&path, &data[..data.len() - 1]).unwrap();
6974        assert!(
6975            load_global_save(&mut ctx)
6976                .unwrap_err()
6977                .to_string()
6978                .contains("payload truncated")
6979        );
6980        data[4..8].copy_from_slice(&99i32.to_le_bytes());
6981        fs::write(&path, &data).unwrap();
6982        assert!(
6983            load_global_save(&mut ctx)
6984                .unwrap_err()
6985                .to_string()
6986                .contains("unsupported global save version")
6987        );
6988
6989        let _ = fs::remove_dir_all(project_dir);
6990    }
6991}