Skip to main content

siglus_scene_vm/runtime/
mod.rs

1//! Runtime scaffolding for command execution.
2//!
3//! Scene bytecode is dispatched by numeric form/owner codes. Siglus user
4//! commands are script procedures entered by `SceneVm` through Scene.pck.
5
6pub mod constants;
7pub mod forms;
8pub mod graphics;
9pub mod input;
10pub mod opcode;
11
12pub use opcode::OpCode;
13pub mod game_display_info;
14pub mod game_title;
15pub mod gan;
16pub mod globals;
17pub mod int_event;
18pub mod native_ui;
19pub mod net;
20mod scene_metadata;
21pub mod string_semantics;
22pub mod tables;
23pub mod tonecurve;
24#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
25pub mod twitter;
26pub mod ui;
27pub mod unknown;
28pub mod wait;
29mod wipe;
30pub(crate) mod wipe_mask;
31use crate::runtime::forms::codes::syscom_op;
32use crate::runtime::forms::pcmevent as pcmevent_form;
33use crate::runtime::forms::syscom as syscom_form;
34use scene_metadata::SceneMetadata;
35
36use crate::runtime::globals::{HashMap, HashSet};
37use anyhow::{Result, anyhow};
38use std::cell::RefCell;
39use std::rc::Rc;
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::{Arc, OnceLock};
42
43use crate::assets::RgbaImage;
44use crate::audio::{AudioHub, BgmEngine, KoeEngine, PcmEngine, SeEngine};
45use crate::image_manager::{ImageHandle, ImageManager};
46use crate::layer::{
47    ClipRect, LayerId, LayerManager, RenderFrame, RenderSprite, Sprite, SpriteFit, SpriteId,
48    SpriteRuntimeLight, SpriteSizeMode, WipeRenderPlan,
49};
50use crate::movie::MovieManager;
51use crate::text_render::{FontCache, TextStyle, embedded_default_font_names};
52use siglus_assets::scene_pck::{ScenePck, ScenePckDecodeOptions};
53use std::fs;
54use std::path::{Path, PathBuf};
55
56#[derive(Debug, Clone)]
57pub enum Value {
58    Int(i64),
59    Str(String),
60    /// An element chain as raw i32 codes (as stored on the VM int stack).
61    Element(Vec<i32>),
62    /// A nested list value (FM_LIST).
63    List(Vec<Value>),
64    /// A named argument (id -> value), used by some engine commands.
65    NamedArg {
66        id: i32,
67        value: Box<Value>,
68    },
69}
70
71impl Value {
72    pub fn as_i64(&self) -> Option<i64> {
73        match self {
74            Value::Int(v) => Some(*v),
75            Value::NamedArg { value, .. } => value.as_i64(),
76            _ => None,
77        }
78    }
79
80    pub fn named_id(&self) -> Option<i32> {
81        match self {
82            Value::NamedArg { id, .. } => Some(*id),
83            _ => None,
84        }
85    }
86
87    pub fn unwrap_named(&self) -> &Value {
88        match self {
89            Value::NamedArg { value, .. } => value.as_ref(),
90            _ => self,
91        }
92    }
93    pub fn as_str(&self) -> Option<&str> {
94        match self {
95            Value::Str(s) => Some(s.as_str()),
96            Value::NamedArg { value, .. } => value.as_str(),
97            _ => None,
98        }
99    }
100}
101
102#[derive(Debug, Clone)]
103pub struct Command {
104    pub name: String,
105    /// Optional numeric code for VM forms.
106    pub code: Option<opcode::OpCode>,
107    pub args: Vec<Value>,
108}
109
110#[derive(Debug, Clone)]
111struct MsgBackLayoutEntry {
112    history_index: usize,
113    text: String,
114    total_pos: i32,
115    height: i32,
116}
117
118#[derive(Debug, Clone)]
119struct MsgBackSeparatorLayout {
120    file: Option<String>,
121    total_pos: i32,
122    height: i32,
123}
124
125#[derive(Debug, Clone, Default)]
126struct MsgBackLayout {
127    entries: Vec<MsgBackLayoutEntry>,
128    separators: Vec<MsgBackSeparatorLayout>,
129    total_height: i32,
130}
131
132/// State used by EXCALL runtime helpers.
133///
134/// We intentionally keep these names offset-based instead of guessing their meaning.
135#[derive(Debug, Clone)]
136pub struct ExcallCompatState {
137    pub ready: bool,
138    /// C_elm_excall::free() runs frame-action end callbacks before clearing
139    /// its child lists/stages. Rust drains those callbacks immediately after
140    /// command dispatch, so resource release is deferred until that drain.
141    pub pending_free: bool,
142    pub ex_call_flag: bool,
143    pub flag_204: bool,
144    pub flag_2148: bool,
145    pub script_proc_requested: bool,
146    pub script_proc_pop_requested: bool,
147
148    // C_elm_excall owns an independent set of SCRIPT font overrides.
149    // They are not aliases of Gp_local: cmd_script.cpp routes
150    // EXCALL.SCRIPT.{87..95} to Gp_excall->m_font_name / m_pod.
151    pub font_name: String,
152    pub font_bold: i64,
153    pub font_shadow: i64,
154    // Newer C_elm_excall POD field corresponding to SCRIPT.98/99/100.
155    pub joypad_mode_override: i64,
156}
157
158impl Default for ExcallCompatState {
159    fn default() -> Self {
160        Self {
161            ready: false,
162            pending_free: false,
163            ex_call_flag: false,
164            flag_204: false,
165            flag_2148: false,
166            script_proc_requested: false,
167            script_proc_pop_requested: false,
168            font_name: String::new(),
169            font_bold: -1,
170            font_shadow: -1,
171            joypad_mode_override: -1,
172        }
173    }
174}
175
176/// Optional external handler for numeric forms.
177///
178/// The project can keep game-specific implementations (e.g. SCREEN/MSGBK)
179/// outside this crate, while still letting the VM dispatch through here.
180pub trait ExternalFormHandler: Send + Sync {
181    /// Return true if the form ID was handled.
182    fn dispatch_form(
183        &self,
184        ctx: &mut CommandContext,
185        form_id: u32,
186        args: &[Value],
187    ) -> anyhow::Result<bool>;
188}
189/// Cooperative script-process boundary, mirroring Siglus' `TNM_PROC_TYPE_*`
190/// model at the VM/runtime boundary.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum ProcKind {
193    Script,
194    Disp,
195    Frame,
196    Command,
197    MessageBlock,
198    MessageWait,
199    KeyWait,
200    TimeWait,
201    MovieWait,
202    WipeWait,
203    AudioWait,
204    EventWait,
205    Selection,
206    SystemModal,
207}
208
209/// Spare `Vec<i32>` buffers for element paths and object chains, which the
210/// VM makes and drops for every property access: reusing them saves an
211/// allocation and a free each time (a tenth of the VM's frame time in
212/// particle scripts, more with the console allocators).
213#[derive(Debug, Default)]
214pub struct IntVecPool {
215    spare: Vec<Vec<i32>>,
216}
217
218impl IntVecPool {
219    const MAX_SPARE: usize = 32;
220    const MAX_CAPACITY: usize = 64;
221
222    /// An empty vector, reused when one is spare.
223    pub fn take(&mut self) -> Vec<i32> {
224        self.spare.pop().unwrap_or_default()
225    }
226
227    /// A copy of `items` in a reused vector.
228    pub fn take_copy(&mut self, items: &[i32]) -> Vec<i32> {
229        let mut v = self.take();
230        v.extend_from_slice(items);
231        v
232    }
233
234    /// Keeps `v` for reuse (small ones, a few at a time).
235    pub fn give(&mut self, mut v: Vec<i32>) {
236        if self.spare.len() < Self::MAX_SPARE && v.capacity() <= Self::MAX_CAPACITY {
237            v.clear();
238            self.spare.push(v);
239        }
240    }
241}
242
243impl Clone for IntVecPool {
244    /// Spare buffers are not state: a copy starts without them.
245    fn clone(&self) -> Self {
246        Self::default()
247    }
248}
249
250#[derive(Debug, Clone, Default)]
251pub struct VmCallMeta {
252    pub element: Vec<i32>,
253    pub al_id: i64,
254    pub ret_form: i64,
255}
256
257#[derive(Debug, Clone)]
258pub struct DebugActiveTextureEntry {
259    pub image_id: ImageHandle,
260    pub width: u32,
261    pub height: u32,
262    pub source_label: String,
263    pub submitted_this_frame: bool,
264    pub visible_refs: usize,
265    pub total_refs: usize,
266    pub ref_summary: String,
267}
268
269#[derive(Debug, Default, Clone)]
270struct DebugActiveTextureAccum {
271    width: u32,
272    height: u32,
273    source_label: String,
274    submitted_this_frame: bool,
275    visible_refs: usize,
276    total_refs: usize,
277    ref_labels: Vec<String>,
278}
279
280fn sg_mwnd_state_trace_runtime(
281    scene: &str,
282    scene_no: &str,
283    line: i64,
284    reason: &str,
285    stage_idx: i64,
286    mwnd_idx: usize,
287    old_open: bool,
288    new_open: bool,
289    m: &globals::MwndState,
290) {
291    if !sg_debug_enabled() {
292        return;
293    }
294    eprintln!(
295        "[SG_DEBUG][MWND_STATE_TRACE] scene={} scene_no={} line={} reason={} stage={} mwnd={} old_open={} new_open={} buttons={} faces={} objects={} waku={} filter={} pos={:?} size={:?} open_anim=({}, {}) close_anim=({}, {}) selection={} msg_len={} name_len={}",
296        scene,
297        scene_no,
298        line,
299        reason,
300        stage_idx,
301        mwnd_idx,
302        old_open,
303        new_open,
304        m.button_list.len(),
305        m.face_list.len(),
306        m.object_list.len(),
307        if m.waku_file.is_empty() {
308            "-"
309        } else {
310            m.waku_file.as_str()
311        },
312        if m.filter_file.is_empty() {
313            "-"
314        } else {
315            m.filter_file.as_str()
316        },
317        m.window_pos,
318        m.window_size,
319        m.open_anime_type,
320        m.open_anime_time,
321        m.close_anime_type,
322        m.close_anime_time,
323        m.selection.is_some(),
324        m.msg_text.len(),
325        m.name_text.len(),
326    );
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum RuntimeSaveKind {
331    Normal,
332    Quick,
333    End,
334    Inner,
335}
336
337/// GPU service for original-engine capture commands. Captures must pass through
338/// the same render graph as presentation so meshes, depth, fog and shader state
339/// are preserved.
340pub trait FrameCaptureBackend {
341    fn capture_render_frame(
342        &mut self,
343        images: &ImageManager,
344        frame: &RenderFrame,
345        logical_width: u32,
346        logical_height: u32,
347    ) -> Result<RgbaImage>;
348}
349
350pub type FrameCaptureBackendRef = Rc<RefCell<dyn FrameCaptureBackend>>;
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub struct RuntimeSaveRequest {
354    pub kind: RuntimeSaveKind,
355    pub index: usize,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub struct RuntimeLoadRequest {
360    pub kind: RuntimeSaveKind,
361    pub index: usize,
362}
363
364/// Engine-side cache mirroring the C++ `S_tnm_local_save` (a.k.a. `Gp_eng->m_local_save`).
365/// Populated by GLOBAL_SAVEPOINT (`tnm_save_local`) and after a successful load
366/// (`tnm_load_local_on_file`). All save-to-file paths (normal / quick / end) must
367/// pull from this cache instead of re-serializing the live runtime.
368#[derive(Debug, Clone, Default)]
369pub struct LocalSaveSnapshot {
370    pub save_id: [u16; 7],
371    pub append_dir: String,
372    pub append_name: String,
373    pub save_scene_title: String,
374    pub save_msg: String,
375    pub save_full_msg: String,
376    pub local_stream: Vec<u8>,
377    pub local_ex_stream: Vec<u8>,
378    pub sel_saves: Vec<crate::original_save::OriginalLocalSaveEnvelope>,
379}
380
381#[derive(Debug, Clone)]
382struct MouseCursorFrameRuntime {
383    image_id: ImageHandle,
384    hot_x: i32,
385    hot_y: i32,
386}
387
388#[derive(Debug, Clone)]
389struct MouseCursorRuntime {
390    frames: Vec<MouseCursorFrameRuntime>,
391    anime_speed_ms: i64,
392}
393
394pub struct CommandContext {
395    /// Reused element/chain buffers (see `IntVecPool`).
396    pub int_vec_pool: IntVecPool,
397    pub project_dir: PathBuf,
398
399    /// Project-wide variable DWORD used by encrypted Emote PSB files. Native
400    /// hosts preload/recover this before scene execution; direct VM users still
401    /// pick up an explicitly configured `key.toml` value here.
402    pub emote_key: Option<u32>,
403    pub images: ImageManager,
404    pub layers: LayerManager,
405    /// 1x1 white sprite used for screen-space overlays (filters, etc.).
406    pub solid_white: ImageHandle,
407
408    // Keep BGM before AudioHub so streaming handles stop while the mixer still
409    // exists; Rust drops struct fields in declaration order.
410    pub bgm: BgmEngine,
411    pub audio: AudioHub,
412
413    pub koe: KoeEngine,
414    pub pcm: PcmEngine,
415    pub se: SeEngine,
416
417    pub movie: MovieManager,
418
419    /// Runtime numeric constants (form/element/op codes).
420    pub ids: constants::RuntimeConstants,
421
422    /// Graphics runtime state for stage/object sprite binding.
423    pub gfx: graphics::GfxRuntime,
424
425    /// UI runtime (text window, message waits, etc.).
426    pub ui: ui::UiRuntime,
427    /// Shared font cache for stage/object text rendering.
428    pub font_cache: FontCache,
429
430    /// Runtime-visible input state (button manager, waits, runtime systems).
431    pub input: input::InputState,
432    /// Script-visible input state (`Gp_script_input` in the original engine).
433    pub script_input: input::InputState,
434
435    /// Current render target size (used for UI layout).
436    pub screen_w: u32,
437    pub screen_h: u32,
438
439    /// Shared wgpu renderer used by synchronous capture commands.
440    frame_capture_backend: Option<FrameCaptureBackendRef>,
441
442    /// VM blocking state (WAIT / WAIT_KEY).
443    pub wait: wait::VmWait,
444
445    /// Cooperative proc boundary generation. Form handlers bump this when they
446    /// perform an original-engine proc switch/push.
447    proc_generation: u64,
448    last_proc_kind: ProcKind,
449
450    /// Lightweight network/browser helper mirroring the engine's `tnm_net` slot.
451    pub net: net::TnmNet,
452
453    /// Gameexe-driven asset tables (CGTABLE / DATABASE / THUMBTABLE).
454    pub tables: tables::AssetTables,
455
456    /// Scene names/read-flag shapes derived from the resident Scene.pck.
457    /// Replaced when the active append changes, like tnm_reload_scene_pck().
458    scene_metadata: RefCell<Option<(String, Arc<SceneMetadata>)>>,
459
460    /// Value stack used by form handlers to return results.
461    pub stack: Vec<Value>,
462
463    pub unknown: unknown::UnknownOpRecorder,
464
465    pub globals: globals::GlobalState,
466    pub tonecurve: tonecurve::ToneCurveRuntime,
467
468    pub excall_state: ExcallCompatState,
469
470    /// Reused ownership set for the render-tree rebuild.  Keeping the table
471    /// capacity avoids allocating a new HashSet every frame while storing only
472    /// compact backend ids rather than cloned Sprite payloads.
473    render_object_keys_scratch: HashSet<(LayerId, SpriteId)>,
474    mouse_cursor_cache: HashMap<(i64, String), MouseCursorRuntime>,
475    failed_gfx_image_repairs: HashSet<(String, i64, usize, String, u32)>,
476
477    /// Optional project-provided form handler (game-specific).
478    pub external_forms: Option<Arc<dyn ExternalFormHandler>>,
479
480    /// Optional platform-native UI backend used by mobile ports.
481    pub native_ui_backend: Option<Arc<dyn native_ui::NativeUiBackend>>,
482    pub native_ui: native_ui::NativeUiRuntime,
483
484    /// Current scene number tracked by the VM.
485    pub current_scene_no: Option<i64>,
486    /// Current scene name tracked by the VM.
487    pub current_scene_name: Option<String>,
488    /// Current source line tracked by the VM (`CD_NL`).
489    pub current_line_no: i64,
490
491    /// Current VM-originated form call metadata. Form handlers read this instead of
492    /// relying on trailing wrapper arguments.
493    pub vm_call: Option<VmCallMeta>,
494
495    /// Set by concrete message/voice command handlers when original C++ consumes
496    /// the following read-flag integer through Gp_lexer->pop_ret<int>().
497    pending_read_flag_no: bool,
498    pending_selbtn_read_flag_no: bool,
499    /// Exact MWND that owns the deferred lexer read-flag operand. The scene
500    /// number is captured when the command is dispatched because the VM pops
501    /// the flag only after the form handler returns.
502    pending_mwnd_read_flag_target: Option<(u32, i64, usize, i64)>,
503
504    // Original C_tnm_global skip state.  SCRIPT.skip_trigger is local/armed;
505    // these fields are global frame/runtime state and deliberately live outside
506    // ScriptRuntimeState so EXCALL/local-state replacement cannot erase an
507    // already activated skip.
508    skip_because_skip_trigger: bool,
509    msg_wait_skip_by_click: bool,
510    read_skip_enable_flag: bool,
511    cant_auto_skip_before_click: bool,
512    // C_tnm_btn_mng keeps the active button across DECIDE down/up even when
513    // C_elm_object::button_event() later rejects a DISABLE button. Keep that
514    // capture separate from event/action state so a disabled toolbar button
515    // can consume the click without producing sounds or actions.
516    button_decide_capture: bool,
517
518    // flow_proc.cpp::tnm_message_key_wait_proc() deliberately yields back to
519    // drawing while message skip is active instead of draining every skipped
520    // MESSAGE_KEY_WAIT in one frame_main_proc pass.  The original engine tunes
521    // how many message waits may be consumed between draws from its rolling
522    // ~1-second frame-rate sample.  Keep the same global counters here.
523    disp_because_msg_wait_cnt: i32,
524    disp_because_msg_wait_cnt_max: i32,
525    frame_rate_100msec: [i32; 10],
526    frame_rate_100msec_index: usize,
527    frame_rate_100msec_total: i32,
528    frame_rate_bucket_frame_cnt: u32,
529    frame_rate_bucket_elapsed_ms: u64,
530    // Gp_timer->real_time is refreshed at the start of each original
531    // frame_main_proc pass.  The port needs an equivalent wall-clock guard so
532    // one aggressive skip pump cannot monopolize the event loop for >100 ms.
533    frame_main_proc_started_at: Option<crate::platform_time::Instant>,
534
535    /// Deferred VM-owned save request. The form handler can only see CommandContext;
536    /// the VM consumes this after the command returns so the saved stream includes
537    /// the current lexer pc and stacks.
538    pending_runtime_save: Option<RuntimeSaveRequest>,
539    /// Deferred VM-owned load request, consumed by SceneVm after the command returns.
540    pending_runtime_load: Option<RuntimeLoadRequest>,
541    /// Explicit scene and Z label supplied by GLOBAL.RETURNMENU(scene[, z]).
542    /// Original cmd_global.cpp routes these overloads through
543    /// tnm_syscom_restart_from_scene(), not the return-to-menu proc.
544    pub pending_scene_restart: Option<(String, i32)>,
545    runtime_load_completed: bool,
546
547    /// Engine-equivalent of `Gp_eng->m_local_save`. Built at GLOBAL_SAVEPOINT and
548    /// refreshed by `tnm_load_local_on_file`-equivalent load. Save-to-file dispatch
549    /// reads from here so the saved stream reflects the savepoint, not the live
550    /// menu/UI state when the user picks a slot.
551    pub local_save_snapshot: Option<LocalSaveSnapshot>,
552
553    /// Set when the engine wants the next safe boundary (post-command, pre-next-step)
554    /// to (re)build `local_save_snapshot`. Mirrors C++ `tnm_msg_proc_start_msg_block`
555    /// calling `tnm_init_local_save` + `tnm_set_save_point` at message-block start.
556    /// Form handlers can't build the snapshot themselves (they don't see SceneVm),
557    /// so they request via this flag and the VM drains it at a safe point.
558    pub pending_auto_savepoint: bool,
559
560    /// `C_elm_btn_select::decide` pushes the result and immediately calls
561    /// `tnm_set_sel_point()`.  CommandContext cannot snapshot VM stacks, so it
562    /// carries the result to SceneVm's frame boundary where the exact resume
563    /// point is recorded with that value temporarily present on the int stack.
564    pending_sel_point_result: Option<i32>,
565
566    frame_clock_last: Option<crate::platform_time::Instant>,
567    /// Nanoseconds left after converting wall-clock frame intervals to the
568    /// integer-millisecond timer domain used by the original engine. This is
569    /// required when VSync is disabled: independently truncating every <1 ms
570    /// frame would otherwise make START_REAL counters stop advancing.
571    frame_clock_sub_ms_ns: u32,
572    last_button_hover_sound_pos: Option<(i32, i32)>,
573    suppress_next_right_syscom_open: bool,
574}
575
576/// Convert a high-resolution wall-clock interval into the integer-millisecond
577/// timer domain used by Siglus without throwing away sub-millisecond time.
578///
579/// The original engine samples `timeGetTime()` and subtracts two integer
580/// millisecond timestamps. With a high-resolution `Instant`, truncating each
581/// individual interval instead would lose time permanently at >1000 FPS. Keep
582/// the remainder so a sequence such as 0.4 + 0.4 + 0.4 ms advances by 1 ms.
583fn accumulate_integer_millis(elapsed: std::time::Duration, sub_ms_ns: &mut u32) -> i32 {
584    const NS_PER_MS: u128 = 1_000_000;
585    let total_ns = elapsed.as_nanos().saturating_add(u128::from(*sub_ms_ns));
586    let elapsed_ms = total_ns / NS_PER_MS;
587    *sub_ms_ns = (total_ns % NS_PER_MS) as u32;
588    elapsed_ms.min(i32::MAX as u128) as i32
589}
590
591#[cfg(test)]
592mod frame_clock_tests {
593    use super::accumulate_integer_millis;
594    use std::time::Duration;
595
596    #[test]
597    fn sub_millisecond_frames_accumulate_instead_of_disappearing() {
598        let mut remainder = 0;
599        assert_eq!(
600            accumulate_integer_millis(Duration::from_micros(400), &mut remainder),
601            0
602        );
603        assert_eq!(remainder, 400_000);
604        assert_eq!(
605            accumulate_integer_millis(Duration::from_micros(400), &mut remainder),
606            0
607        );
608        assert_eq!(remainder, 800_000);
609        assert_eq!(
610            accumulate_integer_millis(Duration::from_micros(400), &mut remainder),
611            1
612        );
613        assert_eq!(remainder, 200_000);
614    }
615
616    #[test]
617    fn ordinary_frame_delta_preserves_fractional_remainder() {
618        let mut remainder = 0;
619        assert_eq!(
620            accumulate_integer_millis(Duration::from_micros(16_666), &mut remainder),
621            16
622        );
623        assert_eq!(remainder, 666_000);
624        assert_eq!(
625            accumulate_integer_millis(Duration::from_micros(16_666), &mut remainder),
626            17
627        );
628        assert_eq!(remainder, 332_000);
629    }
630}
631
632impl CommandContext {
633    pub fn sync_script_input_from_runtime(&mut self) {
634        self.script_input = self.input.clone();
635    }
636
637    pub fn proc_generation(&self) -> u64 {
638        self.proc_generation
639    }
640
641    pub fn request_read_flag_no(&mut self) {
642        self.pending_read_flag_no = true;
643        // Global PRINT/KOE aliases normally route through the current MWND,
644        // but retain an exact fallback target for compact/testcase aliases.
645        if self.pending_mwnd_read_flag_target.is_none() {
646            let form_id = if self.ids.form_global_stage != 0 {
647                self.ids.form_global_stage
648            } else {
649                crate::runtime::constants::global_form::STAGE_ALT
650            };
651            self.pending_mwnd_read_flag_target = Some((
652                form_id,
653                self.globals.current_mwnd_stage_idx,
654                self.globals.current_mwnd_no.unwrap_or(0),
655                self.current_scene_no.unwrap_or(-1),
656            ));
657        }
658    }
659
660    pub fn request_read_flag_no_for_mwnd(&mut self, form_id: u32, stage_idx: i64, mwnd_idx: usize) {
661        self.pending_read_flag_no = true;
662        self.pending_selbtn_read_flag_no = false;
663        self.pending_mwnd_read_flag_target = Some((
664            form_id,
665            stage_idx,
666            mwnd_idx,
667            self.current_scene_no.unwrap_or(-1),
668        ));
669    }
670
671    pub fn request_read_flag_no_for_selbtn(&mut self) {
672        self.pending_read_flag_no = true;
673        self.pending_selbtn_read_flag_no = true;
674        self.pending_mwnd_read_flag_target = None;
675    }
676
677    pub fn take_read_flag_no_request(&mut self) -> bool {
678        let requested = std::mem::take(&mut self.pending_read_flag_no);
679        if !requested {
680            self.pending_selbtn_read_flag_no = false;
681            self.pending_mwnd_read_flag_target = None;
682        }
683        requested
684    }
685
686    /// Drop the target retained by `take_read_flag_no_request()` when the
687    /// current scene uses the legacy command ABI with no trailing read-flag
688    /// operand.  Do not submit `-1`: old scenes did not perform the newer
689    /// read/skip bookkeeping for that command at all.
690    pub fn discard_read_flag_no_request(&mut self) {
691        self.pending_read_flag_no = false;
692        self.pending_selbtn_read_flag_no = false;
693        self.pending_mwnd_read_flag_target = None;
694    }
695
696    pub fn submit_read_flag_no(&mut self, value: i32) {
697        if std::mem::take(&mut self.pending_selbtn_read_flag_no) {
698            self.pending_mwnd_read_flag_target = None;
699            let scene_no = self.current_scene_no.unwrap_or(-1);
700            self.globals.selbtn.read_flag_scene_no = scene_no;
701            self.globals.selbtn.read_flag_flag_no = value as i64;
702            // eng_message.cpp::tnm_msg_proc_selbtn_start() updates the current
703            // read flag and both read-skip/auto-skip gates before starting the
704            // selection proc.  The previous port only stored SELBTN's flag, so
705            // toolbar READ_SKIP could retain stale eligibility across choices.
706            self.set_current_read_flag_for_skip(scene_no, value as i64);
707            return;
708        }
709
710        let Some((form_id, stage_idx, mwnd_idx, scene_no)) =
711            self.pending_mwnd_read_flag_target.take()
712        else {
713            return;
714        };
715        self.set_current_read_flag_for_skip(scene_no, value as i64);
716        let mut commit_now = false;
717        if let Some(mwnd) = self
718            .globals
719            .stage_forms
720            .get_mut(&form_id)
721            .and_then(|st| st.mwnd_lists.get_mut(&stage_idx))
722            .and_then(|list| list.get_mut(mwnd_idx))
723        {
724            mwnd.read_flag_stock.push((scene_no, value as i64));
725            // A synchronous PRINT reaches clear-ready before the VM can pop
726            // this trailing operand. Commit immediately in that case so the
727            // deferred ABI preserves C++ tnm_msg_proc_clear_ready ordering.
728            commit_now = mwnd.clear_ready;
729        }
730        if commit_now {
731            let stock = self
732                .globals
733                .stage_forms
734                .get_mut(&form_id)
735                .and_then(|st| st.mwnd_lists.get_mut(&stage_idx))
736                .and_then(|list| list.get_mut(mwnd_idx))
737                .map(|mwnd| std::mem::take(&mut mwnd.read_flag_stock))
738                .unwrap_or_default();
739            for (scene_no, flag_no) in stock {
740                self.globals.set_read_flag(scene_no, flag_no);
741            }
742        }
743    }
744
745    pub fn request_runtime_save(&mut self, kind: RuntimeSaveKind, index: usize) {
746        self.pending_runtime_save = Some(RuntimeSaveRequest { kind, index });
747    }
748
749    /// Form-handler entrypoint matching C++ `tnm_msg_proc_start_msg_block`'s
750    /// `tnm_init_local_save` + (gated) `tnm_set_save_point` calls. Honors the
751    /// `dont_set_save_point` script flag (suspends auto SAVEPOINT for special
752    /// blocks). The VM drains the request at the next safe boundary and runs
753    /// `build_local_save_snapshot`.
754    pub fn request_auto_savepoint(&mut self) {
755        self.globals.syscom.current_save_message.clear();
756        if self.globals.script.dont_set_save_point {
757            return;
758        }
759        self.pending_auto_savepoint = true;
760    }
761
762    pub fn take_pending_auto_savepoint(&mut self) -> bool {
763        std::mem::take(&mut self.pending_auto_savepoint)
764    }
765
766    pub fn request_sel_point_with_result(&mut self, result: i64) {
767        self.pending_sel_point_result = Some(result.clamp(i32::MIN as i64, i32::MAX as i64) as i32);
768    }
769
770    pub fn take_pending_sel_point_result(&mut self) -> Option<i32> {
771        self.pending_sel_point_result.take()
772    }
773
774    pub fn request_runtime_load(&mut self, kind: RuntimeSaveKind, index: usize) {
775        self.pending_runtime_load = Some(RuntimeLoadRequest { kind, index });
776    }
777
778    pub fn take_runtime_save_request(&mut self) -> Option<RuntimeSaveRequest> {
779        self.pending_runtime_save.take()
780    }
781
782    pub fn take_runtime_load_request(&mut self) -> Option<RuntimeLoadRequest> {
783        self.pending_runtime_load.take()
784    }
785
786    pub fn begin_runtime_load_apply(&mut self) {
787        // Mirror C++ `tnm_finish_local()` + `tnm_reinit_local(false)`. The loaded
788        // local stream re-populates per-scene state via `parse_original_local_stream`,
789        // but anything that isn't *always* present in the stream (or that lives outside
790        // it entirely - layers, UI, focus, menu, pending button actions, etc.) must be
791        // torn down here so the save/load menu we're loading away from can't leak
792        // through to the restored scene. Without this, a stage that the live save menu
793        // populated but the snapshot omits would survive across the load.
794        self.layers.clear_all();
795        self.gfx = graphics::GfxRuntime::default();
796        self.ui = ui::UiRuntime::default();
797        self.wait = wait::VmWait::default();
798        self.stack.clear();
799        self.render_object_keys_scratch.clear();
800        self.render_object_keys_scratch.shrink_to_fit();
801        self.vm_call = None;
802        self.pending_read_flag_no = false;
803        self.pending_selbtn_read_flag_no = false;
804        self.pending_mwnd_read_flag_target = None;
805        self.pending_sel_point_result = None;
806        self.frame_clock_last = None;
807        self.frame_clock_sub_ms_ns = 0;
808        self.frame_main_proc_started_at = None;
809        self.disp_because_msg_wait_cnt = 0;
810        self.disp_because_msg_wait_cnt_max = 0;
811        self.last_button_hover_sound_pos = None;
812        // The save/load menu runs as an EXCALL scene. Loading replaces that
813        // script context without executing EXCALL.FREE, so its ready flag must
814        // not select the menu-local stage for later game wipes.
815        self.excall_state = ExcallCompatState::default();
816
817        self.globals.focused_editbox = None;
818        self.globals.focused_stage_group = None;
819        self.globals.focused_stage_mwnd = None;
820        self.globals.current_stage_object = None;
821        self.globals.current_object_chain = None;
822        self.globals.pending_button_actions.clear();
823        self.globals.pending_frame_action_finishes.clear();
824        self.globals.capture_for_object_image = None;
825        self.globals.save_thumb_capture_image = None;
826        self.globals.save_thumb_capture_prior = crate::runtime::forms::syscom::CAPTURE_PRIOR_NONE;
827        self.globals.selbtn = globals::BtnSelectRuntimeState::default();
828        self.globals.syscom.pending_proc = None;
829        self.globals.syscom.menu_open = false;
830        self.globals.syscom.menu_kind = None;
831        self.globals.syscom.menu_result = None;
832        self.globals.syscom.fallback_dialog = None;
833        self.globals.syscom.fallback_origin = None;
834        self.globals.syscom.msg_back_open = false;
835        self.globals.syscom.msg_back_proc_initialized = false;
836        self.globals.system.messagebox_modal = None;
837        self.globals.system.messagebox_modal_result = None;
838        self.globals.finish_wipe();
839
840        // Per-scene state that lives in `globals` and is reconstructed from the
841        // loaded local stream. Wipe before parsing so that snapshot entries that
842        // are simply *absent* (the snapshot has no mask list, no editbox, etc.)
843        // truly become absent post-load instead of inheriting from the menu.
844        self.globals.stage_forms.clear();
845        self.globals.screen_forms.clear();
846        self.globals.counter_lists.clear();
847        self.globals.frame_actions.clear();
848        self.globals.frame_action_lists.clear();
849        self.globals.mask_lists.clear();
850        self.globals.pcm_event_lists.clear();
851        self.globals.editbox_lists.clear();
852        self.globals.msgbk_forms.clear();
853    }
854
855    pub fn mark_runtime_load_completed(&mut self) {
856        self.runtime_load_completed = true;
857    }
858
859    pub fn take_runtime_load_completed(&mut self) -> bool {
860        std::mem::take(&mut self.runtime_load_completed)
861    }
862
863    pub fn needs_continuous_frame(&self) -> bool {
864        fn frame_action_needs_tick(fa: &globals::ObjectFrameActionState) -> bool {
865            fa.counter.is_running() || (!fa.cmd_name.is_empty() && !fa.end_flag)
866        }
867
868        fn screen_effect_needs_tick(e: &globals::ScreenEffectState) -> bool {
869            e.x.check_event()
870                || e.y.check_event()
871                || e.z.check_event()
872                || e.mono.check_event()
873                || e.reverse.check_event()
874                || e.bright.check_event()
875                || e.dark.check_event()
876                || e.color_r.check_event()
877                || e.color_g.check_event()
878                || e.color_b.check_event()
879                || e.color_rate.check_event()
880                || e.color_add_r.check_event()
881                || e.color_add_g.check_event()
882                || e.color_add_b.check_event()
883        }
884
885        fn object_needs_tick(obj: &globals::ObjectState) -> bool {
886            obj.any_event_active()
887                || frame_action_needs_tick(&obj.frame_action)
888                || obj.frame_action_ch.iter().any(frame_action_needs_tick)
889                || obj.movie.playing
890                || obj.gan.is_active()
891                // Emote Progress also drives procedural blinking, breathing
892                // and physics after authored timelines finish. Keep ticking
893                // a live player while the script waits for reader input.
894                || (obj.object_type == 12 && obj.emote.runtime.is_some())
895                || obj.runtime.child_objects.iter().any(object_needs_tick)
896        }
897
898        if self.wait.needs_continuous_frame() {
899            return true;
900        }
901        // C_elm_stage::update_time() advances C_elm_btn_select every frame.
902        // TNM_PROC_TYPE_SEL_BTN itself is idle-friendly and does not keep the
903        // host redraw loop awake. Keep requesting frames only while the selector
904        // has time-driven work; once it is fully open and waiting for player
905        // input the engine may sleep again until the next input event.
906        let selbtn = &self.globals.selbtn;
907        if selbtn.open_anime_type > 0
908            || selbtn.close_anime_type > 0
909            || selbtn.decide_anime_type > 0
910            || selbtn.capture_now_flag
911        {
912            return true;
913        }
914        if self.pcm.needs_tick() {
915            return true;
916        }
917        if self
918            .ui
919            .needs_continuous_frame(&self.globals.script, &self.globals.syscom)
920        {
921            return true;
922        }
923        if self.globals.mov.playing || self.globals.wipe.is_some() {
924            return true;
925        }
926        if self.custom_mouse_cursor_needs_tick() {
927            return true;
928        }
929        if !self.globals.pending_frame_action_finishes.is_empty()
930            || !self.globals.pending_button_actions.is_empty()
931        {
932            return true;
933        }
934        if self
935            .globals
936            .counter_lists
937            .values()
938            .any(|v| v.iter().any(|c| c.is_running()))
939        {
940            return true;
941        }
942        if self
943            .globals
944            .int_event_roots
945            .values()
946            .any(|e| e.check_event())
947            || self
948                .globals
949                .int_event_lists
950                .values()
951                .any(|v| v.iter().any(|e| e.check_event()))
952        {
953            return true;
954        }
955        if self
956            .globals
957            .frame_actions
958            .values()
959            .any(frame_action_needs_tick)
960            || self
961                .globals
962                .frame_action_lists
963                .values()
964                .any(|v| v.iter().any(frame_action_needs_tick))
965        {
966            return true;
967        }
968        if self.globals.screen_forms.values().any(|screen| {
969            screen.effect_list.iter().any(screen_effect_needs_tick)
970                || screen.quake_list.iter().any(|q| q.is_active())
971                || screen.shake.is_active()
972        }) {
973            return true;
974        }
975        let wipe_active = self.globals.wipe.is_some();
976        let mwnd_ui_state = self
977            .ui
978            .current_mwnd_window_render_state(self.screen_w, self.screen_h);
979        self.globals.stage_forms.values().any(|stage| {
980            stage.object_lists.iter().any(|(&stage_idx, list)| {
981                if stage_idx == TNM_STAGE_NEXT_I64 && !wipe_active {
982                    return false;
983                }
984                list.iter().enumerate().any(|(obj_idx, obj)| {
985                    !stage.is_embedded_object_slot(stage_idx, obj_idx)
986                        && stage.object_slot_is_used(stage_idx, obj_idx)
987                        && object_needs_tick(obj)
988                })
989            }) || stage.mwnd_lists.iter().any(|(&stage_idx, list)| {
990                if stage_idx == TNM_STAGE_NEXT_I64 && !wipe_active {
991                    return false;
992                }
993                list.iter().any(|m| {
994                    let Some((window_x, window_y)) = m.window_pos else {
995                        return false;
996                    };
997                    let Some((window_w, window_h)) = m.window_size else {
998                        return false;
999                    };
1000                    if window_w <= 0 || window_h <= 0 {
1001                        return false;
1002                    }
1003                    let visible_or_animating = m.open
1004                        || mwnd_ui_state.is_some_and(|ui| {
1005                            ui.x as i64 == window_x
1006                                && ui.y as i64 == window_y
1007                                && ui.w as i64 == window_w
1008                                && ui.h as i64 == window_h
1009                        });
1010                    visible_or_animating
1011                        && (m.object_list.iter().any(object_needs_tick)
1012                            || m.button_list.iter().any(object_needs_tick)
1013                            || m.face_list.iter().any(object_needs_tick))
1014                })
1015            })
1016        })
1017    }
1018
1019    pub fn last_proc_kind(&self) -> ProcKind {
1020        self.last_proc_kind
1021    }
1022
1023    pub fn request_proc_boundary(&mut self, kind: ProcKind) {
1024        self.last_proc_kind = kind;
1025        self.proc_generation = self.proc_generation.wrapping_add(1);
1026    }
1027
1028    pub fn request_disp_proc_boundary(&mut self) {
1029        self.request_proc_boundary(ProcKind::Disp);
1030    }
1031
1032    pub fn request_message_block_proc_boundary(&mut self) {
1033        self.request_proc_boundary(ProcKind::MessageBlock);
1034    }
1035
1036    pub fn request_message_wait_proc_boundary(&mut self) {
1037        self.request_proc_boundary(ProcKind::MessageWait);
1038    }
1039
1040    pub fn request_wait_proc_boundary(&mut self, kind: ProcKind) {
1041        self.request_proc_boundary(kind);
1042    }
1043
1044    /// End the active wipe with the same teardown performed by
1045    /// `C_tnm_wipe::end()` in the original engine.
1046    ///
1047    /// `GlobalState::finish_wipe()` only clears the timing marker and is kept
1048    /// for whole-runtime resets.  Live wipe completion must use this method so
1049    /// the copied NEXT stage and all of its backend sprites are reinitialized.
1050    pub fn finish_wipe_runtime(&mut self) {
1051        let Some(stage_form_id) = self.globals.wipe.as_ref().map(|w| w.stage_form_id) else {
1052            return;
1053        };
1054        self.globals.finish_wipe();
1055        crate::runtime::forms::stage::reinit_wipe_next_stage(self, stage_form_id);
1056    }
1057
1058    pub fn notify_wait_key(&mut self) -> bool {
1059        let wipe_skipped = {
1060            let wait = &mut self.wait;
1061            let globals = &mut self.globals;
1062            wait.notify_key(globals, &self.ids)
1063        };
1064        self.finish_skipped_movie_waits();
1065        if wipe_skipped {
1066            self.finish_wipe_runtime();
1067        }
1068        wipe_skipped
1069    }
1070
1071    pub fn notify_movie_wait_down_up(&mut self, result: i64) -> bool {
1072        let skipped = {
1073            let wait = &mut self.wait;
1074            let globals = &mut self.globals;
1075            wait.notify_movie_down_up(globals, &self.ids, result)
1076        };
1077        if skipped {
1078            if sg_debug_enabled() {
1079                eprintln!("[SG_DEBUG][WAIT_KEY] down_up result={}", result);
1080            }
1081            self.finish_skipped_movie_waits();
1082            if !self.globals.mov.playing && self.globals.mov.file_name.is_some() {
1083                self.close_global_movie_runtime();
1084            }
1085        }
1086        skipped
1087    }
1088
1089    fn runtime_ctrl_skip_flags(&self) -> (bool, bool) {
1090        // eng_frame.cpp suppresses Ctrl/Ctrl+Shift skip while an editbox owns
1091        // keyboard focus. Ctrl+Shift is message-only skip (`cs_skip_flag`).
1092        if self.globals.focused_editbox.is_some() {
1093            return (false, false);
1094        }
1095        let ctrl = self.input.vk_is_down(0x11);
1096        let shift = self.input.vk_is_down(0x10);
1097        (ctrl && !shift, ctrl && shift)
1098    }
1099
1100    fn runtime_is_skip_unread_msg(&self) -> bool {
1101        let configured = self
1102            .globals
1103            .syscom
1104            .config_int
1105            .get(&syscom_op::GET_SKIP_UNREAD_MESSAGE_ONOFF)
1106            .copied()
1107            .unwrap_or({
1108                if self.globals.syscom.original_config.skip_unread_message_flag {
1109                    1
1110                } else {
1111                    0
1112                }
1113            })
1114            != 0;
1115        self.globals.script.skip_unread_message || configured
1116    }
1117
1118    fn runtime_read_skip_is_enable(&self) -> bool {
1119        let f = self.globals.syscom.read_skip;
1120        f.enable && f.exist && (self.runtime_is_skip_unread_msg() || self.read_skip_enable_flag)
1121    }
1122
1123    fn set_current_read_flag_for_skip(&mut self, scene_no: i64, flag_no: i64) {
1124        self.globals.script.cur_read_flag_scn_no = scene_no;
1125        self.globals.script.cur_read_flag_flag_no = flag_no;
1126        // eng_message.cpp updates against the previously committed value. The
1127        // new flag is only committed later by tnm_msg_proc_clear_ready().
1128        self.runtime_update_read_skip();
1129        self.runtime_update_start_skip();
1130    }
1131
1132    fn runtime_update_read_skip(&mut self) {
1133        let scene_no = self.globals.script.cur_read_flag_scn_no;
1134        let flag_no = self.globals.script.cur_read_flag_flag_no;
1135        self.read_skip_enable_flag = self.globals.read_flag(scene_no, flag_no);
1136        if !self.read_skip_enable_flag && !self.runtime_is_skip_unread_msg() {
1137            self.globals.syscom.read_skip.onoff = false;
1138            self.cant_auto_skip_before_click = true;
1139        }
1140        if !self.runtime_read_skip_is_enable() {
1141            self.globals.syscom.read_skip.onoff = false;
1142        }
1143    }
1144
1145    fn runtime_update_start_skip(&mut self) {
1146        if self.cant_auto_skip_before_click {
1147            return;
1148        }
1149        if self.globals.syscom.auto_skip.onoff
1150            && (self.read_skip_enable_flag || self.runtime_is_skip_unread_msg())
1151            && self.runtime_read_skip_is_enable()
1152        {
1153            self.globals.syscom.read_skip.onoff = true;
1154        }
1155    }
1156
1157    fn runtime_update_read_skip_menu(&mut self) {
1158        if !self.runtime_read_skip_is_enable() {
1159            self.globals.syscom.read_skip.onoff = false;
1160        }
1161    }
1162
1163    fn runtime_is_skipping(&self) -> bool {
1164        // Exact ordering from eng_etc.cpp::tnm_is_skipping().
1165        if self.globals.syscom.msg_back_open {
1166            return false;
1167        }
1168        let script = &self.globals.script;
1169        if script.ctrl_disable {
1170            return false;
1171        }
1172        let (ctrl_skip, _) = self.runtime_ctrl_skip_flags();
1173        if ctrl_skip {
1174            return true;
1175        }
1176        if script.skip_disable {
1177            return false;
1178        }
1179        self.skip_because_skip_trigger || self.globals.syscom.read_skip.onoff
1180    }
1181
1182    fn runtime_is_skipping_msg(&self) -> bool {
1183        // Exact ordering from eng_etc.cpp::tnm_is_skipping_msg().
1184        if self.globals.syscom.msg_back_open {
1185            return false;
1186        }
1187        let script = &self.globals.script;
1188        if script.ctrl_disable {
1189            return false;
1190        }
1191        let (ctrl_skip, ctrl_shift_skip) = self.runtime_ctrl_skip_flags();
1192        if ctrl_skip || ctrl_shift_skip {
1193            return true;
1194        }
1195        if script.skip_disable {
1196            return false;
1197        }
1198        self.skip_because_skip_trigger || self.globals.syscom.read_skip.onoff
1199    }
1200
1201    fn clear_script_trigger_skip(&mut self) {
1202        self.skip_because_skip_trigger = false;
1203    }
1204
1205    fn register_skip_user_action(&mut self) {
1206        // eng_frame.cpp drops the auto-skip guard on DECIDE/CANCEL/Ctrl down.
1207        self.cant_auto_skip_before_click = false;
1208    }
1209
1210    fn stop_read_skip_by_decide_down(&mut self, vk: u8) -> bool {
1211        self.register_skip_user_action();
1212        // eng_frame.cpp consumes DECIDE-DOWN when it stops read-skip. tona3's
1213        // BUTTON::use_down_stock() also clears down_up_stock, which is crucial:
1214        // the matching release must not advance MESSAGE_KEY_WAIT as a second
1215        // logical click.
1216        if self.globals.syscom.read_skip.onoff
1217            && !self.globals.script.not_stop_skip_by_click
1218            && self.input.use_vk_down_stock(vk)
1219        {
1220            self.globals.syscom.read_skip.onoff = false;
1221            self.cant_auto_skip_before_click = true;
1222            return true;
1223        }
1224        false
1225    }
1226
1227    /// eng_frame.cpp script-trigger promotion for a DECIDE source. Returns true
1228    /// iff this input edge was consumed by the trigger logic.
1229    fn process_script_skip_trigger_decide(&mut self, vk: u8) -> bool {
1230        if self.excall_state.ex_call_flag || !self.globals.script.skip_trigger {
1231            return false;
1232        }
1233
1234        if self.wait.message_reveal_waiting() && !self.ui.message_wait_text_fully_revealed() {
1235            if self.globals.script.not_skip_msg_by_click || !self.input.use_vk_down_up_stock(vk) {
1236                return false;
1237            }
1238            self.msg_wait_skip_by_click = true;
1239            self.ui.reveal_message_now();
1240            return true;
1241        }
1242
1243        // Fully revealed text uses UP stock in the original, not DOWN_UP.
1244        // Consuming UP also invalidates the pair and prevents the same physical
1245        // release from immediately advancing MESSAGE_KEY_WAIT.
1246        if self.input.use_vk_up_stock(vk) {
1247            self.skip_because_skip_trigger = true;
1248            return true;
1249        }
1250        false
1251    }
1252
1253    /// Wheel-down follows the same script-trigger state transition, but the
1254    /// platform event itself is already the consumable stock.
1255    fn process_script_skip_trigger_wheel_down(&mut self) -> bool {
1256        if self.excall_state.ex_call_flag || !self.globals.script.skip_trigger {
1257            return false;
1258        }
1259        if self.wait.message_reveal_waiting() && !self.ui.message_wait_text_fully_revealed() {
1260            if self.globals.script.not_skip_msg_by_click {
1261                return false;
1262            }
1263            self.msg_wait_skip_by_click = true;
1264            self.ui.reveal_message_now();
1265            return true;
1266        }
1267        self.skip_because_skip_trigger = true;
1268        true
1269    }
1270
1271    fn should_wheel_advance_message(&self) -> bool {
1272        const GET_WHEEL_NEXT_MESSAGE_ONOFF: i32 = 305;
1273        self.globals
1274            .syscom
1275            .config_int
1276            .get(&GET_WHEEL_NEXT_MESSAGE_ONOFF)
1277            .copied()
1278            .unwrap_or(1)
1279            != 0
1280    }
1281
1282    fn should_stop_koe_on_advance(&self) -> bool {
1283        const GET_KOE_DONT_STOP_ONOFF: i32 = 308;
1284        let syscom_dont_stop = self
1285            .globals
1286            .syscom
1287            .config_int
1288            .get(&GET_KOE_DONT_STOP_ONOFF)
1289            .copied()
1290            .unwrap_or(0)
1291            != 0;
1292        let script = &self.globals.script;
1293        let mut dont_stop = syscom_dont_stop || script.koe_dont_stop_on_flag;
1294        if script.koe_dont_stop_off_flag {
1295            dont_stop = false;
1296        }
1297        !dont_stop
1298    }
1299
1300    fn is_modifier_key(k: input::VmKey) -> bool {
1301        matches!(
1302            k,
1303            input::VmKey::Shift | input::VmKey::Control | input::VmKey::Meta | input::VmKey::Alt
1304        )
1305    }
1306
1307    fn cancel_pending_editbox_composition(&mut self) {
1308        let Some((form_id, idx)) = self.globals.focused_editbox else {
1309            return;
1310        };
1311        if let Some(eb) = self
1312            .globals
1313            .editbox_lists
1314            .get_mut(&form_id)
1315            .and_then(|list| list.boxes.get_mut(idx))
1316        {
1317            eb.cancel_pending_composition_clear();
1318        }
1319    }
1320
1321    pub(crate) fn set_focused_editbox(&mut self, target: Option<(u32, usize)>) {
1322        let old = self.globals.focused_editbox;
1323        if old == target {
1324            return;
1325        }
1326        if let Some((form_id, idx)) = old
1327            && let Some(eb) = self
1328                .globals
1329                .editbox_lists
1330                .get_mut(&form_id)
1331                .and_then(|list| list.boxes.get_mut(idx))
1332        {
1333            eb.cancel_composition();
1334            eb.mouse_selecting = false;
1335        }
1336        self.globals.focused_editbox = target;
1337        if let Some((form_id, idx)) = target
1338            && let Some(eb) = self
1339                .globals
1340                .editbox_lists
1341                .get_mut(&form_id)
1342                .and_then(|list| list.boxes.get_mut(idx))
1343        {
1344            eb.ensure_caret_visible_for_focus();
1345        }
1346    }
1347
1348    fn sync_editbox_runtime(&mut self) {
1349        let sw = self.screen_w as i32;
1350        let sh = self.screen_h as i32;
1351        let display_cnt = self.globals.change_display_mode_proc_cnt;
1352        for list in self.globals.editbox_lists.values_mut() {
1353            for eb in &mut list.boxes {
1354                eb.update_rect(sw, sh);
1355                eb.frame(display_cnt);
1356            }
1357        }
1358        if let Some((form_id, idx)) = self.globals.focused_editbox {
1359            let keep = self
1360                .globals
1361                .editbox_lists
1362                .get(&form_id)
1363                .and_then(|list| list.boxes.get(idx))
1364                .map(|eb| eb.created)
1365                .unwrap_or(false);
1366            if !keep {
1367                self.set_focused_editbox(None);
1368            }
1369        }
1370    }
1371
1372    fn toggle_screen_size_mode_for_editbox(&mut self) {
1373        const GET_WINDOW_MODE: i32 = syscom_op::GET_WINDOW_MODE;
1374        let current = self
1375            .globals
1376            .syscom
1377            .config_int
1378            .get(&GET_WINDOW_MODE)
1379            .copied()
1380            .unwrap_or(0);
1381        let next = if current == 0 { 1 } else { 0 };
1382        self.globals.syscom.config_int.insert(GET_WINDOW_MODE, next);
1383        self.globals.change_display_mode_proc_cnt =
1384            self.globals.change_display_mode_proc_cnt.max(2);
1385    }
1386
1387    fn move_editbox_focus(&mut self, forward: bool) {
1388        let Some((form_id, idx)) = self.globals.focused_editbox else {
1389            return;
1390        };
1391        let target = {
1392            let Some(list) = self.globals.editbox_lists.get(&form_id) else {
1393                return;
1394            };
1395            let len = list.boxes.len();
1396            if len == 0 {
1397                return;
1398            }
1399            let mut cur = idx;
1400            let mut target = None;
1401            for _ in 0..len {
1402                cur = if forward {
1403                    (cur + 1) % len
1404                } else {
1405                    (cur + len - 1) % len
1406                };
1407                if list.boxes.get(cur).is_some_and(|eb| eb.created) {
1408                    target = Some((form_id, cur));
1409                    break;
1410                }
1411            }
1412            target
1413        };
1414        if target.is_some() {
1415            self.set_focused_editbox(target);
1416        }
1417    }
1418
1419    /// Advance the current message wait.
1420    ///
1421    /// Returns true when the input was consumed only to reveal the rest of the
1422    /// typewriter text. In that case the VM key wait must stay blocked.
1423    fn advance_message_wait(&mut self, allow: bool) -> bool {
1424        if !allow || !self.ui.mwnd.msg.waiting {
1425            return false;
1426        }
1427        if !self.ui.message_wait_text_fully_revealed() {
1428            self.ui.reveal_message_now();
1429            return true;
1430        }
1431        // MESSAGE_WAIT completing is not itself a click/key wait. PP/R/PAGE
1432        // expose MESSAGE_KEY_WAIT only after the reveal proc has popped.
1433        if !self.wait.message_key_waiting() {
1434            return false;
1435        }
1436        self.wait.finish_message_key_wait();
1437        match self.ui.end_wait_message() {
1438            ui::MessageWaitClearAction::None => {}
1439            ui::MessageWaitClearAction::Clear => self.clear_current_mwnd_after_wait(),
1440            ui::MessageWaitClearAction::NovelClear => {
1441                forms::stage::novel_clear_current_mwnd_after_wait(self);
1442            }
1443        }
1444        if self.should_stop_koe_on_advance() {
1445            // eng_message.cpp stops the active C_elm_koe voice here.  The old
1446            // port accidentally stopped SE and every PCM/PCMCH channel instead.
1447            let _ = self.koe.stop(None);
1448            self.globals.sound_routing.bgmfade2_need_flag = false;
1449        }
1450        false
1451    }
1452
1453    fn clear_current_mwnd_after_wait(&mut self) {
1454        // eng_message.cpp::tnm_msg_proc_clear_ready stops a sustained
1455        // SCRIPT.SET_SKIP_TRIGGER skip at the normal clear boundary.
1456        self.clear_script_trigger_skip();
1457        let default_form_id = if self.ids.form_global_stage != 0 {
1458            self.ids.form_global_stage
1459        } else {
1460            constants::global_form::STAGE_ALT
1461        };
1462        let target = self.globals.focused_stage_mwnd.unwrap_or((
1463            default_form_id,
1464            self.globals.current_mwnd_stage_idx,
1465            self.globals.current_mwnd_no.unwrap_or(0),
1466        ));
1467        let (form_id, stage_idx, mwnd_idx) = target;
1468        let mut read_flags = Vec::new();
1469        if let Some(m) = self
1470            .globals
1471            .stage_forms
1472            .get_mut(&form_id)
1473            .and_then(|st| st.mwnd_lists.get_mut(&stage_idx))
1474            .and_then(|list| list.get_mut(mwnd_idx))
1475        {
1476            // tnm_msg_proc_clear_ready(): mark for deferred clear, commit the
1477            // block's read flags, and end block-local message modes.  Do not
1478            // erase glyphs/cursor/name here; C_elm_mwnd::clear() runs only
1479            // when the next message block starts.
1480            m.clear_ready = true;
1481            m.msg_block_started = false;
1482            m.multi_msg = false;
1483            m.key_icon_appear = false;
1484            m.key_icon_pos = None;
1485            m.text_dirty = false;
1486            read_flags = std::mem::take(&mut m.read_flag_stock);
1487        }
1488        for (scene_no, flag_no) in read_flags {
1489            self.globals.set_read_flag(scene_no, flag_no);
1490        }
1491        self.globals.script.multi_msg_mode = false;
1492        self.globals.script.cur_koe_no = -1;
1493        self.globals.script.cur_chr_no = -1;
1494        self.globals.script.auto_mode_moji_cnt = 0;
1495        self.globals.syscom.replay_koe = None;
1496        if self.globals.script.async_msg_mode_once {
1497            self.globals.script.async_msg_mode = false;
1498            self.globals.script.async_msg_mode_once = false;
1499        }
1500    }
1501    pub fn new(project_dir: PathBuf) -> Self {
1502        let mut unknown = unknown::UnknownOpRecorder::default();
1503        let tables = tables::AssetTables::load(&project_dir, &mut unknown);
1504        // All native entry points (including the desktop executable) must
1505        // recover/cache the PSB key before script execution can create a model.
1506        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1507        let emote_key = crate::emote_key::preload_emote_key(&project_dir);
1508        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1509        let emote_key = crate::resource::load_project_emote_key(&project_dir)
1510            .ok()
1511            .flatten();
1512        let initial_append = crate::resource::initial_select_ini_append(&project_dir);
1513
1514        let ids = constants::RuntimeConstants::default();
1515
1516        let audio = AudioHub::new();
1517        let mut images = ImageManager::new(project_dir.clone());
1518        let solid_white = images.solid_rgba((255, 255, 255, 255));
1519        let tonecurve = tonecurve::ToneCurveRuntime::new(&project_dir);
1520
1521        let mut ctx = Self {
1522            images,
1523            layers: LayerManager::default(),
1524            audio,
1525            bgm: BgmEngine::new(project_dir.clone()),
1526            koe: KoeEngine::new(project_dir.clone()),
1527            pcm: PcmEngine::new(project_dir.clone()),
1528            se: SeEngine::new(project_dir.clone()),
1529            movie: MovieManager::new(project_dir.clone()),
1530            project_dir,
1531            emote_key,
1532            solid_white,
1533            tables,
1534            scene_metadata: RefCell::new(None),
1535            stack: Vec::new(),
1536            unknown,
1537            ids,
1538            int_vec_pool: IntVecPool::default(),
1539            gfx: graphics::GfxRuntime::default(),
1540            ui: ui::UiRuntime::default(),
1541            font_cache: FontCache::new(),
1542            input: input::InputState::default(),
1543            script_input: input::InputState::default(),
1544            wait: wait::VmWait::default(),
1545            proc_generation: 0,
1546            last_proc_kind: ProcKind::Script,
1547            net: net::TnmNet::default(),
1548
1549            screen_w: 1280,
1550            screen_h: 720,
1551            frame_capture_backend: None,
1552            globals: globals::GlobalState::default(),
1553            tonecurve,
1554            excall_state: ExcallCompatState::default(),
1555            render_object_keys_scratch: HashSet::default(),
1556            mouse_cursor_cache: HashMap::default(),
1557            failed_gfx_image_repairs: HashSet::default(),
1558            external_forms: None,
1559            native_ui_backend: None,
1560            native_ui: native_ui::NativeUiRuntime::default(),
1561            current_scene_no: None,
1562            current_scene_name: None,
1563            current_line_no: -1,
1564            vm_call: None,
1565            pending_read_flag_no: false,
1566            pending_selbtn_read_flag_no: false,
1567            pending_mwnd_read_flag_target: None,
1568            skip_because_skip_trigger: false,
1569            msg_wait_skip_by_click: false,
1570            read_skip_enable_flag: false,
1571            cant_auto_skip_before_click: false,
1572            button_decide_capture: false,
1573            disp_because_msg_wait_cnt: 0,
1574            disp_because_msg_wait_cnt_max: 0,
1575            frame_rate_100msec: [0; 10],
1576            frame_rate_100msec_index: 0,
1577            frame_rate_100msec_total: 0,
1578            frame_rate_bucket_frame_cnt: 0,
1579            frame_rate_bucket_elapsed_ms: 0,
1580            frame_main_proc_started_at: None,
1581            pending_runtime_save: None,
1582            pending_runtime_load: None,
1583            pending_scene_restart: None,
1584            runtime_load_completed: false,
1585            local_save_snapshot: None,
1586            pending_auto_savepoint: false,
1587            pending_sel_point_result: None,
1588            frame_clock_last: None,
1589            frame_clock_sub_ms_ns: 0,
1590            last_button_hover_sound_pos: None,
1591            suppress_next_right_syscom_open: false,
1592        };
1593        ctx.set_active_append(initial_append.dir, initial_append.name);
1594        ctx.apply_gameexe_runtime_defaults();
1595        ctx
1596    }
1597
1598    /// Update the active append selected by the original `Gp_dir` state and
1599    /// keep all resource managers that cache it in sync.  SceneVm observes the
1600    /// same value and reloads Scene.pck only when this directory changes.
1601    pub fn set_active_append(&mut self, append_dir: String, append_name: String) {
1602        if !self.globals.append_dir.eq_ignore_ascii_case(&append_dir) {
1603            self.scene_metadata.get_mut().take();
1604        }
1605        self.globals.append_dir = append_dir;
1606        self.globals.append_name = append_name;
1607        let active_append = self.globals.append_dir.clone();
1608        self.images.set_current_append_dir_ref(&active_append);
1609        self.movie.set_current_append_dir_ref(&active_append);
1610        self.bgm.set_current_append_dir_ref(&active_append);
1611    }
1612
1613    /// Restore the startup append selected by the first `Select.ini` entry,
1614    /// matching `tnm_scene_proc_restart_from_menu_scene()`.
1615    pub fn reset_active_append_to_initial(&mut self) {
1616        let append = crate::resource::initial_select_ini_append(&self.project_dir);
1617        self.set_active_append(append.dir, append.name);
1618    }
1619
1620    /// Select the KOE JITAN rate with the same policy as the original engine.
1621    ///
1622    /// * `explicit = Some(..)` is EXKOE's named `jitan` argument.
1623    /// * `replay = true` is backlog/SYSCOM voice replay.
1624    /// * normal message KOE follows `tnm_is_auto_mode()` and chooses the
1625    ///   normal or auto-mode JITAN switch.
1626    pub(crate) fn koe_jitan_rate(&self, explicit: Option<bool>, replay: bool) -> u16 {
1627        use crate::runtime::forms::codes::syscom_op::*;
1628
1629        let cfg = |op: i32, fallback: i64| {
1630            self.globals
1631                .syscom
1632                .config_int
1633                .get(&op)
1634                .copied()
1635                .unwrap_or(fallback)
1636        };
1637        let original = &self.globals.syscom.original_config;
1638
1639        let enabled = if let Some(enabled) = explicit {
1640            enabled
1641        } else if replay {
1642            cfg(
1643                GET_JITAN_KOE_REPLAY_ONOFF,
1644                if original.jitan_msgbk_onoff { 1 } else { 0 },
1645            ) != 0
1646        } else {
1647            // C++ tnm_is_auto_mode(): script temporary auto-mode OR the
1648            // persistent system configuration auto-mode.
1649            let auto_mode =
1650                self.globals.script.auto_mode_flag || self.globals.syscom.auto_mode.onoff;
1651            let (op, fallback) = if auto_mode {
1652                (
1653                    GET_JITAN_AUTO_MODE_ONOFF,
1654                    if original.jitan_auto_mode_onoff { 1 } else { 0 },
1655                )
1656            } else {
1657                (
1658                    GET_JITAN_NORMAL_ONOFF,
1659                    if original.jitan_normal_onoff { 1 } else { 0 },
1660                )
1661            };
1662            cfg(op, fallback) != 0
1663        };
1664
1665        if !enabled {
1666            return 100;
1667        }
1668
1669        // SET_JITAN_SPEED is 100..300 in the public config API, while
1670        // C_jitan_cnv itself defensively clamps 100..400.  Preserve the latter
1671        // for values loaded from legacy config data.
1672        cfg(GET_JITAN_SPEED, original.jitan_speed).clamp(100, 400) as u16
1673    }
1674
1675    pub(crate) fn effective_font_name(&self) -> &str {
1676        let config = &self.globals.syscom.original_config.font_name;
1677        if self.globals.syscom.msg_back_open {
1678            return config;
1679        }
1680        if self.excall_state.ex_call_flag {
1681            if self.excall_state.font_name.is_empty() {
1682                config
1683            } else {
1684                &self.excall_state.font_name
1685            }
1686        } else if self.globals.script.font_name.is_empty() {
1687            config
1688        } else {
1689            &self.globals.script.font_name
1690        }
1691    }
1692
1693    pub(crate) fn effective_font_shadow_mode(&self) -> i64 {
1694        let config = self.globals.syscom.original_config.font_shadow;
1695        let value = if self.globals.syscom.msg_back_open {
1696            config
1697        } else if self.excall_state.ex_call_flag {
1698            if self.excall_state.font_shadow >= 0 {
1699                self.excall_state.font_shadow
1700            } else {
1701                config
1702            }
1703        } else if self.globals.script.font_shadow >= 0 {
1704            self.globals.script.font_shadow
1705        } else {
1706            config
1707        };
1708        crate::text_render::normalize_font_shadow_mode(value)
1709    }
1710
1711    pub(crate) fn effective_font_bold(&self) -> bool {
1712        let config = self.globals.syscom.original_config.font_futoku;
1713        if self.globals.syscom.msg_back_open {
1714            config
1715        } else if self.excall_state.ex_call_flag {
1716            if self.excall_state.font_bold >= 0 {
1717                self.excall_state.font_bold != 0
1718            } else {
1719                config
1720            }
1721        } else if self.globals.script.font_bold >= 0 {
1722            self.globals.script.font_bold != 0
1723        } else {
1724            config
1725        }
1726    }
1727
1728    fn apply_gameexe_runtime_defaults(&mut self) {
1729        self.initialize_flag_lists();
1730        self.globals.script.cursor_no = self.mouse_cursor_default_no();
1731        // C++ keeps the local/excall overrides at -1 so they continue to follow
1732        // the current system configuration until SCRIPT.SET_FONT_* is used.
1733        self.globals.script.font_bold = -1;
1734        self.globals.script.font_shadow = -1;
1735        let text = self.gameexe_color(self.tables.mwnd_render.moji_color);
1736        let shadow = self.gameexe_color(self.tables.mwnd_render.shadow_color);
1737        let fuchi = (self.tables.mwnd_render.fuchi_color >= 0)
1738            .then_some(self.gameexe_color(self.tables.mwnd_render.fuchi_color));
1739        self.ui.set_text_colors_full(text, shadow, fuchi);
1740    }
1741
1742    fn configured_flag_count(&self, global: bool) -> usize {
1743        let keys = if global {
1744            ["#GLOBAL_FLAG.CNT", "GLOBAL_FLAG.CNT"]
1745        } else {
1746            ["#FLAG.CNT", "FLAG.CNT"]
1747        };
1748        self.tables
1749            .gameexe
1750            .as_ref()
1751            .and_then(|cfg| keys.into_iter().find_map(|key| cfg.get_usize(key)))
1752            .unwrap_or(1000)
1753            .min(10000)
1754    }
1755
1756    fn initialize_flag_lists(&mut self) {
1757        use crate::runtime::forms::codes;
1758
1759        let local_count = self.configured_flag_count(false);
1760        let global_count = self.configured_flag_count(true);
1761        for form in [
1762            codes::ELM_GLOBAL_A,
1763            codes::ELM_GLOBAL_B,
1764            codes::ELM_GLOBAL_C,
1765            codes::ELM_GLOBAL_D,
1766            codes::ELM_GLOBAL_E,
1767            codes::ELM_GLOBAL_F,
1768            codes::ELM_GLOBAL_X,
1769        ] {
1770            let list = self.globals.int_lists.entry(form as u32).or_default();
1771            if list.len() < local_count {
1772                list.resize(local_count, 0);
1773            }
1774        }
1775        for form in [codes::ELM_GLOBAL_G, codes::ELM_GLOBAL_Z] {
1776            let list = self.globals.int_lists.entry(form as u32).or_default();
1777            if list.len() < global_count {
1778                list.resize(global_count, 0);
1779            }
1780        }
1781        let local_strings = self
1782            .globals
1783            .str_lists
1784            .entry(codes::ELM_GLOBAL_S as u32)
1785            .or_default();
1786        if local_strings.len() < local_count {
1787            local_strings.resize_with(local_count, String::new);
1788        }
1789        let global_strings = self
1790            .globals
1791            .str_lists
1792            .entry(codes::ELM_GLOBAL_M as u32)
1793            .or_default();
1794        if global_strings.len() < global_count {
1795            global_strings.resize_with(global_count, String::new);
1796        }
1797        for form in [
1798            codes::ELM_GLOBAL_NAMAE_LOCAL,
1799            codes::ELM_GLOBAL_NAMAE_GLOBAL,
1800        ] {
1801            let list = self.globals.str_lists.entry(form as u32).or_default();
1802            let name_count = 26 + 26 * 26;
1803            if list.len() < name_count {
1804                list.resize_with(name_count, String::new);
1805            }
1806        }
1807    }
1808
1809    fn gameexe_color(&self, color_no: i64) -> (u8, u8, u8) {
1810        if color_no >= 0
1811            && let Some(&c) = self.tables.color_table.get(color_no as usize)
1812        {
1813            return c;
1814        }
1815        (255, 255, 255)
1816    }
1817
1818    fn gameexe_value(&self, key: &str) -> Option<&str> {
1819        self.tables.gameexe.as_ref()?.get_value(key)
1820    }
1821
1822    fn gameexe_raw(&self, key: &str) -> Option<&str> {
1823        self.tables.gameexe.as_ref()?.get_unquoted(key)
1824    }
1825
1826    fn gameexe_string(&self, key: &str) -> Option<String> {
1827        self.gameexe_raw(key)
1828            .map(|s| s.trim().to_string())
1829            .filter(|s| !s.is_empty())
1830    }
1831
1832    fn gameexe_rgba_default(&self, key: &str, default: (u8, u8, u8, u8)) -> (u8, u8, u8, u8) {
1833        let vals = Self::parse_i64_list(self.gameexe_value(key));
1834        if vals.len() >= 4 {
1835            (
1836                vals[0].clamp(0, 255) as u8,
1837                vals[1].clamp(0, 255) as u8,
1838                vals[2].clamp(0, 255) as u8,
1839                vals[3].clamp(0, 255) as u8,
1840            )
1841        } else {
1842            default
1843        }
1844    }
1845
1846    fn syscom_filter_config_rgba(&self) -> (u8, u8, u8, u8) {
1847        let default = self.gameexe_rgba_default("CONFIG.FILTER_COLOR", (0, 0, 0, 128));
1848        let cfg = &self.globals.syscom.config_int;
1849        let pick = |key: i32, fallback: u8| -> u8 {
1850            cfg.get(&key)
1851                .copied()
1852                .unwrap_or(fallback as i64)
1853                .clamp(0, 255) as u8
1854        };
1855        (
1856            pick(syscom_op::GET_FILTER_COLOR_R, default.0),
1857            pick(syscom_op::GET_FILTER_COLOR_G, default.1),
1858            pick(syscom_op::GET_FILTER_COLOR_B, default.2),
1859            pick(syscom_op::GET_FILTER_COLOR_A, default.3),
1860        )
1861    }
1862
1863    fn parse_first_i64(raw: &str) -> Option<i64> {
1864        raw.split(|c: char| c == ',' || c.is_whitespace())
1865            .find_map(|part| {
1866                let t = part.trim();
1867                if t.is_empty() {
1868                    None
1869                } else {
1870                    t.parse::<i64>().ok()
1871                }
1872            })
1873    }
1874
1875    fn parse_i64_list(raw: Option<&str>) -> Vec<i64> {
1876        let Some(raw) = raw else {
1877            return Vec::new();
1878        };
1879        raw.split(|c: char| c == ',' || c.is_whitespace())
1880            .filter_map(|part| {
1881                let t = part.trim();
1882                if t.is_empty() {
1883                    None
1884                } else {
1885                    t.parse::<i64>().ok()
1886                }
1887            })
1888            .collect()
1889    }
1890
1891    fn gameexe_i64_default(&self, key: &str, default: i64) -> i64 {
1892        self.gameexe_value(key)
1893            .and_then(Self::parse_first_i64)
1894            .unwrap_or(default)
1895    }
1896
1897    fn mouse_cursor_count(&self) -> usize {
1898        self.gameexe_value("#MOUSE_CURSOR.CNT")
1899            .or_else(|| self.gameexe_value("MOUSE_CURSOR.CNT"))
1900            .and_then(Self::parse_first_i64)
1901            .filter(|v| *v >= 0)
1902            .map(|v| v as usize)
1903            .unwrap_or(16)
1904            .min(256)
1905    }
1906
1907    fn mouse_cursor_default_no(&self) -> i64 {
1908        let cnt = self.mouse_cursor_count() as i64;
1909        let no = self
1910            .gameexe_value("#MOUSE_CURSOR.DEFAULT")
1911            .or_else(|| self.gameexe_value("MOUSE_CURSOR.DEFAULT"))
1912            .and_then(Self::parse_first_i64)
1913            .unwrap_or(-1);
1914        if no >= 0 && no < cnt { no } else { -1 }
1915    }
1916
1917    fn mouse_cursor_file_name(&self, cursor_no: i64) -> Option<String> {
1918        if cursor_no < 0 || cursor_no as usize >= self.mouse_cursor_count() {
1919            return None;
1920        }
1921        self.tables
1922            .gameexe
1923            .as_ref()
1924            .and_then(|cfg| {
1925                cfg.get_indexed_field_unquoted("MOUSE_CURSOR", cursor_no as usize, "FILE")
1926            })
1927            .map(|s| s.trim().to_string())
1928            .filter(|s| !s.is_empty())
1929    }
1930
1931    fn mouse_cursor_anime_speed(&self, cursor_no: i64) -> i64 {
1932        if cursor_no < 0 || cursor_no as usize >= self.mouse_cursor_count() {
1933            return 100;
1934        }
1935        self.tables
1936            .gameexe
1937            .as_ref()
1938            .and_then(|cfg| cfg.get_indexed_field("MOUSE_CURSOR", cursor_no as usize, "SPEED"))
1939            .and_then(Self::parse_first_i64)
1940            .unwrap_or(100)
1941    }
1942
1943    fn load_mouse_cursor_runtime(&mut self, cursor_no: i64) -> Option<&MouseCursorRuntime> {
1944        let append_dir = self.images.current_append_dir().to_string();
1945        let key = (cursor_no, append_dir);
1946        if !self.mouse_cursor_cache.contains_key(&key)
1947            && let Some(loaded) = self.load_mouse_cursor_runtime_uncached(cursor_no)
1948        {
1949            self.mouse_cursor_cache.insert(key.clone(), loaded);
1950        }
1951        self.mouse_cursor_cache.get(&key)
1952    }
1953
1954    fn load_mouse_cursor_runtime_uncached(&mut self, cursor_no: i64) -> Option<MouseCursorRuntime> {
1955        let file_name = self.mouse_cursor_file_name(cursor_no)?;
1956        let (path, pct) = match crate::resource::find_g00_image_with_append_dir(
1957            &self.project_dir,
1958            self.images.current_append_dir(),
1959            &file_name,
1960        ) {
1961            Ok(v) => v,
1962            Err(err) => {
1963                self.unknown.record_note(&format!(
1964                    "mouse_cursor.not_found:no={cursor_no}:file={file_name}:{err}"
1965                ));
1966                return None;
1967            }
1968        };
1969        if pct != crate::resource::PctType::G00 {
1970            self.unknown.record_note(&format!(
1971                "mouse_cursor.unsupported_type:no={cursor_no}:file={file_name}:path={}",
1972                path.display()
1973            ));
1974            return None;
1975        }
1976        let bytes = match crate::resource::read_file_bytes(&path) {
1977            Ok(v) => v,
1978            Err(err) => {
1979                self.unknown.record_note(&format!(
1980                    "mouse_cursor.read_failed:no={cursor_no}:path={}:{}",
1981                    path.display(),
1982                    err
1983                ));
1984                return None;
1985            }
1986        };
1987        let decoded = match crate::assets::g00::decode_g00(&bytes) {
1988            Ok(v) => v,
1989            Err(err) => {
1990                self.unknown.record_note(&format!(
1991                    "mouse_cursor.decode_failed:no={cursor_no}:path={}:{}",
1992                    path.display(),
1993                    err
1994                ));
1995                return None;
1996            }
1997        };
1998        if decoded.frames.is_empty() {
1999            return None;
2000        }
2001        let mut frames = Vec::with_capacity(decoded.frames.len());
2002        for (idx, img) in decoded.frames.iter().enumerate() {
2003            if img.width != 32 || img.height != 32 {
2004                self.unknown.record_note(&format!(
2005                    "mouse_cursor.invalid_size:no={cursor_no}:file={file_name}:patno={idx}:{}x{}",
2006                    img.width, img.height
2007                ));
2008                return None;
2009            }
2010            let image_id = match self.images.load_file(&path, idx) {
2011                Ok(id) => id,
2012                Err(err) => {
2013                    self.unknown.record_note(&format!(
2014                        "mouse_cursor.frame_load_failed:no={cursor_no}:file={file_name}:patno={idx}:{err}"
2015                    ));
2016                    return None;
2017                }
2018            };
2019            frames.push(MouseCursorFrameRuntime {
2020                image_id,
2021                hot_x: img.center_x,
2022                hot_y: img.center_y,
2023            });
2024        }
2025        Some(MouseCursorRuntime {
2026            frames,
2027            anime_speed_ms: self.mouse_cursor_anime_speed(cursor_no),
2028        })
2029    }
2030
2031    pub fn has_active_custom_mouse_cursor(&mut self) -> bool {
2032        let cursor_no = self.globals.script.cursor_no;
2033        self.load_mouse_cursor_runtime(cursor_no).is_some()
2034    }
2035
2036    fn custom_mouse_cursor_needs_tick(&self) -> bool {
2037        let cursor_no = self.globals.script.cursor_no;
2038        if cursor_no < 0 || cursor_no as usize >= self.mouse_cursor_count() {
2039            return false;
2040        }
2041        self.mouse_cursor_anime_speed(cursor_no) > 0
2042            && self.mouse_cursor_file_name(cursor_no).is_some()
2043    }
2044
2045    fn append_mouse_cursor_sprite(&mut self, list: &mut Vec<RenderSprite>) {
2046        if !self.globals.script.cursor_runtime_visible || self.globals.script.cursor_disp_off {
2047            return;
2048        }
2049        if !self.input.has_mouse_position() {
2050            return;
2051        }
2052        let cursor_no = self.globals.script.cursor_no;
2053        let cur_time = self.globals.local_real_time.max(0) as u64;
2054        let frame = {
2055            let Some(cursor) = self.load_mouse_cursor_runtime(cursor_no) else {
2056                return;
2057            };
2058            if cursor.frames.is_empty() {
2059                return;
2060            }
2061            let pat_no = if cursor.anime_speed_ms <= 0 {
2062                0usize
2063            } else {
2064                ((cur_time / cursor.anime_speed_ms as u64) as usize) % cursor.frames.len()
2065            };
2066            cursor.frames[pat_no].clone()
2067        };
2068
2069        let mut sprite = Sprite {
2070            image_id: Some(frame.image_id.clone()),
2071            visible: true,
2072            fit: SpriteFit::PixelRect,
2073            size_mode: SpriteSizeMode::Intrinsic,
2074            x: self.input.mouse_x.saturating_sub(frame.hot_x),
2075            y: self.input.mouse_y.saturating_sub(frame.hot_y),
2076            alpha: 255,
2077            tr: 255,
2078            alpha_blend: true,
2079            alpha_test: false,
2080            object_anchor: false,
2081            order: i32::MAX,
2082            ..Default::default()
2083        };
2084
2085        list.push(RenderSprite::with_sorter(
2086            None,
2087            None,
2088            i32::MAX,
2089            i32::MAX,
2090            sprite,
2091        ));
2092    }
2093
2094    fn gameexe_pair_default(&self, key: &str, default: (i64, i64)) -> (i64, i64) {
2095        let vals = Self::parse_i64_list(self.gameexe_value(key));
2096        if vals.len() >= 2 {
2097            (vals[0], vals[1])
2098        } else {
2099            default
2100        }
2101    }
2102
2103    fn gameexe_rect_default(
2104        &self,
2105        key: &str,
2106        default: (i64, i64, i64, i64),
2107    ) -> (i64, i64, i64, i64) {
2108        let vals = Self::parse_i64_list(self.gameexe_value(key));
2109        if vals.len() >= 4 {
2110            (vals[0], vals[1], vals[2], vals[3])
2111        } else {
2112            default
2113        }
2114    }
2115
2116    fn msg_back_button_pos(&self, key: &str, default: (i32, i32)) -> (i32, i32) {
2117        let vals = Self::parse_i64_list(self.gameexe_value(key));
2118        if vals.len() >= 2 {
2119            (vals[0] as i32, vals[1] as i32)
2120        } else {
2121            default
2122        }
2123    }
2124
2125    #[doc(hidden)]
2126    pub fn install_scene_metadata(&self, append_dir: &str, pck: &ScenePck) -> Result<()> {
2127        let mut slot = self.scene_metadata.borrow_mut();
2128        if slot
2129            .as_ref()
2130            .is_some_and(|(cached_append, _)| cached_append.eq_ignore_ascii_case(append_dir))
2131        {
2132            return Ok(());
2133        }
2134        *slot = Some((
2135            append_dir.to_string(),
2136            Arc::new(SceneMetadata::from_pack(pck)?),
2137        ));
2138        Ok(())
2139    }
2140
2141    pub(crate) fn scene_metadata(&self) -> Result<Arc<SceneMetadata>> {
2142        let active_append = self.globals.append_dir.clone();
2143        if let Some((cached_append, metadata)) = self.scene_metadata.borrow().as_ref()
2144            && cached_append.eq_ignore_ascii_case(&active_append)
2145        {
2146            return Ok(Arc::clone(metadata));
2147        }
2148
2149        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
2150        let pck = {
2151            let scene_pck_path =
2152                crate::resource::find_scene_pck_path_for_append(&self.project_dir, &active_append)?;
2153            let bytes = crate::resource::read_file_bytes(&scene_pck_path)?;
2154            let key_cfg = ["key.toml", "Key.toml"].iter().find_map(|name| {
2155                let p = self.project_dir.join(name);
2156                if !crate::resource::wasm_path_is_file(&p) {
2157                    return None;
2158                }
2159                let text = crate::resource::read_file_to_string(&p).ok()?;
2160                siglus_assets::key_toml::parse_key_toml(&text).ok()
2161            });
2162            let exe = key_cfg
2163                .as_ref()
2164                .and_then(|cfg| cfg.exe_key16)
2165                .map(|v| v.to_vec());
2166            let string_encryption_override = key_cfg
2167                .map(|cfg| cfg.override_string_encryption)
2168                .unwrap_or_default();
2169            let opt = ScenePckDecodeOptions {
2170                exe_angou_element: exe,
2171                easy_angou_code: Some(siglus_assets::keys::SCENE_KEY.to_vec()),
2172                string_encryption_override,
2173            };
2174            ScenePck::load_lazy_from_bytes(bytes, &opt)?
2175        };
2176        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
2177        let pck = {
2178            let scene_pck_path =
2179                crate::resource::find_scene_pck_path_for_append(&self.project_dir, &active_append)?;
2180            let opt = crate::resource::load_scene_pck_decode_options(&self.project_dir)?;
2181            ScenePck::load_lazy(&scene_pck_path, &opt)?
2182        };
2183        self.install_scene_metadata(&active_append, &pck)?;
2184        let slot = self.scene_metadata.borrow();
2185        Ok(Arc::clone(
2186            &slot.as_ref().expect("scene metadata installed").1,
2187        ))
2188    }
2189
2190    pub fn lookup_scene_no(&self, scene_name: &str) -> Result<i64> {
2191        if scene_name.is_empty() {
2192            anyhow::bail!("empty scene name")
2193        }
2194        let scene_no = self
2195            .scene_metadata()?
2196            .find_scene_no(scene_name)
2197            .ok_or_else(|| anyhow::anyhow!("scene not found: {}", scene_name))?;
2198        Ok(scene_no as i64)
2199    }
2200
2201    /// Reinitialize scene-local engine state.
2202    ///
2203    /// This mirrors `C_tnm_eng::reinit_local(true)`: local flag banks and
2204    /// render/input/sound objects are rebuilt, while global G/Z/M flags, global
2205    /// names, read flags, loaded configuration, global save state and the active
2206    /// append survive.  The previous implementation replaced the entire
2207    /// `GlobalState`, which was equivalent to calling `init_global()` on every
2208    /// scene restart and destroyed data that the original engine retains.
2209    pub fn reset_for_scene_restart(&mut self) {
2210        use crate::runtime::forms::codes;
2211
2212        let append_dir = self.globals.append_dir.clone();
2213        let append_name = self.globals.append_name.clone();
2214
2215        // Drop the old streaming BGM engine before replacing its AudioHub so
2216        // decoder stop commands are delivered to the old mixer.
2217        self.bgm = BgmEngine::new(self.project_dir.clone());
2218        self.audio = AudioHub::new();
2219        self.koe = KoeEngine::new(self.project_dir.clone());
2220        self.pcm = PcmEngine::new(self.project_dir.clone());
2221        self.se = SeEngine::new(self.project_dir.clone());
2222        self.movie = MovieManager::new(self.project_dir.clone());
2223        self.images = ImageManager::new(self.project_dir.clone());
2224        self.mouse_cursor_cache.clear();
2225        self.solid_white = self.images.solid_rgba((255, 255, 255, 255));
2226        self.layers.clear_all();
2227        self.gfx = graphics::GfxRuntime::default();
2228        self.ui = ui::UiRuntime::default();
2229        self.font_cache = FontCache::new();
2230        self.wait = wait::VmWait::default();
2231        self.stack.clear();
2232
2233        // C_elm_flag::init_local(): A..F/X/S and local NAMAE are local;
2234        // G/Z/M and global NAMAE are intentionally untouched.
2235        let local_count = self.configured_flag_count(false);
2236        for form in [
2237            codes::ELM_GLOBAL_A,
2238            codes::ELM_GLOBAL_B,
2239            codes::ELM_GLOBAL_C,
2240            codes::ELM_GLOBAL_D,
2241            codes::ELM_GLOBAL_E,
2242            codes::ELM_GLOBAL_F,
2243            codes::ELM_GLOBAL_X,
2244        ] {
2245            self.globals
2246                .int_lists
2247                .insert(form as u32, vec![0; local_count]);
2248        }
2249        self.globals
2250            .str_lists
2251            .insert(codes::ELM_GLOBAL_S as u32, vec![String::new(); local_count]);
2252        self.globals.str_lists.insert(
2253            codes::ELM_GLOBAL_NAMAE_LOCAL as u32,
2254            vec![String::new(); 26 + 26 * 26],
2255        );
2256
2257        // Element/runtime objects recreated by reinit_local().
2258        self.globals.counter_lists.clear();
2259        self.globals.pcm_event_lists.clear();
2260        self.globals.pcmch_persistent.clear();
2261        self.globals.sound_routing = globals::SoundRoutingState::default();
2262        self.globals.int_event_roots.clear();
2263        self.globals.int_event_lists.clear();
2264        self.globals.int_props.clear();
2265        self.globals.str_props.clear();
2266        self.globals.g00buf.clear();
2267        self.globals.g00buf_names.clear();
2268        self.globals.mask_lists.clear();
2269        self.globals.editbox_lists.clear();
2270        self.globals.focused_editbox = None;
2271        self.globals.frame_actions.clear();
2272        self.globals.frame_action_lists.clear();
2273        self.globals.pending_frame_action_finishes.clear();
2274        self.globals.pending_button_actions.clear();
2275        self.globals.stage_forms.clear();
2276        self.globals.focused_stage_group = None;
2277        self.globals.focused_stage_mwnd = None;
2278        self.globals.current_mwnd_element = vec![
2279            forms::codes::ELM_GLOBAL_FRONT,
2280            forms::codes::ELM_STAGE_MWND,
2281            forms::codes::ELM_ARRAY,
2282            0,
2283        ];
2284        self.globals.current_sel_mwnd_element = vec![
2285            forms::codes::ELM_GLOBAL_FRONT,
2286            forms::codes::ELM_STAGE_MWND,
2287            forms::codes::ELM_ARRAY,
2288            1,
2289        ];
2290        self.globals.last_mwnd_element.clear();
2291        self.globals.current_mwnd_no = Some(0);
2292        self.globals.current_mwnd_stage_idx = 1;
2293        self.globals.current_sel_mwnd_no = Some(1);
2294        self.globals.current_sel_mwnd_stage_idx = 1;
2295        self.globals.last_mwnd_no = None;
2296        self.globals.last_mwnd_stage_idx = 1;
2297        self.globals.local_real_time = 0;
2298        self.globals.local_game_time = 0;
2299        self.globals.local_wipe_time = 0;
2300        self.globals.local_flag_h.clear();
2301        self.globals.local_flag_i.clear();
2302        self.globals.local_flag_j.clear();
2303        self.globals.selbtn = globals::BtnSelectRuntimeState::default();
2304        self.globals.current_stage_object = None;
2305        self.globals.current_object_chain = None;
2306        self.globals.screen_forms.clear();
2307        self.globals.msgbk_forms.clear();
2308        self.globals.script = globals::ScriptRuntimeState::default();
2309        self.globals.mov = globals::GlobalMovieState::default();
2310        self.globals.capture_image = None;
2311        self.globals.capture_for_object_image = None;
2312        self.globals.save_thumb_capture_image = None;
2313        self.globals.save_thumb_capture_prior = 0;
2314        self.globals.wipe = None;
2315        self.globals.lights.clear();
2316        self.globals.fog_global = globals::FogGlobalState::default();
2317
2318        // tnm_syscom_init_syscom_flag() resets local menu interaction state but
2319        // does not reload global.sav or config.sav.  Preserve the loaded config
2320        // and total play time while rebuilding the local Syscom state.
2321        let total_play_time = self.globals.syscom.total_play_time;
2322        let system_extra_int_value = self.globals.syscom.system_extra_int_value;
2323        let system_extra_str_value =
2324            std::mem::take(&mut self.globals.syscom.system_extra_str_value);
2325        let config_int = std::mem::take(&mut self.globals.syscom.config_int);
2326        let config_str = std::mem::take(&mut self.globals.syscom.config_str);
2327        let original_config = self.globals.syscom.original_config.clone();
2328        let font_list = std::mem::take(&mut self.globals.syscom.font_list);
2329        let return_scene_once = self.globals.syscom.return_scene_once.take();
2330        self.globals.syscom = globals::SyscomRuntimeState::default();
2331        self.globals.syscom.total_play_time = total_play_time;
2332        self.globals.syscom.system_extra_int_value = system_extra_int_value;
2333        self.globals.syscom.system_extra_str_value = system_extra_str_value;
2334        self.globals.syscom.config_int = config_int;
2335        self.globals.syscom.config_str = config_str;
2336        self.globals.syscom.original_config = original_config;
2337        self.globals.syscom.font_list = font_list;
2338        self.globals.syscom.return_scene_once = return_scene_once;
2339
2340        self.tonecurve = tonecurve::ToneCurveRuntime::new(&self.project_dir);
2341        self.excall_state = ExcallCompatState::default();
2342        self.render_object_keys_scratch.clear();
2343        self.render_object_keys_scratch.shrink_to_fit();
2344        self.input.clear_all();
2345        self.script_input.clear_all();
2346        self.vm_call = None;
2347        self.pending_read_flag_no = false;
2348        self.pending_selbtn_read_flag_no = false;
2349        self.pending_mwnd_read_flag_target = None;
2350        self.skip_because_skip_trigger = false;
2351        self.msg_wait_skip_by_click = false;
2352        self.read_skip_enable_flag = false;
2353        self.cant_auto_skip_before_click = false;
2354        self.button_decide_capture = false;
2355        self.disp_because_msg_wait_cnt = 0;
2356        self.disp_because_msg_wait_cnt_max = 0;
2357        self.frame_rate_100msec = [0; 10];
2358        self.frame_rate_100msec_index = 0;
2359        self.frame_rate_100msec_total = 0;
2360        self.frame_rate_bucket_frame_cnt = 0;
2361        self.frame_rate_bucket_elapsed_ms = 0;
2362        self.frame_main_proc_started_at = None;
2363        self.pending_runtime_save = None;
2364        self.pending_runtime_load = None;
2365        self.pending_scene_restart = None;
2366        self.runtime_load_completed = false;
2367        self.local_save_snapshot = None;
2368        self.pending_auto_savepoint = false;
2369        self.pending_sel_point_result = None;
2370        self.frame_clock_last = None;
2371        self.frame_clock_sub_ms_ns = 0;
2372        self.last_button_hover_sound_pos = None;
2373
2374        self.set_active_append(append_dir, append_name);
2375        self.apply_gameexe_runtime_defaults();
2376        forms::syscom::apply_audio_config(self);
2377    }
2378
2379    /// Install or clear an external form handler.
2380    pub fn set_external_form_handler(&mut self, h: Option<Arc<dyn ExternalFormHandler>>) {
2381        self.external_forms = h;
2382    }
2383
2384    // ------------------------------------------------------------------
2385    // Object button runtime
2386    // ------------------------------------------------------------------
2387
2388    fn active_button_stage_form_id(&self) -> Option<u32> {
2389        const EXCALL_LOCAL_NS_XOR: u32 = 0x4000;
2390        let normal_stage_form = if self.ids.form_global_stage != 0 {
2391            self.ids.form_global_stage
2392        } else {
2393            crate::runtime::forms::codes::FORM_GLOBAL_STAGE
2394        };
2395        if self.excall_state.ex_call_flag {
2396            if self.excall_state.ready {
2397                Some(normal_stage_form ^ EXCALL_LOCAL_NS_XOR)
2398            } else {
2399                None
2400            }
2401        } else {
2402            Some(normal_stage_form)
2403        }
2404    }
2405
2406    fn load_any_image_for_hit(
2407        images: &mut ImageManager,
2408        file: &str,
2409        patno: i64,
2410    ) -> Option<crate::image_manager::ImageHandle> {
2411        let pat_u32 = if patno < 0 { 0 } else { patno as u32 };
2412        if let Ok(id) = images.load_g00(file, pat_u32) {
2413            return Some(id);
2414        }
2415        if let Ok(id) = images.load_bg(file) {
2416            return Some(id);
2417        }
2418        None
2419    }
2420
2421    fn hit_test_sprite_rect(x: i32, y: i32, w: u32, h: u32, mx: i32, my: i32) -> bool {
2422        let x2 = x.saturating_add(w as i32);
2423        let y2 = y.saturating_add(h as i32);
2424        mx >= x && mx < x2 && my >= y && my < y2
2425    }
2426
2427    fn alpha_test_image(img: &crate::assets::RgbaImage, local_x: i32, local_y: i32) -> bool {
2428        if local_x < 0 || local_y < 0 {
2429            return false;
2430        }
2431        let lx = local_x as u32;
2432        let ly = local_y as u32;
2433        if lx >= img.width || ly >= img.height {
2434            return false;
2435        }
2436        let idx = ((ly * img.width + lx) * 4 + 3) as usize;
2437        img.rgba.get(idx).copied().unwrap_or(0) != 0
2438    }
2439
2440    fn play_button_se_no(&mut self, se_no: i64) {
2441        if se_no < 0 {
2442            return;
2443        }
2444        let Some(file_name) = self
2445            .tables
2446            .se_file_names
2447            .get(se_no as usize)
2448            .and_then(|v| v.as_deref())
2449            .filter(|s| !s.is_empty())
2450        else {
2451            self.unknown
2452                .record_note(&format!("se.table.missing:{se_no}"));
2453            return;
2454        };
2455        if self.se.play_file_name(&mut self.audio, file_name).is_err() {
2456            self.unknown
2457                .record_note(&format!("se.play.failed:{se_no}:{file_name}"));
2458        }
2459    }
2460
2461    fn button_template_se_no(&self, template_no: i64, event: ButtonSeEvent) -> Option<i64> {
2462        if template_no < 0 {
2463            return None;
2464        }
2465        let template = self.tables.button_se_templates.get(template_no as usize)?;
2466        let se_no = match event {
2467            ButtonSeEvent::Hit => template.hit_no,
2468            ButtonSeEvent::Push => template.push_no,
2469            ButtonSeEvent::Decide => template.decide_no,
2470        };
2471        (se_no >= 0).then_some(se_no)
2472    }
2473
2474    fn play_button_template_se(&mut self, template_no: i64, event: ButtonSeEvent) {
2475        if let Some(se_no) = self.button_template_se_no(template_no, event) {
2476            self.play_button_se_no(se_no);
2477        }
2478    }
2479
2480    fn update_object_button_hover(&mut self) {
2481        if !self.input.has_mouse_position() {
2482            return;
2483        }
2484        let mx = self.input.mouse_x;
2485        let my = self.input.mouse_y;
2486        let play_hover_sound = match self.last_button_hover_sound_pos {
2487            Some((last_x, last_y)) if last_x == mx && last_y == my => false,
2488            Some(_) => true,
2489            None => false,
2490        };
2491        self.last_button_hover_sound_pos = Some((mx, my));
2492        let Some(form_id) = self.active_button_stage_form_id() else {
2493            return;
2494        };
2495        let read_skip_runtime_enabled = self.runtime_read_skip_is_enable();
2496        let mut hit_sounds = Vec::new();
2497        if sg_input_trace_enabled() {
2498            eprintln!("[SG_DEBUG][INPUT] hover mouse=({}, {})", mx, my);
2499        }
2500
2501        {
2502            let Some(st) = self.globals.stage_forms.get_mut(&form_id) else {
2503                return;
2504            };
2505
2506            let images = &mut self.images;
2507            let layers = &self.layers;
2508            let gfx = &self.gfx;
2509            let ids = &self.ids;
2510            // C_elm_stage::{regist_button,button_event} gates every top-level
2511            // OBJECT by the destination slot's immutable is_use() flag.
2512            let slot_use_by_stage = st.object_slot_use.clone();
2513            let (object_lists, group_lists, embedded_by_stage) = (
2514                &mut st.object_lists,
2515                &mut st.group_lists,
2516                &st.embedded_object_slots_by_stage,
2517            );
2518
2519            let mut stage_ids: Vec<i64> = object_lists.keys().copied().collect();
2520            stage_ids.sort_unstable();
2521            for stage_idx in &stage_ids {
2522                let Some(objs) = object_lists.get_mut(stage_idx) else {
2523                    continue;
2524                };
2525                for (obj_idx, obj) in objs.iter_mut().enumerate() {
2526                    if embedded_by_stage
2527                        .get(stage_idx)
2528                        .is_some_and(|slots| slots.contains(&obj_idx))
2529                    {
2530                        continue;
2531                    }
2532                    if !slot_use_by_stage
2533                        .get(stage_idx)
2534                        .and_then(|flags| flags.get(obj_idx))
2535                        .copied()
2536                        .unwrap_or(true)
2537                    {
2538                        continue;
2539                    }
2540                    clear_button_hit_recursive(obj);
2541                }
2542            }
2543
2544            let mut group_stage_ids: Vec<i64> = group_lists.keys().copied().collect();
2545            group_stage_ids.sort_unstable();
2546            for stage_idx in group_stage_ids {
2547                let Some(groups) = group_lists.get_mut(&stage_idx) else {
2548                    continue;
2549                };
2550                for (group_idx, g) in groups.iter_mut().enumerate() {
2551                    if !g.is_doing() {
2552                        g.hit_button_no = -1;
2553                        g.hit_runtime_slot = None;
2554                        continue;
2555                    }
2556                    let Some(objs) = object_lists.get_mut(&stage_idx) else {
2557                        g.hit_button_no = -1;
2558                        g.hit_runtime_slot = None;
2559                        continue;
2560                    };
2561
2562                    let mut best: Option<ButtonHitCandidate> = None;
2563                    let mut tied = false;
2564                    for (obj_idx, obj) in objs.iter_mut().enumerate() {
2565                        if embedded_by_stage
2566                            .get(&stage_idx)
2567                            .is_some_and(|slots| slots.contains(&obj_idx))
2568                        {
2569                            continue;
2570                        }
2571                        if !slot_use_by_stage
2572                            .get(&stage_idx)
2573                            .and_then(|flags| flags.get(obj_idx))
2574                            .copied()
2575                            .unwrap_or(true)
2576                        {
2577                            continue;
2578                        }
2579                        // C_elm_object::button_event reports the button manager's
2580                        // held press to the group every frame. A script can call
2581                        // start(_cancel) each frame, clearing group selection state
2582                        // while the underlying object button remains pressed.
2583                        if let Some((button_no, slot)) =
2584                            pushed_group_button_recursive(obj_idx, obj, group_idx)
2585                        {
2586                            g.pushed_button_no = button_no;
2587                            g.pushed_runtime_slot = Some(slot);
2588                        }
2589                        if let Some(hit) = hit_test_object_button_recursive(
2590                            images,
2591                            layers,
2592                            gfx,
2593                            ids,
2594                            &self.globals.syscom,
2595                            read_skip_runtime_enabled,
2596                            stage_idx,
2597                            group_idx,
2598                            mx,
2599                            my,
2600                            obj_idx,
2601                            obj,
2602                            None,
2603                        ) {
2604                            merge_button_hit(&mut best, &mut tied, hit);
2605                        }
2606                    }
2607
2608                    if !tied {
2609                        if let Some(hit) = best {
2610                            if hit.event_enabled {
2611                                g.hit_button_no = hit.button_no;
2612                                g.hit_runtime_slot = Some(hit.runtime_slot);
2613                            } else {
2614                                // The native button manager still owns the hit,
2615                                // but C_elm_object::button_event() returns before
2616                                // notifying its group when the real state is DISABLE.
2617                                g.hit_button_no = -1;
2618                                g.hit_runtime_slot = None;
2619                            }
2620                            if sg_debug_enabled() {
2621                                eprintln!(
2622                                    "[SG_DEBUG][INPUT] group stage={} group={} hit_button={} slot={} order={} started={} pushed={} decided={}",
2623                                    stage_idx,
2624                                    group_idx,
2625                                    hit.button_no,
2626                                    hit.runtime_slot,
2627                                    hit.sort_key.display_tuple(),
2628                                    g.started,
2629                                    g.pushed_button_no,
2630                                    g.decided_button_no
2631                                );
2632                            }
2633                            if hit.event_enabled && play_hover_sound && !hit.was_hit {
2634                                hit_sounds.push(hit.se_no);
2635                            }
2636                            for (obj_idx, obj) in objs.iter_mut().enumerate() {
2637                                if embedded_by_stage
2638                                    .get(&stage_idx)
2639                                    .is_some_and(|slots| slots.contains(&obj_idx))
2640                                {
2641                                    continue;
2642                                }
2643                                if !slot_use_by_stage
2644                                    .get(&stage_idx)
2645                                    .and_then(|flags| flags.get(obj_idx))
2646                                    .copied()
2647                                    .unwrap_or(true)
2648                                {
2649                                    continue;
2650                                }
2651                                set_button_hit_by_runtime_slot_recursive(
2652                                    obj_idx,
2653                                    obj,
2654                                    hit.runtime_slot,
2655                                );
2656                            }
2657                        } else {
2658                            g.hit_button_no = -1;
2659                            g.hit_runtime_slot = None;
2660                            if sg_debug_enabled() {
2661                                eprintln!(
2662                                    "[SG_DEBUG][INPUT] group stage={} group={} no_hit started={}",
2663                                    stage_idx, group_idx, g.started
2664                                );
2665                            }
2666                        }
2667                    } else {
2668                        g.hit_button_no = -1;
2669                        g.hit_runtime_slot = None;
2670                        if sg_debug_enabled() {
2671                            eprintln!(
2672                                "[SG_DEBUG][INPUT] group stage={} group={} hit_tie",
2673                                stage_idx, group_idx
2674                            );
2675                        }
2676                    }
2677                }
2678            }
2679
2680            let mut standalone_best: Option<ButtonHitCandidate> = None;
2681            let mut standalone_tied = false;
2682            for stage_idx in &stage_ids {
2683                let Some(objs) = object_lists.get_mut(stage_idx) else {
2684                    continue;
2685                };
2686                for (obj_idx, obj) in objs.iter_mut().enumerate() {
2687                    if embedded_by_stage
2688                        .get(stage_idx)
2689                        .is_some_and(|slots| slots.contains(&obj_idx))
2690                    {
2691                        continue;
2692                    }
2693                    if !slot_use_by_stage
2694                        .get(stage_idx)
2695                        .and_then(|flags| flags.get(obj_idx))
2696                        .copied()
2697                        .unwrap_or(true)
2698                    {
2699                        continue;
2700                    }
2701                    if let Some(hit) = hit_test_standalone_action_button_recursive(
2702                        images,
2703                        layers,
2704                        gfx,
2705                        ids,
2706                        &self.globals.syscom,
2707                        read_skip_runtime_enabled,
2708                        None,
2709                        *stage_idx,
2710                        mx,
2711                        my,
2712                        obj_idx,
2713                        obj,
2714                        None,
2715                    ) {
2716                        merge_button_hit(&mut standalone_best, &mut standalone_tied, hit);
2717                    }
2718                }
2719            }
2720            if !standalone_tied && let Some(hit) = standalone_best {
2721                if hit.event_enabled && play_hover_sound && !hit.was_hit {
2722                    hit_sounds.push(hit.se_no);
2723                }
2724                for stage_idx in &stage_ids {
2725                    let Some(objs) = object_lists.get_mut(stage_idx) else {
2726                        continue;
2727                    };
2728                    for (obj_idx, obj) in objs.iter_mut().enumerate() {
2729                        if embedded_by_stage
2730                            .get(stage_idx)
2731                            .is_some_and(|slots| slots.contains(&obj_idx))
2732                        {
2733                            continue;
2734                        }
2735                        if !slot_use_by_stage
2736                            .get(stage_idx)
2737                            .and_then(|flags| flags.get(obj_idx))
2738                            .copied()
2739                            .unwrap_or(true)
2740                        {
2741                            continue;
2742                        }
2743                        set_button_hit_by_runtime_slot_recursive(obj_idx, obj, hit.runtime_slot);
2744                    }
2745                }
2746            }
2747        }
2748
2749        {
2750            let mwnd_ui_state = self
2751                .ui
2752                .current_mwnd_window_render_state(self.screen_w, self.screen_h);
2753            let mwnd_hidden = self.globals.script.mwnd_disp_off_flag
2754                || self.globals.syscom.hide_mwnd.onoff
2755                || self.globals.syscom.msg_back_open;
2756            if let Some(st) = self.globals.stage_forms.get_mut(&form_id) {
2757                let images = &mut self.images;
2758                let layers = &self.layers;
2759                let gfx = &self.gfx;
2760                let ids = &self.ids;
2761                let mut standalone_best: Option<ButtonHitCandidate> = None;
2762                let mut standalone_tied = false;
2763                let mut stage_ids: Vec<i64> = st.mwnd_lists.keys().copied().collect();
2764                stage_ids.sort_unstable();
2765                for stage_idx in &stage_ids {
2766                    let Some(mwnds) = st.mwnd_lists.get_mut(stage_idx) else {
2767                        continue;
2768                    };
2769                    for mwnd in mwnds {
2770                        for obj in &mut mwnd.button_list {
2771                            clear_button_hit_recursive(obj);
2772                        }
2773                        for obj in &mut mwnd.face_list {
2774                            clear_button_hit_recursive(obj);
2775                        }
2776                        for obj in &mut mwnd.object_list {
2777                            clear_button_hit_recursive(obj);
2778                        }
2779                        if mwnd_hidden || !mwnd.open {
2780                            continue;
2781                        }
2782                        let Some((window_x, window_y)) = mwnd.window_pos else {
2783                            continue;
2784                        };
2785                        let Some((window_w, window_h)) = mwnd.window_size else {
2786                            continue;
2787                        };
2788                        if window_w <= 0 || window_h <= 0 {
2789                            continue;
2790                        }
2791                        let ui_state = mwnd_ui_state.filter(|ui| {
2792                            ui.x as i64 == window_x
2793                                && ui.y as i64 == window_y
2794                                && ui.w as i64 == window_w
2795                                && ui.h as i64 == window_h
2796                        });
2797                        let anim_parent =
2798                            ui_state.map(|ui| mwnd_anim_parent_from_ui_state(mwnd, ui));
2799                        let button_len = mwnd.button_list.len();
2800                        for button_idx in 0..button_len {
2801                            let skip = {
2802                                let obj = &mwnd.button_list[button_idx];
2803                                !object_button_renderable_by_syscom(&self.globals.syscom, obj)
2804                            };
2805                            if skip {
2806                                continue;
2807                            }
2808                            let parent = apply_mwnd_window_anim_parent(
2809                                mwnd_button_parent_render_state(
2810                                    mwnd, button_idx, window_x, window_y, window_w, window_h,
2811                                ),
2812                                anim_parent.clone(),
2813                            );
2814                            let obj = &mut mwnd.button_list[button_idx];
2815                            if let Some(hit) = hit_test_standalone_action_button_recursive(
2816                                images,
2817                                layers,
2818                                gfx,
2819                                ids,
2820                                &self.globals.syscom,
2821                                read_skip_runtime_enabled,
2822                                Some(button_idx),
2823                                *stage_idx,
2824                                mx,
2825                                my,
2826                                button_idx,
2827                                obj,
2828                                Some(parent),
2829                            ) {
2830                                merge_button_hit(&mut standalone_best, &mut standalone_tied, hit);
2831                            }
2832                        }
2833                        let face_len = mwnd.face_list.len();
2834                        for face_idx in 0..face_len {
2835                            let parent = apply_mwnd_window_anim_parent(
2836                                mwnd_face_parent_render_state(mwnd, face_idx, window_x, window_y),
2837                                anim_parent.clone(),
2838                            );
2839                            let obj = &mut mwnd.face_list[face_idx];
2840                            if let Some(hit) = hit_test_standalone_action_button_recursive(
2841                                images,
2842                                layers,
2843                                gfx,
2844                                ids,
2845                                &self.globals.syscom,
2846                                read_skip_runtime_enabled,
2847                                None,
2848                                *stage_idx,
2849                                mx,
2850                                my,
2851                                face_idx,
2852                                obj,
2853                                Some(parent),
2854                            ) {
2855                                merge_button_hit(&mut standalone_best, &mut standalone_tied, hit);
2856                            }
2857                        }
2858                        let object_parent = apply_mwnd_window_anim_parent(
2859                            mwnd_parent_render_state_at(mwnd, window_x, window_y),
2860                            anim_parent,
2861                        );
2862                        let object_len = mwnd.object_list.len();
2863                        for object_idx in 0..object_len {
2864                            let obj = &mut mwnd.object_list[object_idx];
2865                            if let Some(hit) = hit_test_standalone_action_button_recursive(
2866                                images,
2867                                layers,
2868                                gfx,
2869                                ids,
2870                                &self.globals.syscom,
2871                                read_skip_runtime_enabled,
2872                                None,
2873                                *stage_idx,
2874                                mx,
2875                                my,
2876                                object_idx,
2877                                obj,
2878                                Some(object_parent.clone()),
2879                            ) {
2880                                merge_button_hit(&mut standalone_best, &mut standalone_tied, hit);
2881                            }
2882                        }
2883                    }
2884                }
2885                if !standalone_tied && let Some(hit) = standalone_best {
2886                    if hit.event_enabled && play_hover_sound && !hit.was_hit {
2887                        hit_sounds.push(hit.se_no);
2888                    }
2889                    for stage_idx in &stage_ids {
2890                        let Some(mwnds) = st.mwnd_lists.get_mut(stage_idx) else {
2891                            continue;
2892                        };
2893                        for mwnd in mwnds {
2894                            for (button_idx, obj) in mwnd.button_list.iter_mut().enumerate() {
2895                                set_button_hit_by_runtime_slot_recursive(
2896                                    button_idx,
2897                                    obj,
2898                                    hit.runtime_slot,
2899                                );
2900                            }
2901                            for (face_idx, obj) in mwnd.face_list.iter_mut().enumerate() {
2902                                set_button_hit_by_runtime_slot_recursive(
2903                                    face_idx,
2904                                    obj,
2905                                    hit.runtime_slot,
2906                                );
2907                            }
2908                            for (object_idx, obj) in mwnd.object_list.iter_mut().enumerate() {
2909                                set_button_hit_by_runtime_slot_recursive(
2910                                    object_idx,
2911                                    obj,
2912                                    hit.runtime_slot,
2913                                );
2914                            }
2915                        }
2916                    }
2917                }
2918            }
2919        }
2920
2921        for se_no in hit_sounds {
2922            self.play_button_template_se(se_no, ButtonSeEvent::Hit);
2923        }
2924        self.update_mwnd_message_button_hover(play_hover_sound);
2925    }
2926
2927    fn update_mwnd_message_button_hover(&mut self, play_hover_sound: bool) -> bool {
2928        let hit = self.ui.mwnd_message_button_hit(
2929            self.input.mouse_x,
2930            self.input.mouse_y,
2931            self.screen_w,
2932            self.screen_h,
2933        );
2934        let mut play_se = None;
2935        let mut active = false;
2936        for st in self.globals.stage_forms.values_mut() {
2937            for groups in st.group_lists.values_mut() {
2938                for group in groups {
2939                    group.hit_message_button = false;
2940                }
2941            }
2942        }
2943        if let Some(hit) = hit
2944            && let Some(group) = self
2945                .globals
2946                .stage_forms
2947                .get_mut(&hit.form_id)
2948                .and_then(|st| st.group_lists.get_mut(&hit.stage_idx))
2949                .and_then(|groups| groups.get_mut(hit.group_no.max(0) as usize))
2950            && group.is_doing()
2951        {
2952            let changed = group.hit_button_no != hit.btn_no || !group.hit_message_button;
2953            group.hit_button_no = hit.btn_no;
2954            group.hit_runtime_slot = None;
2955            group.hit_message_button = true;
2956            group.message_button_se_no = hit.se_no;
2957            active = true;
2958            if changed && play_hover_sound {
2959                play_se = Some(hit.se_no);
2960            }
2961        }
2962        if let Some(se) = play_se {
2963            self.play_button_template_se(se, ButtonSeEvent::Hit);
2964        }
2965        active
2966    }
2967
2968    fn handle_mwnd_message_button_mouse_down(&mut self) -> bool {
2969        let mut play_se = None;
2970        let mut consumed = false;
2971        for st in self.globals.stage_forms.values_mut() {
2972            for groups in st.group_lists.values_mut() {
2973                for group in groups {
2974                    if group.is_doing() && group.hit_message_button && group.hit_button_no >= 0 {
2975                        group.pushed_button_no = group.hit_button_no;
2976                        group.pushed_runtime_slot = None;
2977                        group.pushed_message_button = true;
2978                        play_se = Some(group.message_button_se_no);
2979                        consumed = true;
2980                    }
2981                }
2982            }
2983        }
2984        if let Some(se) = play_se {
2985            self.play_button_template_se(se, ButtonSeEvent::Push);
2986        }
2987        consumed
2988    }
2989
2990    fn handle_mwnd_message_button_mouse_up(&mut self) -> bool {
2991        let mut play_se = None;
2992        let mut result_to_push = None;
2993        let mut consumed = false;
2994        let mut clear_focus = None;
2995        for (form_id, st) in self.globals.stage_forms.iter_mut() {
2996            for (stage_idx, groups) in st.group_lists.iter_mut() {
2997                for (group_idx, group) in groups.iter_mut().enumerate() {
2998                    if !group.pushed_message_button {
2999                        continue;
3000                    }
3001                    consumed = true;
3002                    let same = group.hit_message_button
3003                        && group.hit_button_no == group.pushed_button_no
3004                        && group.pushed_button_no >= 0;
3005                    let pushed = group.pushed_button_no;
3006                    let se_no = group.message_button_se_no;
3007                    let was_waiting = group.wait_flag;
3008                    if same && group.decide(pushed) {
3009                        play_se = Some(se_no);
3010                        if was_waiting {
3011                            group.wait_flag = false;
3012                            result_to_push = Some(pushed);
3013                            clear_focus = Some((*form_id, *stage_idx, group_idx));
3014                        }
3015                    } else {
3016                        group.pushed_button_no = -1;
3017                        group.pushed_message_button = false;
3018                    }
3019                }
3020            }
3021        }
3022        if let Some(value) = result_to_push {
3023            self.stack.push(Value::Int(value));
3024        }
3025        if clear_focus.is_some() && self.globals.focused_stage_group == clear_focus {
3026            self.globals.focused_stage_group = None;
3027        }
3028        if let Some(se) = play_se {
3029            self.play_button_template_se(se, ButtonSeEvent::Decide);
3030        }
3031        consumed
3032    }
3033
3034    fn handle_object_button_mouse_down(&mut self, b: input::VmMouseButton) -> bool {
3035        // The original button manager separates pushed_this_frame from decided_this_frame.
3036        // Press starts the push state; release inside the same button decides it.
3037        self.update_object_button_hover();
3038        if matches!(b, input::VmMouseButton::Left) && self.handle_mwnd_message_button_mouse_down() {
3039            return true;
3040        }
3041
3042        let Some(form_id) = self.active_button_stage_form_id() else {
3043            return false;
3044        };
3045        let read_skip_runtime_enabled = self.runtime_read_skip_is_enable();
3046        let mut template_sounds = Vec::new();
3047        let mut direct_sounds = Vec::new();
3048        let mut consumed_button = false;
3049
3050        {
3051            let Some(st) = self.globals.stage_forms.get_mut(&form_id) else {
3052                return false;
3053            };
3054
3055            let slot_use_by_stage = st.object_slot_use.clone();
3056            let (object_lists, group_lists, embedded_by_stage) = (
3057                &mut st.object_lists,
3058                &mut st.group_lists,
3059                &st.embedded_object_slots_by_stage,
3060            );
3061
3062            match b {
3063                input::VmMouseButton::Left => {
3064                    let mut group_stage_ids: Vec<i64> = group_lists.keys().copied().collect();
3065                    group_stage_ids.sort_unstable();
3066                    for stage_idx in group_stage_ids {
3067                        let Some(groups) = group_lists.get_mut(&stage_idx) else {
3068                            continue;
3069                        };
3070                        for (group_idx, g) in groups.iter_mut().enumerate() {
3071                            if !g.is_doing() {
3072                                continue;
3073                            }
3074                            let hit = g.hit_button_no;
3075                            let Some(hit_slot) = g.hit_runtime_slot else {
3076                                continue;
3077                            };
3078                            if hit < 0 {
3079                                continue;
3080                            }
3081                            if g.pushed_runtime_slot != Some(hit_slot)
3082                                && let Some(objs) = object_lists.get(&stage_idx)
3083                                && let Some(se_no) =
3084                                    find_button_se_no_in_list_by_runtime_slot(objs, hit_slot)
3085                            {
3086                                template_sounds.push(se_no);
3087                            }
3088                            g.pushed_button_no = hit;
3089                            g.pushed_runtime_slot = Some(hit_slot);
3090                            if let Some(objs) = object_lists.get_mut(&stage_idx) {
3091                                for (obj_idx, obj) in objs.iter_mut().enumerate() {
3092                                    if embedded_by_stage
3093                                        .get(&stage_idx)
3094                                        .is_some_and(|slots| slots.contains(&obj_idx))
3095                                        || !slot_use_by_stage
3096                                            .get(&stage_idx)
3097                                            .and_then(|flags| flags.get(obj_idx))
3098                                            .copied()
3099                                            .unwrap_or(true)
3100                                    {
3101                                        continue;
3102                                    }
3103                                    set_button_pushed_by_runtime_slot_recursive(
3104                                        obj_idx, obj, hit_slot,
3105                                    );
3106                                }
3107                            }
3108                        }
3109                    }
3110
3111                    let mut stage_ids: Vec<i64> = object_lists.keys().copied().collect();
3112                    stage_ids.sort_unstable();
3113                    for stage_idx in stage_ids {
3114                        let Some(objs) = object_lists.get_mut(&stage_idx) else {
3115                            continue;
3116                        };
3117                        for (obj_idx, obj) in objs.iter_mut().enumerate() {
3118                            if embedded_by_stage
3119                                .get(&stage_idx)
3120                                .is_some_and(|slots| slots.contains(&obj_idx))
3121                                || !slot_use_by_stage
3122                                    .get(&stage_idx)
3123                                    .and_then(|flags| flags.get(obj_idx))
3124                                    .copied()
3125                                    .unwrap_or(true)
3126                            {
3127                                continue;
3128                            }
3129                            if registered_button_hit_recursive(obj) {
3130                                consumed_button = true;
3131                            }
3132                            if let Some(se_no) = mark_standalone_button_pushed_from_hit_recursive(
3133                                obj_idx,
3134                                obj,
3135                                &self.globals.syscom,
3136                                read_skip_runtime_enabled,
3137                                None,
3138                            ) {
3139                                template_sounds.push(se_no);
3140                            }
3141                        }
3142                    }
3143                }
3144                input::VmMouseButton::Right => {
3145                    let mut candidates: Vec<(i64, usize, i64)> = Vec::new();
3146                    let mut group_stage_ids: Vec<i64> = group_lists.keys().copied().collect();
3147                    group_stage_ids.sort_unstable();
3148                    for stage_idx in group_stage_ids {
3149                        let Some(groups) = group_lists.get(&stage_idx) else {
3150                            continue;
3151                        };
3152                        for (group_idx, g) in groups.iter().enumerate() {
3153                            if g.is_doing() && g.cancel_flag {
3154                                candidates.push((g.cancel_priority, group_idx, stage_idx));
3155                            }
3156                        }
3157                    }
3158                    candidates.sort_by_key(|a| std::cmp::Reverse(a.0));
3159                    if let Some((_priority, group_idx, stage_idx)) = candidates.first().copied()
3160                        && let Some(groups) = group_lists.get_mut(&stage_idx)
3161                        && let Some(g) = groups.get_mut(group_idx)
3162                    {
3163                        let was_waiting = g.wait_flag;
3164                        let cancel_se_no = g.cancel_se_no;
3165                        if g.cancel().is_some() {
3166                            if sg_debug_enabled() {
3167                                eprintln!(
3168                                    "[SG_DEBUG][GROUP] cancel form={} stage={} group={} wait={} result_button={} se={}",
3169                                    form_id,
3170                                    stage_idx,
3171                                    group_idx,
3172                                    was_waiting,
3173                                    g.result_button_no,
3174                                    cancel_se_no
3175                                );
3176                            }
3177                            if was_waiting {
3178                                self.stack.push(Value::Int(globals::TNM_GROUP_CANCELED));
3179                            }
3180                            g.wait_flag = false;
3181                            direct_sounds.push(cancel_se_no);
3182                            if self.globals.focused_stage_group
3183                                == Some((form_id, stage_idx, group_idx))
3184                            {
3185                                self.globals.focused_stage_group = None;
3186                            }
3187                        }
3188                    }
3189                }
3190                _ => {}
3191            }
3192        }
3193
3194        {
3195            let mwnd_hidden = self.globals.script.mwnd_disp_off_flag
3196                || self.globals.syscom.hide_mwnd.onoff
3197                || self.globals.syscom.msg_back_open;
3198            let syscom = self.globals.syscom.clone();
3199            if let Some(st) = self.globals.stage_forms.get_mut(&form_id) {
3200                let mut stage_ids: Vec<i64> = st.mwnd_lists.keys().copied().collect();
3201                stage_ids.sort_unstable();
3202                for stage_idx in stage_ids {
3203                    let Some(mwnds) = st.mwnd_lists.get_mut(&stage_idx) else {
3204                        continue;
3205                    };
3206                    for mwnd in mwnds {
3207                        if mwnd_hidden || !mwnd.open {
3208                            continue;
3209                        }
3210                        let Some((_, _)) = mwnd.window_pos else {
3211                            continue;
3212                        };
3213                        let Some((window_w, window_h)) = mwnd.window_size else {
3214                            continue;
3215                        };
3216                        if window_w <= 0 || window_h <= 0 {
3217                            continue;
3218                        }
3219                        for (button_idx, obj) in mwnd.button_list.iter_mut().enumerate() {
3220                            if !object_button_renderable_by_syscom(&syscom, obj) {
3221                                continue;
3222                            }
3223                            if registered_button_hit_recursive(obj) {
3224                                consumed_button = true;
3225                            }
3226                            if let Some(se_no) = mark_standalone_button_pushed_from_hit_recursive(
3227                                button_idx,
3228                                obj,
3229                                &syscom,
3230                                read_skip_runtime_enabled,
3231                                Some(button_idx),
3232                            ) {
3233                                template_sounds.push(se_no);
3234                            }
3235                        }
3236                        for (face_idx, obj) in mwnd.face_list.iter_mut().enumerate() {
3237                            if !object_button_renderable_by_syscom(&syscom, obj) {
3238                                continue;
3239                            }
3240                            if registered_button_hit_recursive(obj) {
3241                                consumed_button = true;
3242                            }
3243                            if let Some(se_no) = mark_standalone_button_pushed_from_hit_recursive(
3244                                face_idx,
3245                                obj,
3246                                &syscom,
3247                                read_skip_runtime_enabled,
3248                                None,
3249                            ) {
3250                                template_sounds.push(se_no);
3251                            }
3252                        }
3253                        for (object_idx, obj) in mwnd.object_list.iter_mut().enumerate() {
3254                            if !object_button_renderable_by_syscom(&syscom, obj) {
3255                                continue;
3256                            }
3257                            if registered_button_hit_recursive(obj) {
3258                                consumed_button = true;
3259                            }
3260                            if let Some(se_no) = mark_standalone_button_pushed_from_hit_recursive(
3261                                object_idx,
3262                                obj,
3263                                &syscom,
3264                                read_skip_runtime_enabled,
3265                                None,
3266                            ) {
3267                                template_sounds.push(se_no);
3268                            }
3269                        }
3270                    }
3271                }
3272            }
3273        }
3274
3275        let consumed = consumed_button || !template_sounds.is_empty() || !direct_sounds.is_empty();
3276        if consumed && matches!(b, input::VmMouseButton::Left) {
3277            // C_tnm_btn_mng::hit_test_proc owns the active button until the
3278            // matching release. This capture exists even when button_event()
3279            // rejects DISABLE, so both DECIDE edges must remain out of script.
3280            self.button_decide_capture = true;
3281            let _ = self.input.use_vk_down_stock(0x01);
3282        }
3283        for se_no in template_sounds {
3284            self.play_button_template_se(se_no, ButtonSeEvent::Push);
3285        }
3286        for se_no in direct_sounds {
3287            self.play_button_se_no(se_no);
3288        }
3289        consumed
3290    }
3291
3292    fn handle_object_button_mouse_up(&mut self, b: input::VmMouseButton) -> bool {
3293        if !matches!(b, input::VmMouseButton::Left) {
3294            return false;
3295        }
3296        let captured_by_button_manager = std::mem::take(&mut self.button_decide_capture);
3297
3298        self.update_object_button_hover();
3299        if self.handle_mwnd_message_button_mouse_up() {
3300            return true;
3301        }
3302
3303        let Some(form_id) = self.active_button_stage_form_id() else {
3304            if captured_by_button_manager {
3305                let _ = self.input.use_vk_up_stock(0x01);
3306            }
3307            return captured_by_button_manager;
3308        };
3309        let read_skip_runtime_enabled = self.runtime_read_skip_is_enable();
3310        let syscom = self.globals.syscom.clone();
3311        let mut pending_button_actions = Vec::new();
3312        let mut sounds = Vec::new();
3313        let mut consumed_button = false;
3314
3315        {
3316            let Some(st) = self.globals.stage_forms.get_mut(&form_id) else {
3317                return false;
3318            };
3319
3320            let slot_use_by_stage = st.object_slot_use.clone();
3321            let (object_lists, group_lists, embedded_by_stage) = (
3322                &mut st.object_lists,
3323                &mut st.group_lists,
3324                &st.embedded_object_slots_by_stage,
3325            );
3326
3327            let mut group_stage_ids: Vec<i64> = group_lists.keys().copied().collect();
3328            group_stage_ids.sort_unstable();
3329            for stage_idx in group_stage_ids {
3330                let Some(groups) = group_lists.get_mut(&stage_idx) else {
3331                    continue;
3332                };
3333                for (group_idx, g) in groups.iter_mut().enumerate() {
3334                    if !g.is_doing() {
3335                        continue;
3336                    }
3337                    let pushed = g.pushed_button_no;
3338                    let pushed_slot = g.pushed_runtime_slot;
3339                    let release_keeps_push = pushed_slot
3340                        .and_then(|slot| {
3341                            object_lists.get(&stage_idx).map(|objs| {
3342                                object_button_push_keep_in_list_by_runtime_slot(objs, slot)
3343                            })
3344                        })
3345                        .unwrap_or(false);
3346                    let released_on_same_button = pushed >= 0
3347                        && pushed_slot.is_some()
3348                        && (g.hit_runtime_slot == pushed_slot || release_keeps_push);
3349                    if released_on_same_button {
3350                        let was_waiting = g.wait_flag;
3351                        let action_slot = pushed_slot.unwrap();
3352                        let event_enabled = object_lists
3353                            .get(&stage_idx)
3354                            .map(|objs| {
3355                                button_event_enabled_in_list_by_runtime_slot(
3356                                    objs,
3357                                    action_slot,
3358                                    &syscom,
3359                                    read_skip_runtime_enabled,
3360                                )
3361                            })
3362                            .unwrap_or(false);
3363                        if event_enabled && g.decide(pushed) {
3364                            if sg_debug_enabled() {
3365                                eprintln!(
3366                                    "[SG_DEBUG][GROUP] decide form={} stage={} group={} button={} slot={} wait={}",
3367                                    form_id, stage_idx, group_idx, pushed, action_slot, was_waiting
3368                                );
3369                            }
3370                            if let Some(objs) = object_lists.get(&stage_idx) {
3371                                if let Some(se_no) =
3372                                    find_button_se_no_in_list_by_runtime_slot(objs, action_slot)
3373                                {
3374                                    sounds.push(se_no);
3375                                }
3376                                for (obj_idx, obj) in objs.iter().enumerate() {
3377                                    if embedded_by_stage
3378                                        .get(&stage_idx)
3379                                        .is_some_and(|slots| slots.contains(&obj_idx))
3380                                        || !slot_use_by_stage
3381                                            .get(&stage_idx)
3382                                            .and_then(|flags| flags.get(obj_idx))
3383                                            .copied()
3384                                            .unwrap_or(true)
3385                                    {
3386                                        continue;
3387                                    }
3388                                    collect_button_decided_action_by_runtime_slot_recursive(
3389                                        obj_idx,
3390                                        obj,
3391                                        action_slot,
3392                                        &mut pending_button_actions,
3393                                    );
3394                                }
3395                            }
3396                            if was_waiting {
3397                                self.stack.push(Value::Int(pushed));
3398                                g.wait_flag = false;
3399                                if self.globals.focused_stage_group
3400                                    == Some((form_id, stage_idx, group_idx))
3401                                {
3402                                    self.globals.focused_stage_group = None;
3403                                }
3404                            }
3405                        }
3406                    } else {
3407                        g.pushed_button_no = -1;
3408                        g.pushed_runtime_slot = None;
3409                    }
3410                }
3411            }
3412
3413            let mut stage_ids: Vec<i64> = object_lists.keys().copied().collect();
3414            stage_ids.sort_unstable();
3415            for stage_idx in &stage_ids {
3416                let Some(objs) = object_lists.get(stage_idx) else {
3417                    continue;
3418                };
3419                for (obj_idx, obj) in objs.iter().enumerate() {
3420                    if embedded_by_stage
3421                        .get(stage_idx)
3422                        .is_some_and(|slots| slots.contains(&obj_idx))
3423                        || !slot_use_by_stage
3424                            .get(stage_idx)
3425                            .and_then(|flags| flags.get(obj_idx))
3426                            .copied()
3427                            .unwrap_or(true)
3428                    {
3429                        continue;
3430                    }
3431                    if standalone_button_pushed_recursive(obj) {
3432                        consumed_button = true;
3433                    }
3434                    collect_standalone_button_decided_actions_recursive(
3435                        obj,
3436                        &mut pending_button_actions,
3437                        &mut sounds,
3438                        &syscom,
3439                        read_skip_runtime_enabled,
3440                        None,
3441                    );
3442                }
3443            }
3444
3445            for stage_idx in &stage_ids {
3446                let Some(objs) = object_lists.get_mut(stage_idx) else {
3447                    continue;
3448                };
3449                for (obj_idx, obj) in objs.iter_mut().enumerate() {
3450                    if embedded_by_stage
3451                        .get(stage_idx)
3452                        .is_some_and(|slots| slots.contains(&obj_idx))
3453                        || !slot_use_by_stage
3454                            .get(stage_idx)
3455                            .and_then(|flags| flags.get(obj_idx))
3456                            .copied()
3457                            .unwrap_or(true)
3458                    {
3459                        continue;
3460                    }
3461                    clear_button_pushed_recursive(obj);
3462                }
3463            }
3464        }
3465
3466        {
3467            let mwnd_hidden = self.globals.script.mwnd_disp_off_flag
3468                || self.globals.syscom.hide_mwnd.onoff
3469                || self.globals.syscom.msg_back_open;
3470            let syscom = self.globals.syscom.clone();
3471            if let Some(st) = self.globals.stage_forms.get_mut(&form_id) {
3472                let mut stage_ids: Vec<i64> = st.mwnd_lists.keys().copied().collect();
3473                stage_ids.sort_unstable();
3474                for stage_idx in &stage_ids {
3475                    let Some(mwnds) = st.mwnd_lists.get(stage_idx) else {
3476                        continue;
3477                    };
3478                    for mwnd in mwnds {
3479                        if mwnd_hidden || !mwnd.open {
3480                            continue;
3481                        }
3482                        let Some((_, _)) = mwnd.window_pos else {
3483                            continue;
3484                        };
3485                        let Some((window_w, window_h)) = mwnd.window_size else {
3486                            continue;
3487                        };
3488                        if window_w <= 0 || window_h <= 0 {
3489                            continue;
3490                        }
3491                        for (button_idx, obj) in mwnd.button_list.iter().enumerate() {
3492                            if !object_button_renderable_by_syscom(&syscom, obj)
3493                                || button_effective_disabled(
3494                                    &syscom,
3495                                    obj,
3496                                    Some(button_idx),
3497                                    read_skip_runtime_enabled,
3498                                )
3499                                || syscom.mwnd_btn_touch_disable
3500                            {
3501                                continue;
3502                            }
3503                            collect_standalone_button_decided_actions_recursive(
3504                                obj,
3505                                &mut pending_button_actions,
3506                                &mut sounds,
3507                                &syscom,
3508                                read_skip_runtime_enabled,
3509                                Some(button_idx),
3510                            );
3511                        }
3512                        for obj in &mwnd.face_list {
3513                            if !object_button_renderable_by_syscom(&syscom, obj)
3514                                || button_effective_disabled(
3515                                    &syscom,
3516                                    obj,
3517                                    None,
3518                                    read_skip_runtime_enabled,
3519                                )
3520                                || syscom.mwnd_btn_touch_disable
3521                            {
3522                                continue;
3523                            }
3524                            collect_standalone_button_decided_actions_recursive(
3525                                obj,
3526                                &mut pending_button_actions,
3527                                &mut sounds,
3528                                &syscom,
3529                                read_skip_runtime_enabled,
3530                                None,
3531                            );
3532                        }
3533                        for obj in &mwnd.object_list {
3534                            if !object_button_renderable_by_syscom(&syscom, obj)
3535                                || button_effective_disabled(
3536                                    &syscom,
3537                                    obj,
3538                                    None,
3539                                    read_skip_runtime_enabled,
3540                                )
3541                                || syscom.mwnd_btn_touch_disable
3542                            {
3543                                continue;
3544                            }
3545                            collect_standalone_button_decided_actions_recursive(
3546                                obj,
3547                                &mut pending_button_actions,
3548                                &mut sounds,
3549                                &syscom,
3550                                read_skip_runtime_enabled,
3551                                None,
3552                            );
3553                        }
3554                    }
3555                }
3556                for stage_idx in &stage_ids {
3557                    let Some(mwnds) = st.mwnd_lists.get_mut(stage_idx) else {
3558                        continue;
3559                    };
3560                    for mwnd in mwnds {
3561                        for obj in &mut mwnd.button_list {
3562                            clear_button_pushed_recursive(obj);
3563                        }
3564                        for obj in &mut mwnd.face_list {
3565                            clear_button_pushed_recursive(obj);
3566                        }
3567                        for obj in &mut mwnd.object_list {
3568                            clear_button_pushed_recursive(obj);
3569                        }
3570                    }
3571                }
3572            }
3573        }
3574
3575        let consumed = captured_by_button_manager
3576            || consumed_button
3577            || !pending_button_actions.is_empty()
3578            || !sounds.is_empty();
3579        if consumed {
3580            // The native button manager consumes DECIDE-UP before
3581            // C_elm_object::button_event() checks DISABLE.  Without this, a
3582            // toolbar click can leak into SCRIPT.SET_SKIP_TRIGGER/message wait.
3583            let _ = self.input.use_vk_up_stock(0x01);
3584        }
3585        self.globals
3586            .pending_button_actions
3587            .extend(pending_button_actions);
3588        for se_no in sounds {
3589            self.play_button_template_se(se_no, ButtonSeEvent::Decide);
3590        }
3591        consumed
3592    }
3593    // ------------------------------------------------------------------
3594    // Input bridge (platform event -> VM state)
3595    // ------------------------------------------------------------------
3596
3597    pub fn platform_shortcuts_blocked(&self) -> bool {
3598        self.globals.system.messagebox_modal.is_some()
3599            || self.globals.syscom.menu_open
3600            || self.globals.syscom.msg_back_open
3601            || self.globals.selbtn.started
3602            || self.globals.selbtn.processing_flag_0
3603            || self.globals.focused_editbox.is_some()
3604    }
3605
3606    fn is_vm_key_disabled(&self, k: input::VmKey) -> bool {
3607        input::vmkey_to_vk_code(k)
3608            .map(|vk| self.globals.script.key_disable.contains(&vk))
3609            .unwrap_or(false)
3610    }
3611
3612    pub fn on_key_down(&mut self, k: input::VmKey) {
3613        if self.handle_system_messagebox_key(k) {
3614            return;
3615        }
3616        if self.handle_msg_back_key(k) {
3617            return;
3618        }
3619        self.cancel_pending_editbox_composition();
3620
3621        // A native Win32 EDIT owns keyboard focus before the engine's selection
3622        // handlers. Preserve that ordering for the cross-platform editor.
3623        let editbox_active = self.editbox_accepts_keyboard_input();
3624        let mut input_recorded = false;
3625        if editbox_active {
3626            if self.is_vm_key_disabled(k) {
3627                return;
3628            }
3629            self.input.on_key_down(k);
3630            input_recorded = true;
3631            if Self::is_modifier_key(k) {
3632                return;
3633            }
3634            if self.handle_editbox_key(k) {
3635                return;
3636            }
3637        }
3638
3639        if self.globals.syscom.hide_mwnd.onoff
3640            && matches!(
3641                k,
3642                input::VmKey::Enter | input::VmKey::Escape | input::VmKey::Space
3643            )
3644        {
3645            if !input_recorded {
3646                self.input.on_key_down(k);
3647            }
3648            return;
3649        }
3650        if self.handle_selbtn_key(k) {
3651            return;
3652        }
3653        if self.is_vm_key_disabled(k) {
3654            return;
3655        }
3656        if !input_recorded {
3657            self.input.on_key_down(k);
3658        }
3659        match k {
3660            input::VmKey::Enter | input::VmKey::Space => {
3661                if let Some(vk) = input::vmkey_to_vk_code(k) {
3662                    let _ = self.stop_read_skip_by_decide_down(vk);
3663                }
3664            }
3665            input::VmKey::Escape | input::VmKey::Control => self.register_skip_user_action(),
3666            _ => {}
3667        }
3668        if Self::is_modifier_key(k) {
3669            return;
3670        }
3671
3672        let handled_mwnd_selection = self.handle_mwnd_selection_key(k);
3673
3674        // Stage group selection runtime: map Enter/Escape to a decision.
3675        if !handled_mwnd_selection
3676            && let Some((form_id, stage_idx, group_idx)) = self.globals.focused_stage_group
3677            && let Some(st) = self.globals.stage_forms.get_mut(&form_id)
3678            && let Some(list) = st.group_lists.get_mut(&stage_idx)
3679            && let Some(g) = list.get_mut(group_idx)
3680        {
3681            match k {
3682                input::VmKey::Enter => {
3683                    let button_no = if g.hit_button_no >= 0 {
3684                        g.hit_button_no
3685                    } else {
3686                        0
3687                    };
3688                    let was_waiting = g.wait_flag;
3689                    if g.decide(button_no) {
3690                        if sg_debug_enabled() {
3691                            eprintln!(
3692                                "[SG_DEBUG][GROUP] key_decide form={} stage={} group={} button={} wait={}",
3693                                form_id, stage_idx, group_idx, button_no, was_waiting
3694                            );
3695                        }
3696                        if was_waiting {
3697                            self.stack.push(Value::Int(button_no));
3698                        }
3699                        g.wait_flag = false;
3700                        self.globals.focused_stage_group = None;
3701                    }
3702                }
3703                input::VmKey::Escape => {
3704                    let was_waiting = g.wait_flag;
3705                    if g.cancel().is_some() {
3706                        if sg_debug_enabled() {
3707                            eprintln!(
3708                                "[SG_DEBUG][GROUP] key_cancel form={} stage={} group={} wait={} result_button={}",
3709                                form_id, stage_idx, group_idx, was_waiting, g.result_button_no
3710                            );
3711                        }
3712                        if was_waiting {
3713                            self.stack.push(Value::Int(globals::TNM_GROUP_CANCELED));
3714                        }
3715                        g.wait_flag = false;
3716                        self.globals.focused_stage_group = None;
3717                    }
3718                }
3719                _ => {}
3720            }
3721        }
3722    }
3723
3724    pub fn on_key_up(&mut self, k: input::VmKey) {
3725        self.cancel_pending_editbox_composition();
3726        if self.is_vm_key_disabled(k) {
3727            return;
3728        }
3729        self.input.on_key_up(k);
3730        if self.editbox_accepts_keyboard_input()
3731            && !matches!(k, input::VmKey::F(_) | input::VmKey::Other(_))
3732        {
3733            return;
3734        }
3735        if self.globals.syscom.hide_mwnd.onoff
3736            && matches!(
3737                k,
3738                input::VmKey::Enter | input::VmKey::Escape | input::VmKey::Space
3739            )
3740        {
3741            self.globals.syscom.hide_mwnd.onoff = false;
3742            return;
3743        }
3744        if let Some(vk) = input::vmkey_to_vk_code(k) {
3745            match k {
3746                input::VmKey::Enter | input::VmKey::Space => {
3747                    // eng_frame.cpp checks the script trigger's DECIDE-UP stock
3748                    // independently from DOWN_UP once the message is fully
3749                    // revealed.  The helper itself requires DOWN_UP only for
3750                    // the still-revealing path.
3751                    if self.process_script_skip_trigger_decide(vk) {
3752                        return;
3753                    }
3754                    if self.input.vk_down_up_stock(vk) {
3755                        if self.notify_movie_wait_down_up(1) {
3756                            return;
3757                        }
3758                        if !self.advance_message_wait(true) {
3759                            // TNM_PROC_TYPE_KEY_WAIT consumes DECIDE down-up.
3760                            self.notify_wait_key();
3761                        }
3762                    }
3763                }
3764                input::VmKey::Escape if self.input.vk_down_up_stock(vk) => {
3765                    let _ = self.notify_movie_wait_down_up(-1);
3766                }
3767                _ => {}
3768            }
3769        }
3770    }
3771
3772    pub fn on_text_input(&mut self, text: &str) {
3773        if self.globals.system.messagebox_modal.is_some() {
3774            return;
3775        }
3776        let Some((form_id, idx)) = self.globals.focused_editbox else {
3777            return;
3778        };
3779        let Some(list) = self.globals.editbox_lists.get_mut(&form_id) else {
3780            return;
3781        };
3782        let Some(eb) = list.boxes.get_mut(idx) else {
3783            return;
3784        };
3785        if !eb.created || !eb.visible {
3786            return;
3787        }
3788        eb.commit_text(text);
3789    }
3790
3791    pub fn on_ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) {
3792        if self.globals.system.messagebox_modal.is_some() {
3793            return;
3794        }
3795        let Some((form_id, idx)) = self.globals.focused_editbox else {
3796            return;
3797        };
3798        let Some(eb) = self
3799            .globals
3800            .editbox_lists
3801            .get_mut(&form_id)
3802            .and_then(|list| list.boxes.get_mut(idx))
3803        else {
3804            return;
3805        };
3806        if !eb.created || !eb.visible {
3807            return;
3808        }
3809        eb.set_ime_preedit(text, cursor);
3810    }
3811
3812    pub fn on_ime_disabled(&mut self) {
3813        let Some((form_id, idx)) = self.globals.focused_editbox else {
3814            return;
3815        };
3816        if let Some(eb) = self
3817            .globals
3818            .editbox_lists
3819            .get_mut(&form_id)
3820            .and_then(|list| list.boxes.get_mut(idx))
3821        {
3822            eb.cancel_composition();
3823        }
3824    }
3825
3826    pub fn editbox_accepts_keyboard_input(&self) -> bool {
3827        let Some((form_id, idx)) = self.globals.focused_editbox else {
3828            return false;
3829        };
3830        self.globals
3831            .editbox_lists
3832            .get(&form_id)
3833            .and_then(|list| list.boxes.get(idx))
3834            .map(|eb| eb.created && eb.visible)
3835            .unwrap_or(false)
3836    }
3837
3838    pub fn editbox_accepts_direct_text(&self) -> bool {
3839        let Some((form_id, idx)) = self.globals.focused_editbox else {
3840            return false;
3841        };
3842        let Some(editbox) = self
3843            .globals
3844            .editbox_lists
3845            .get(&form_id)
3846            .and_then(|list| list.boxes.get(idx))
3847        else {
3848            return false;
3849        };
3850        editbox.created && editbox.visible && !editbox.is_composing()
3851    }
3852
3853    pub fn focused_editbox_ime_area(&self) -> Option<(i32, i32, i32, i32)> {
3854        let (form_id, idx) = self.globals.focused_editbox?;
3855        let eb = self.globals.editbox_lists.get(&form_id)?.boxes.get(idx)?;
3856        if !eb.created || !eb.visible {
3857            return None;
3858        }
3859        Some((
3860            eb.caret_window_x().max(eb.window_x),
3861            eb.window_y.saturating_add(1),
3862            1,
3863            eb.window_moji_size.max(1).min(eb.window_h.max(1)),
3864        ))
3865    }
3866
3867    fn open_syscom_menu_from_cancel_key(&mut self) -> bool {
3868        // Original C++ cancel_call_proc(): right-click/Escape/Z is VK_EX_CANCEL.
3869        // When the local syscom menu is enabled, it clears read-skip and calls
3870        // tnm_syscom_open(); tnm_syscom_open() enters CANCEL_SCENE when configured.
3871        if self.globals.syscom.syscom_menu_disable {
3872            return false;
3873        }
3874        if self.globals.syscom.msg_back_open || self.globals.syscom.hide_mwnd.onoff {
3875            return false;
3876        }
3877        // Original C++ cancel_call_proc() does not open another cancel/syscom scene
3878        // while an EXCALL scene is active.  This is required for syscom scenes such
3879        // as CANCEL_SCENE/sys10_qm00: the right-click stock must remain available to
3880        // that scene's own script instead of recursively opening CANCEL_SCENE again.
3881        if self.excall_state.ex_call_flag {
3882            return false;
3883        }
3884        if self.movie.current().is_some() {
3885            return false;
3886        }
3887        self.globals.syscom.read_skip.onoff = false;
3888        self.globals.syscom.pending_proc = Some(globals::SyscomPendingProc {
3889            kind: globals::SyscomPendingProcKind::OpenSyscomMenu,
3890            warning: false,
3891            se_play: false,
3892            fade_out: false,
3893            leave_msgbk: false,
3894            save_id: 0,
3895        });
3896        if env_is_set!("SG_PROC_FLOW_TRACE") {
3897            eprintln!(
3898                "[SG_PROC_FLOW] open_syscom_menu_from_cancel_key scene={:?} line={} pending_proc={:?}",
3899                self.current_scene_name, self.current_line_no, self.globals.syscom.pending_proc
3900            );
3901        }
3902        true
3903    }
3904
3905    pub fn on_mouse_move(&mut self, x: i32, y: i32) {
3906        self.input.on_mouse_move(x, y);
3907        if self.update_editbox_drag_selection() {
3908            return;
3909        }
3910        if let Some(idx) = self.selbtn_hit_index(x, y) {
3911            if let Some(pressed) = self.globals.selbtn.pressed_index {
3912                let inside = pressed == idx;
3913                if self.globals.selbtn.pressed_inside != inside {
3914                    self.globals.selbtn.pressed_inside = inside;
3915                    self.sync_selbtn_item_selection();
3916                }
3917            }
3918            if self.globals.selbtn.cursor != idx {
3919                self.globals.selbtn.cursor = idx;
3920                self.sync_selbtn_item_selection();
3921            }
3922            return;
3923        }
3924        if self.globals.selbtn.pressed_index.is_some() && self.globals.selbtn.pressed_inside {
3925            self.globals.selbtn.pressed_inside = false;
3926            self.sync_selbtn_item_selection();
3927        }
3928        if self.handle_msg_back_mouse_move() {
3929            return;
3930        }
3931        self.update_object_button_hover();
3932    }
3933
3934    pub fn on_mouse_down(&mut self, b: input::VmMouseButton) {
3935        if sg_input_trace_enabled() {
3936            eprintln!(
3937                "[SG_DEBUG][INPUT] mouse_down {:?} at=({}, {})",
3938                b, self.input.mouse_x, self.input.mouse_y
3939            );
3940        }
3941        if self.handle_system_messagebox_click(b) {
3942            return;
3943        }
3944        if self.globals.syscom.msg_back_open {
3945            self.input.on_mouse_down(b);
3946            self.handle_msg_back_mouse_down(b);
3947            return;
3948        }
3949        self.cancel_pending_editbox_composition();
3950        if self.begin_editbox_mouse_down(b) {
3951            self.input.on_mouse_down(b);
3952            return;
3953        }
3954        if self.handle_selbtn_mouse_down(b) {
3955            return;
3956        }
3957        let handled_mwnd_selection = self.handle_mwnd_selection_click(b);
3958        self.input.on_mouse_down(b);
3959        let handled_button = if !handled_mwnd_selection {
3960            self.handle_object_button_mouse_down(b)
3961        } else {
3962            false
3963        };
3964        if matches!(b, input::VmMouseButton::Right) && handled_button {
3965            self.suppress_next_right_syscom_open = true;
3966        }
3967        if !handled_button && !handled_mwnd_selection {
3968            match b {
3969                input::VmMouseButton::Left => {
3970                    let _ = self.stop_read_skip_by_decide_down(0x01);
3971                }
3972                input::VmMouseButton::Right => self.register_skip_user_action(),
3973                _ => {}
3974            }
3975        }
3976    }
3977
3978    fn begin_editbox_mouse_down(&mut self, b: input::VmMouseButton) -> bool {
3979        if !matches!(b, input::VmMouseButton::Left) {
3980            return false;
3981        }
3982        let x = self.input.mouse_x;
3983        let y = self.input.mouse_y;
3984        let mut hits = Vec::new();
3985        for (form_id, list) in &self.globals.editbox_lists {
3986            for (idx, eb) in list.boxes.iter().enumerate() {
3987                if eb.contains_point(x, y) {
3988                    hits.push((*form_id, idx));
3989                }
3990            }
3991        }
3992        hits.sort_unstable();
3993        let Some(target) = hits.into_iter().next_back() else {
3994            self.set_focused_editbox(None);
3995            return false;
3996        };
3997        self.set_focused_editbox(Some(target));
3998        let extend = self.input.vk_is_down(0x10);
3999        if let Some(eb) = self
4000            .globals
4001            .editbox_lists
4002            .get_mut(&target.0)
4003            .and_then(|list| list.boxes.get_mut(target.1))
4004        {
4005            eb.set_cursor_from_window_x(x, extend);
4006            eb.mouse_selecting = true;
4007        }
4008        true
4009    }
4010
4011    fn update_editbox_drag_selection(&mut self) -> bool {
4012        let Some((form_id, idx)) = self.globals.focused_editbox else {
4013            return false;
4014        };
4015        if !self.input.vk_is_down(0x01) {
4016            return false;
4017        }
4018        let x = self.input.mouse_x;
4019        let Some(eb) = self
4020            .globals
4021            .editbox_lists
4022            .get_mut(&form_id)
4023            .and_then(|list| list.boxes.get_mut(idx))
4024        else {
4025            return false;
4026        };
4027        if !eb.mouse_selecting {
4028            return false;
4029        }
4030        eb.set_cursor_from_window_x(x, true);
4031        true
4032    }
4033
4034    fn finish_editbox_mouse_up(&mut self, b: input::VmMouseButton) -> bool {
4035        if !matches!(b, input::VmMouseButton::Left) {
4036            return false;
4037        }
4038        let Some((form_id, idx)) = self.globals.focused_editbox else {
4039            return false;
4040        };
4041        let Some(eb) = self
4042            .globals
4043            .editbox_lists
4044            .get_mut(&form_id)
4045            .and_then(|list| list.boxes.get_mut(idx))
4046        else {
4047            return false;
4048        };
4049        let was_selecting = eb.mouse_selecting;
4050        eb.mouse_selecting = false;
4051        was_selecting
4052    }
4053
4054    pub fn on_mouse_up(&mut self, b: input::VmMouseButton) {
4055        if sg_input_trace_enabled() {
4056            eprintln!(
4057                "[SG_DEBUG][INPUT] mouse_up {:?} at=({}, {})",
4058                b, self.input.mouse_x, self.input.mouse_y
4059            );
4060        }
4061        self.input.on_mouse_up(b);
4062        if self.finish_editbox_mouse_up(b) {
4063            return;
4064        }
4065        if self.handle_msg_back_mouse_up(b) {
4066            return;
4067        }
4068        if self.handle_selbtn_mouse_up(b) {
4069            return;
4070        }
4071        let left_down_up =
4072            matches!(b, input::VmMouseButton::Left) && self.input.vk_down_up_stock(0x01);
4073        let right_down_up =
4074            matches!(b, input::VmMouseButton::Right) && self.input.vk_down_up_stock(0x02);
4075        let movie_skipped = if left_down_up {
4076            self.notify_movie_wait_down_up(1)
4077        } else if right_down_up {
4078            self.notify_movie_wait_down_up(-1)
4079        } else {
4080            false
4081        };
4082        if movie_skipped {
4083            return;
4084        }
4085        if right_down_up {
4086            if std::mem::take(&mut self.suppress_next_right_syscom_open) {
4087                return;
4088            }
4089            if self.open_syscom_menu_from_cancel_key() {
4090                return;
4091            }
4092        }
4093        let handled_button = self.handle_object_button_mouse_up(b);
4094        if handled_button {
4095            return;
4096        }
4097        if matches!(b, input::VmMouseButton::Left) && self.process_script_skip_trigger_decide(0x01)
4098        {
4099            return;
4100        }
4101        if left_down_up && !self.advance_message_wait(true) {
4102            self.notify_wait_key();
4103        }
4104    }
4105
4106    pub fn on_mouse_wheel(&mut self, delta_y: i32) {
4107        self.input.on_mouse_wheel(delta_y);
4108        if self.globals.syscom.msg_back_open {
4109            if delta_y > 0 {
4110                self.msg_back_target_up();
4111            } else if delta_y < 0 {
4112                self.msg_back_target_down();
4113            }
4114            return;
4115        }
4116        if delta_y > 0 && self.msg_back_is_enable() {
4117            self.open_msg_back_proc();
4118            return;
4119        }
4120        if delta_y < 0 && self.should_wheel_advance_message() {
4121            if self.process_script_skip_trigger_wheel_down() {
4122                return;
4123            }
4124            let _ = self.advance_message_wait(true);
4125        }
4126    }
4127
4128    fn finish_skipped_movie_waits(&mut self) {
4129        while let Some(info) = self.wait.take_movie_skip() {
4130            let Some(st) = self.globals.stage_forms.get_mut(&info.stage_form_id) else {
4131                continue;
4132            };
4133            let Some(list) = st.object_lists.get_mut(&info.stage_idx) else {
4134                continue;
4135            };
4136            let Some(obj) = find_object_by_runtime_slot_mut(list, info.runtime_slot) else {
4137                continue;
4138            };
4139
4140            // Key skip triggers C_elm_object::init_type(true) on the actual object that owns
4141            // the movie, including nested CHILD objects addressed by runtime slot.
4142            let audio_id = obj.movie.audio_id.take();
4143            let backend = obj.backend.clone();
4144            obj.init_type_like();
4145
4146            if let Some(id) = audio_id {
4147                self.movie.stop_audio(id);
4148            }
4149            if let globals::ObjectBackend::Movie {
4150                layer_id,
4151                sprite_id,
4152                ..
4153            } = backend
4154                && let Some(layer) = self.layers.layer_mut(layer_id)
4155                && let Some(sprite) = layer.sprite_mut(sprite_id)
4156            {
4157                sprite.visible = false;
4158                sprite.image_id = None;
4159            }
4160        }
4161    }
4162
4163    fn handle_editbox_key(&mut self, k: input::VmKey) -> bool {
4164        let Some((form_id, idx)) = self.globals.focused_editbox else {
4165            return false;
4166        };
4167        let alt_down = self.input.vk_is_down(0x12);
4168        let shift_down = self.input.vk_is_down(0x10);
4169        let shortcut_down = self.input.vk_is_down(0x11) || self.input.vk_is_down(0x5B);
4170        let clipboard = self.globals.editbox_clipboard.clone();
4171        let mut move_focus: Option<bool> = None;
4172        let mut toggle_screen = false;
4173        let mut copied_text: Option<String> = None;
4174        let consumed = {
4175            let Some(eb) = self
4176                .globals
4177                .editbox_lists
4178                .get_mut(&form_id)
4179                .and_then(|list| list.boxes.get_mut(idx))
4180            else {
4181                return false;
4182            };
4183            if !eb.created || !eb.visible {
4184                return false;
4185            }
4186
4187            // While an IME owns composition, editing keys must stay with the IME.
4188            // winit will subsequently deliver the updated Preedit/Commit event.
4189            if eb.is_composing() {
4190                !matches!(k, input::VmKey::F(_) | input::VmKey::Other(_))
4191            } else {
4192                match k {
4193                    input::VmKey::Enter => {
4194                        if alt_down {
4195                            toggle_screen = true;
4196                        } else {
4197                            eb.action_flag = crate::runtime::globals::EDITBOX_ACTION_DECIDED;
4198                        }
4199                        true
4200                    }
4201                    input::VmKey::Escape => {
4202                        eb.action_flag = crate::runtime::globals::EDITBOX_ACTION_CANCELED;
4203                        true
4204                    }
4205                    input::VmKey::Backspace => {
4206                        eb.delete_backward(shortcut_down);
4207                        true
4208                    }
4209                    input::VmKey::Delete => {
4210                        eb.delete_forward(shortcut_down);
4211                        true
4212                    }
4213                    input::VmKey::ArrowLeft => {
4214                        eb.move_cursor_left(shift_down, shortcut_down);
4215                        true
4216                    }
4217                    input::VmKey::ArrowRight => {
4218                        eb.move_cursor_right(shift_down, shortcut_down);
4219                        true
4220                    }
4221                    input::VmKey::ArrowUp | input::VmKey::ArrowDown => true,
4222                    input::VmKey::Home => {
4223                        eb.move_cursor_home(shift_down);
4224                        true
4225                    }
4226                    input::VmKey::End => {
4227                        eb.move_cursor_end(shift_down);
4228                        true
4229                    }
4230                    input::VmKey::Tab => {
4231                        move_focus = Some(!shift_down);
4232                        true
4233                    }
4234                    input::VmKey::Letter(letter) if shortcut_down => {
4235                        match letter.to_ascii_uppercase() {
4236                            'A' => eb.select_all(),
4237                            'C' => copied_text = eb.selected_text(),
4238                            'X' => copied_text = eb.cut_selection(),
4239                            'V' if !clipboard.is_empty() => eb.commit_text(&clipboard),
4240                            'V' => {}
4241                            'Z' if shift_down => eb.redo(),
4242                            'Z' => eb.undo(),
4243                            'Y' => eb.redo(),
4244                            _ => {}
4245                        }
4246                        true
4247                    }
4248                    input::VmKey::Space | input::VmKey::Letter(_) | input::VmKey::Digit(_) => true,
4249                    input::VmKey::Shift
4250                    | input::VmKey::Control
4251                    | input::VmKey::Meta
4252                    | input::VmKey::Alt => false,
4253                    input::VmKey::F(_) | input::VmKey::Other(_) => false,
4254                }
4255            }
4256        };
4257
4258        if let Some(text) = copied_text {
4259            self.globals.editbox_clipboard = text;
4260        }
4261        if toggle_screen {
4262            self.toggle_screen_size_mode_for_editbox();
4263        }
4264        if let Some(forward) = move_focus {
4265            self.move_editbox_focus(forward);
4266        }
4267        consumed
4268    }
4269
4270    pub fn begin_input_frame(&mut self) {
4271        // eng_frame.cpp clears this one-frame consumable at frame start.
4272        self.msg_wait_skip_by_click = false;
4273    }
4274
4275    /// Mark the start of one C++-style `frame_main_proc` script pass.
4276    pub fn begin_frame_main_proc_pass(&mut self) {
4277        self.frame_main_proc_started_at = Some(crate::platform_time::Instant::now());
4278    }
4279
4280    fn update_skip_frame_rate_sample(&mut self, real_delta_ms: i32) {
4281        // eng_frame.cpp keeps ten 100-ms samples. `frame_cnt` counts rendered
4282        // frames since the current bucket began and, if several buckets elapsed,
4283        // divides by that number before inserting the sample.
4284        self.frame_rate_bucket_elapsed_ms = self
4285            .frame_rate_bucket_elapsed_ms
4286            .saturating_add(real_delta_ms.max(0) as u64);
4287        let periods = self.frame_rate_bucket_elapsed_ms / 100;
4288        if let Some(sample) = (self.frame_rate_bucket_frame_cnt as u64).checked_div(periods) {
4289            let sample = sample.min(i32::MAX as u64) as i32;
4290            self.frame_rate_100msec[self.frame_rate_100msec_index] = sample;
4291            self.frame_rate_100msec_index = (self.frame_rate_100msec_index + 1) % 10;
4292            self.frame_rate_100msec_total = self.frame_rate_100msec.iter().copied().sum();
4293            self.frame_rate_bucket_elapsed_ms -= periods * 100;
4294            self.frame_rate_bucket_frame_cnt = 0;
4295        }
4296        self.frame_rate_bucket_frame_cnt = self.frame_rate_bucket_frame_cnt.saturating_add(1);
4297    }
4298
4299    fn should_draw_after_message_key_wait_advance(&mut self) -> bool {
4300        // flow_proc.cpp::tnm_message_key_wait_proc(): without vsync waiting the
4301        // engine always leaves frame_main_proc for drawing after a message.
4302        // With vsync enabled it adaptively allows a small number of messages to
4303        // be skipped between draws, targeting roughly 30 fps and never more than
4304        // 30 message waits per draw (max == 3000, scaled by 100).
4305        if self.globals.script.wait_display_vsync_off_flag {
4306            return true;
4307        }
4308
4309        self.disp_because_msg_wait_cnt = self.disp_because_msg_wait_cnt.saturating_add(1);
4310
4311        let pump_over_100ms = self
4312            .frame_main_proc_started_at
4313            .as_ref()
4314            .map(|start| {
4315                crate::platform_time::Instant::now()
4316                    .saturating_duration_since(*start)
4317                    .as_millis()
4318                    > 100
4319            })
4320            .unwrap_or(false);
4321        if pump_over_100ms {
4322            self.disp_because_msg_wait_cnt = 0;
4323            self.disp_because_msg_wait_cnt_max = 0;
4324            return true;
4325        }
4326
4327        if self.disp_because_msg_wait_cnt >= self.disp_because_msg_wait_cnt_max / 100 {
4328            self.disp_because_msg_wait_cnt = 0;
4329            if self.frame_rate_100msec_total < 30 {
4330                self.disp_because_msg_wait_cnt_max = self
4331                    .disp_because_msg_wait_cnt_max
4332                    .saturating_sub((30 - self.frame_rate_100msec_total) * 10)
4333                    .max(0);
4334            } else {
4335                self.disp_because_msg_wait_cnt_max = self
4336                    .disp_because_msg_wait_cnt_max
4337                    .saturating_add(self.frame_rate_100msec_total - 29)
4338                    .min(3000);
4339            }
4340            return true;
4341        }
4342
4343        false
4344    }
4345
4346    pub fn wait_poll(&mut self) -> bool {
4347        self.poll_native_messagebox_result();
4348
4349        // flow_proc.cpp::tnm_message_wait_proc(): click/message-skip first
4350        // forces every pending glyph visible, then MESSAGE_WAIT can pop.
4351        let skipping_msg_before_reveal = self.runtime_is_skipping_msg();
4352        if self.wait.message_reveal_waiting()
4353            && (self.msg_wait_skip_by_click || skipping_msg_before_reveal)
4354            && !self.ui.message_wait_text_fully_revealed()
4355        {
4356            self.ui.reveal_message_now();
4357        }
4358
4359        // TNM_PROC_TYPE_MESSAGE_WAIT is not a key wait: it completes as soon
4360        // as C_elm_mwnd has revealed the full typewriter message. PP/R/PAGE
4361        // then expose the MESSAGE_KEY_WAIT that was pushed below it.
4362        if self.wait.message_reveal_waiting() && self.ui.message_wait_text_fully_revealed() {
4363            let promoted_to_key_wait = self.wait.finish_message_reveal();
4364            if promoted_to_key_wait {
4365                // tnm_message_key_wait_proc clears the local trigger every pass.
4366                self.globals.script.skip_trigger = false;
4367            } else {
4368                self.ui.finish_message_reveal_wait();
4369            }
4370        }
4371
4372        // A sustained Ctrl/read/script-trigger skip immediately releases
4373        // MESSAGE_KEY_WAIT. `msg_wait_skip_by_click` intentionally does not.
4374        if self.wait.message_key_waiting() {
4375            self.globals.script.skip_trigger = false;
4376            if self.runtime_is_skipping_msg() {
4377                let was_waiting = self.wait.message_key_waiting();
4378                let _ = self.advance_message_wait(true);
4379                let advanced = was_waiting && !self.wait.message_key_waiting();
4380                if advanced && self.should_draw_after_message_key_wait_advance() {
4381                    // `tnm_message_key_wait_proc()` returns false here. In the
4382                    // original frame_main_proc loop that means: stop processing
4383                    // procs, draw once, then resume SCRIPT on the next frame.
4384                    self.request_disp_proc_boundary();
4385                }
4386            }
4387        }
4388
4389        let skipping = self.runtime_is_skipping();
4390        if !skipping {
4391            // eng_frame.cpp resets only the adaptive maximum when fast-forward
4392            // is no longer active.
4393            self.disp_because_msg_wait_cnt_max = 0;
4394        }
4395        if skipping && self.wait.mwnd_animation_waiting() {
4396            // flow_proc.cpp ends the concrete MWND animation before releasing
4397            // OPEN_WAIT/CLOSE_WAIT; do not leave the renderer mid-transition.
4398            self.ui.finish_mwnd_animation();
4399        }
4400        if skipping && self.wait.wipe {
4401            // tnm_wipe_wait_proc calls C_tnm_wipe::end() on skip.
4402            self.finish_wipe_runtime();
4403        }
4404        let (wait, stack, bgm, koe, se, pcm, globals) = (
4405            &mut self.wait,
4406            &mut self.stack,
4407            &mut self.bgm,
4408            &mut self.koe,
4409            &mut self.se,
4410            &mut self.pcm,
4411            &mut self.globals,
4412        );
4413        wait.poll(stack, bgm, koe, se, pcm, globals, &self.ids, skipping)
4414    }
4415
4416    pub fn push(&mut self, v: Value) {
4417        self.stack.push(v);
4418    }
4419
4420    pub fn pop(&mut self) -> Option<Value> {
4421        self.stack.pop()
4422    }
4423
4424    pub fn set_native_ui_backend(&mut self, backend: Option<Arc<dyn native_ui::NativeUiBackend>>) {
4425        self.native_ui_backend = backend;
4426    }
4427
4428    /// Return the game title for platform UI and runtime dialogs.
4429    ///
4430    /// The value is read from Gameexe `GAMENAME` when available. If Gameexe is
4431    /// missing, undecodable, or the field is empty, this returns the project
4432    /// directory name, then `Siglus` as the final fallback.
4433    pub fn game_title(&self) -> String {
4434        game_title::resolve_game_title(self.tables.gameexe.as_ref(), &self.project_dir)
4435    }
4436
4437    /// Return the game display name for bundle/mobile UI.
4438    pub fn game_name(&self) -> String {
4439        self.game_title()
4440    }
4441
4442    /// Return display metadata for platform UI.
4443    ///
4444    /// If the game directory contains `cover.png`, `cover.jpg`, `cover.jpeg`,
4445    /// `thumbnail.png`, or `icon.png`, `cover` is populated. Otherwise callers
4446    /// should display the game name.
4447    pub fn game_display_info(&self) -> game_display_info::GameDisplayInfo {
4448        let cover = game_display_info::resolve_game_cover_from_project_dir(&self.project_dir);
4449        let name = self.game_name();
4450        game_display_info::GameDisplayInfo {
4451            title: name.clone(),
4452            name,
4453            cover,
4454        }
4455    }
4456
4457    /// Return the optional cover for bundle/mobile UI.
4458    pub fn game_cover(&self) -> Option<game_display_info::GameCover> {
4459        game_display_info::resolve_game_cover_from_project_dir(&self.project_dir)
4460    }
4461
4462    pub fn submit_native_messagebox_result(&mut self, request_id: u64, value: i64) {
4463        self.native_ui.enqueue_messagebox_result(request_id, value);
4464        self.poll_native_messagebox_result();
4465    }
4466
4467    pub fn request_system_messagebox(
4468        &mut self,
4469        kind: i32,
4470        debug_only: bool,
4471        text: String,
4472        buttons: Vec<globals::SystemMessageBoxButton>,
4473    ) {
4474        self.request_system_messagebox_internal(kind, debug_only, text, buttons, true);
4475    }
4476
4477    pub fn request_system_messagebox_no_return(
4478        &mut self,
4479        kind: i32,
4480        debug_only: bool,
4481        text: String,
4482        buttons: Vec<globals::SystemMessageBoxButton>,
4483    ) {
4484        self.request_system_messagebox_internal(kind, debug_only, text, buttons, false);
4485    }
4486
4487    /// Show an engine-rendered modal without delegating to a platform-native
4488    /// message box.  Syscom's built-in fallback menus use this path because
4489    /// they are multi-step in-game UI and must behave identically on every
4490    /// winit target.
4491    pub fn request_internal_system_messagebox_no_return(
4492        &mut self,
4493        kind: i32,
4494        debug_only: bool,
4495        text: String,
4496        buttons: Vec<globals::SystemMessageBoxButton>,
4497    ) {
4498        let request_id = self.native_ui.next_messagebox_request_id();
4499        self.globals.system.messagebox_modal_result = None;
4500        self.globals.system.messagebox_modal = Some(globals::SystemMessageBoxModalState {
4501            request_id,
4502            kind,
4503            text,
4504            debug_only,
4505            buttons,
4506            cursor: 0,
4507            native_pending: false,
4508            complete_wait_with_value: false,
4509        });
4510        self.wait.wait_system_modal();
4511    }
4512
4513    /// `eng_chihaya.cpp::tnm_open_chihaya_bench_dialog()`.  The original call
4514    /// does not return to the script until the modal information window closes.
4515    pub fn request_chihaya_bench_dialog(&mut self, text: String) {
4516        let request_id = self.native_ui.next_messagebox_request_id();
4517        let native_pending = self.native_ui_backend.is_some();
4518        let buttons = vec![globals::SystemMessageBoxButton {
4519            label: "CLOSE".to_string(),
4520            value: 0,
4521        }];
4522        self.globals.system.messagebox_modal_result = None;
4523        self.globals.system.messagebox_modal = Some(globals::SystemMessageBoxModalState {
4524            request_id,
4525            kind: 17, // SYSTEM.MESSAGEBOX_OK; only used by the in-engine fallback.
4526            text: text.clone(),
4527            debug_only: false,
4528            buttons,
4529            cursor: 0,
4530            native_pending,
4531            complete_wait_with_value: false,
4532        });
4533        self.wait.wait_system_modal();
4534
4535        if let Some(backend) = self.native_ui_backend.as_ref() {
4536            backend.show_chihaya_bench_dialog(native_ui::NativeChihayaBenchDialogRequest {
4537                request_id,
4538                title: self.game_title(),
4539                text,
4540            });
4541        }
4542    }
4543
4544    fn request_system_messagebox_internal(
4545        &mut self,
4546        kind: i32,
4547        debug_only: bool,
4548        text: String,
4549        buttons: Vec<globals::SystemMessageBoxButton>,
4550        complete_wait_with_value: bool,
4551    ) {
4552        let request_id = self.native_ui.next_messagebox_request_id();
4553        let native_pending = self.native_ui_backend.is_some();
4554        self.globals.system.messagebox_modal_result = None;
4555        self.globals.system.messagebox_modal = Some(globals::SystemMessageBoxModalState {
4556            request_id,
4557            kind,
4558            text: text.clone(),
4559            debug_only,
4560            buttons,
4561            cursor: 0,
4562            native_pending,
4563            complete_wait_with_value,
4564        });
4565        self.wait.wait_system_modal();
4566
4567        if let Some(backend) = self.native_ui_backend.as_ref() {
4568            backend.show_system_messagebox(native_ui::NativeMessageBoxRequest {
4569                request_id,
4570                kind: native_ui::NativeMessageBoxKind::from_system_op(kind),
4571                title: self.game_title(),
4572                message: text,
4573                buttons: self
4574                    .globals
4575                    .system
4576                    .messagebox_modal
4577                    .as_ref()
4578                    .map(|modal| {
4579                        modal
4580                            .buttons
4581                            .iter()
4582                            .map(|button| native_ui::NativeMessageBoxButton {
4583                                label: button.label.clone(),
4584                                value: button.value,
4585                            })
4586                            .collect()
4587                    })
4588                    .unwrap_or_default(),
4589                debug_only,
4590            });
4591        }
4592    }
4593
4594    fn poll_native_messagebox_result(&mut self) {
4595        while let Some(result) = self.native_ui.pop_messagebox_result() {
4596            let Some(modal) = self.globals.system.messagebox_modal.as_ref() else {
4597                continue;
4598            };
4599            if modal.request_id != result.request_id {
4600                continue;
4601            }
4602            let max_value = modal.buttons.iter().map(|b| b.value).max().unwrap_or(0);
4603            let value = result.value.clamp(0, max_value);
4604            self.finish_system_messagebox(value);
4605            break;
4606        }
4607    }
4608
4609    pub fn set_screen_size(&mut self, w: u32, h: u32) {
4610        self.screen_w = w;
4611        self.screen_h = h;
4612        self.ui.sync_layout(&mut self.layers, w, h);
4613        self.sync_editbox_runtime();
4614    }
4615
4616    pub fn tick_frame(&mut self) {
4617        #[cfg(target_os = "vita")]
4618        self.movie.evict_idle_streams();
4619        let now = crate::platform_time::Instant::now();
4620        let last = self.frame_clock_last.replace(now);
4621        let real_delta_ms = match last {
4622            Some(last) => {
4623                let elapsed = now.saturating_duration_since(last);
4624                accumulate_integer_millis(elapsed, &mut self.frame_clock_sub_ms_ns)
4625            }
4626            None => {
4627                // Preserve the existing first-frame bootstrap, but start the
4628                // sub-millisecond accumulator from a clean boundary.
4629                self.frame_clock_sub_ms_ns = 0;
4630                16
4631            }
4632        };
4633        // eng_frame.cpp advances game-time and wipe-time at 32x while the
4634        // engine is in Ctrl/read/script-trigger skip. Real-time audio/fades do
4635        // not accelerate.
4636        let skipping = self.runtime_is_skipping();
4637        let game_delta_ms = if skipping {
4638            real_delta_ms.saturating_mul(32)
4639        } else {
4640            real_delta_ms
4641        };
4642        self.update_selbtn_animation(game_delta_ms as i64);
4643        let trace = env_is_set!("SG_CTX_TICK_TRACE");
4644        if trace {
4645            eprintln!(
4646                "[SG_CTX_TICK] start game_delta_ms={} real_delta_ms={}",
4647                game_delta_ms, real_delta_ms
4648            );
4649        }
4650        self.sync_editbox_runtime();
4651        self.poll_native_messagebox_result();
4652        syscom_form::poll_fallback_dialog(self);
4653        if trace {
4654            eprintln!("[SG_CTX_TICK] after sync_editbox_runtime");
4655        }
4656        self.sync_mwnd_window_ui();
4657        if trace {
4658            eprintln!("[SG_CTX_TICK] after sync_mwnd_window_ui");
4659        }
4660        self.ui.tick(
4661            &mut self.layers,
4662            &mut self.images,
4663            &self.project_dir,
4664            self.screen_w,
4665            self.screen_h,
4666            &self.globals.script,
4667            &self.globals.syscom,
4668            &self.globals.editbox_lists,
4669            self.globals.focused_editbox,
4670        );
4671        for ((form_id, stage_idx, mwnd_idx), visible) in self.ui.mwnd_reveal_counts() {
4672            if let Some(mwnd) = self
4673                .globals
4674                .stage_forms
4675                .get_mut(&form_id)
4676                .and_then(|st| st.mwnd_lists.get_mut(&stage_idx))
4677                .and_then(|list| list.get_mut(mwnd_idx))
4678            {
4679                for page in &mut mwnd.message_pages {
4680                    for glyph in &mut page.glyphs {
4681                        glyph.appeared = glyph.ruby || glyph.reveal_index <= visible;
4682                    }
4683                    let page_body = page.glyphs.iter().filter(|glyph| !glyph.ruby).count();
4684                    let page_first = page
4685                        .glyphs
4686                        .iter()
4687                        .filter(|glyph| !glyph.ruby)
4688                        .map(|glyph| glyph.reveal_index)
4689                        .min()
4690                        .unwrap_or(1);
4691                    page.disp_moji_cnt = visible
4692                        .saturating_sub(page_first.saturating_sub(1))
4693                        .min(page_body)
4694                        .min(i32::MAX as usize) as i64;
4695                }
4696                for glyph in &mut mwnd.glyphs {
4697                    glyph.appeared = glyph.ruby || glyph.reveal_index <= visible;
4698                }
4699                let active_first = mwnd
4700                    .glyphs
4701                    .iter()
4702                    .filter(|glyph| !glyph.ruby)
4703                    .map(|glyph| glyph.reveal_index)
4704                    .min()
4705                    .unwrap_or(visible.saturating_add(1));
4706                let active_body = mwnd.glyphs.iter().filter(|glyph| !glyph.ruby).count();
4707                mwnd.disp_moji_cnt = visible
4708                    .saturating_sub(active_first.saturating_sub(1))
4709                    .min(active_body)
4710                    .min(i32::MAX as usize) as i64;
4711            }
4712        }
4713        // Apply syscom flags that should skip visual transitions immediately.
4714        self.apply_syscom_skip_flags();
4715        if trace {
4716            eprintln!("[SG_CTX_TICK] after apply_syscom_skip_flags");
4717        }
4718        // Sync message length for auto-mode timing.
4719        self.globals.script.auto_mode_moji_cnt =
4720            self.ui.message_text().unwrap_or("").chars().count() as i64;
4721        if self
4722            .ui
4723            .auto_advance_due(&self.globals.script, &self.globals.syscom)
4724            && !self.advance_message_wait(true)
4725        {
4726            self.notify_wait_key();
4727        }
4728        // Message-window hide is visibility-only here.  UI.tick applies script hide,
4729        // SYSCOM hide, and message-back proc state without clearing message contents.
4730        self.sync_syscom_menu_ui();
4731        if trace {
4732            eprintln!("[SG_CTX_TICK] after sync_syscom_menu_ui");
4733        }
4734        self.sync_mwnd_selection_ui();
4735        if trace {
4736            eprintln!("[SG_CTX_TICK] after sync_mwnd_selection_ui");
4737        }
4738        // C++ eng_frame.cpp updates the allocated EXCALL runtime even while
4739        // local SCRIPT.time_stop_flag freezes the gameplay scene.  Resolve the
4740        // synthetic EXCALL storage ids before mutably borrowing globals.
4741        let excall_tick = self
4742            .excall_state
4743            .ready
4744            .then(|| crate::runtime::forms::excall::tick_targets(self));
4745        self.globals.tick_frame(
4746            game_delta_ms,
4747            real_delta_ms,
4748            &self.tables.shake_templates,
4749            excall_tick,
4750        );
4751        if trace {
4752            eprintln!("[SG_CTX_TICK] after globals.tick_frame");
4753        }
4754        if self
4755            .globals
4756            .wipe
4757            .as_ref()
4758            .is_some_and(globals::WipeState::is_done)
4759        {
4760            self.finish_wipe_runtime();
4761            if trace {
4762                eprintln!("[SG_CTX_TICK] after finish_wipe_runtime");
4763            }
4764        }
4765        self.apply_object_event_animations();
4766        if trace {
4767            eprintln!("[SG_CTX_TICK] after apply_object_event_animations");
4768        }
4769        self.sync_weather_objects(game_delta_ms, real_delta_ms);
4770        if trace {
4771            eprintln!("[SG_CTX_TICK] after sync_weather_objects");
4772        }
4773        pcmevent_form::tick_all(self, game_delta_ms, real_delta_ms);
4774        let _ = self.bgm.tick(&mut self.audio);
4775        self.pcm.tick();
4776        self.koe.tick(&mut self.audio);
4777        syscom_form::update_audio_routing(self, real_delta_ms, false);
4778        if trace {
4779            eprintln!("[SG_CTX_TICK] after pcmevent/bgm/pcm/audio routing");
4780        }
4781        self.sync_emote_objects();
4782        self.sync_movie_objects();
4783        if trace {
4784            eprintln!("[SG_CTX_TICK] after sync_movie_objects");
4785        }
4786        self.sync_global_movie();
4787        if trace {
4788            eprintln!("[SG_CTX_TICK] after sync_global_movie");
4789        }
4790        self.update_object_button_hover();
4791        if trace {
4792            eprintln!("[SG_CTX_TICK] after update_object_button_hover");
4793        }
4794        self.apply_object_disp_override();
4795        if trace {
4796            eprintln!("[SG_CTX_TICK] after apply_object_disp_override");
4797        }
4798        self.update_skip_frame_rate_sample(real_delta_ms);
4799    }
4800
4801    fn apply_syscom_skip_flags(&mut self) {
4802        let no_wipe = self.globals.syscom.original_config.no_wipe_anime_flag;
4803        // CONFIG.SKIP_WIPE_ANIME controls whether WIPE_WAIT accepts input;
4804        // only CONFIG.NO_WIPE_ANIME suppresses the transition itself.
4805        if no_wipe && self.globals.wipe.is_some() {
4806            self.finish_wipe_runtime();
4807        }
4808    }
4809
4810    fn apply_object_event_animations(&mut self) {
4811        let wipe_active = self.globals.wipe.is_some();
4812        let ids = self.ids.clone();
4813        let gfx = &mut self.gfx;
4814        let images = &mut self.images;
4815        let layers = &mut self.layers;
4816        let mwnd_ui_state = self
4817            .ui
4818            .current_mwnd_window_render_state(self.screen_w, self.screen_h);
4819        let mut form_ids: Vec<u32> = self.globals.stage_forms.keys().copied().collect();
4820        form_ids.sort_unstable();
4821        for form_id in form_ids {
4822            let Some(st) = self.globals.stage_forms.get_mut(&form_id) else {
4823                continue;
4824            };
4825            let mut stage_ids: Vec<i64> = st
4826                .object_lists
4827                .keys()
4828                .chain(st.mwnd_lists.keys())
4829                .copied()
4830                .collect();
4831            stage_ids.sort_unstable();
4832            stage_ids.dedup();
4833            for stage_idx in stage_ids {
4834                if stage_idx == TNM_STAGE_NEXT_I64 && !wipe_active {
4835                    continue;
4836                }
4837                let embedded_slots = st.embedded_object_slots_by_stage.get(&stage_idx).cloned();
4838                let slot_use = st.object_slot_use.get(&stage_idx).cloned();
4839                let Some(objs) = st.object_lists.get_mut(&stage_idx) else {
4840                    continue;
4841                };
4842                for (obj_idx, obj) in objs.iter_mut().enumerate() {
4843                    if embedded_slots
4844                        .as_ref()
4845                        .is_some_and(|slots| slots.contains(&obj_idx))
4846                    {
4847                        continue;
4848                    }
4849                    if !slot_use
4850                        .as_ref()
4851                        .and_then(|flags| flags.get(obj_idx))
4852                        .copied()
4853                        .unwrap_or(true)
4854                    {
4855                        continue;
4856                    }
4857                    apply_object_event_animations_recursive(
4858                        &ids,
4859                        gfx,
4860                        images,
4861                        layers,
4862                        stage_idx,
4863                        object_runtime_slot(obj_idx, obj) as i64,
4864                        obj,
4865                    );
4866                }
4867            }
4868
4869            let mut mwnd_stage_ids: Vec<i64> = st.mwnd_lists.keys().copied().collect();
4870            mwnd_stage_ids.sort_unstable();
4871            for stage_idx in mwnd_stage_ids {
4872                if stage_idx == TNM_STAGE_NEXT_I64 && !wipe_active {
4873                    continue;
4874                }
4875                let Some(mwnds) = st.mwnd_lists.get_mut(&stage_idx) else {
4876                    continue;
4877                };
4878                for mwnd in mwnds {
4879                    let Some((window_x, window_y)) = mwnd.window_pos else {
4880                        continue;
4881                    };
4882                    let Some((window_w, window_h)) = mwnd.window_size else {
4883                        continue;
4884                    };
4885                    if window_w <= 0 || window_h <= 0 {
4886                        continue;
4887                    }
4888                    let visible_or_animating = mwnd.open
4889                        || mwnd_ui_state.is_some_and(|ui| {
4890                            ui.x as i64 == window_x
4891                                && ui.y as i64 == window_y
4892                                && ui.w as i64 == window_w
4893                                && ui.h as i64 == window_h
4894                        });
4895                    if !visible_or_animating {
4896                        continue;
4897                    }
4898                    for (obj_idx, obj) in mwnd.button_list.iter_mut().enumerate() {
4899                        apply_object_event_animations_recursive(
4900                            &ids,
4901                            gfx,
4902                            images,
4903                            layers,
4904                            stage_idx,
4905                            object_runtime_slot(obj_idx, obj) as i64,
4906                            obj,
4907                        );
4908                    }
4909                    for (obj_idx, obj) in mwnd.face_list.iter_mut().enumerate() {
4910                        apply_object_event_animations_recursive(
4911                            &ids,
4912                            gfx,
4913                            images,
4914                            layers,
4915                            stage_idx,
4916                            object_runtime_slot(obj_idx, obj) as i64,
4917                            obj,
4918                        );
4919                    }
4920                    for (obj_idx, obj) in mwnd.object_list.iter_mut().enumerate() {
4921                        apply_object_event_animations_recursive(
4922                            &ids,
4923                            gfx,
4924                            images,
4925                            layers,
4926                            stage_idx,
4927                            object_runtime_slot(obj_idx, obj) as i64,
4928                            obj,
4929                        );
4930                    }
4931                }
4932            }
4933        }
4934    }
4935
4936    fn apply_object_masks(&mut self) {
4937        let Some(mask_info) = self.build_mask_info() else {
4938            return;
4939        };
4940        if mask_info.is_empty() {
4941            return;
4942        }
4943
4944        let mut resolved_masks = HashMap::default();
4945        for (mask_name, _, _) in mask_info.iter().flatten() {
4946            if resolved_masks.contains_key(mask_name) {
4947                continue;
4948            }
4949            if let Some(id) = self.resolve_mask_image(mask_name) {
4950                resolved_masks.insert(mask_name.clone(), id);
4951            }
4952        }
4953
4954        let ids = self.ids.clone();
4955        let gfx = &mut self.gfx;
4956        let layers = &mut self.layers;
4957        let mut form_ids: Vec<u32> = self.globals.stage_forms.keys().copied().collect();
4958        form_ids.sort_unstable();
4959        for form_id in form_ids {
4960            let Some(st) = self.globals.stage_forms.get_mut(&form_id) else {
4961                continue;
4962            };
4963            let mut stage_ids: Vec<i64> = st.object_lists.keys().copied().collect();
4964            stage_ids.sort_unstable();
4965            for stage_idx in stage_ids {
4966                let Some(objs) = st.object_lists.get_mut(&stage_idx) else {
4967                    continue;
4968                };
4969                for (obj_idx, obj) in objs.iter_mut().enumerate() {
4970                    apply_object_masks_recursive(
4971                        &ids,
4972                        gfx,
4973                        layers,
4974                        stage_idx,
4975                        object_runtime_slot(obj_idx, obj) as i64,
4976                        obj,
4977                        &mask_info,
4978                        &resolved_masks,
4979                    );
4980                }
4981            }
4982        }
4983    }
4984
4985    fn active_mask_list(&self) -> Option<&globals::MaskListState> {
4986        if self.ids.form_global_mask != 0 {
4987            return self.globals.mask_lists.get(&self.ids.form_global_mask);
4988        }
4989        None
4990    }
4991
4992    fn build_mask_info(&self) -> Option<Vec<Option<(String, i32, i32)>>> {
4993        let ml = self.active_mask_list()?;
4994        let mut out = Vec::with_capacity(ml.masks.len());
4995        for m in &ml.masks {
4996            let Some(name) = m.name.as_ref() else {
4997                out.push(None);
4998                continue;
4999            };
5000            if name.is_empty() {
5001                out.push(None);
5002                continue;
5003            }
5004            let x = m.x_event.get_total_value();
5005            let y = m.y_event.get_total_value();
5006            out.push(Some((name.clone(), x, y)));
5007        }
5008        Some(out)
5009    }
5010
5011    fn resolve_mask_image(&mut self, name: &str) -> Option<ImageHandle> {
5012        if name.is_empty() {
5013            return None;
5014        }
5015        if let Some(path) = resolve_mask_path(&self.project_dir, name)
5016            && let Ok(id) = self.images.load_file(&path, 0)
5017        {
5018            return Some(id);
5019        }
5020        if let Ok(id) = self.images.load_g00(name, 0) {
5021            return Some(id);
5022        }
5023        if let Ok(id) = self.images.load_bg(name) {
5024            return Some(id);
5025        }
5026        None
5027    }
5028
5029    fn apply_object_tonecurves(&mut self) {
5030        let ids = self.ids.clone();
5031        let gfx = &mut self.gfx;
5032        let images = &mut self.images;
5033        let layers = &mut self.layers;
5034        let tonecurve = &mut self.tonecurve;
5035        let mut form_ids: Vec<u32> = self.globals.stage_forms.keys().copied().collect();
5036        form_ids.sort_unstable();
5037        for form_id in form_ids {
5038            let Some(st) = self.globals.stage_forms.get_mut(&form_id) else {
5039                continue;
5040            };
5041            let mut stage_ids: Vec<i64> = st.object_lists.keys().copied().collect();
5042            stage_ids.sort_unstable();
5043            for stage_idx in stage_ids {
5044                let Some(objs) = st.object_lists.get_mut(&stage_idx) else {
5045                    continue;
5046                };
5047                for (obj_idx, obj) in objs.iter_mut().enumerate() {
5048                    apply_object_tonecurves_recursive(
5049                        &ids,
5050                        gfx,
5051                        images,
5052                        layers,
5053                        tonecurve,
5054                        stage_idx,
5055                        object_runtime_slot(obj_idx, obj) as i64,
5056                        obj,
5057                    );
5058                }
5059            }
5060        }
5061    }
5062
5063    fn apply_gan_effects(&mut self, sprites: &mut Vec<RenderSprite>) {
5064        let mut index: HashMap<(Option<LayerId>, Option<SpriteId>), usize> = HashMap::default();
5065        for (i, s) in sprites.iter().enumerate() {
5066            index.insert((s.layer_id, s.sprite_id), i);
5067        }
5068
5069        let gfx = &mut self.gfx;
5070        let images = &mut self.images;
5071        let mut form_ids: Vec<u32> = self.globals.stage_forms.keys().copied().collect();
5072        form_ids.sort_unstable();
5073        for form_id in form_ids {
5074            let Some(st) = self.globals.stage_forms.get_mut(&form_id) else {
5075                continue;
5076            };
5077
5078            let mut object_stage_ids: Vec<i64> = st.object_lists.keys().copied().collect();
5079            object_stage_ids.sort_unstable();
5080            for stage_idx in object_stage_ids {
5081                let Some(objs) = st.object_lists.get_mut(&stage_idx) else {
5082                    continue;
5083                };
5084                for (obj_idx, obj) in objs.iter_mut().enumerate() {
5085                    apply_gan_effects_recursive(
5086                        gfx,
5087                        images,
5088                        sprites,
5089                        &index,
5090                        stage_idx,
5091                        object_runtime_slot(obj_idx, obj) as i64,
5092                        obj,
5093                    );
5094                }
5095            }
5096
5097            let mut mwnd_stage_ids: Vec<i64> = st.mwnd_lists.keys().copied().collect();
5098            mwnd_stage_ids.sort_unstable();
5099            for stage_idx in mwnd_stage_ids {
5100                let Some(mwnds) = st.mwnd_lists.get_mut(&stage_idx) else {
5101                    continue;
5102                };
5103                for mwnd in mwnds {
5104                    for (obj_idx, obj) in mwnd.button_list.iter_mut().enumerate() {
5105                        apply_gan_effects_recursive(
5106                            gfx,
5107                            images,
5108                            sprites,
5109                            &index,
5110                            stage_idx,
5111                            object_runtime_slot(obj_idx, obj) as i64,
5112                            obj,
5113                        );
5114                    }
5115                    for (obj_idx, obj) in mwnd.face_list.iter_mut().enumerate() {
5116                        apply_gan_effects_recursive(
5117                            gfx,
5118                            images,
5119                            sprites,
5120                            &index,
5121                            stage_idx,
5122                            object_runtime_slot(obj_idx, obj) as i64,
5123                            obj,
5124                        );
5125                    }
5126                    for (obj_idx, obj) in mwnd.object_list.iter_mut().enumerate() {
5127                        apply_gan_effects_recursive(
5128                            gfx,
5129                            images,
5130                            sprites,
5131                            &index,
5132                            stage_idx,
5133                            object_runtime_slot(obj_idx, obj) as i64,
5134                            obj,
5135                        );
5136                    }
5137                }
5138            }
5139
5140            let mut btnsel_stage_ids: Vec<i64> = st.btnselitem_lists.keys().copied().collect();
5141            btnsel_stage_ids.sort_unstable();
5142            for stage_idx in btnsel_stage_ids {
5143                let Some(items) = st.btnselitem_lists.get_mut(&stage_idx) else {
5144                    continue;
5145                };
5146                for item in items {
5147                    for (obj_idx, obj) in item.object_list.iter_mut().enumerate() {
5148                        apply_gan_effects_recursive(
5149                            gfx,
5150                            images,
5151                            sprites,
5152                            &index,
5153                            stage_idx,
5154                            object_runtime_slot(obj_idx, obj) as i64,
5155                            obj,
5156                        );
5157                    }
5158                }
5159            }
5160        }
5161    }
5162
5163    fn apply_object_disp_override(&mut self) {
5164        const GET_OBJECT_DISP_ONOFF: i32 = 278;
5165        let disp_on = self
5166            .globals
5167            .syscom
5168            .config_int
5169            .get(&GET_OBJECT_DISP_ONOFF)
5170            .copied()
5171            .unwrap_or(1)
5172            != 0;
5173        if disp_on {
5174            return;
5175        }
5176
5177        let ui_layer = self.ui.mwnd.layer;
5178        for (stage_idx, list) in self
5179            .globals
5180            .stage_forms
5181            .values()
5182            .flat_map(|st| st.object_lists.iter())
5183        {
5184            for (obj_idx, obj) in list.iter().enumerate() {
5185                match &obj.backend {
5186                    globals::ObjectBackend::String { layer_id, .. } => {
5187                        if Some(*layer_id) == ui_layer {
5188                            continue;
5189                        }
5190                        for (_, sprite_id) in layer_backed_object_sprite_bindings(&obj.backend) {
5191                            if let Some(spr) = self
5192                                .layers
5193                                .layer_mut(*layer_id)
5194                                .and_then(|layer| layer.sprite_mut(sprite_id))
5195                            {
5196                                spr.visible = false;
5197                            }
5198                        }
5199                    }
5200                    globals::ObjectBackend::Rect {
5201                        layer_id,
5202                        sprite_id,
5203                        ..
5204                    }
5205                    | globals::ObjectBackend::Movie {
5206                        layer_id,
5207                        sprite_id,
5208                        ..
5209                    } => {
5210                        if Some(*layer_id) == ui_layer {
5211                            continue;
5212                        }
5213                        if let Some(layer) = self.layers.layer_mut(*layer_id)
5214                            && let Some(spr) = layer.sprite_mut(*sprite_id)
5215                        {
5216                            spr.visible = false;
5217                        }
5218                    }
5219                    globals::ObjectBackend::Number {
5220                        layer_id,
5221                        sprite_ids,
5222                    }
5223                    | globals::ObjectBackend::Weather {
5224                        layer_id,
5225                        sprite_ids,
5226                    } => {
5227                        if Some(*layer_id) == ui_layer {
5228                            continue;
5229                        }
5230                        if let Some(layer) = self.layers.layer_mut(*layer_id) {
5231                            for sid in sprite_ids {
5232                                if let Some(spr) = layer.sprite_mut(*sid) {
5233                                    spr.visible = false;
5234                                }
5235                            }
5236                        }
5237                    }
5238                    globals::ObjectBackend::Gfx => {
5239                        if let Some((lid, sid)) =
5240                            self.gfx.object_sprite_binding(*stage_idx, obj_idx as i64)
5241                        {
5242                            if Some(lid) == ui_layer {
5243                                continue;
5244                            }
5245                            if let Some(layer) = self.layers.layer_mut(lid)
5246                                && let Some(spr) = layer.sprite_mut(sid)
5247                            {
5248                                spr.visible = false;
5249                            }
5250                        }
5251                    }
5252                    _ => {}
5253                }
5254            }
5255        }
5256    }
5257
5258    fn handle_system_messagebox_key(&mut self, k: input::VmKey) -> bool {
5259        let Some(modal) = self.globals.system.messagebox_modal.as_mut() else {
5260            return false;
5261        };
5262        if modal.native_pending {
5263            return true;
5264        }
5265        let mut finish_value: Option<i64> = None;
5266        match k {
5267            input::VmKey::ArrowLeft | input::VmKey::ArrowUp => {
5268                let len = modal.buttons.len();
5269                if len > 0 {
5270                    modal.cursor = if modal.cursor == 0 {
5271                        len - 1
5272                    } else {
5273                        modal.cursor - 1
5274                    };
5275                }
5276            }
5277            input::VmKey::ArrowRight | input::VmKey::ArrowDown | input::VmKey::Tab => {
5278                let len = modal.buttons.len();
5279                if len > 0 {
5280                    modal.cursor = (modal.cursor + 1) % len;
5281                }
5282            }
5283            input::VmKey::Enter | input::VmKey::Space => {
5284                finish_value = Some(modal.selected_value());
5285            }
5286            input::VmKey::Escape => {
5287                finish_value = Some(modal.cancel_value());
5288            }
5289            input::VmKey::Digit(d) => {
5290                let idx = d.saturating_sub(1) as usize;
5291                if idx < modal.buttons.len() {
5292                    modal.cursor = idx;
5293                    finish_value = Some(modal.selected_value());
5294                }
5295            }
5296            _ => {}
5297        }
5298        if let Some(value) = finish_value {
5299            self.finish_system_messagebox(value);
5300        }
5301        true
5302    }
5303
5304    fn handle_system_messagebox_click(&mut self, b: input::VmMouseButton) -> bool {
5305        let Some(modal) = self.globals.system.messagebox_modal.as_mut() else {
5306            return false;
5307        };
5308        if modal.native_pending {
5309            return true;
5310        }
5311        match b {
5312            input::VmMouseButton::Left => {
5313                let len = modal.buttons.len().max(1);
5314                let max_idx = len - 1;
5315                // Address the rows the overlay actually drew. The overlay text is
5316                // `body` + a blank line + one line per button + a blank line + the
5317                // help line, and the renderer reports how many lines that came to
5318                // after wrapping — so the button block is the `len` lines before the
5319                // trailing blank/help pair. Guessing the pitch (the older
5320                // `40 + (body_lines + 2) * 30` estimate) drifts by rows and made the
5321                // fallback config menu select a different entry than the one tapped.
5322                let mut idx = None;
5323                if let Some((lines, pitch, top)) = self.ui.sys_overlay_metrics() {
5324                    if lines >= len + 2 {
5325                        let first_button_line = lines - 2 - len;
5326                        let y0 = top + first_button_line as f32 * pitch;
5327                        idx = Some(
5328                            (((self.input.mouse_y as f32 - y0).max(0.0) / pitch) as usize)
5329                                .min(max_idx),
5330                        );
5331                    }
5332                }
5333                let idx = idx.unwrap_or_else(|| {
5334                    let body_lines = modal.text.lines().count() as i32;
5335                    let first_button_y = 40 + (body_lines + 2) * 30;
5336                    (((self.input.mouse_y - first_button_y).max(0) / 30) as usize).min(max_idx)
5337                });
5338                modal.cursor = idx;
5339                let value = modal.selected_value();
5340                self.finish_system_messagebox(value);
5341            }
5342            input::VmMouseButton::Right => {
5343                let value = modal.cancel_value();
5344                self.finish_system_messagebox(value);
5345            }
5346            _ => {}
5347        }
5348        true
5349    }
5350
5351    fn finish_system_messagebox(&mut self, value: i64) {
5352        let complete_wait_with_value = self
5353            .globals
5354            .system
5355            .messagebox_modal
5356            .as_ref()
5357            .map(|modal| modal.complete_wait_with_value)
5358            .unwrap_or(false);
5359        self.globals.system.messagebox_modal = None;
5360        self.globals.system.messagebox_modal_result = Some(value);
5361        if complete_wait_with_value {
5362            self.wait.finish_system_modal(Value::Int(value));
5363        } else {
5364            self.wait.finish_system_modal_void();
5365        }
5366        self.ui.set_sys_overlay(false, String::new());
5367    }
5368
5369    fn sync_system_messagebox_ui(&mut self) -> bool {
5370        let Some(modal) = self.globals.system.messagebox_modal.as_ref() else {
5371            return false;
5372        };
5373        if modal.native_pending {
5374            // A host-provided native dialog owns presentation and input.
5375            self.ui.set_sys_overlay(false, String::new());
5376            return true;
5377        }
5378
5379        let mut text = modal.text.clone();
5380        if !text.is_empty() && !text.ends_with('\n') {
5381            text.push('\n');
5382        }
5383        text.push('\n');
5384        for (index, button) in modal.buttons.iter().enumerate() {
5385            if index == modal.cursor {
5386                text.push_str("▶ ");
5387            } else {
5388                text.push_str("  ");
5389            }
5390            text.push_str(&button.label);
5391            if index == modal.cursor {
5392                text.push_str(" ◀");
5393            }
5394            text.push('\n');
5395        }
5396        text.push_str("\n↑↓/←→: select   Enter: decide   Esc: back");
5397        self.ui.set_sys_overlay(true, text);
5398        true
5399    }
5400
5401    fn msg_back_state(&self) -> Option<&globals::MsgBackState> {
5402        let form_id = self.ids.form_global_msgbk;
5403        if form_id == 0 {
5404            return None;
5405        }
5406        self.globals.msgbk_forms.get(&form_id)
5407    }
5408
5409    fn sync_msg_back_history_capacity(&mut self) {
5410        let max_count = self
5411            .gameexe_i64_default("MSGBK.HISTORY_CNT", 256)
5412            .clamp(1, 4096) as usize;
5413        let form_id = self.ids.form_global_msgbk;
5414        if form_id == 0 {
5415            return;
5416        }
5417        if let Some(st) = self.globals.msgbk_forms.get_mut(&form_id) {
5418            st.set_history_cnt_max(max_count);
5419        }
5420    }
5421
5422    fn msg_back_entry_has_content(entry: &globals::MsgBackEntry) -> bool {
5423        entry.pct_flag
5424            || !entry.msg_str.is_empty()
5425            || !entry.disp_name.is_empty()
5426            || !entry.original_name.is_empty()
5427            || !entry.koe_no_list.is_empty()
5428    }
5429
5430    fn msg_back_visible_entry_indices(&self) -> Vec<usize> {
5431        self.msg_back_state()
5432            .map(|st| {
5433                st.ordered_history_indices()
5434                    .into_iter()
5435                    .filter(|&i| {
5436                        st.history
5437                            .get(i)
5438                            .is_some_and(Self::msg_back_entry_has_content)
5439                    })
5440                    .collect()
5441            })
5442            .unwrap_or_default()
5443    }
5444
5445    fn msg_back_is_enable(&self) -> bool {
5446        self.globals.syscom.msg_back.check_enabled() != 0 && !self.globals.script.msg_back_disable
5447    }
5448
5449    fn msg_back_line_step(&self) -> i32 {
5450        let moji_size = self.gameexe_i64_default("MSGBK.MOJI_SIZE", 24).max(1) as i32;
5451        let moji_space = self.gameexe_pair_default("MSGBK.MOJI_SPACE", (-1, 10));
5452        (moji_size + moji_space.1 as i32).max(1)
5453    }
5454
5455    fn msg_back_text_area_width(
5456        moji_cnt: (i64, i64),
5457        moji_size: i32,
5458        moji_space: (i64, i64),
5459    ) -> i32 {
5460        let cols = moji_cnt.0.max(1) as i32;
5461        moji_size
5462            .saturating_mul(cols)
5463            .saturating_add((moji_space.0 as i32).saturating_mul((cols - 1).max(0)))
5464            .max(1)
5465    }
5466
5467    fn msg_back_text_area_height(
5468        moji_cnt: (i64, i64),
5469        moji_size: i32,
5470        moji_space: (i64, i64),
5471    ) -> i32 {
5472        let rows = moji_cnt.1.max(1) as i32;
5473        moji_size
5474            .saturating_mul(rows)
5475            .saturating_add((moji_space.1 as i32).saturating_mul((rows - 1).max(0)))
5476            .max(1)
5477    }
5478
5479    fn msg_back_is_hankaku(ch: char) -> bool {
5480        ch.is_ascii() || matches!(ch as u32, 0xFF61..=0xFF9F)
5481    }
5482
5483    fn msg_back_is_kinsoku_moji(ch: char) -> bool {
5484        matches!(
5485            ch,
5486            'ぁ' | 'ぃ'
5487                | 'ぅ'
5488                | 'ぇ'
5489                | 'ぉ'
5490                | 'っ'
5491                | 'ゃ'
5492                | 'ゅ'
5493                | 'ょ'
5494                | 'ゎ'
5495                | 'ァ'
5496                | 'ィ'
5497                | 'ゥ'
5498                | 'ェ'
5499                | 'ォ'
5500                | 'ッ'
5501                | 'ャ'
5502                | 'ュ'
5503                | 'ョ'
5504                | 'ヮ'
5505                | 'ヵ'
5506                | 'ヶ'
5507                | '゙'
5508                | '゚'
5509                | '。'
5510                | '、'
5511                | '!'
5512                | '?'
5513                | ':'
5514                | ';'
5515                | '」'
5516                | ')'
5517                | ']'
5518                | '>'
5519                | '}'
5520                | '\''
5521                | '"'
5522                | 'ー'
5523                | '・'
5524                | '.'
5525                | ','
5526                | 'ァ'
5527                | 'ィ'
5528                | 'ゥ'
5529                | 'ェ'
5530                | 'ォ'
5531                | 'ッ'
5532                | 'ャ'
5533                | 'ュ'
5534                | 'ョ'
5535        )
5536    }
5537
5538    fn msg_back_entry_text(entry: &globals::MsgBackEntry) -> String {
5539        if entry.pct_flag {
5540            return String::new();
5541        }
5542        let mut out = String::new();
5543        if !entry.disp_name.is_empty() {
5544            out.push_str(&entry.disp_name);
5545            out.push('\u{0007}');
5546        }
5547        if !entry.msg_str.is_empty() {
5548            out.push_str(&entry.msg_str);
5549            out.push('\u{0007}');
5550        }
5551        out
5552    }
5553
5554    fn msg_back_measure_entry_text(
5555        entry: &globals::MsgBackEntry,
5556        moji_cnt: (i64, i64),
5557        moji_size: i32,
5558        moji_space: (i64, i64),
5559    ) -> (String, i32) {
5560        let text = Self::msg_back_entry_text(entry);
5561        if text.is_empty() {
5562            return (text, moji_size.max(1));
5563        }
5564
5565        let msg_w = Self::msg_back_text_area_width(moji_cnt, moji_size, moji_space);
5566        let msg_h = Self::msg_back_text_area_height(moji_cnt, moji_size, moji_space);
5567        let space_x = moji_space.0 as i32;
5568        let space_y = moji_space.1 as i32;
5569        let line_step = (moji_size + space_y).max(1);
5570        let mut x = 0i32;
5571        let mut y = 0i32;
5572        let mut indent_pos = 0i32;
5573        let mut indent_moji = '\0';
5574        let mut indent_cnt = 0i32;
5575        let mut line_head = true;
5576
5577        let clear_indent = |indent_pos: &mut i32, indent_moji: &mut char, indent_cnt: &mut i32| {
5578            *indent_pos = 0;
5579            *indent_moji = '\0';
5580            *indent_cnt = 0;
5581        };
5582        let new_line_indent = |x: &mut i32, y: &mut i32, indent_pos: i32| {
5583            *x = indent_pos;
5584            *y = (*y).saturating_add(line_step);
5585        };
5586
5587        for ch in text.chars() {
5588            if ch == '\r' {
5589                continue;
5590            }
5591            if ch == '\n' {
5592                new_line_indent(&mut x, &mut y, indent_pos);
5593                line_head = true;
5594                continue;
5595            }
5596            if ch == '\u{0007}' {
5597                clear_indent(&mut indent_pos, &mut indent_moji, &mut indent_cnt);
5598                new_line_indent(&mut x, &mut y, indent_pos);
5599                line_head = true;
5600                continue;
5601            }
5602
5603            let this_moji_size = if Self::msg_back_is_hankaku(ch) {
5604                (moji_size / 2).max(1)
5605            } else {
5606                moji_size.max(1)
5607            };
5608            let this_check_size = this_moji_size.saturating_add(space_x);
5609            let mut auto_indent = false;
5610            if x.saturating_add(this_check_size) > msg_w.saturating_add(moji_size)
5611                || (x.saturating_add(this_check_size) > msg_w
5612                    && !Self::msg_back_is_kinsoku_moji(ch))
5613            {
5614                new_line_indent(&mut x, &mut y, indent_pos);
5615                auto_indent = true;
5616            }
5617            if auto_indent && (ch == ' ' || ch == ' ') {
5618                continue;
5619            }
5620            if y >= msg_h {
5621                break;
5622            }
5623
5624            x = x.saturating_add(this_moji_size).saturating_add(space_x);
5625
5626            if ch == '「' || ch == '『' || ch == '(' {
5627                if line_head {
5628                    indent_pos = x;
5629                    indent_moji = ch;
5630                    indent_cnt = 1;
5631                } else if ch == indent_moji {
5632                    indent_cnt += 1;
5633                }
5634            }
5635            if indent_cnt > 0
5636                && ((indent_moji == '「' && ch == '」')
5637                    || (indent_moji == '『' && ch == '』')
5638                    || (indent_moji == '(' && ch == ')'))
5639            {
5640                indent_cnt -= 1;
5641                if indent_cnt == 0 {
5642                    clear_indent(&mut indent_pos, &mut indent_moji, &mut indent_cnt);
5643                }
5644            }
5645            line_head = false;
5646        }
5647
5648        let height = y.saturating_sub(space_y).max(moji_size.max(1));
5649        (text, height)
5650    }
5651
5652    fn msg_back_image_size_by_name(&mut self, file: Option<&str>) -> Option<(i32, i32)> {
5653        let raw = file.map(str::trim).filter(|s| !s.is_empty())?;
5654        let id = self
5655            .images
5656            .load_g00(raw, 0)
5657            .or_else(|_| self.images.load_bg_frame(raw, 0))
5658            .or_else(|_| {
5659                let path = self.project_dir.join(raw);
5660                self.images.load_file(&path, 0)
5661            })
5662            .ok()?;
5663        self.images
5664            .get(&id)
5665            .map(|img| (img.width as i32, img.height as i32))
5666    }
5667
5668    fn msg_back_image_size_from_gameexe(&mut self, key: &str) -> Option<(i32, i32)> {
5669        let file = self.gameexe_string(key);
5670        self.msg_back_image_size_by_name(file.as_deref())
5671    }
5672
5673    fn build_msg_back_layout(&mut self) -> MsgBackLayout {
5674        self.sync_msg_back_history_capacity();
5675        let mut out = MsgBackLayout::default();
5676        let indices = self.msg_back_visible_entry_indices();
5677        let entries: Vec<(usize, globals::MsgBackEntry)> = {
5678            let Some(st) = self.msg_back_state() else {
5679                return out;
5680            };
5681            indices
5682                .into_iter()
5683                .filter_map(|history_index| {
5684                    st.history
5685                        .get(history_index)
5686                        .cloned()
5687                        .map(|entry| (history_index, entry))
5688                })
5689                .collect()
5690        };
5691        if entries.is_empty() {
5692            return out;
5693        }
5694
5695        let moji_cnt = self.gameexe_pair_default("MSGBK.MOJI_CNT", (20, 15));
5696        let moji_size = self.gameexe_i64_default("MSGBK.MOJI_SIZE", 24).max(1) as i32;
5697        let moji_space = self.gameexe_pair_default("MSGBK.MOJI_SPACE", (-1, 10));
5698        let separator_file = self.gameexe_string("MSGBK.SEPARATOR_FILE");
5699        let separator_top_file = self.gameexe_string("MSGBK.SEPARATOR_TOP_FILE");
5700        let separator_bottom_file = self.gameexe_string("MSGBK.SEPARATOR_BOTTOM_FILE");
5701        let separator_height = self
5702            .msg_back_image_size_by_name(separator_file.as_deref())
5703            .map(|(_, h)| h.max(0))
5704            .unwrap_or(0);
5705        let separator_top_height = self
5706            .msg_back_image_size_by_name(separator_top_file.as_deref())
5707            .map(|(_, h)| h.max(0))
5708            .unwrap_or(0);
5709        let separator_bottom_height = self
5710            .msg_back_image_size_by_name(separator_bottom_file.as_deref())
5711            .map(|(_, h)| h.max(0))
5712            .unwrap_or(0);
5713
5714        if separator_top_file.is_some() && separator_top_height > 0 {
5715            out.separators.push(MsgBackSeparatorLayout {
5716                file: separator_top_file.clone(),
5717                total_pos: -separator_top_height,
5718                height: separator_top_height,
5719            });
5720        }
5721
5722        let mut total_height = 0i32;
5723        let mut last_margin = 0i32;
5724        for (visible_pos, (history_index, entry)) in entries.iter().enumerate() {
5725            if entry.pct_flag {
5726                let total_pos = total_height;
5727                let height = self
5728                    .msg_back_image_size_by_name(Some(entry.msg_str.as_str()))
5729                    .map(|(_, h)| h.max(1))
5730                    .unwrap_or_else(|| moji_size.max(1));
5731                out.entries.push(MsgBackLayoutEntry {
5732                    history_index: *history_index,
5733                    text: String::new(),
5734                    total_pos,
5735                    height,
5736                });
5737                total_height = total_height.saturating_add(height);
5738                last_margin = 0;
5739            } else {
5740                let (text, height) =
5741                    Self::msg_back_measure_entry_text(entry, moji_cnt, moji_size, moji_space);
5742                let total_pos = total_height.saturating_add(last_margin);
5743                out.entries.push(MsgBackLayoutEntry {
5744                    history_index: *history_index,
5745                    text,
5746                    total_pos,
5747                    height,
5748                });
5749                total_height = total_height
5750                    .saturating_add(last_margin)
5751                    .saturating_add(height);
5752                last_margin = moji_size;
5753            }
5754
5755            if visible_pos + 1 < entries.len() {
5756                if separator_file.is_some() && separator_height > 0 {
5757                    out.separators.push(MsgBackSeparatorLayout {
5758                        file: separator_file.clone(),
5759                        total_pos: total_height,
5760                        height: separator_height,
5761                    });
5762                    total_height = total_height.saturating_add(separator_height);
5763                    last_margin = 0;
5764                }
5765            } else if separator_bottom_file.is_some() && separator_bottom_height > 0 {
5766                out.separators.push(MsgBackSeparatorLayout {
5767                    file: separator_bottom_file.clone(),
5768                    total_pos: total_height,
5769                    height: separator_bottom_height,
5770                });
5771                total_height = total_height.saturating_add(separator_bottom_height);
5772                last_margin = 0;
5773            }
5774        }
5775        out.total_height = total_height.max(0);
5776        out
5777    }
5778
5779    fn msg_back_slider_track(&self) -> (i32, i32, i32) {
5780        let vals = Self::parse_i64_list(self.gameexe_value("MSGBK_ITEM.SLIDER.POS"));
5781        if vals.len() >= 3 {
5782            (vals[0] as i32, vals[1] as i32, vals[2] as i32)
5783        } else {
5784            (0, 0, 0)
5785        }
5786    }
5787
5788    fn msg_back_slider_size_i32(&mut self) -> (i32, i32) {
5789        if let Some((w, h)) = self.msg_back_image_size_from_gameexe("MSGBK_ITEM.SLIDER.FILE") {
5790            return (w.max(0), h.max(0));
5791        }
5792        self.ui
5793            .msg_back_slider_size()
5794            .map(|(w, h)| (w as i32, h as i32))
5795            .unwrap_or((0, 0))
5796    }
5797
5798    fn limit_i32(a: i32, v: i32, b: i32) -> i32 {
5799        let lo = a.min(b);
5800        let hi = a.max(b);
5801        v.clamp(lo, hi)
5802    }
5803
5804    fn linear_i32(x: i32, x1: i32, y1: i32, x2: i32, y2: i32) -> i32 {
5805        if x1 == x2 {
5806            return y1;
5807        }
5808        let num = (x as i64 - x1 as i64) * (y2 as i64 - y1 as i64);
5809        (y1 as i64 + num / (x2 as i64 - x1 as i64)) as i32
5810    }
5811
5812    fn msg_back_scroll_limits(&self, layout: &MsgBackLayout) -> Option<(i32, i32)> {
5813        let first = layout.entries.first()?;
5814        let last = layout.entries.last()?;
5815        let window_size = self.gameexe_pair_default("MSGBK.WINDOW_SIZE", (780, 580));
5816        let wind_height = window_size.1.max(1) as i32;
5817        let msgsp = wind_height / 2 - first.height / 2;
5818        let mut msgep = wind_height / 2 + last.height / 2 - layout.total_height;
5819        if layout.entries.len() == 1 {
5820            msgep = msgsp;
5821        }
5822        Some((msgep, msgsp))
5823    }
5824
5825    fn msg_back_calc_target_no_from_scroll(&mut self, layout: &MsgBackLayout) {
5826        if layout.entries.is_empty() {
5827            self.globals.syscom.msg_back_target_no = -1;
5828            return;
5829        }
5830        let window_size = self.gameexe_pair_default("MSGBK.WINDOW_SIZE", (780, 580));
5831        let center = (window_size.1.max(1) as i32) / 2;
5832        let mut target = layout
5833            .entries
5834            .last()
5835            .map(|e| e.history_index as isize)
5836            .unwrap_or(-1);
5837        for entry in layout.entries.iter().rev() {
5838            if self
5839                .globals
5840                .syscom
5841                .msg_back_scroll_pos
5842                .saturating_add(entry.total_pos)
5843                .saturating_add(entry.height)
5844                >= center
5845            {
5846                target = entry.history_index as isize;
5847            }
5848        }
5849        self.globals.syscom.msg_back_target_no = target;
5850    }
5851
5852    fn msg_back_calc_slider_pos_from_scroll(&mut self, layout: &MsgBackLayout) {
5853        let Some((msgep, msgsp)) = self.msg_back_scroll_limits(layout) else {
5854            let (_x, top, _bottom) = self.msg_back_slider_track();
5855            self.globals.syscom.msg_back_scroll_pos = 0;
5856            self.globals.syscom.msg_back_slider_pos = top;
5857            return;
5858        };
5859        let (_x, top, bottom) = self.msg_back_slider_track();
5860        let slider_h = self.msg_back_slider_size_i32().1.max(0);
5861        let slider_end = bottom.saturating_sub(slider_h);
5862        self.globals.syscom.msg_back_scroll_pos =
5863            Self::limit_i32(msgep, self.globals.syscom.msg_back_scroll_pos, msgsp);
5864        self.globals.syscom.msg_back_slider_pos = Self::linear_i32(
5865            self.globals.syscom.msg_back_scroll_pos,
5866            msgep,
5867            slider_end,
5868            msgsp,
5869            top,
5870        );
5871        self.globals.syscom.msg_back_slider_pos =
5872            Self::limit_i32(top, self.globals.syscom.msg_back_slider_pos, slider_end);
5873    }
5874
5875    fn msg_back_calc_scroll_pos_from_slider(&mut self, layout: &MsgBackLayout) {
5876        let Some((msgep, msgsp)) = self.msg_back_scroll_limits(layout) else {
5877            self.globals.syscom.msg_back_scroll_pos = 0;
5878            return;
5879        };
5880        let (_x, top, bottom) = self.msg_back_slider_track();
5881        let slider_h = self.msg_back_slider_size_i32().1.max(0);
5882        let slider_end = bottom.saturating_sub(slider_h);
5883        self.globals.syscom.msg_back_slider_pos =
5884            Self::limit_i32(top, self.globals.syscom.msg_back_slider_pos, slider_end);
5885        self.globals.syscom.msg_back_scroll_pos = Self::linear_i32(
5886            self.globals.syscom.msg_back_slider_pos,
5887            top,
5888            msgsp,
5889            slider_end,
5890            msgep,
5891        );
5892        self.globals.syscom.msg_back_scroll_pos =
5893            Self::limit_i32(msgep, self.globals.syscom.msg_back_scroll_pos, msgsp);
5894    }
5895
5896    fn msg_back_calc_scroll_pos_from_target(&mut self, layout: &MsgBackLayout) {
5897        if layout.entries.is_empty() {
5898            self.globals.syscom.msg_back_target_no = -1;
5899            self.globals.syscom.msg_back_scroll_pos = 0;
5900            return;
5901        }
5902        let target_no = self.globals.syscom.msg_back_target_no;
5903        let entry = layout
5904            .entries
5905            .iter()
5906            .find(|entry| entry.history_index as isize == target_no)
5907            .unwrap_or_else(|| layout.entries.last().expect("layout is not empty"));
5908        self.globals.syscom.msg_back_target_no = entry.history_index as isize;
5909        let window_size = self.gameexe_pair_default("MSGBK.WINDOW_SIZE", (780, 580));
5910        let wind_height = window_size.1.max(1) as i32;
5911        self.globals.syscom.msg_back_scroll_pos =
5912            wind_height / 2 - (entry.total_pos + entry.height / 2);
5913    }
5914
5915    fn msg_back_update_pos_from_scroll(&mut self, layout: &MsgBackLayout) {
5916        self.msg_back_calc_target_no_from_scroll(layout);
5917        self.msg_back_calc_slider_pos_from_scroll(layout);
5918    }
5919
5920    fn msg_back_update_pos_from_slider(&mut self, layout: &MsgBackLayout) {
5921        self.msg_back_calc_scroll_pos_from_slider(layout);
5922        self.msg_back_calc_target_no_from_scroll(layout);
5923    }
5924
5925    fn msg_back_update_pos_from_target(&mut self, layout: &MsgBackLayout) {
5926        self.msg_back_calc_scroll_pos_from_target(layout);
5927        self.msg_back_calc_slider_pos_from_scroll(layout);
5928    }
5929
5930    fn msg_back_target_up(&mut self) {
5931        let layout = self.build_msg_back_layout();
5932        if layout.entries.is_empty() {
5933            return;
5934        }
5935        let current = self.globals.syscom.msg_back_target_no;
5936        let pos = layout
5937            .entries
5938            .iter()
5939            .position(|entry| entry.history_index as isize == current)
5940            .unwrap_or_else(|| layout.entries.len().saturating_sub(1));
5941        let next_pos = pos.saturating_sub(1);
5942        self.globals.syscom.msg_back_target_no = layout.entries[next_pos].history_index as isize;
5943        self.msg_back_update_pos_from_target(&layout);
5944    }
5945
5946    fn msg_back_target_down(&mut self) {
5947        let layout = self.build_msg_back_layout();
5948        if layout.entries.is_empty() {
5949            return;
5950        }
5951        let current = self.globals.syscom.msg_back_target_no;
5952        let pos = layout
5953            .entries
5954            .iter()
5955            .position(|entry| entry.history_index as isize == current)
5956            .unwrap_or_else(|| layout.entries.len().saturating_sub(1));
5957        let next_pos = (pos + 1).min(layout.entries.len() - 1);
5958        self.globals.syscom.msg_back_target_no = layout.entries[next_pos].history_index as isize;
5959        self.msg_back_update_pos_from_target(&layout);
5960    }
5961
5962    fn msg_back_window_contains(&self, x: i32, y: i32) -> bool {
5963        let window_pos = self.gameexe_pair_default("MSGBK.WINDOW_POS", (10, 10));
5964        let window_size = self.gameexe_pair_default("MSGBK.WINDOW_SIZE", (780, 580));
5965        let left = window_pos.0 as i32;
5966        let top = window_pos.1 as i32;
5967        let right = left.saturating_add(window_size.0.max(1) as i32);
5968        let bottom = top.saturating_add(window_size.1.max(1) as i32);
5969        left <= x && x < right && top <= y && y < bottom
5970    }
5971
5972    fn msg_back_initialize_open_state(&mut self, layout: &MsgBackLayout) {
5973        let (_x, _top, bottom) = self.msg_back_slider_track();
5974        let slider_h = self.msg_back_slider_size_i32().1.max(0);
5975        self.globals.syscom.msg_back_msg_total_height = layout.total_height;
5976
5977        // C_elm_msg_back::open() first places the slider at the bottom, derives
5978        // scroll/slider from that position, and only then assigns m_target_no to
5979        // m_history_last_pos. Do not use the last message target to drive the
5980        // initial scroll position here.
5981        self.globals.syscom.msg_back_slider_pos = bottom.saturating_sub(slider_h);
5982        self.msg_back_update_pos_from_slider(layout);
5983        self.msg_back_update_pos_from_scroll(layout);
5984        self.globals.syscom.msg_back_target_no = if layout.entries.is_empty() {
5985            -1
5986        } else if let Some(st) = self.msg_back_state() {
5987            if layout
5988                .entries
5989                .iter()
5990                .any(|entry| entry.history_index == st.history_last_pos)
5991            {
5992                st.history_last_pos as isize
5993            } else {
5994                layout
5995                    .entries
5996                    .last()
5997                    .map(|entry| entry.history_index as isize)
5998                    .unwrap_or(-1)
5999            }
6000        } else {
6001            layout
6002                .entries
6003                .last()
6004                .map(|entry| entry.history_index as isize)
6005                .unwrap_or(-1)
6006        };
6007        self.globals.syscom.msg_back_slider_dragging = false;
6008        self.globals.syscom.msg_back_content_dragging = false;
6009        self.globals.syscom.msg_back_proc_initialized = true;
6010    }
6011
6012    fn open_msg_back_proc(&mut self) {
6013        if !self.msg_back_is_enable() {
6014            return;
6015        }
6016        self.globals.syscom.read_skip.onoff = false;
6017        self.globals.syscom.msg_back_open = true;
6018        self.globals.syscom.pending_proc = Some(globals::SyscomPendingProc {
6019            kind: globals::SyscomPendingProcKind::MsgBack,
6020            warning: false,
6021            se_play: false,
6022            fade_out: false,
6023            leave_msgbk: false,
6024            save_id: 0,
6025        });
6026        let layout = self.build_msg_back_layout();
6027        self.msg_back_initialize_open_state(&layout);
6028    }
6029
6030    fn close_msg_back_proc(&mut self) {
6031        self.globals.syscom.msg_back_open = false;
6032        self.globals.syscom.msg_back_slider_dragging = false;
6033        self.globals.syscom.msg_back_content_dragging = false;
6034        self.globals.syscom.msg_back_proc_initialized = false;
6035        self.ui.set_msg_back_projection(None);
6036        self.ui.set_sys_overlay(false, String::new());
6037    }
6038
6039    fn replay_msg_back_koe(&mut self, history_index: usize) {
6040        let Some(entry) = self
6041            .globals
6042            .msgbk_forms
6043            .get_mut(&self.ids.form_global_msgbk)
6044            .and_then(|state| state.history.get_mut(history_index))
6045        else {
6046            return;
6047        };
6048        if entry.koe_no_list.is_empty() {
6049            return;
6050        }
6051        // C_elm_msg_back::button_proc cycles through voices on repeated clicks.
6052        let index = usize::try_from(entry.koe_play_no)
6053            .ok()
6054            .filter(|index| *index < entry.koe_no_list.len())
6055            .unwrap_or(0);
6056        let koe_no = entry.koe_no_list[index];
6057        let chara_no = entry.chr_no_list.get(index).copied().unwrap_or(-1);
6058        entry.koe_play_no = index as i64 + 1;
6059
6060        // Backlog playback is EXKOE in the original engine: route the selected
6061        // character's volume without changing the ongoing message's voice.
6062        forms::global::remember_global_koe(self, koe_no, chara_no, true);
6063        let jitan_rate = self.koe_jitan_rate(None, true);
6064        if let Err(err) = self.koe.play_koe_no_with_rate(
6065            &mut self.audio,
6066            koe_no,
6067            &self.globals.append_dir,
6068            jitan_rate,
6069        ) {
6070            log::error!("MSGBK voice replay failed koe_no={koe_no} chara_no={chara_no}: {err:#}");
6071        }
6072    }
6073
6074    fn handle_msg_back_key(&mut self, k: input::VmKey) -> bool {
6075        if !self.globals.syscom.msg_back_open {
6076            return false;
6077        }
6078        match k {
6079            input::VmKey::Escape | input::VmKey::Enter | input::VmKey::Space => {
6080                self.close_msg_back_proc();
6081            }
6082            input::VmKey::ArrowUp | input::VmKey::ArrowLeft => self.msg_back_target_up(),
6083            input::VmKey::ArrowDown | input::VmKey::ArrowRight => self.msg_back_target_down(),
6084            input::VmKey::F(5) => {
6085                let layout = self.build_msg_back_layout();
6086                if let Some(entry) = layout.entries.first() {
6087                    self.globals.syscom.msg_back_target_no = entry.history_index as isize;
6088                    self.msg_back_update_pos_from_target(&layout);
6089                }
6090            }
6091            input::VmKey::F(6) => {
6092                let layout = self.build_msg_back_layout();
6093                if let Some(entry) = layout.entries.last() {
6094                    self.globals.syscom.msg_back_target_no = entry.history_index as isize;
6095                    self.msg_back_update_pos_from_target(&layout);
6096                }
6097            }
6098            _ => {}
6099        }
6100        true
6101    }
6102
6103    fn handle_msg_back_mouse_down(&mut self, b: input::VmMouseButton) -> bool {
6104        if !self.globals.syscom.msg_back_open {
6105            return false;
6106        }
6107        match b {
6108            input::VmMouseButton::Right => {
6109                self.close_msg_back_proc();
6110            }
6111            input::VmMouseButton::Left => {
6112                match self
6113                    .ui
6114                    .msg_back_hit_action(self.input.mouse_x, self.input.mouse_y)
6115                {
6116                    Some(ui::MsgBackHitAction::Close) => self.close_msg_back_proc(),
6117                    Some(ui::MsgBackHitAction::Up) => self.msg_back_target_up(),
6118                    Some(ui::MsgBackHitAction::Down) => self.msg_back_target_down(),
6119                    Some(ui::MsgBackHitAction::ReplayKoe(index)) => self.replay_msg_back_koe(index),
6120                    Some(ui::MsgBackHitAction::Slider) => {
6121                        self.globals.syscom.msg_back_slider_dragging = true;
6122                        self.globals.syscom.msg_back_slider_drag_start_mouse = self.input.mouse_y;
6123                        self.globals.syscom.msg_back_slider_drag_start_pos =
6124                            self.globals.syscom.msg_back_slider_pos;
6125                    }
6126                    None => {
6127                        if self.msg_back_window_contains(self.input.mouse_x, self.input.mouse_y) {
6128                            self.globals.syscom.msg_back_content_dragging = true;
6129                            self.globals.syscom.msg_back_content_drag_start_mouse =
6130                                self.input.mouse_y;
6131                            self.globals.syscom.msg_back_content_drag_start_scroll_pos =
6132                                self.globals.syscom.msg_back_scroll_pos;
6133                        }
6134                    }
6135                }
6136            }
6137            _ => {}
6138        }
6139        true
6140    }
6141
6142    fn handle_msg_back_mouse_up(&mut self, b: input::VmMouseButton) -> bool {
6143        if !self.globals.syscom.msg_back_open {
6144            return false;
6145        }
6146        if matches!(b, input::VmMouseButton::Left) {
6147            self.globals.syscom.msg_back_slider_dragging = false;
6148            self.globals.syscom.msg_back_content_dragging = false;
6149        }
6150        true
6151    }
6152
6153    fn handle_msg_back_mouse_move(&mut self) -> bool {
6154        if !self.globals.syscom.msg_back_open {
6155            return false;
6156        }
6157        if self.globals.syscom.msg_back_slider_dragging {
6158            let layout = self.build_msg_back_layout();
6159            self.globals.syscom.msg_back_slider_pos = self
6160                .globals
6161                .syscom
6162                .msg_back_slider_drag_start_pos
6163                .saturating_add(
6164                    self.input.mouse_y - self.globals.syscom.msg_back_slider_drag_start_mouse,
6165                );
6166            self.msg_back_update_pos_from_slider(&layout);
6167            return true;
6168        }
6169        if self.globals.syscom.msg_back_content_dragging {
6170            let layout = self.build_msg_back_layout();
6171            self.globals.syscom.msg_back_scroll_pos = self
6172                .globals
6173                .syscom
6174                .msg_back_content_drag_start_scroll_pos
6175                .saturating_sub(
6176                    self.globals.syscom.msg_back_content_drag_start_mouse - self.input.mouse_y,
6177                );
6178            self.msg_back_update_pos_from_scroll(&layout);
6179            return true;
6180        }
6181
6182        let layout = self.build_msg_back_layout();
6183        self.globals.syscom.msg_back_mouse_target_no = -1;
6184        let window_pos = self.gameexe_pair_default("MSGBK.WINDOW_POS", (10, 10));
6185        let window_size = self.gameexe_pair_default("MSGBK.WINDOW_SIZE", (780, 580));
6186        let disp_margin = self.gameexe_rect_default("MSGBK.DISP_MARGIN", (20, 20, 20, 20));
6187        let local_x = self.input.mouse_x.saturating_sub(window_pos.0 as i32);
6188        let local_y = self.input.mouse_y.saturating_sub(window_pos.1 as i32);
6189        let in_display_rect = local_x >= disp_margin.0 as i32
6190            && local_x < window_size.0.max(1) as i32 - disp_margin.2 as i32
6191            && local_y >= disp_margin.1 as i32
6192            && local_y < window_size.1.max(1) as i32 - disp_margin.3 as i32;
6193        if in_display_rect {
6194            for entry in layout.entries.iter() {
6195                let top = entry
6196                    .total_pos
6197                    .saturating_add(self.globals.syscom.msg_back_scroll_pos);
6198                let bottom = top.saturating_add(entry.height);
6199                if top <= local_y && local_y < bottom {
6200                    self.globals.syscom.msg_back_mouse_target_no = entry.history_index as isize;
6201                    break;
6202                }
6203            }
6204        }
6205        false
6206    }
6207
6208    fn msg_back_build_visible_text(&self, layout: &MsgBackLayout) -> (String, i32) {
6209        if layout.entries.is_empty() {
6210            return (
6211                String::new(),
6212                self.gameexe_rect_default("MSGBK.DISP_MARGIN", (20, 20, 20, 20))
6213                    .1 as i32,
6214            );
6215        }
6216        let window_size = self.gameexe_pair_default("MSGBK.WINDOW_SIZE", (780, 580));
6217        let disp_margin = self.gameexe_rect_default("MSGBK.DISP_MARGIN", (20, 20, 20, 20));
6218        let clip_top = disp_margin.1 as i32;
6219        let clip_bottom = window_size.1.max(1) as i32 - disp_margin.3 as i32;
6220        let scroll = self.globals.syscom.msg_back_scroll_pos;
6221        let line_step = self.msg_back_line_step();
6222        let mut first_idx = None;
6223        let mut last_idx = None;
6224        for (i, entry) in layout.entries.iter().enumerate() {
6225            let top = entry.total_pos.saturating_add(scroll);
6226            let bottom = top.saturating_add(entry.height);
6227            if bottom > clip_top && top < clip_bottom {
6228                if first_idx.is_none() {
6229                    first_idx = Some(i);
6230                }
6231                last_idx = Some(i);
6232            }
6233        }
6234        let Some(first) = first_idx else {
6235            let target_no = self.globals.syscom.msg_back_target_no;
6236            let entry = layout
6237                .entries
6238                .iter()
6239                .find(|entry| entry.history_index as isize == target_no)
6240                .unwrap_or_else(|| layout.entries.last().expect("layout is not empty"));
6241            return (entry.text.clone(), entry.total_pos.saturating_add(scroll));
6242        };
6243        let last = last_idx.unwrap_or(first);
6244        let mut text = String::new();
6245        for i in first..=last {
6246            let entry = &layout.entries[i];
6247            if entry.text.is_empty() {
6248                continue;
6249            }
6250            if !text.is_empty() {
6251                let prev = &layout.entries[i - 1];
6252                let gap = entry.total_pos - (prev.total_pos + prev.height);
6253                let blank_lines = (gap / line_step).max(0) as usize;
6254                for _ in 0..blank_lines {
6255                    text.push('\n');
6256                }
6257            }
6258            text.push_str(&entry.text);
6259            if !text.ends_with('\n') {
6260                text.push('\n');
6261            }
6262        }
6263        (text, layout.entries[first].total_pos.saturating_add(scroll))
6264    }
6265
6266    fn build_msg_back_projection(&mut self) -> Option<ui::MsgBackUiProjection> {
6267        if !self.globals.syscom.msg_back_open {
6268            return None;
6269        }
6270        let layout = self.build_msg_back_layout();
6271        self.globals.syscom.msg_back_msg_total_height = layout.total_height;
6272        if !self.globals.syscom.msg_back_proc_initialized {
6273            self.msg_back_initialize_open_state(&layout);
6274        } else {
6275            self.msg_back_update_pos_from_scroll(&layout);
6276        }
6277
6278        let window_pos = self.gameexe_pair_default("MSGBK.WINDOW_POS", (10, 10));
6279        let window_size = self.gameexe_pair_default("MSGBK.WINDOW_SIZE", (780, 580));
6280        let disp_margin = self.gameexe_rect_default("MSGBK.DISP_MARGIN", (20, 20, 20, 20));
6281        let filter_margin = self.gameexe_rect_default("MSGBK.FILTER_MARGIN", (0, 0, 0, 0));
6282        let filter_rgba = self.gameexe_rgba_default("MSGBK.FILTER_COLOR", (0, 0, 0, 0));
6283        let filter_config_rgba = self.syscom_filter_config_rgba();
6284        let moji_space = self.gameexe_pair_default("MSGBK.MOJI_SPACE", (-1, 10));
6285        let moji_size = self.gameexe_i64_default("MSGBK.MOJI_SIZE", 24).max(1);
6286        let msg_pos = self.gameexe_i64_default("MSGBK.MESSAGE_POS", 30) as i32;
6287        let order = self.gameexe_i64_default("MSGBK.ORDER", 10000) as i32;
6288        let scroll = self.globals.syscom.msg_back_scroll_pos;
6289        let (dl, dt, dr, db) = disp_margin;
6290        let clip_top = dt as i32;
6291        let clip_bottom = window_size.1.max(1) as i32 - db as i32;
6292        let moji_cnt = self.gameexe_pair_default("MSGBK.MOJI_CNT", (20, 15));
6293        let text_width =
6294            Self::msg_back_text_area_width(moji_cnt, moji_size as i32, moji_space) as u32;
6295        let font_shadow_mode = self.effective_font_shadow_mode();
6296        let (font_shadow, font_fuchi) =
6297            crate::text_render::font_shadow_mode_flags(font_shadow_mode);
6298        let font_bold = self.effective_font_bold();
6299        let base_style = TextStyle {
6300            color: self.gameexe_color(self.tables.mwnd_render.moji_color),
6301            shadow_color: self.gameexe_color(self.tables.mwnd_render.shadow_color),
6302            fuchi_color: self.gameexe_color(self.tables.mwnd_render.fuchi_color),
6303            shadow_mode: font_shadow_mode,
6304            shadow: font_shadow,
6305            fuchi: font_fuchi,
6306            bold: font_bold,
6307        };
6308        let active_style = TextStyle {
6309            color: self.gameexe_color(self.gameexe_i64_default("MSGBK.ACTIVE_MOJI_COLOR", 7)),
6310            shadow_color: self
6311                .gameexe_color(self.gameexe_i64_default("MSGBK.ACTIVE_MOJI_SHADOW_COLOR", 0)),
6312            fuchi_color: self
6313                .gameexe_color(self.gameexe_i64_default("MSGBK.ACTIVE_MOJI_FUCHI_COLOR", 0)),
6314            shadow_mode: font_shadow_mode,
6315            shadow: font_shadow,
6316            fuchi: font_fuchi,
6317            bold: font_bold,
6318        };
6319        let debug_style = TextStyle {
6320            color: self.gameexe_color(self.gameexe_i64_default("MSGBK.DEBUG_MOJI_COLOR", 5)),
6321            shadow_color: self
6322                .gameexe_color(self.gameexe_i64_default("MSGBK.DEBUG_MOJI_SHADOW_COLOR", 0)),
6323            fuchi_color: self
6324                .gameexe_color(self.gameexe_i64_default("MSGBK.DEBUG_MOJI_FUCHI_COLOR", 0)),
6325            shadow_mode: font_shadow_mode,
6326            shadow: font_shadow,
6327            fuchi: font_fuchi,
6328            bold: font_bold,
6329        };
6330
6331        let koe_btn_file = self.gameexe_string("MSGBK_ITEM.KOE_BTN.FILE");
6332        let koe_btn_pos = self.msg_back_button_pos("MSGBK_ITEM.KOE_BTN.POS", (-20, -10));
6333        let load_btn_file = self.gameexe_string("MSGBK_ITEM.LOAD_BTN.FILE");
6334        let load_btn_pos = self.msg_back_button_pos("MSGBK_ITEM.LOAD_BTN.POS", (-20, 0));
6335
6336        let mut text_entries = Vec::new();
6337        let mut koe_buttons = Vec::new();
6338        let mut load_buttons = Vec::new();
6339        let separators = layout
6340            .separators
6341            .iter()
6342            .filter_map(|sep| {
6343                if sep.file.is_none() || sep.height <= 0 {
6344                    return None;
6345                }
6346                let local_y = sep.total_pos.saturating_add(scroll);
6347                let bottom = local_y.saturating_add(sep.height);
6348                if bottom > clip_top && local_y < clip_bottom {
6349                    Some(ui::MsgBackImageProjection {
6350                        file: sep.file.clone(),
6351                        x: 0,
6352                        y: local_y,
6353                    })
6354                } else {
6355                    None
6356                }
6357            })
6358            .collect::<Vec<_>>();
6359
6360        if let Some(st) = self.msg_back_state() {
6361            for layout_entry in layout.entries.iter() {
6362                let Some(entry) = st.history.get(layout_entry.history_index) else {
6363                    continue;
6364                };
6365                let local_y = layout_entry.total_pos.saturating_add(scroll);
6366                let bottom = local_y.saturating_add(layout_entry.height);
6367                let is_in_rect = bottom > clip_top && local_y < clip_bottom;
6368                if is_in_rect && !layout_entry.text.is_empty() {
6369                    let mut style = base_style;
6370                    if self.globals.system.debug_flag
6371                        && self.globals.syscom.msg_back_target_no
6372                            == layout_entry.history_index as isize
6373                    {
6374                        style = debug_style;
6375                    }
6376                    if self.globals.syscom.msg_back_mouse_target_no
6377                        == layout_entry.history_index as isize
6378                    {
6379                        style = active_style;
6380                    }
6381                    text_entries.push(ui::MsgBackTextProjection {
6382                        history_index: layout_entry.history_index,
6383                        text: layout_entry.text.clone(),
6384                        x: msg_pos,
6385                        y: local_y,
6386                        width: text_width,
6387                        height: layout_entry.height.max(1) as u32,
6388                        style,
6389                    });
6390                }
6391                if is_in_rect && entry.pct_flag {
6392                    koe_buttons.push(ui::MsgBackEntryButtonProjection {
6393                        history_index: layout_entry.history_index,
6394                        file: Some(entry.msg_str.clone()),
6395                        x: msg_pos.saturating_add(entry.pct_pos_x),
6396                        y: local_y.saturating_add(entry.pct_pos_y),
6397                    });
6398                } else if is_in_rect && !entry.koe_no_list.is_empty() {
6399                    koe_buttons.push(ui::MsgBackEntryButtonProjection {
6400                        history_index: layout_entry.history_index,
6401                        file: koe_btn_file.clone(),
6402                        x: msg_pos.saturating_add(koe_btn_pos.0),
6403                        y: local_y.saturating_add(koe_btn_pos.1),
6404                    });
6405                }
6406                if is_in_rect && entry.save_id_check_flag {
6407                    load_buttons.push(ui::MsgBackEntryButtonProjection {
6408                        history_index: layout_entry.history_index,
6409                        file: load_btn_file.clone(),
6410                        x: msg_pos.saturating_add(load_btn_pos.0),
6411                        y: local_y.saturating_add(load_btn_pos.1),
6412                    });
6413                }
6414            }
6415        }
6416
6417        let (slider_x, _slider_top, _slider_bottom) = self.msg_back_slider_track();
6418        if env_is_set!("SG_MSGBK_TRACE") {
6419            eprintln!(
6420                "[SG_MSGBK_TRACE][PROJECTION] entries={} separators={} text={} koe={} load={} total_height={} scroll={} slider={} target={} mouse_target={}",
6421                layout.entries.len(),
6422                layout.separators.len(),
6423                text_entries.len(),
6424                koe_buttons.len(),
6425                load_buttons.len(),
6426                layout.total_height,
6427                self.globals.syscom.msg_back_scroll_pos,
6428                self.globals.syscom.msg_back_slider_pos,
6429                self.globals.syscom.msg_back_target_no,
6430                self.globals.syscom.msg_back_mouse_target_no
6431            );
6432            for entry in &layout.entries {
6433                eprintln!(
6434                    "[SG_MSGBK_TRACE][LAYOUT] history_index={} total_pos={} height={} has_text={}",
6435                    entry.history_index,
6436                    entry.total_pos,
6437                    entry.height,
6438                    !entry.text.is_empty()
6439                );
6440            }
6441            for sep in &layout.separators {
6442                eprintln!(
6443                    "[SG_MSGBK_TRACE][SEPARATOR] file={:?} total_pos={} height={}",
6444                    sep.file, sep.total_pos, sep.height
6445                );
6446            }
6447        }
6448        Some(ui::MsgBackUiProjection {
6449            window_x: window_pos.0 as i32,
6450            window_y: window_pos.1 as i32,
6451            window_w: window_size.0.max(1) as u32,
6452            window_h: window_size.1.max(1) as u32,
6453            disp_margin,
6454            msg_pos,
6455            moji_size,
6456            moji_space: Some(moji_space),
6457            order,
6458            filter_layer_rep: self.tables.mwnd_render.filter_layer_rep as i32,
6459            waku_layer_rep: self.tables.mwnd_render.waku_layer_rep as i32,
6460            moji_layer_rep: self.tables.mwnd_render.moji_layer_rep as i32,
6461            waku_file: self.gameexe_string("MSGBK.BACK_FILE"),
6462            filter_file: self.gameexe_string("MSGBK.FILTER_FILE"),
6463            filter_margin,
6464            filter_rgba,
6465            filter_config_rgba,
6466            text_entries,
6467            separators,
6468            koe_buttons,
6469            load_buttons,
6470            close_btn_file: self.gameexe_string("MSGBK_ITEM.CLOSE_BTN.FILE"),
6471            close_btn_pos: self.msg_back_button_pos("MSGBK_ITEM.CLOSE_BTN.POS", (0, 0)),
6472            msg_up_btn_file: self.gameexe_string("MSGBK_ITEM.MSG_UP_BTN.FILE"),
6473            msg_up_btn_pos: self.msg_back_button_pos("MSGBK_ITEM.MSG_UP_BTN.POS", (0, 0)),
6474            msg_down_btn_file: self.gameexe_string("MSGBK_ITEM.MSG_DOWN_BTN.FILE"),
6475            msg_down_btn_pos: self.msg_back_button_pos("MSGBK_ITEM.MSG_DOWN_BTN.POS", (0, 0)),
6476            slider_file: self.gameexe_string("MSGBK_ITEM.SLIDER.FILE"),
6477            slider_rect: (
6478                slider_x,
6479                self.msg_back_slider_track().1,
6480                slider_x,
6481                self.msg_back_slider_track().2,
6482            ),
6483            slider_pos: (slider_x, self.globals.syscom.msg_back_slider_pos),
6484            ex_btn_files: [
6485                self.gameexe_string("MSGBK_ITEM.EX_BTN_1.FILE"),
6486                self.gameexe_string("MSGBK_ITEM.EX_BTN_2.FILE"),
6487                self.gameexe_string("MSGBK_ITEM.EX_BTN_3.FILE"),
6488                self.gameexe_string("MSGBK_ITEM.EX_BTN_4.FILE"),
6489            ],
6490            ex_btn_pos: [
6491                self.msg_back_button_pos("MSGBK_ITEM.EX_BTN_1.POS", (0, 0)),
6492                self.msg_back_button_pos("MSGBK_ITEM.EX_BTN_2.POS", (0, 0)),
6493                self.msg_back_button_pos("MSGBK_ITEM.EX_BTN_3.POS", (0, 0)),
6494                self.msg_back_button_pos("MSGBK_ITEM.EX_BTN_4.POS", (0, 0)),
6495            ],
6496        })
6497    }
6498
6499    fn sync_syscom_menu_ui(&mut self) {
6500        self.ui.set_msg_back_projection(None);
6501        self.ui.set_sys_overlay(false, String::new());
6502        if self.sync_system_messagebox_ui() {
6503            return;
6504        }
6505        if self.globals.syscom.msg_back_open {
6506            let projection = self.build_msg_back_projection();
6507            self.ui.set_msg_back_projection(projection);
6508            return;
6509        }
6510        if self.globals.syscom.menu_open {
6511            log::error!("SYSCOM menu proc is not implemented; fake Rust text menu is disabled");
6512            self.globals.syscom.menu_open = false;
6513            self.globals.syscom.menu_kind = None;
6514            self.globals.syscom.menu_result = None;
6515        }
6516    }
6517
6518    fn selbtn_choice_selectable(choice: &globals::BtnSelectChoiceState) -> bool {
6519        choice.item_type == TNM_SEL_ITEM_TYPE_ON_I64
6520    }
6521
6522    fn next_selbtn_cursor(&self, dir: i32) -> usize {
6523        let choices = &self.globals.selbtn.choices;
6524        if choices.is_empty() {
6525            return 0;
6526        }
6527        let len = choices.len() as i32;
6528        let mut idx = self.globals.selbtn.cursor.min(choices.len() - 1) as i32;
6529        for _ in 0..choices.len() {
6530            idx = (idx + dir).rem_euclid(len);
6531            if Self::selbtn_choice_selectable(&choices[idx as usize]) {
6532                return idx as usize;
6533            }
6534        }
6535        self.globals.selbtn.cursor.min(choices.len() - 1)
6536    }
6537
6538    fn selbtn_linear_limit(
6539        now: i64,
6540        start: i64,
6541        start_value: i64,
6542        end: i64,
6543        end_value: i64,
6544    ) -> i64 {
6545        if now >= end {
6546            return end_value;
6547        }
6548        if now <= start || start == end {
6549            return start_value;
6550        }
6551        (end_value - start_value).saturating_mul(now - start) / (end - start) + start_value
6552    }
6553
6554    fn selbtn_speed_up_limit(
6555        now: i64,
6556        start: i64,
6557        start_value: i64,
6558        end: i64,
6559        end_value: i64,
6560    ) -> i64 {
6561        if start == end {
6562            return end_value;
6563        }
6564        let current = if start < end {
6565            now.clamp(start, end)
6566        } else {
6567            now.clamp(end, start)
6568        };
6569        let t = (current - start) as f64;
6570        let d = (end - start) as f64;
6571        (t * t * (end_value - start_value) as f64 / (d * d) + start_value as f64) as i64
6572    }
6573
6574    fn selbtn_speed_down_limit(
6575        now: i64,
6576        start: i64,
6577        start_value: i64,
6578        end: i64,
6579        end_value: i64,
6580    ) -> i64 {
6581        if start == end {
6582            return end_value;
6583        }
6584        let current = if start < end {
6585            now.clamp(start, end)
6586        } else {
6587            now.clamp(end, start)
6588        };
6589        let t = (current - end) as f64;
6590        let d = (end - start) as f64;
6591        (-t * t * (end_value - start_value) as f64 / (d * d) + end_value as f64) as i64
6592    }
6593
6594    fn selbtn_accepts_input(&self) -> bool {
6595        let sel = &self.globals.selbtn;
6596        sel.started
6597            && sel.appear_flag
6598            && sel.open_anime_type == 0
6599            && sel.decide_anime_type == 0
6600            && sel.close_anime_type == 0
6601            && !sel.capture_now_flag
6602    }
6603
6604    fn deliver_selbtn_result(&mut self) {
6605        if self.globals.selbtn.result_delivered {
6606            return;
6607        }
6608        let result = self.globals.selbtn.result;
6609        self.globals.selbtn.result_delivered = true;
6610        // C_elm_btn_select::decide() records the return value immediately, but
6611        // TNM_PROC_TYPE_SEL_BTN continues blocking until the configured sync
6612        // point. Keep that value inside VmWait so unrelated KEY_WAIT releases
6613        // cannot resume the command with the default integer value 0.
6614        self.wait.set_selbtn_result(result);
6615    }
6616
6617    fn end_selbtn_close_animation(&mut self) {
6618        self.globals.selbtn.close_anime_type = 0;
6619        self.globals.selbtn.started = false;
6620        self.globals.selbtn.appear_flag = false;
6621        self.globals.selbtn.processing_flag_0 = false;
6622        self.globals.selbtn.processing_flag_1 = false;
6623        self.globals.selbtn.processing_flag_2 = false;
6624        self.globals.selbtn.pressed_index = None;
6625        self.globals.selbtn.pressed_inside = false;
6626        if self.globals.selbtn.capture_flag {
6627            crate::runtime::forms::syscom::free_runtime_save_thumb_capture(
6628                self,
6629                crate::runtime::forms::syscom::CAPTURE_PRIOR_SAVE,
6630            );
6631        }
6632        self.clear_selbtn_items_from_front_stage();
6633    }
6634
6635    fn begin_selbtn_close_animation(&mut self) {
6636        let template_no = self.globals.selbtn.template_no.max(0) as usize;
6637        let tmpl = self
6638            .tables
6639            .sel_btn_templates
6640            .get(template_no)
6641            .cloned()
6642            .unwrap_or_default();
6643        let sel = &mut self.globals.selbtn;
6644        sel.appear_flag = false;
6645        sel.started = false;
6646        sel.close_anime_type = tmpl.close_anime_type.max(0);
6647        sel.close_anime_time = tmpl.close_anime_time.max(0);
6648        sel.close_anime_cur_time = 0;
6649        if sel.decide_sel_no >= 0 && tmpl.decide_anime_type >= 1 && sel.close_anime_type >= 1 {
6650            // C_elm_btn_select::close forces a plain fade after a decide
6651            // animation so only the selected item remains visible.
6652            sel.close_anime_type = 1;
6653        }
6654        sel.processing_flag_1 = false;
6655        let end_immediately = sel.close_anime_type == 0;
6656        if end_immediately {
6657            self.end_selbtn_close_animation();
6658        }
6659    }
6660
6661    fn finish_selbtn(&mut self, result: i64) {
6662        if !self.selbtn_accepts_input() {
6663            return;
6664        }
6665        self.globals.selbtn.result = result;
6666        self.globals.selbtn.started = false;
6667        self.globals.selbtn.pressed_index = None;
6668        self.globals.selbtn.pressed_inside = false;
6669        self.globals.selbtn.decide_sel_no = result;
6670        // Original order in C_elm_btn_select::decide(): push sel_no, set the
6671        // selection point, then clear processing_flag_2.
6672        self.deliver_selbtn_result();
6673        self.request_sel_point_with_result(result);
6674        self.globals.selbtn.processing_flag_2 = false;
6675        if result >= 0 {
6676            if let Some(choice) = self.globals.selbtn.choices.get(result as usize) {
6677                self.globals.syscom.system_extra_str_value = choice.text.clone();
6678            }
6679        } else {
6680            self.globals.syscom.system_extra_str_value = "(キャンセル)".to_string();
6681        }
6682
6683        // C_elm_btn_select::button_event commits its registered read flag
6684        // immediately after decide/cancel, before any sync point releases the
6685        // script.  The registration is one-shot and is cleared either way.
6686        let read_scene_no = self.globals.selbtn.read_flag_scene_no;
6687        let read_flag_no = self.globals.selbtn.read_flag_flag_no;
6688        let _ = self.globals.set_read_flag(read_scene_no, read_flag_no);
6689        self.globals.selbtn.read_flag_scene_no = -1;
6690        self.globals.selbtn.read_flag_flag_no = -1;
6691
6692        let template_no = self.globals.selbtn.template_no.max(0) as usize;
6693        let tmpl = self
6694            .tables
6695            .sel_btn_templates
6696            .get(template_no)
6697            .cloned()
6698            .unwrap_or_default();
6699        if result >= 0 && tmpl.decide_anime_type >= 1 {
6700            self.globals.selbtn.decide_anime_type = tmpl.decide_anime_type;
6701            self.globals.selbtn.decide_anime_time = tmpl.decide_anime_time.max(0);
6702            self.globals.selbtn.decide_anime_cur_time = 0;
6703            if self.globals.selbtn.decide_anime_time == 0 {
6704                self.globals.selbtn.decide_anime_type = 0;
6705                self.begin_selbtn_close_animation();
6706            }
6707        } else {
6708            self.begin_selbtn_close_animation();
6709        }
6710    }
6711
6712    fn update_selbtn_animation(&mut self, elapsed_ms: i64) {
6713        let elapsed_ms = elapsed_ms.max(0);
6714        let active = {
6715            let sel = &self.globals.selbtn;
6716            sel.appear_flag || sel.close_anime_type > 0 || sel.decide_anime_type > 0
6717        };
6718        if !active {
6719            return;
6720        }
6721
6722        {
6723            let sel = &mut self.globals.selbtn;
6724            sel.open_anime_cur_time = sel.open_anime_cur_time.saturating_add(elapsed_ms);
6725            sel.close_anime_cur_time = sel.close_anime_cur_time.saturating_add(elapsed_ms);
6726            sel.decide_anime_cur_time = sel.decide_anime_cur_time.saturating_add(elapsed_ms);
6727        }
6728
6729        let item_count = self
6730            .globals
6731            .stage_forms
6732            .get(&self.ids.form_global_stage)
6733            .and_then(|st| st.btnselitem_lists.get(&TNM_STAGE_FRONT_I64))
6734            .map(Vec::len)
6735            .unwrap_or(0);
6736        let template_no = self.globals.selbtn.template_no.max(0) as usize;
6737        let tmpl = self
6738            .tables
6739            .sel_btn_templates
6740            .get(template_no)
6741            .cloned()
6742            .unwrap_or_default();
6743        let stagger_y_count = if tmpl.max_y_cnt > 0 {
6744            (tmpl.max_y_cnt as usize).min(item_count)
6745        } else {
6746            item_count
6747        };
6748        // The C++ end-time calculation uses the actual vertical item count.
6749        // Keep zero for an empty selection, but use one only as a modulo guard
6750        // inside the item loop (which is empty in that case).
6751        let y_count = stagger_y_count.max(1);
6752
6753        // Capture is a one-frame state in C_elm_btn_select.  Once the capture
6754        // completes, the optional selection-start farcall is queued through
6755        // the same VM-owned action path used by object buttons.
6756        if self.globals.selbtn.capture_now_flag {
6757            self.globals.selbtn.capture_now_flag = false;
6758            let scene = self.globals.selbtn.sel_start_call_scn.clone();
6759            let z_no = self.globals.selbtn.sel_start_call_z_no;
6760            if !scene.is_empty() && z_no >= 0 {
6761                self.globals
6762                    .pending_button_actions
6763                    .push(globals::PendingButtonAction {
6764                        kind: globals::PendingButtonActionKind::UserCall {
6765                            scn_name: scene,
6766                            cmd_name: String::new(),
6767                            z_no,
6768                        },
6769                    });
6770            }
6771        }
6772
6773        let stagger = (stagger_y_count as i64).saturating_mul(50);
6774        let open_stagger = if matches!(self.globals.selbtn.open_anime_type, 2..=6) {
6775            stagger
6776        } else {
6777            0
6778        };
6779        let open_end = self
6780            .globals
6781            .selbtn
6782            .open_anime_time
6783            .saturating_add(open_stagger);
6784        let decide_end = self.globals.selbtn.decide_anime_time;
6785
6786        if self.globals.selbtn.open_anime_type > 0
6787            && self.globals.selbtn.open_anime_cur_time >= open_end
6788        {
6789            self.globals.selbtn.open_anime_type = 0;
6790            if self.globals.selbtn.capture_flag {
6791                self.globals.selbtn.capture_now_flag = true;
6792                crate::runtime::forms::syscom::prepare_runtime_save_thumb_capture_with_priority(
6793                    self,
6794                    crate::runtime::forms::syscom::CAPTURE_PRIOR_SAVE,
6795                );
6796            }
6797        }
6798        let close_stagger = if matches!(self.globals.selbtn.close_anime_type, 2..=6) {
6799            stagger
6800        } else {
6801            0
6802        };
6803        let close_end = self
6804            .globals
6805            .selbtn
6806            .close_anime_time
6807            .saturating_add(close_stagger);
6808        if self.globals.selbtn.close_anime_type > 0
6809            && self.globals.selbtn.close_anime_cur_time >= close_end
6810        {
6811            self.end_selbtn_close_animation();
6812            return;
6813        }
6814        // The original update order is capture -> open -> close -> decide.
6815        // In particular, a close animation started by end_decide_anime() must
6816        // not also be advanced/completed by the close test in the same tick.
6817        if self.globals.selbtn.decide_anime_type > 0
6818            && self.globals.selbtn.decide_anime_cur_time >= decide_end
6819        {
6820            self.begin_selbtn_close_animation();
6821            self.globals.selbtn.decide_anime_type = 0;
6822        }
6823
6824        let sel = self.globals.selbtn.clone();
6825        let screen_w = self.screen_w as i64;
6826        let screen_h = self.screen_h as i64;
6827        let decided_pos = if sel.decide_sel_no >= 0 {
6828            self.globals
6829                .selbtn
6830                .choices
6831                .get(sel.decide_sel_no as usize)
6832                .map(|choice| choice.pos)
6833        } else {
6834            None
6835        };
6836        if let Some(items) = self
6837            .globals
6838            .stage_forms
6839            .get_mut(&self.ids.form_global_stage)
6840            .and_then(|st| st.btnselitem_lists.get_mut(&TNM_STAGE_FRONT_I64))
6841        {
6842            for (index, item) in items.iter_mut().enumerate() {
6843                let row = index % y_count;
6844                let mut tr = 255i64;
6845                let mut x_rep = 0i64;
6846                let mut y_rep = 0i64;
6847
6848                match sel.open_anime_type {
6849                    1 => {
6850                        tr = Self::selbtn_linear_limit(
6851                            sel.open_anime_cur_time,
6852                            0,
6853                            0,
6854                            sel.open_anime_time,
6855                            255,
6856                        );
6857                    }
6858                    2 => {
6859                        let time = sel
6860                            .open_anime_cur_time
6861                            .saturating_sub(50 * (y_count.saturating_sub(row)) as i64);
6862                        tr = Self::selbtn_linear_limit(time, 0, 0, sel.open_anime_time, 255);
6863                        y_rep = Self::selbtn_speed_down_limit(
6864                            time,
6865                            0,
6866                            -screen_h,
6867                            sel.open_anime_time,
6868                            0,
6869                        );
6870                    }
6871                    3 => {
6872                        let time = sel.open_anime_cur_time.saturating_sub(50 * row as i64);
6873                        tr = Self::selbtn_linear_limit(time, 0, 0, sel.open_anime_time, 255);
6874                        y_rep = Self::selbtn_speed_down_limit(
6875                            time,
6876                            0,
6877                            screen_h,
6878                            sel.open_anime_time,
6879                            0,
6880                        );
6881                    }
6882                    4 => {
6883                        let time = sel.open_anime_cur_time.saturating_sub(50 * row as i64);
6884                        tr = Self::selbtn_linear_limit(time, 0, 0, sel.open_anime_time, 255);
6885                        x_rep = Self::selbtn_speed_down_limit(
6886                            time,
6887                            0,
6888                            -screen_w,
6889                            sel.open_anime_time,
6890                            0,
6891                        );
6892                    }
6893                    5 => {
6894                        let time = sel.open_anime_cur_time.saturating_sub(50 * row as i64);
6895                        tr = Self::selbtn_linear_limit(time, 0, 0, sel.open_anime_time, 255);
6896                        x_rep = Self::selbtn_speed_down_limit(
6897                            time,
6898                            0,
6899                            screen_w,
6900                            sel.open_anime_time,
6901                            0,
6902                        );
6903                    }
6904                    6 => {
6905                        let time = sel.open_anime_cur_time.saturating_sub(50 * row as i64);
6906                        tr = Self::selbtn_linear_limit(time, 0, 0, sel.open_anime_time, 255);
6907                        let start = if row % 2 == 0 { screen_w } else { -screen_w };
6908                        x_rep =
6909                            Self::selbtn_speed_down_limit(time, 0, start, sel.open_anime_time, 0);
6910                    }
6911                    _ => {}
6912                }
6913
6914                if sel.decide_sel_no >= 0 && index as i64 != sel.decide_sel_no {
6915                    match sel.decide_anime_type {
6916                        1 => {
6917                            tr = Self::selbtn_linear_limit(
6918                                sel.decide_anime_cur_time,
6919                                0,
6920                                255,
6921                                sel.decide_anime_time,
6922                                0,
6923                            );
6924                        }
6925                        2 => {
6926                            if let Some((decided_x, decided_y)) = decided_pos {
6927                                let (my_x, my_y) = item.pos;
6928                                tr = Self::selbtn_linear_limit(
6929                                    sel.decide_anime_cur_time,
6930                                    0,
6931                                    255,
6932                                    sel.decide_anime_time,
6933                                    0,
6934                                );
6935                                x_rep = Self::selbtn_speed_down_limit(
6936                                    sel.decide_anime_cur_time,
6937                                    0,
6938                                    0,
6939                                    sel.decide_anime_time,
6940                                    decided_x - my_x,
6941                                );
6942                                y_rep = Self::selbtn_speed_down_limit(
6943                                    sel.decide_anime_cur_time,
6944                                    0,
6945                                    0,
6946                                    sel.decide_anime_time,
6947                                    decided_y - my_y,
6948                                );
6949                            }
6950                        }
6951                        _ => {}
6952                    }
6953                }
6954
6955                if sel.close_anime_type >= 1
6956                    && tmpl.decide_anime_type >= 1
6957                    && sel.decide_sel_no >= 0
6958                    && index as i64 != sel.decide_sel_no
6959                {
6960                    tr = 0;
6961                } else {
6962                    match sel.close_anime_type {
6963                        1 => {
6964                            tr = Self::selbtn_linear_limit(
6965                                sel.close_anime_cur_time,
6966                                0,
6967                                255,
6968                                sel.close_anime_time,
6969                                0,
6970                            );
6971                        }
6972                        2 => {
6973                            let time = sel.close_anime_cur_time.saturating_sub(50 * row as i64);
6974                            tr = Self::selbtn_linear_limit(time, 0, 255, sel.close_anime_time, 0);
6975                            y_rep = Self::selbtn_speed_up_limit(
6976                                time,
6977                                0,
6978                                0,
6979                                sel.close_anime_time,
6980                                -screen_h,
6981                            );
6982                        }
6983                        3 => {
6984                            let time = sel
6985                                .close_anime_cur_time
6986                                .saturating_sub(50 * (y_count.saturating_sub(row)) as i64);
6987                            tr = Self::selbtn_linear_limit(time, 0, 255, sel.close_anime_time, 0);
6988                            y_rep = Self::selbtn_speed_up_limit(
6989                                time,
6990                                0,
6991                                0,
6992                                sel.close_anime_time,
6993                                screen_h,
6994                            );
6995                        }
6996                        4 => {
6997                            let time = sel.close_anime_cur_time.saturating_sub(50 * row as i64);
6998                            tr = Self::selbtn_linear_limit(time, 0, 255, sel.close_anime_time, 0);
6999                            x_rep = Self::selbtn_speed_up_limit(
7000                                time,
7001                                0,
7002                                0,
7003                                sel.close_anime_time,
7004                                -screen_w,
7005                            );
7006                        }
7007                        5 => {
7008                            let time = sel.close_anime_cur_time.saturating_sub(50 * row as i64);
7009                            tr = Self::selbtn_linear_limit(time, 0, 255, sel.close_anime_time, 0);
7010                            x_rep = Self::selbtn_speed_up_limit(
7011                                time,
7012                                0,
7013                                0,
7014                                sel.close_anime_time,
7015                                screen_w,
7016                            );
7017                        }
7018                        6 => {
7019                            let time = sel.close_anime_cur_time.saturating_sub(50 * row as i64);
7020                            tr = Self::selbtn_linear_limit(time, 0, 255, sel.close_anime_time, 0);
7021                            let end = if row % 2 == 0 { screen_w } else { -screen_w };
7022                            x_rep =
7023                                Self::selbtn_speed_up_limit(time, 0, 0, sel.close_anime_time, end);
7024                        }
7025                        _ => {}
7026                    }
7027                }
7028
7029                item.animation_offset = (x_rep, y_rep);
7030                item.animation_tr = Some(tr.clamp(0, 255));
7031            }
7032        }
7033    }
7034
7035    fn sync_selbtn_item_selection(&mut self) {
7036        if let Some(st) = self
7037            .globals
7038            .stage_forms
7039            .get_mut(&self.ids.form_global_stage)
7040            && let Some(items) = st.btnselitem_lists.get_mut(&TNM_STAGE_FRONT_I64)
7041        {
7042            for (idx, item) in items.iter_mut().enumerate() {
7043                item.selected = idx == self.globals.selbtn.cursor;
7044                let selectable = item.item_type == TNM_SEL_ITEM_TYPE_ON_I64;
7045                item.button_state = if item.item_type == TNM_SEL_ITEM_TYPE_READ_I64 {
7046                    TNM_BTN_STATE_DISABLE
7047                } else if self.globals.selbtn.pressed_index == Some(idx)
7048                    && self.globals.selbtn.pressed_inside
7049                    && selectable
7050                {
7051                    TNM_BTN_STATE_PUSH
7052                } else if item.selected && selectable {
7053                    TNM_BTN_STATE_HIT
7054                } else {
7055                    TNM_BTN_STATE_NORMAL
7056                };
7057            }
7058        }
7059    }
7060
7061    fn hide_selbtn_object_backing(&mut self, obj: &globals::ObjectState) {
7062        match &obj.backend {
7063            globals::ObjectBackend::Gfx => {
7064                if let Some(slot) = obj.nested_runtime_slot {
7065                    let _ = self.gfx.object_clear(
7066                        &mut self.images,
7067                        &mut self.layers,
7068                        TNM_STAGE_FRONT_I64,
7069                        slot as i64,
7070                    );
7071                }
7072            }
7073            globals::ObjectBackend::None => {}
7074            backend => {
7075                for (layer_id, sprite_id) in layer_backed_object_sprite_bindings(backend) {
7076                    if let Some(sprite) = self
7077                        .layers
7078                        .layer_mut(layer_id)
7079                        .and_then(|layer| layer.sprite_mut(sprite_id))
7080                    {
7081                        sprite.visible = false;
7082                        sprite.image_id = None;
7083                    }
7084                }
7085            }
7086        }
7087        for child in &obj.runtime.child_objects {
7088            self.hide_selbtn_object_backing(child);
7089        }
7090    }
7091
7092    fn clear_selbtn_items_from_front_stage(&mut self) {
7093        let old_items = self
7094            .globals
7095            .stage_forms
7096            .get(&self.ids.form_global_stage)
7097            .and_then(|st| st.btnselitem_lists.get(&TNM_STAGE_FRONT_I64))
7098            .cloned()
7099            .unwrap_or_default();
7100        for item in &old_items {
7101            for obj in item.generated_objects.iter().chain(item.object_list.iter()) {
7102                self.hide_selbtn_object_backing(obj);
7103            }
7104        }
7105        if let Some(st) = self
7106            .globals
7107            .stage_forms
7108            .get_mut(&self.ids.form_global_stage)
7109        {
7110            st.btnselitem_lists.remove(&TNM_STAGE_FRONT_I64);
7111        }
7112    }
7113
7114    fn handle_selbtn_key(&mut self, k: input::VmKey) -> bool {
7115        if !self.globals.selbtn.started {
7116            return false;
7117        }
7118        if !self.selbtn_accepts_input() {
7119            return true;
7120        }
7121        match k {
7122            input::VmKey::ArrowUp => {
7123                self.globals.selbtn.cursor = self.next_selbtn_cursor(-1);
7124                self.sync_selbtn_item_selection();
7125                true
7126            }
7127            input::VmKey::ArrowDown => {
7128                self.globals.selbtn.cursor = self.next_selbtn_cursor(1);
7129                self.sync_selbtn_item_selection();
7130                true
7131            }
7132            input::VmKey::Enter => {
7133                let idx = self.globals.selbtn.cursor;
7134                if self
7135                    .globals
7136                    .selbtn
7137                    .choices
7138                    .get(idx)
7139                    .is_some_and(Self::selbtn_choice_selectable)
7140                {
7141                    self.finish_selbtn(idx as i64);
7142                }
7143                true
7144            }
7145            input::VmKey::Escape if self.globals.selbtn.cancel_enable => {
7146                self.finish_selbtn(-1);
7147                true
7148            }
7149            _ => true,
7150        }
7151    }
7152
7153    fn handle_selbtn_mouse_down(&mut self, b: input::VmMouseButton) -> bool {
7154        if !self.globals.selbtn.started {
7155            return false;
7156        }
7157        if !self.selbtn_accepts_input() {
7158            self.input.on_mouse_down(b);
7159            return true;
7160        }
7161        self.input.on_mouse_down(b);
7162        match b {
7163            input::VmMouseButton::Left => {
7164                if let Some(idx) = self.selbtn_hit_index(self.input.mouse_x, self.input.mouse_y) {
7165                    self.globals.selbtn.cursor = idx;
7166                    self.globals.selbtn.pressed_index = Some(idx);
7167                    self.globals.selbtn.pressed_inside = true;
7168                    self.sync_selbtn_item_selection();
7169                }
7170                true
7171            }
7172            input::VmMouseButton::Right => true,
7173            _ => true,
7174        }
7175    }
7176
7177    fn handle_selbtn_mouse_up(&mut self, b: input::VmMouseButton) -> bool {
7178        let selection_active = self.globals.selbtn.started
7179            || self.globals.selbtn.pressed_index.is_some()
7180            || self.globals.selbtn.decide_anime_type > 0
7181            || self.globals.selbtn.close_anime_type > 0;
7182        if !selection_active {
7183            return false;
7184        }
7185        match b {
7186            input::VmMouseButton::Left => {
7187                let pressed = self.globals.selbtn.pressed_index.take();
7188                let inside = std::mem::take(&mut self.globals.selbtn.pressed_inside);
7189                let released = self.selbtn_hit_index(self.input.mouse_x, self.input.mouse_y);
7190                self.sync_selbtn_item_selection();
7191                if self.selbtn_accepts_input() && inside && pressed.is_some() && pressed == released
7192                {
7193                    self.finish_selbtn(pressed.unwrap_or(0) as i64);
7194                }
7195                true
7196            }
7197            input::VmMouseButton::Right => {
7198                if self.selbtn_accepts_input()
7199                    && self.globals.selbtn.cancel_enable
7200                    && self.input.vk_down_up_stock(0x02)
7201                {
7202                    self.finish_selbtn(-1);
7203                }
7204                true
7205            }
7206            _ => true,
7207        }
7208    }
7209
7210    fn selbtn_hit_index(&self, mx: i32, my: i32) -> Option<usize> {
7211        if !self.selbtn_accepts_input() || self.globals.selbtn.choices.is_empty() {
7212            return None;
7213        }
7214        for (idx, choice) in self.globals.selbtn.choices.iter().enumerate().rev() {
7215            if !Self::selbtn_choice_selectable(choice) {
7216                continue;
7217            }
7218            let (x, y) = choice.pos;
7219            let (w, h) = choice.size;
7220            let x0 = x as i32;
7221            let y0 = y as i32;
7222            let x1 = x.saturating_add(w.max(1)) as i32;
7223            let y1 = y.saturating_add(h.max(1)) as i32;
7224            if mx >= x0 && mx < x1 && my >= y0 && my < y1 {
7225                return Some(idx);
7226            }
7227        }
7228        None
7229    }
7230
7231    fn handle_mwnd_selection_key(&mut self, k: input::VmKey) -> bool {
7232        let Some((form_id, stage_idx, mwnd_idx)) = self.globals.focused_stage_mwnd else {
7233            return false;
7234        };
7235        let trace_scene = self
7236            .current_scene_name
7237            .as_deref()
7238            .unwrap_or("<none>")
7239            .to_string();
7240        let trace_scene_no = self
7241            .current_scene_no
7242            .map(|v| v.to_string())
7243            .unwrap_or_else(|| "-".to_string());
7244        let trace_line = self.current_line_no;
7245        let mut clear_focus = false;
7246        let mut handled = false;
7247        let mut close_anim: Option<(i64, i64)> = None;
7248        let mut result_to_push: Option<i64> = None;
7249        if let Some(st) = self.globals.stage_forms.get_mut(&form_id) {
7250            if let Some(list) = st.mwnd_lists.get_mut(&stage_idx) {
7251                if let Some(m) = list.get_mut(mwnd_idx) {
7252                    let close_time = m.close_anime_time;
7253                    let close_type = m.close_anime_type;
7254                    let mut close_after = false;
7255                    let mut clear_selection = false;
7256                    if let Some(sel) = m.selection.as_mut() {
7257                        handled = match k {
7258                            input::VmKey::ArrowUp => {
7259                                if !sel.choices.is_empty() {
7260                                    sel.cursor = if sel.cursor == 0 {
7261                                        sel.choices.len() - 1
7262                                    } else {
7263                                        sel.cursor - 1
7264                                    };
7265                                }
7266                                true
7267                            }
7268                            input::VmKey::ArrowDown => {
7269                                if !sel.choices.is_empty() {
7270                                    sel.cursor = (sel.cursor + 1) % sel.choices.len();
7271                                }
7272                                true
7273                            }
7274                            input::VmKey::Enter => {
7275                                sel.result = (sel.cursor as i64) + 1;
7276                                result_to_push = Some(sel.result);
7277                                close_after = sel.close_mwnd;
7278                                clear_selection = true;
7279                                clear_focus = true;
7280                                true
7281                            }
7282                            input::VmKey::Escape if sel.cancel_enable => {
7283                                sel.result = -1;
7284                                result_to_push = Some(sel.result);
7285                                close_after = sel.close_mwnd;
7286                                clear_selection = true;
7287                                clear_focus = true;
7288                                true
7289                            }
7290                            _ => false,
7291                        };
7292                    } else {
7293                        clear_focus = true;
7294                    }
7295                    if clear_selection {
7296                        m.selection = None;
7297                    }
7298                    if close_after {
7299                        let old_open = m.open;
7300                        m.open = false;
7301                        sg_mwnd_state_trace_runtime(
7302                            &trace_scene,
7303                            &trace_scene_no,
7304                            trace_line,
7305                            "MWND_SELECTION_KEY_CLOSE",
7306                            stage_idx,
7307                            mwnd_idx,
7308                            old_open,
7309                            m.open,
7310                            m,
7311                        );
7312                        close_anim = Some((close_type, close_time));
7313                    }
7314                } else {
7315                    clear_focus = true;
7316                }
7317            } else {
7318                clear_focus = true;
7319            }
7320        } else {
7321            clear_focus = true;
7322        }
7323        if clear_focus {
7324            self.globals.focused_stage_mwnd = None;
7325        }
7326        if let Some(v) = result_to_push {
7327            self.stack.push(Value::Int(v));
7328        }
7329        if let Some((ty, ms)) = close_anim {
7330            self.ui.begin_mwnd_close(ty, ms);
7331        }
7332        handled
7333    }
7334
7335    fn handle_mwnd_selection_click(&mut self, b: input::VmMouseButton) -> bool {
7336        let Some((form_id, stage_idx, mwnd_idx)) = self.globals.focused_stage_mwnd else {
7337            return false;
7338        };
7339        let trace_scene = self
7340            .current_scene_name
7341            .as_deref()
7342            .unwrap_or("<none>")
7343            .to_string();
7344        let trace_scene_no = self
7345            .current_scene_no
7346            .map(|v| v.to_string())
7347            .unwrap_or_else(|| "-".to_string());
7348        let trace_line = self.current_line_no;
7349        let mut clear_focus = false;
7350        let mut handled = false;
7351        let mut close_anim: Option<(i64, i64)> = None;
7352        let mut result_to_push: Option<i64> = None;
7353        if let Some(st) = self.globals.stage_forms.get_mut(&form_id) {
7354            if let Some(list) = st.mwnd_lists.get_mut(&stage_idx) {
7355                if let Some(m) = list.get_mut(mwnd_idx) {
7356                    let close_time = m.close_anime_time;
7357                    let close_type = m.close_anime_type;
7358                    let mut close_after = false;
7359                    let mut clear_selection = false;
7360                    if let Some(sel) = m.selection.as_mut() {
7361                        handled = match b {
7362                            input::VmMouseButton::Left => {
7363                                sel.result = (sel.cursor as i64) + 1;
7364                                result_to_push = Some(sel.result);
7365                                close_after = sel.close_mwnd;
7366                                clear_selection = true;
7367                                clear_focus = true;
7368                                true
7369                            }
7370                            input::VmMouseButton::Right if sel.cancel_enable => {
7371                                sel.result = -1;
7372                                result_to_push = Some(sel.result);
7373                                close_after = sel.close_mwnd;
7374                                clear_selection = true;
7375                                clear_focus = true;
7376                                true
7377                            }
7378                            _ => false,
7379                        };
7380                    } else {
7381                        clear_focus = true;
7382                    }
7383                    if clear_selection {
7384                        m.selection = None;
7385                    }
7386                    if close_after {
7387                        let old_open = m.open;
7388                        m.open = false;
7389                        sg_mwnd_state_trace_runtime(
7390                            &trace_scene,
7391                            &trace_scene_no,
7392                            trace_line,
7393                            "MWND_SELECTION_MOUSE_CLOSE",
7394                            stage_idx,
7395                            mwnd_idx,
7396                            old_open,
7397                            m.open,
7398                            m,
7399                        );
7400                        close_anim = Some((close_type, close_time));
7401                    }
7402                } else {
7403                    clear_focus = true;
7404                }
7405            } else {
7406                clear_focus = true;
7407            }
7408        } else {
7409            clear_focus = true;
7410        }
7411        if clear_focus {
7412            self.globals.focused_stage_mwnd = None;
7413        }
7414        if let Some(v) = result_to_push {
7415            self.stack.push(Value::Int(v));
7416        }
7417        if let Some((ty, ms)) = close_anim {
7418            self.ui.begin_mwnd_close(ty, ms);
7419        }
7420        handled
7421    }
7422
7423    fn sync_mwnd_window_ui(&mut self) {
7424        let focused = self.globals.focused_stage_mwnd;
7425        let wipe_active = self.globals.wipe.is_some();
7426        let color_table = &self.tables.color_table;
7427        let resolve_color = |color_no: i64| -> (u8, u8, u8) {
7428            if color_no >= 0 {
7429                color_table
7430                    .get(color_no as usize)
7431                    .copied()
7432                    .unwrap_or((255, 255, 255))
7433            } else {
7434                (255, 255, 255)
7435            }
7436        };
7437        let mut projections = Vec::new();
7438        let use_chara_color = self.globals.syscom.original_config.message_chrcolor_flag;
7439        let font_shadow_mode = self.effective_font_shadow_mode();
7440        let font_bold = self.effective_font_bold();
7441        let (draw_shadow, draw_fuchi) =
7442            crate::text_render::font_shadow_mode_flags(font_shadow_mode);
7443
7444        for (form_id, st) in &self.globals.stage_forms {
7445            for (stage_idx, list) in &st.mwnd_lists {
7446                if *stage_idx == TNM_STAGE_NEXT_I64 && !wipe_active {
7447                    continue;
7448                }
7449                for (mwnd_idx, m) in list.iter().enumerate() {
7450                    if !m.initialized_from_gameexe {
7451                        continue;
7452                    }
7453                    let key_icon_template = if m.icon_no >= 0 {
7454                        self.tables.icon_templates.get(m.icon_no as usize)
7455                    } else {
7456                        None
7457                    };
7458                    let page_icon_template = if m.page_icon_no >= 0 {
7459                        self.tables.icon_templates.get(m.page_icon_no as usize)
7460                    } else {
7461                        None
7462                    };
7463                    let msg_moji_no = m
7464                        .moji_color
7465                        .or(if use_chara_color {
7466                            m.chara_moji_color
7467                        } else {
7468                            None
7469                        })
7470                        .unwrap_or(m.default_moji_color);
7471                    let msg_shadow_no = m
7472                        .shadow_color
7473                        .or(if use_chara_color {
7474                            m.chara_shadow_color
7475                        } else {
7476                            None
7477                        })
7478                        .unwrap_or(m.default_shadow_color);
7479                    let msg_fuchi_no = m
7480                        .fuchi_color
7481                        .or(if use_chara_color {
7482                            m.chara_fuchi_color
7483                        } else {
7484                            None
7485                        })
7486                        .unwrap_or(m.default_fuchi_color);
7487                    let name_moji_no = m
7488                        .name_moji_color
7489                        .or(if use_chara_color {
7490                            m.chara_moji_color
7491                        } else {
7492                            None
7493                        })
7494                        .unwrap_or(m.default_name_moji_color);
7495                    let name_shadow_no = m
7496                        .name_shadow_color
7497                        .or(if use_chara_color {
7498                            m.chara_shadow_color
7499                        } else {
7500                            None
7501                        })
7502                        .unwrap_or(m.default_name_shadow_color);
7503                    let name_fuchi_no = m
7504                        .name_fuchi_color
7505                        .or(if use_chara_color {
7506                            m.chara_fuchi_color
7507                        } else {
7508                            None
7509                        })
7510                        .unwrap_or(m.default_name_fuchi_color);
7511
7512                    let select_emoji = |requested_size: i64| -> Option<(&str, i64)> {
7513                        let mut best: Option<&crate::runtime::tables::EmojiTemplate> = None;
7514                        for item in &self.tables.emoji_templates {
7515                            if item.file_name.is_empty() || item.font_size <= 0 {
7516                                continue;
7517                            }
7518                            best = match best {
7519                                None => Some(item),
7520                                Some(cur) if cur.font_size > requested_size => {
7521                                    if item.font_size < cur.font_size {
7522                                        Some(item)
7523                                    } else {
7524                                        Some(cur)
7525                                    }
7526                                }
7527                                Some(cur)
7528                                    if item.font_size <= requested_size
7529                                        && item.font_size > cur.font_size =>
7530                                {
7531                                    Some(item)
7532                                }
7533                                Some(cur) => Some(cur),
7534                            };
7535                        }
7536                        best.map(|item| (item.file_name.as_str(), item.font_size))
7537                    };
7538
7539                    let projection = crate::runtime::ui::MwndProjectionState {
7540                        bg_file: (!m.waku_file.is_empty()).then(|| m.waku_file.clone()),
7541                        filter_file: (!m.filter_file.is_empty()).then(|| m.filter_file.clone()),
7542                        filter_margin: m.filter_margin,
7543                        filter_color: m.filter_color,
7544                        filter_config_color: m.filter_config_color,
7545                        filter_config_tr: m.filter_config_tr,
7546                        face_file: (!m.face_file.is_empty()).then(|| m.face_file.clone()),
7547                        face_no: m.face_no,
7548                        rep_pos: m.rep_pos,
7549                        window_pos: m.window_pos,
7550                        window_size: m.window_size,
7551                        message_pos: m.message_pos,
7552                        message_margin: m.message_margin,
7553                        window_moji_cnt: m.window_moji_cnt,
7554                        moji_size: m.moji_size.or(Some(m.default_moji_size.max(1))),
7555                        moji_space: m.moji_space,
7556                        mwnd_extend_type: m.mwnd_extend_type,
7557                        moji_color: m.moji_color,
7558                        shadow_color: m.shadow_color,
7559                        fuchi_color: m.fuchi_color,
7560                        chara_moji_color: m.chara_moji_color,
7561                        chara_shadow_color: m.chara_shadow_color,
7562                        chara_fuchi_color: m.chara_fuchi_color,
7563                        name_moji_color: m.name_moji_color,
7564                        name_shadow_color: m.name_shadow_color,
7565                        name_fuchi_color: m.name_fuchi_color,
7566                        resolved_msg_color: resolve_color(msg_moji_no),
7567                        resolved_msg_shadow_color: resolve_color(msg_shadow_no),
7568                        resolved_msg_fuchi_color: (msg_fuchi_no >= 0)
7569                            .then(|| resolve_color(msg_fuchi_no)),
7570                        resolved_name_color: resolve_color(name_moji_no),
7571                        resolved_name_shadow_color: resolve_color(name_shadow_no),
7572                        resolved_name_fuchi_color: (name_fuchi_no >= 0)
7573                            .then(|| resolve_color(name_fuchi_no)),
7574                        font_shadow_mode,
7575                        font_bold,
7576                        key_icon_file: key_icon_template
7577                            .and_then(|t| (!t.file_name.is_empty()).then(|| t.file_name.clone())),
7578                        key_icon_pat_cnt: key_icon_template.map(|t| t.anime_pat_cnt).unwrap_or(1),
7579                        key_icon_speed: key_icon_template.map(|t| t.anime_speed).unwrap_or(100),
7580                        page_icon_file: page_icon_template
7581                            .and_then(|t| (!t.file_name.is_empty()).then(|| t.file_name.clone())),
7582                        page_icon_pat_cnt: page_icon_template.map(|t| t.anime_pat_cnt).unwrap_or(1),
7583                        page_icon_speed: page_icon_template.map(|t| t.anime_speed).unwrap_or(100),
7584                        key_icon_appear: m.key_icon_appear,
7585                        key_icon_mode: m.key_icon_mode,
7586                        key_icon_pos: m.key_icon_pos,
7587                        icon_pos_type: m.icon_pos_type,
7588                        icon_pos_base: m.icon_pos_base,
7589                        icon_pos: m.icon_pos,
7590                        slide_enabled: m.slide_msg,
7591                        slide_time: m.slide_time,
7592                        vertical_writing: m.vertical_writing,
7593                        name_text: m.name_text.clone(),
7594                        name_extend_type: m.name_extend_type,
7595                        name_window_align: m.name_window_align,
7596                        name_window_pos: m.name_window_pos,
7597                        name_window_size: m.name_window_size,
7598                        name_window_rect: m.name_window_rect,
7599                        name_message_pos: m.name_message_pos,
7600                        name_message_pos_rep: m.name_message_pos_rep,
7601                        name_message_margin: m.name_message_margin,
7602                        name_glyphs: m
7603                            .name_glyphs
7604                            .iter()
7605                            .map(|g| {
7606                                let emoji = if g.moji_type == 0 {
7607                                    None
7608                                } else {
7609                                    select_emoji(g.size.max(1))
7610                                };
7611                                crate::runtime::ui::MwndGlyphProjection {
7612                                    moji_type: g.moji_type,
7613                                    code: g.code,
7614                                    ch: g.ch,
7615                                    x: g.x.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
7616                                    y: g.y.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
7617                                    size: g.size.max(1).min(i32::MAX as i64) as i32,
7618                                    color: resolve_color(g.moji_color_no),
7619                                    shadow_color: resolve_color(g.shadow_color_no),
7620                                    fuchi_color: resolve_color(g.fuchi_color_no),
7621                                    shadow_mode: font_shadow_mode,
7622                                    shadow: draw_shadow,
7623                                    fuchi: draw_fuchi,
7624                                    bold: font_bold && g.moji_type == 0,
7625                                    reveal_index: 0,
7626                                    ruby: false,
7627                                    appeared: true,
7628                                    emoji_file: emoji.map(|(file, _)| file.to_string()),
7629                                    emoji_font_size: emoji
7630                                        .map(|(_, size)| size as i32)
7631                                        .unwrap_or(0),
7632                                    message_button: None,
7633                                }
7634                            })
7635                            .collect(),
7636                        msg_text: m
7637                            .message_pages
7638                            .iter()
7639                            .map(|page| page.msg_text.as_str())
7640                            .chain(std::iter::once(m.msg_text.as_str()))
7641                            .collect::<String>(),
7642                        glyphs: m
7643                            .message_pages
7644                            .iter()
7645                            .flat_map(|page| page.glyphs.iter())
7646                            .chain(m.glyphs.iter())
7647                            .map(|g| {
7648                                let mut color_no = g.moji_color_no;
7649                                let mut shadow_color_no = g.shadow_color_no;
7650                                let mut fuchi_color_no = g.fuchi_color_no;
7651                                if let Some(button) = &g.message_button {
7652                                    let button_state = st
7653                                        .group_lists
7654                                        .get(stage_idx)
7655                                        .and_then(|groups| {
7656                                            groups.get(button.group_no.max(0) as usize)
7657                                        })
7658                                        .map(|group| {
7659                                            if group.pushed_button_no == button.btn_no {
7660                                                2
7661                                            } else if group.hit_button_no == button.btn_no {
7662                                                1
7663                                            } else {
7664                                                0
7665                                            }
7666                                        })
7667                                        .unwrap_or(0);
7668                                    if let Some(action) = self
7669                                        .tables
7670                                        .message_button_templates
7671                                        .get(button.action_no.max(0) as usize)
7672                                    {
7673                                        let button_color = action.color_no[button_state];
7674                                        if button_color >= 0 {
7675                                            color_no = button_color;
7676                                            // C_elm_mwnd_msg::frame resets both edge colors to
7677                                            // the global MWND colors whenever a message-button
7678                                            // state color is applied.
7679                                            shadow_color_no = self.tables.mwnd_render.shadow_color;
7680                                            fuchi_color_no = self.tables.mwnd_render.fuchi_color;
7681                                        }
7682                                    }
7683                                }
7684                                let emoji = if g.moji_type == 0 {
7685                                    None
7686                                } else {
7687                                    select_emoji(g.size.max(1))
7688                                };
7689                                crate::runtime::ui::MwndGlyphProjection {
7690                                    moji_type: g.moji_type,
7691                                    code: g.code,
7692                                    ch: g.ch,
7693                                    x: g.x.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
7694                                    y: g.y.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
7695                                    size: g.size.max(1).min(i32::MAX as i64) as i32,
7696                                    color: resolve_color(color_no),
7697                                    shadow_color: resolve_color(shadow_color_no),
7698                                    fuchi_color: resolve_color(fuchi_color_no),
7699                                    shadow_mode: font_shadow_mode,
7700                                    shadow: draw_shadow,
7701                                    fuchi: draw_fuchi,
7702                                    bold: font_bold && g.moji_type == 0,
7703                                    reveal_index: g.reveal_index,
7704                                    ruby: g.ruby,
7705                                    appeared: g.appeared,
7706                                    emoji_file: emoji.map(|(file, _)| file.to_string()),
7707                                    emoji_font_size: emoji
7708                                        .map(|(_, size)| size as i32)
7709                                        .unwrap_or(0),
7710                                    message_button: g.message_button.as_ref().map(|button| {
7711                                        crate::runtime::ui::MwndMessageButtonProjection {
7712                                            btn_no: button.btn_no,
7713                                            group_no: button.group_no,
7714                                            action_no: button.action_no,
7715                                            se_no: button.se_no,
7716                                        }
7717                                    }),
7718                                }
7719                            })
7720                            .collect(),
7721                        open: m.open,
7722                        open_anime_type: m.open_anime_type,
7723                        open_anime_time: m.open_anime_time,
7724                        close_anime_type: m.close_anime_type,
7725                        close_anime_time: m.close_anime_time,
7726                        order: m.order,
7727                        layer: m.layer,
7728                    };
7729                    projections.push(((*form_id, *stage_idx, mwnd_idx), projection));
7730                }
7731            }
7732        }
7733
7734        self.ui.apply_mwnd_projection_set(projections, focused);
7735    }
7736
7737    fn sync_mwnd_selection_ui(&mut self) {
7738        if self.globals.system.messagebox_modal.is_some() {
7739            return;
7740        }
7741        self.ui.set_sys_overlay(false, String::new());
7742    }
7743
7744    fn sync_emote_objects(&mut self) {
7745        let koe_playing = self.koe.is_playing_any();
7746        let live_mouth = if koe_playing {
7747            self.koe.current_mouth_volume()
7748        } else {
7749            0.0
7750        };
7751        let koe_chara_no = self.globals.sound_routing.koe_chara_no;
7752        let koe_ex = self.globals.sound_routing.koe_ex_flag;
7753        let mouth_stop = self.globals.script.emote_mouth_stop_flag;
7754
7755        let layers = &mut self.layers;
7756        for stage in self.globals.stage_forms.values_mut() {
7757            let mut stage_ids: Vec<i64> = stage.object_lists.keys().copied().collect();
7758            stage_ids.sort_unstable();
7759            for stage_idx in stage_ids {
7760                let slot_use = stage.object_slot_use.get(&stage_idx).cloned();
7761                let embedded = stage
7762                    .embedded_object_slots_by_stage
7763                    .get(&stage_idx)
7764                    .cloned();
7765                let Some(list) = stage.object_lists.get_mut(&stage_idx) else {
7766                    continue;
7767                };
7768                for (obj_idx, obj) in list.iter_mut().enumerate() {
7769                    if embedded
7770                        .as_ref()
7771                        .is_some_and(|slots| slots.contains(&obj_idx))
7772                        || !slot_use
7773                            .as_ref()
7774                            .and_then(|flags| flags.get(obj_idx))
7775                            .copied()
7776                            .unwrap_or(true)
7777                    {
7778                        continue;
7779                    }
7780                    sync_emote_object_recursive(
7781                        layers,
7782                        obj,
7783                        mouth_stop,
7784                        koe_playing,
7785                        koe_ex,
7786                        koe_chara_no,
7787                        live_mouth,
7788                    );
7789                }
7790            }
7791            for items in stage.btnselitem_lists.values_mut() {
7792                for item in items {
7793                    for obj in &mut item.object_list {
7794                        sync_emote_object_recursive(
7795                            layers,
7796                            obj,
7797                            mouth_stop,
7798                            koe_playing,
7799                            koe_ex,
7800                            koe_chara_no,
7801                            live_mouth,
7802                        );
7803                    }
7804                }
7805            }
7806            for mwnds in stage.mwnd_lists.values_mut() {
7807                for mwnd in mwnds {
7808                    for list in [
7809                        &mut mwnd.object_list,
7810                        &mut mwnd.button_list,
7811                        &mut mwnd.face_list,
7812                    ] {
7813                        for obj in list {
7814                            sync_emote_object_recursive(
7815                                layers,
7816                                obj,
7817                                mouth_stop,
7818                                koe_playing,
7819                                koe_ex,
7820                                koe_chara_no,
7821                                live_mouth,
7822                            );
7823                        }
7824                    }
7825                }
7826            }
7827        }
7828    }
7829
7830    fn sync_movie_objects(&mut self) {
7831        let wipe_active = self.globals.wipe.is_some();
7832        let (globals, layers, movie_mgr, audio, gfx, images, ids) = (
7833            &mut self.globals,
7834            &mut self.layers,
7835            &mut self.movie,
7836            &mut self.audio,
7837            &mut self.gfx,
7838            &mut self.images,
7839            &self.ids,
7840        );
7841        let mut decoded_any = false;
7842        let mut form_ids: Vec<u32> = globals.stage_forms.keys().copied().collect();
7843        form_ids.sort_unstable();
7844        for form_id in form_ids {
7845            let Some(st) = globals.stage_forms.get_mut(&form_id) else {
7846                continue;
7847            };
7848            let mut stage_ids: Vec<i64> = st.object_lists.keys().copied().collect();
7849            stage_ids.sort_unstable();
7850            for stage_idx in stage_ids {
7851                if stage_idx == TNM_STAGE_NEXT_I64 && !wipe_active {
7852                    continue;
7853                }
7854                let slot_use = st.object_slot_use.get(&stage_idx).cloned();
7855                let embedded = st.embedded_object_slots_by_stage.get(&stage_idx).cloned();
7856                let Some(objs) = st.object_lists.get_mut(&stage_idx) else {
7857                    continue;
7858                };
7859                for (obj_idx, obj) in objs.iter_mut().enumerate() {
7860                    if embedded
7861                        .as_ref()
7862                        .is_some_and(|slots| slots.contains(&obj_idx))
7863                        || !slot_use
7864                            .as_ref()
7865                            .and_then(|flags| flags.get(obj_idx))
7866                            .copied()
7867                            .unwrap_or(true)
7868                    {
7869                        continue;
7870                    }
7871                    sync_movie_object_recursive(
7872                        ids,
7873                        layers,
7874                        movie_mgr,
7875                        audio,
7876                        gfx,
7877                        images,
7878                        stage_idx,
7879                        object_runtime_slot(obj_idx, obj) as i64,
7880                        obj,
7881                        &mut decoded_any,
7882                    );
7883                }
7884            }
7885        }
7886        let _ = decoded_any;
7887    }
7888
7889    fn close_global_movie_runtime(&mut self) {
7890        let was_active = self.globals.mov.playing
7891            || self.globals.mov.file_name.is_some()
7892            || self.globals.mov.audio_id.is_some()
7893            || self.globals.mov.image_id.is_some();
7894
7895        if let Some(id) = self.globals.mov.audio_id.take() {
7896            self.movie.stop_audio(id);
7897        }
7898        if was_active {
7899            self.movie.stop();
7900        }
7901        if let (Some(layer_id), Some(sprite_id)) =
7902            (self.globals.mov.layer_id, self.globals.mov.sprite_id)
7903            && let Some(sprite) = self
7904                .layers
7905                .layer_mut(layer_id)
7906                .and_then(|l| l.sprite_mut(sprite_id))
7907        {
7908            sprite.visible = false;
7909            sprite.image_id = None;
7910        }
7911        self.globals.mov.image_id = None;
7912        self.globals.mov.last_frame_idx = None;
7913
7914        if was_active {
7915            self.globals.mov.stop();
7916        }
7917    }
7918
7919    fn sync_global_movie(&mut self) {
7920        let trace = env_is_set!("SG_MOVIE_TRACE");
7921        let file_name = self.globals.mov.file_name.clone();
7922
7923        if !self.globals.mov.playing || file_name.as_deref().unwrap_or("").is_empty() {
7924            // Native Siglus closes C_elm_mov when a MOV wait naturally finishes or is skipped.
7925            // Keep that lifecycle here so the movie window, image, and movie audio track do not
7926            // survive past the wait procedure.
7927            self.close_global_movie_runtime();
7928            return;
7929        }
7930        let file_name = file_name.expect("checked global movie file name");
7931
7932        if let Some(id) = self.globals.mov.audio_id {
7933            if let Some(position_ms) = self.movie.audio_playback_position_ms(id) {
7934                self.globals.mov.timer_ms = position_ms;
7935                if self.movie.audio_playback_finished(id) {
7936                    // Audio reaching EOF is not MOV reaching EOF.  The native movie
7937                    // player owns one A/V timeline, but in this port WMV audio and video
7938                    // are decoded by independent workers.  A short/truncated WMA track
7939                    // (or an audio stream whose coded duration is slightly shorter than
7940                    // the video stream) must not tear down GLOBAL.MOV before the video
7941                    // presenter reaches the container/video duration.  Detach the audio
7942                    // clock and let GlobalMovieState::tick() resume the media timer from
7943                    // the last Kira position; normal video-duration completion below will
7944                    // close the movie.
7945                    self.globals.mov.audio_id = None;
7946                    self.globals.mov.audio_start_attempted = true;
7947                }
7948            } else {
7949                // A failed streaming audio decoder must not terminate MOV video.
7950                // MovieManager already removed/stopped the failed handle; continue
7951                // from the current video clock and do not retry the same broken
7952                // stream every frame.
7953                self.globals.mov.audio_id = None;
7954                self.globals.mov.audio_start_attempted = true;
7955            }
7956        }
7957
7958        let (x, y, width, height, timer_ms, last_frame_idx, image_id, need_audio) = {
7959            let m = &self.globals.mov;
7960            (
7961                m.x,
7962                m.y,
7963                m.width.max(1),
7964                m.height.max(1),
7965                m.timer_ms,
7966                m.last_frame_idx,
7967                m.image_id.clone(),
7968                m.audio_id.is_none() && !m.audio_start_attempted,
7969            )
7970        };
7971
7972        let (layer_id, sprite_id) = match (self.globals.mov.layer_id, self.globals.mov.sprite_id) {
7973            (Some(layer_id), Some(sprite_id))
7974                if self
7975                    .layers
7976                    .layer(layer_id)
7977                    .and_then(|l| l.sprite(sprite_id))
7978                    .is_some() =>
7979            {
7980                (layer_id, sprite_id)
7981            }
7982            _ => {
7983                let layer_id = self.layers.create_layer();
7984                let sprite_id = self
7985                    .layers
7986                    .layer_mut(layer_id)
7987                    .expect("newly created global movie layer")
7988                    .create_sprite();
7989                self.globals.mov.layer_id = Some(layer_id);
7990                self.globals.mov.sprite_id = Some(sprite_id);
7991                (layer_id, sprite_id)
7992            }
7993        };
7994
7995        let polled = match self.movie.poll_global_movie_frame(&file_name, timer_ms) {
7996            Ok(Some(frame)) => frame,
7997            Ok(None) => {
7998                // Native Siglus starts MOV playback without blocking the UI thread.
7999                // Keep only the movie timer at the start until the first frame exists.
8000                // Do not reset the global frame clock here, because that throttles all
8001                // counters, frame actions, and object events while the decoder warms up.
8002                if last_frame_idx.is_none() {
8003                    self.globals.mov.timer_ms = 0;
8004                }
8005                return;
8006            }
8007            Err(err) => {
8008                eprintln!("[SG_MOV] error file={} err={:#}", file_name, err);
8009                self.globals.mov.playing = false;
8010                return;
8011            }
8012        };
8013
8014        if let Some(ms) = polled.clamped_timer_ms {
8015            self.globals.mov.timer_ms = ms;
8016        }
8017        if self.globals.mov.total_ms.is_none() || polled.total_ms.is_some() {
8018            self.globals.mov.total_ms = polled.total_ms.or(self.globals.mov.total_ms);
8019        }
8020        if let Some(total) = self.globals.mov.total_ms
8021            && total > 0
8022            && self.globals.mov.timer_ms >= total
8023        {
8024            self.globals.mov.timer_ms = total;
8025            self.globals.mov.playing = false;
8026        }
8027        // WMV-specific startup clamping is decided inside MovieManager. It keeps
8028        // only the movie media clock at zero while ASF/WMA probing completes; the
8029        // VM/global frame clock continues normally, so this does not stall scripts,
8030        // counters, frame actions, or rendering.
8031        let _ = polled.decoded_now;
8032
8033        let frame = polled.frame.clone();
8034        let frame_idx = polled.frame_idx;
8035
8036        if need_audio {
8037            if let Some(track) = polled.audio.as_ref() {
8038                // WMV audio is decoded to PCM on the background probe thread.  It may
8039                // become ready after video presentation has already advanced, so begin
8040                // static PCM playback at the current movie time instead of rewinding the
8041                // movie to zero. Seeking decoded PCM is safe and does not disturb WMA
8042                // packet/superframe state.
8043                let audio_offset_ms = self.globals.mov.timer_ms;
8044                match self
8045                    .movie
8046                    .start_audio(&mut self.audio, track, audio_offset_ms, false)
8047                {
8048                    Ok(id) => {
8049                        self.globals.mov.audio_id = Some(id);
8050                        self.globals.mov.audio_start_attempted = false;
8051                        if trace || sg_debug_enabled() {
8052                            eprintln!(
8053                                "[SG_DEBUG][MOV] audio_start file={} samples={} channels={} rate={} offset_ms={}",
8054                                file_name,
8055                                track.samples.len(),
8056                                track.channels,
8057                                track.sample_rate,
8058                                audio_offset_ms
8059                            );
8060                        }
8061                    }
8062                    Err(err) => {
8063                        eprintln!(
8064                            "[SG_MOV] audio_start.failed file={} channels={} rate={} samples={} err={:#}",
8065                            file_name,
8066                            track.channels,
8067                            track.sample_rate,
8068                            track.samples.len(),
8069                            err
8070                        );
8071                        // Do not let a failed audio startup prevent WMV video from
8072                        // advancing, and do not retry the same stream every tick.
8073                        self.globals.mov.audio_start_attempted = true;
8074                    }
8075                }
8076            } else if polled.audio_ready {
8077                self.globals.mov.audio_start_attempted = true;
8078                if trace || sg_debug_enabled() {
8079                    eprintln!("[SG_DEBUG][MOV] audio_track.missing file={}", file_name);
8080                }
8081            }
8082        }
8083
8084        let frame_idx_changed = last_frame_idx != Some(frame_idx);
8085        // WMV3/VC-1 is decoded in coded order but presented in PTS order. Its
8086        // `frame_idx` can therefore legitimately go backwards (for example
8087        // 0, 2, 1 with one B picture between anchors). Do not apply the generic
8088        // one-step rewind suppression to that stream; MovieManager has already
8089        // selected the correct frame by PTS. Keep the old behavior for MPEG/OMV.
8090        let small_rewind = !polled.frame_idx_is_decode_order
8091            && last_frame_idx
8092                .is_some_and(|last| frame_idx < last && frame_idx.saturating_add(1) >= last);
8093        let show_new = frame_idx_changed && !small_rewind;
8094        let img_id = if let Some(id) = image_id {
8095            if show_new {
8096                let _ = self.images.replace_image_arc(&id, frame);
8097            }
8098            id
8099        } else {
8100            self.images.insert_image_arc(frame.clone())
8101        };
8102        self.globals.mov.image_id = Some(img_id.clone());
8103        if show_new {
8104            self.globals.mov.last_frame_idx = Some(frame_idx);
8105        }
8106
8107        if let Some(sprite) = self
8108            .layers
8109            .layer_mut(layer_id)
8110            .and_then(|l| l.sprite_mut(sprite_id))
8111        {
8112            sprite.visible = true;
8113            sprite.image_id = Some(img_id);
8114            sprite.fit = SpriteFit::PixelRect;
8115            sprite.size_mode = SpriteSizeMode::Explicit { width, height };
8116            sprite.x = x;
8117            sprite.y = y;
8118            sprite.alpha = 255;
8119            sprite.tr = 255;
8120            sprite.alpha_blend = true;
8121            sprite.order = i32::MAX - 16;
8122        }
8123
8124        if trace {
8125            eprintln!(
8126                "[SG_MOVIE_TRACE] global MOV frame file={} idx={} timer={} pos=({}, {}) size={}x{} layer={} sprite={}",
8127                file_name,
8128                frame_idx,
8129                self.globals.mov.timer_ms,
8130                x,
8131                y,
8132                width,
8133                height,
8134                layer_id,
8135                sprite_id
8136            );
8137        }
8138    }
8139
8140    fn sync_weather_objects(&mut self, game_delta_ms: i32, real_delta_ms: i32) {
8141        let wipe_active = self.globals.wipe.is_some();
8142        let screen_w = self.screen_w.max(1) as i64;
8143        let screen_h = self.screen_h.max(1) as i64;
8144        let (globals, layers, images, ids) = (
8145            &mut self.globals,
8146            &mut self.layers,
8147            &mut self.images,
8148            &self.ids,
8149        );
8150        let mut form_ids: Vec<u32> = globals.stage_forms.keys().copied().collect();
8151        form_ids.sort_unstable();
8152        for form_id in form_ids {
8153            let Some(st) = globals.stage_forms.get_mut(&form_id) else {
8154                continue;
8155            };
8156            let mut stage_ids: Vec<i64> = st.object_lists.keys().copied().collect();
8157            stage_ids.sort_unstable();
8158            for stage_idx in stage_ids {
8159                if stage_idx == TNM_STAGE_NEXT_I64 && !wipe_active {
8160                    continue;
8161                }
8162                let slot_use = st.object_slot_use.get(&stage_idx).cloned();
8163                let embedded = st.embedded_object_slots_by_stage.get(&stage_idx).cloned();
8164                let Some(objs) = st.object_lists.get_mut(&stage_idx) else {
8165                    continue;
8166                };
8167                for (obj_idx, obj) in objs.iter_mut().enumerate() {
8168                    if embedded
8169                        .as_ref()
8170                        .is_some_and(|slots| slots.contains(&obj_idx))
8171                        || !slot_use
8172                            .as_ref()
8173                            .and_then(|flags| flags.get(obj_idx))
8174                            .copied()
8175                            .unwrap_or(true)
8176                    {
8177                        continue;
8178                    }
8179                    sync_weather_object_recursive(
8180                        ids,
8181                        layers,
8182                        images,
8183                        screen_w,
8184                        screen_h,
8185                        game_delta_ms,
8186                        real_delta_ms,
8187                        obj,
8188                    );
8189                }
8190            }
8191        }
8192    }
8193
8194    fn repair_missing_gfx_leaf_images(&mut self) {
8195        if !self.gfx.has_missing_bound_object_image(&self.layers) {
8196            return;
8197        }
8198
8199        fn collect(
8200            ids: &crate::runtime::constants::RuntimeConstants,
8201            stage_idx: i64,
8202            objs: &[globals::ObjectState],
8203            out: &mut Vec<(i64, usize, String, i64)>,
8204        ) {
8205            for (idx, obj) in objs.iter().enumerate() {
8206                if obj.object_type != 0
8207                    && obj.object_type != 6
8208                    && matches!(obj.backend, globals::ObjectBackend::Gfx)
8209                {
8210                    let slot = object_runtime_slot(idx, obj);
8211                    let file = obj.file_name.clone();
8212                    if let Some(file) = file
8213                        && !file.is_empty()
8214                    {
8215                        let patno = obj.lookup_int_prop(ids, ids.obj_patno).unwrap_or(0);
8216                        out.push((stage_idx, slot, file, patno));
8217                    }
8218                }
8219                if !obj.runtime.child_objects.is_empty() {
8220                    collect(ids, stage_idx, &obj.runtime.child_objects, out);
8221                }
8222            }
8223        }
8224
8225        let mut tasks: Vec<(i64, usize, String, i64)> = Vec::new();
8226        let mut form_ids: Vec<u32> = self.globals.stage_forms.keys().copied().collect();
8227        form_ids.sort_unstable();
8228        for form_id in form_ids {
8229            let Some(st) = self.globals.stage_forms.get(&form_id) else {
8230                continue;
8231            };
8232            let mut stage_ids: Vec<i64> = st
8233                .object_lists
8234                .keys()
8235                .chain(st.mwnd_lists.keys())
8236                .copied()
8237                .collect();
8238            stage_ids.sort_unstable();
8239            stage_ids.dedup();
8240            for stage_idx in stage_ids {
8241                if let Some(objs) = st.object_lists.get(&stage_idx) {
8242                    collect(&self.ids, stage_idx, objs, &mut tasks);
8243                }
8244                if let Some(mwnds) = st.mwnd_lists.get(&stage_idx) {
8245                    for mwnd in mwnds {
8246                        collect(&self.ids, stage_idx, &mwnd.button_list, &mut tasks);
8247                        collect(&self.ids, stage_idx, &mwnd.face_list, &mut tasks);
8248                        collect(&self.ids, stage_idx, &mwnd.object_list, &mut tasks);
8249                    }
8250                }
8251            }
8252        }
8253
8254        for (stage_idx, runtime_slot, state_file, state_patno) in tasks {
8255            let Some((layer_id, sprite_id)) = self
8256                .gfx
8257                .object_sprite_binding(stage_idx, runtime_slot as i64)
8258            else {
8259                continue;
8260            };
8261            let needs_image = self
8262                .layers
8263                .layer(layer_id)
8264                .and_then(|layer| layer.sprite(sprite_id))
8265                .map(|sprite| sprite.image_id.is_none())
8266                .unwrap_or(false);
8267            if !needs_image {
8268                continue;
8269            }
8270
8271            let file = self
8272                .gfx
8273                .object_peek_file(stage_idx, runtime_slot as i64)
8274                .unwrap_or_else(|| state_file.clone());
8275            if file.is_empty() {
8276                continue;
8277            }
8278            let patno = self
8279                .gfx
8280                .object_peek_patno(stage_idx, runtime_slot as i64)
8281                .unwrap_or(state_patno)
8282                .max(0) as u32;
8283
8284            let repair_key = (
8285                self.globals.append_dir.clone(),
8286                stage_idx,
8287                runtime_slot,
8288                file.clone(),
8289                patno,
8290            );
8291            if self.failed_gfx_image_repairs.contains(&repair_key) {
8292                continue;
8293            }
8294
8295            let img_id = match self.images.load_g00(&file, patno) {
8296                Ok(id) => Ok(id),
8297                Err(_) => self.images.load_bg_frame(&file, patno as usize),
8298            };
8299            match img_id {
8300                Ok(img_id) => {
8301                    if let Some(layer) = self.layers.layer_mut(layer_id)
8302                        && let Some(sprite) = layer.sprite_mut(sprite_id)
8303                    {
8304                        sprite.image_id = Some(img_id.clone());
8305                        if let Some(img) = self.images.get(&img_id) {
8306                            sprite.object_anchor = true;
8307                            sprite.texture_center_x = img.center_x as f32;
8308                            sprite.texture_center_y = img.center_y as f32;
8309                        } else {
8310                            sprite.object_anchor = false;
8311                            sprite.texture_center_x = 0.0;
8312                            sprite.texture_center_y = 0.0;
8313                        }
8314                    }
8315                }
8316                Err(err) => {
8317                    self.failed_gfx_image_repairs.insert(repair_key);
8318                    self.unknown.record_note(&format!(
8319                        "gfx.image.repair.failed:stage={stage_idx}:slot={runtime_slot}:file={file}:patno={patno}:{err}"
8320                    ));
8321                }
8322            }
8323        }
8324    }
8325
8326    /// Build a render list and apply screen/wipe effects.
8327    ///
8328    /// Original Siglus does not render from a flat layer list. It first builds a
8329    /// stage/object sprite tree and then flattens that tree. We mirror that shape here:
8330    /// use the existing layer-backed sprites only as leaf payloads, but rebuild the final
8331    /// submission order from stage -> top-level object -> child objects.
8332    fn build_render_list_pre_wipe(
8333        &mut self,
8334    ) -> (Vec<RenderSprite>, Vec<String>, HashSet<(LayerId, SpriteId)>) {
8335        self.images.organize();
8336        self.layers.reset_runtime_effects();
8337        self.repair_missing_gfx_leaf_images();
8338        self.apply_object_masks();
8339        self.apply_object_tonecurves();
8340        let mut object_keys = std::mem::take(&mut self.render_object_keys_scratch);
8341        object_keys.clear();
8342        mark_all_stage_owned_sprite_keys(self, &mut object_keys);
8343        let base = self.layers.render_list_excluding(|layer_id, sprite_id| {
8344            object_keys.contains(&(layer_id, sprite_id))
8345        });
8346        let (mut list, debug_lines) =
8347            build_siglus_object_render_list(self, &base, TNM_STAGE_FRONT_I64, &mut object_keys);
8348        apply_button_visuals(self, &mut list);
8349        apply_selbtn_item_visuals(self, &mut list);
8350        self.apply_gan_effects(&mut list);
8351        apply_stage_render_effects(&self.globals, &self.ids, TNM_STAGE_FRONT_I64, &mut list);
8352        (list, debug_lines, object_keys)
8353    }
8354
8355    pub fn set_frame_capture_backend(&mut self, backend: Option<FrameCaptureBackendRef>) {
8356        self.frame_capture_backend = backend;
8357    }
8358
8359    pub fn render_frame_with_effects(&mut self) -> RenderFrame {
8360        self.render_frame_with_effects_inner(true)
8361    }
8362
8363    /// Compatibility/debug flattening. Actual presentation uses `RenderFrame`
8364    /// and never rasterizes a stage on the CPU.
8365    pub fn render_list_with_effects(&mut self) -> Vec<RenderSprite> {
8366        self.render_frame_with_effects().debug_flatten()
8367    }
8368
8369    fn render_frame_with_effects_inner(&mut self, include_mouse_cursor: bool) -> RenderFrame {
8370        let (pre_wipe_list, debug_lines, mut object_keys) = self.build_render_list_pre_wipe();
8371        let frame = if let Some(wipe_state) = self.globals.wipe.as_ref().cloned() {
8372            let (wipe_begin_order, wipe_end_order) =
8373                effective_wipe_render_order_bounds(self, &wipe_state);
8374            let base = self.layers.render_list_excluding(|layer_id, sprite_id| {
8375                object_keys.contains(&(layer_id, sprite_id))
8376            });
8377            let (mut next_list, next_debug_lines) =
8378                build_siglus_object_render_list(self, &base, TNM_STAGE_NEXT_I64, &mut object_keys);
8379            apply_button_visuals(self, &mut next_list);
8380            apply_selbtn_item_visuals(self, &mut next_list);
8381            self.apply_gan_effects(&mut next_list);
8382            apply_stage_render_effects(
8383                &self.globals,
8384                &self.ids,
8385                TNM_STAGE_NEXT_I64,
8386                &mut next_list,
8387            );
8388            if config_button_trace_enabled() {
8389                eprintln!(
8390                    "[SG_DEBUG][CONFIG_BUTTON_TRACE][RENDER_PHASE] wipe_active=true pre_wipe_len={} next_len={} next_debug_lines={} wipe_type={}",
8391                    pre_wipe_list.len(),
8392                    next_list.len(),
8393                    next_debug_lines.len(),
8394                    wipe_state.wipe_type,
8395                );
8396                for line in next_debug_lines
8397                    .iter()
8398                    .filter(|line| line.contains("CONFIG_BUTTON_TRACE"))
8399                {
8400                    eprintln!("{}", line);
8401                }
8402            }
8403
8404            let with_low = wipe_state.with_low_order != 0;
8405            let mut under = Vec::new();
8406            let mut current = Vec::new();
8407            let mut over = Vec::new();
8408            for rs in pre_wipe_list.iter().cloned() {
8409                match classify_wipe_partition(
8410                    &rs,
8411                    wipe_state.begin_layer,
8412                    wipe_state.end_layer,
8413                    wipe_begin_order,
8414                    wipe_end_order,
8415                    with_low,
8416                ) {
8417                    WipePartition::Under => under.push(rs),
8418                    WipePartition::Target => current.push(rs),
8419                    WipePartition::Over => over.push(rs),
8420                }
8421            }
8422            let mut next = next_list
8423                .into_iter()
8424                .filter(|rs| {
8425                    classify_wipe_partition(
8426                        rs,
8427                        wipe_state.begin_layer,
8428                        wipe_state.end_layer,
8429                        wipe_begin_order,
8430                        wipe_end_order,
8431                        with_low,
8432                    ) == WipePartition::Target
8433                })
8434                .collect::<Vec<_>>();
8435
8436            under.retain(render_sprite_visible_for_submit);
8437            current.retain(render_sprite_visible_for_submit);
8438            next.retain(render_sprite_visible_for_submit);
8439            over.retain(render_sprite_visible_for_submit);
8440
8441            // Siglus types 0/1/2 do not process isolated target sprites over a
8442            // separately rendered `under` texture. The original engine draws
8443            // complete scenes for these three basic modes:
8444            //
8445            //   type 0: base   = under + NEXT, wipe buffer = under + FRONT
8446            //   type 1: result = under + FRONT
8447            //   type 2: result = under + NEXT
8448            //
8449            // Keeping `under` separate here would render FRONT/NEXT target
8450            // sprites against an opaque black offscreen clear and then place
8451            // that full-screen black texture over the actual lower orders.
8452            // Promote both target lists to complete scene inputs instead.
8453            compose_basic_wipe_scene_inputs(
8454                wipe_state.wipe_type,
8455                &mut under,
8456                &mut current,
8457                &mut next,
8458            );
8459            if include_mouse_cursor {
8460                self.append_mouse_cursor_sprite(&mut over);
8461            }
8462
8463            let progress = wipe::eased_progress(wipe_state.progress(), wipe_state.speed_mode);
8464            RenderFrame {
8465                sprites: Vec::new(),
8466                wipe: Some(WipeRenderPlan {
8467                    under,
8468                    current,
8469                    next,
8470                    over,
8471                    wipe_type: wipe_state.wipe_type,
8472                    option: wipe_state.option,
8473                    progress,
8474                    mask_image_id: wipe_state.mask_image_id,
8475                    random_seed: wipe_state.random_seed,
8476                }),
8477            }
8478        } else {
8479            let mut list = pre_wipe_list;
8480            list.retain(render_sprite_visible_for_submit);
8481            if include_mouse_cursor {
8482                self.append_mouse_cursor_sprite(&mut list);
8483            }
8484            RenderFrame::ordinary(list)
8485        };
8486        self.render_object_keys_scratch = object_keys;
8487
8488        let config_button_trace = config_button_trace_enabled();
8489        let save_load_trace = save_load_render_trace_enabled();
8490        let render_tree_debug = sg_render_tree_debug_enabled();
8491        if config_button_trace || save_load_trace || render_tree_debug {
8492            let debug_list = frame.debug_flatten();
8493            if config_button_trace {
8494                trace_final_render_order(self, &debug_list);
8495            }
8496            if save_load_trace {
8497                trace_save_load_render_sprites(self, &debug_list);
8498            }
8499            if render_tree_debug {
8500                use std::sync::atomic::{AtomicU64, Ordering};
8501                static FRAME_NO: AtomicU64 = AtomicU64::new(0);
8502                let frame_no = FRAME_NO.fetch_add(1, Ordering::Relaxed) + 1;
8503                eprintln!("[SG_DEBUG] ===== frame {} =====", frame_no);
8504                for line in debug_lines {
8505                    eprintln!("{}", line);
8506                }
8507                if let Some(wipe) = self.globals.wipe.as_ref() {
8508                    eprintln!(
8509                        "[SG_DEBUG] wipe active type={} progress={:.3} range=({},{})->({},{}) with_low={} wait={}",
8510                        wipe.wipe_type,
8511                        wipe.progress(),
8512                        wipe.begin_order,
8513                        wipe.begin_layer,
8514                        wipe.end_order,
8515                        wipe.end_layer,
8516                        wipe.with_low_order,
8517                        wipe.wait_flag,
8518                    );
8519                }
8520                eprintln!(
8521                    "[SG_DEBUG] submitted_render_list len={}",
8522                    frame.submitted_sprite_count()
8523                );
8524            }
8525        }
8526        frame
8527    }
8528
8529    // ImageHandle hashes its immutable key and album identity, never the mutable pixels.
8530    #[allow(clippy::mutable_key_type)]
8531    pub fn debug_active_texture_entries(
8532        &self,
8533        submitted: &[RenderSprite],
8534    ) -> Vec<DebugActiveTextureEntry> {
8535        let mut submitted_keys: HashSet<(LayerId, SpriteId)> = HashSet::default();
8536        let mut submitted_images: HashSet<ImageHandle> = HashSet::default();
8537        for rs in submitted {
8538            if let Some(ref id) = rs.sprite.image_id {
8539                submitted_images.insert(id.clone());
8540            }
8541            if let (Some(layer_id), Some(sprite_id)) = (rs.layer_id, rs.sprite_id) {
8542                submitted_keys.insert((layer_id, sprite_id));
8543            }
8544        }
8545
8546        let mut acc: HashMap<ImageHandle, DebugActiveTextureAccum> = HashMap::default();
8547        let mut form_ids: Vec<u32> = self.globals.stage_forms.keys().copied().collect();
8548        form_ids.sort_unstable();
8549        for form_id in form_ids {
8550            let Some(st) = self.globals.stage_forms.get(&form_id) else {
8551                continue;
8552            };
8553            let mut stage_ids: Vec<i64> = st.object_lists.keys().copied().collect();
8554            stage_ids.sort_unstable();
8555            for stage_idx in stage_ids {
8556                let Some(list) = st.object_lists.get(&stage_idx) else {
8557                    continue;
8558                };
8559                for (obj_idx, obj) in list.iter().enumerate() {
8560                    if !st.object_slot_is_used(stage_idx, obj_idx) {
8561                        continue;
8562                    }
8563                    collect_debug_active_textures_from_object(
8564                        self,
8565                        form_id,
8566                        stage_idx,
8567                        obj_idx,
8568                        obj,
8569                        &submitted_keys,
8570                        &submitted_images,
8571                        &mut acc,
8572                    );
8573                }
8574            }
8575        }
8576
8577        let mut out: Vec<DebugActiveTextureEntry> = acc
8578            .into_iter()
8579            .map(|(image_id, entry)| DebugActiveTextureEntry {
8580                image_id,
8581                width: entry.width,
8582                height: entry.height,
8583                source_label: entry.source_label,
8584                submitted_this_frame: entry.submitted_this_frame,
8585                visible_refs: entry.visible_refs,
8586                total_refs: entry.total_refs,
8587                ref_summary: if entry.ref_labels.is_empty() {
8588                    String::new()
8589                } else {
8590                    entry.ref_labels.join(" | ")
8591                },
8592            })
8593            .collect();
8594        out.sort_by(|a, b| {
8595            b.submitted_this_frame
8596                .cmp(&a.submitted_this_frame)
8597                .then_with(|| b.visible_refs.cmp(&a.visible_refs))
8598                .then_with(|| b.total_refs.cmp(&a.total_refs))
8599                .then_with(|| a.image_id.key().cmp(&b.image_id.key()))
8600        });
8601        out
8602    }
8603
8604    /// Capture the current frame through the same wgpu render graph as presentation.
8605    pub fn capture_frame_rgba(&mut self) -> Result<RgbaImage> {
8606        let backend = self
8607            .frame_capture_backend
8608            .clone()
8609            .ok_or_else(|| anyhow!("GPU frame capture requested without an attached renderer"))?;
8610        let frame = self.render_frame_with_effects_inner(false);
8611
8612        {
8613            let mut backend = backend.borrow_mut();
8614            backend.capture_render_frame(&self.images, &frame, self.screen_w, self.screen_h)
8615        }
8616    }
8617
8618    /// Capture only sprites up to the original engine order/layer cut line.
8619    pub fn capture_frame_rgba_until(
8620        &mut self,
8621        end_order: i64,
8622        end_layer: i64,
8623    ) -> Result<RgbaImage> {
8624        let backend = self
8625            .frame_capture_backend
8626            .clone()
8627            .ok_or_else(|| anyhow!("GPU frame capture requested without an attached renderer"))?;
8628        let mut frame = self.render_frame_with_effects_inner(false);
8629        let within = |rs: &RenderSprite| {
8630            i64::from(rs.sorter_order) < end_order
8631                || (i64::from(rs.sorter_order) == end_order
8632                    && i64::from(rs.sorter_layer) <= end_layer)
8633        };
8634        frame.sprites.retain(within);
8635        if let Some(wipe) = frame.wipe.as_mut() {
8636            wipe.under.retain(within);
8637            wipe.current.retain(within);
8638            wipe.next.retain(within);
8639            wipe.over.retain(within);
8640        }
8641
8642        {
8643            let mut backend = backend.borrow_mut();
8644            backend.capture_render_frame(&self.images, &frame, self.screen_w, self.screen_h)
8645        }
8646    }
8647}
8648
8649// ImageHandle hashes its immutable key and album identity, never the mutable pixels.
8650#[allow(clippy::mutable_key_type)]
8651fn collect_debug_active_textures_from_object(
8652    ctx: &CommandContext,
8653    stage_form_id: u32,
8654    stage_idx: i64,
8655    obj_idx: usize,
8656    obj: &globals::ObjectState,
8657    submitted_keys: &HashSet<(LayerId, SpriteId)>,
8658    submitted_images: &HashSet<ImageHandle>,
8659    out: &mut HashMap<ImageHandle, DebugActiveTextureAccum>,
8660) {
8661    if !object_participates_in_tree(obj) {
8662        return;
8663    }
8664
8665    let info = effective_object_info(ctx, stage_idx, obj_idx, obj);
8666    let bound = fetch_bound_render_sprites_any(ctx, stage_idx, info.runtime_slot, obj);
8667    for rs in bound {
8668        let Some(image_id) = rs.sprite.image_id else {
8669            continue;
8670        };
8671        let submitted = submitted_images.contains(&image_id)
8672            || rs
8673                .layer_id
8674                .zip(rs.sprite_id)
8675                .map(|key| submitted_keys.contains(&key))
8676                .unwrap_or(false);
8677        let debug_img = ctx.images.debug_image_info(&image_id);
8678        let entry = out
8679            .entry(image_id)
8680            .or_insert_with(|| DebugActiveTextureAccum {
8681                width: debug_img.as_ref().map(|d| d.width).unwrap_or(0),
8682                height: debug_img.as_ref().map(|d| d.height).unwrap_or(0),
8683                source_label: debug_img
8684                    .as_ref()
8685                    .and_then(|d| {
8686                        d.source_path.as_ref().map(|p| {
8687                            if let Some(frame_index) = d.frame_index {
8688                                format!("{}#{}", p.display(), frame_index)
8689                            } else {
8690                                p.display().to_string()
8691                            }
8692                        })
8693                    })
8694                    .unwrap_or_else(|| {
8695                        obj.file_name
8696                            .clone()
8697                            .unwrap_or_else(|| "<dynamic>".to_string())
8698                    }),
8699                submitted_this_frame: false,
8700                visible_refs: 0,
8701                total_refs: 0,
8702                ref_labels: Vec::new(),
8703            });
8704        entry.submitted_this_frame |= submitted;
8705        entry.total_refs += 1;
8706        if info.disp {
8707            entry.visible_refs += 1;
8708        }
8709        let file = obj.file_name.as_deref().unwrap_or("-");
8710        let ref_label = format!(
8711            "sf{} st{} slot{} {} disp={} backend={}",
8712            stage_form_id,
8713            stage_idx,
8714            info.runtime_slot,
8715            file,
8716            if info.disp { 1 } else { 0 },
8717            debug_object_backend_name(obj)
8718        );
8719        if !entry.ref_labels.iter().any(|s| s == &ref_label) {
8720            if entry.ref_labels.len() < 3 {
8721                entry.ref_labels.push(ref_label);
8722            } else if entry.ref_labels.len() == 3 {
8723                entry.ref_labels.push("...".to_string());
8724            }
8725        }
8726    }
8727
8728    for (child_idx, child) in obj.runtime.child_objects.iter().enumerate() {
8729        collect_debug_active_textures_from_object(
8730            ctx,
8731            stage_form_id,
8732            stage_idx,
8733            child_idx,
8734            child,
8735            submitted_keys,
8736            submitted_images,
8737            out,
8738        );
8739    }
8740}
8741
8742fn debug_object_backend_name(obj: &globals::ObjectState) -> &'static str {
8743    match &obj.backend {
8744        globals::ObjectBackend::None => "None",
8745        globals::ObjectBackend::Gfx => "Gfx",
8746        globals::ObjectBackend::Rect { .. } => "Rect",
8747        globals::ObjectBackend::String { .. } => "String",
8748        globals::ObjectBackend::Number { .. } => "Number",
8749        globals::ObjectBackend::Weather { .. } => "Weather",
8750        globals::ObjectBackend::Movie { .. } => "Movie",
8751    }
8752}
8753
8754#[inline]
8755fn cached_env_flag(slot: &'static std::sync::OnceLock<bool>, name: &str) -> bool {
8756    *slot.get_or_init(|| {
8757        matches!(
8758            std::env::var(name).ok().as_deref(),
8759            Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES")
8760        )
8761    })
8762}
8763
8764fn sg_debug_enabled() -> bool {
8765    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8766    cached_env_flag(&ENABLED, "SG_DEBUG")
8767}
8768
8769fn sg_input_trace_enabled() -> bool {
8770    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8771    cached_env_flag(&ENABLED, "SG_INPUT_TRACE")
8772}
8773
8774fn sg_mwnd_object_trace_enabled() -> bool {
8775    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8776    cached_env_flag(&ENABLED, "SG_MWND_OBJECT_TRACE")
8777}
8778
8779fn sg_render_tree_debug_enabled() -> bool {
8780    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8781    cached_env_flag(&ENABLED, "SG_RENDER_TREE_DEBUG")
8782}
8783
8784fn config_button_trace_enabled() -> bool {
8785    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8786    cached_env_flag(&ENABLED, "SG_CONFIG_BUTTON_TRACE")
8787}
8788
8789fn config_button_trace_object(obj: &globals::ObjectState) -> bool {
8790    if obj.button.enabled || obj.button.state == TNM_BTN_STATE_DISABLE {
8791        return true;
8792    }
8793    let Some(file) = obj.file_name.as_deref() else {
8794        return false;
8795    };
8796    let f = file.to_ascii_lowercase();
8797    f.starts_with("mn_")
8798        || f.contains("config")
8799        || f.contains("conf")
8800        || f.contains("sys")
8801        || f.contains("mw")
8802}
8803
8804fn config_tr_write_trace_file(file: Option<&str>) -> bool {
8805    let Some(name) = file else {
8806        return false;
8807    };
8808    name.starts_with("mn_sm_menu_cbox")
8809        || name.starts_with("mn_cfa_tab_pbtn")
8810        || name.starts_with("mn_cfb_")
8811        || name.starts_with("mn_cfe_")
8812        || name.starts_with("mn_tt_menu")
8813        || name.starts_with("mn_tt_copy")
8814}
8815
8816fn config_tr_write_trace_object(obj_i64: i64, obj: &globals::ObjectState) -> bool {
8817    (100057..=100067).contains(&obj_i64) || config_tr_write_trace_file(obj.file_name.as_deref())
8818}
8819
8820fn trace_config_event_frame_prop_write(
8821    ids: &constants::RuntimeConstants,
8822    stage_i64: i64,
8823    obj_i64: i64,
8824    obj: &globals::ObjectState,
8825    prop_id: i32,
8826    old_value: i64,
8827    new_value: i64,
8828) {
8829    if !sg_debug_enabled() || !config_tr_write_trace_object(obj_i64, obj) {
8830        return;
8831    }
8832    let prop = if ids.obj_tr != 0 && prop_id == ids.obj_tr {
8833        "TR"
8834    } else if ids.obj_alpha != 0 && prop_id == ids.obj_alpha {
8835        "ALPHA"
8836    } else {
8837        return;
8838    };
8839    eprintln!(
8840        "[SG_DEBUG][CONFIG_TR_WRITE_TRACE][EVENT_FRAME] stage={} runtime_slot={} file={} prop={} old={} new={} disp={} tr={} alpha={} backend={:?} used={} children={}",
8841        stage_i64,
8842        obj_i64,
8843        obj.file_name.as_deref().unwrap_or("-"),
8844        prop,
8845        old_value,
8846        new_value,
8847        obj.get_int_prop(ids, ids.obj_disp),
8848        obj.get_int_prop(ids, ids.obj_tr),
8849        obj.get_int_prop(ids, ids.obj_alpha),
8850        obj.backend,
8851        obj.used,
8852        obj.runtime.child_objects.len(),
8853    );
8854}
8855
8856fn save_load_render_trace_enabled() -> bool {
8857    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8858    *ENABLED.get_or_init(|| env_is_set!("SG_SAVELOAD_TRACE"))
8859}
8860
8861fn trace_save_load_render_sprites(ctx: &CommandContext, list: &[RenderSprite]) {
8862    let scene = ctx.current_scene_name.as_deref().unwrap_or("<none>");
8863    let scene_match =
8864        scene.contains("sys10_sv") || scene.contains("save") || scene.contains("load");
8865    let mut emitted = 0usize;
8866    for (idx, rs) in list.iter().enumerate() {
8867        let Some(ref image_id) = rs.sprite.image_id else {
8868            continue;
8869        };
8870        let info = ctx.images.debug_image_info(image_id);
8871        let width = info.as_ref().map(|d| d.width).unwrap_or(0);
8872        let height = info.as_ref().map(|d| d.height).unwrap_or(0);
8873        let source_path = info
8874            .as_ref()
8875            .and_then(|d| d.source_path.as_ref())
8876            .map(|p| p.display().to_string())
8877            .unwrap_or_else(|| "-".to_string());
8878        let source_path_lc = source_path.to_ascii_lowercase();
8879        let source = render_sprite_source_name(ctx, rs);
8880        let source_lc = source.to_ascii_lowercase();
8881        let near_origin =
8882            rs.sprite.x.abs() <= 4 && rs.sprite.y.abs() <= 4 && width >= 16 && height >= 16;
8883        let unowned = source.starts_with("unowned:");
8884        let path_match = source_path_lc.contains("savedata")
8885            || source_path_lc.contains("thumb")
8886            || source_path_lc.contains("capture")
8887            || source_path_lc.contains("mn_sv")
8888            || source_lc.contains("mn_sv")
8889            || source_lc.contains("save")
8890            || source_lc.contains("thumb")
8891            || source_lc.contains("capture");
8892        if !(scene_match || near_origin || unowned || path_match) {
8893            continue;
8894        }
8895        if !(near_origin || unowned || path_match) {
8896            continue;
8897        }
8898        eprintln!(
8899            "[SG_SAVELOAD_TRACE][RENDER] idx={} scene={} source={} layer_id={:?} sprite_id={:?} image={:?} image_size={}x{} image_version={} image_source={} frame={:?} pos=({}, {}) visible={} alpha={} tr={} order=({}, {}) packed_order={} fit={:?} size_mode={:?} clip={:?}",
8900            idx,
8901            scene,
8902            source,
8903            rs.layer_id,
8904            rs.sprite_id,
8905            rs.sprite.image_id,
8906            width,
8907            height,
8908            info.as_ref().map(|d| d.version).unwrap_or(0),
8909            source_path,
8910            info.as_ref().and_then(|d| d.frame_index),
8911            rs.sprite.x,
8912            rs.sprite.y,
8913            rs.sprite.visible,
8914            rs.sprite.alpha,
8915            rs.sprite.tr,
8916            rs.sorter_order,
8917            rs.sorter_layer,
8918            rs.sprite.order,
8919            rs.sprite.fit,
8920            rs.sprite.size_mode,
8921            rs.sprite.dst_clip
8922        );
8923        emitted += 1;
8924        if emitted >= 120 {
8925            eprintln!(
8926                "[SG_SAVELOAD_TRACE][RENDER] truncated after {} entries",
8927                emitted
8928            );
8929            break;
8930        }
8931    }
8932}
8933
8934fn trace_final_render_order(ctx: &CommandContext, list: &[RenderSprite]) {
8935    eprintln!(
8936        "[SG_DEBUG][CONFIG_BUTTON_TRACE][FINAL_ORDER] len={} wipe_active={} selected_stage=front",
8937        list.len(),
8938        ctx.globals.wipe.is_some()
8939    );
8940    for (idx, rs) in list.iter().enumerate() {
8941        let source = render_sprite_source_name(ctx, rs);
8942        eprintln!(
8943            "[SG_DEBUG][CONFIG_BUTTON_TRACE][FINAL_ORDER] idx={} source={} layer_id={:?} sprite_id={:?} sorter=({}, {}) packed_order={} visible={} alpha={} tr={} pos=({}, {}) z={} fit={:?} size={:?} image={:?} blend={:?} clip={:?}",
8944            idx,
8945            source,
8946            rs.layer_id,
8947            rs.sprite_id,
8948            rs.sorter_order,
8949            rs.sorter_layer,
8950            rs.sprite.order,
8951            rs.sprite.visible,
8952            rs.sprite.alpha,
8953            rs.sprite.tr,
8954            rs.sprite.x,
8955            rs.sprite.y,
8956            rs.sprite.z,
8957            rs.sprite.fit,
8958            rs.sprite.size_mode,
8959            rs.sprite.image_id,
8960            rs.sprite.blend,
8961            rs.sprite.dst_clip
8962        );
8963    }
8964}
8965
8966fn render_sprite_source_name(ctx: &CommandContext, rs: &RenderSprite) -> String {
8967    let Some(layer_id) = rs.layer_id else {
8968        return "background".to_string();
8969    };
8970    let Some(sprite_id) = rs.sprite_id else {
8971        return "background".to_string();
8972    };
8973    let mut found: Vec<String> = Vec::new();
8974    let mut form_ids: Vec<u32> = ctx.globals.stage_forms.keys().copied().collect();
8975    form_ids.sort_unstable();
8976    for form_id in form_ids {
8977        let Some(st) = ctx.globals.stage_forms.get(&form_id) else {
8978            continue;
8979        };
8980        let mut stage_ids: Vec<i64> = st
8981            .object_lists
8982            .keys()
8983            .chain(st.mwnd_lists.keys())
8984            .chain(st.btnselitem_lists.keys())
8985            .copied()
8986            .collect();
8987        stage_ids.sort_unstable();
8988        stage_ids.dedup();
8989        for stage_idx in stage_ids {
8990            if let Some(list) = st.object_lists.get(&stage_idx) {
8991                for (obj_idx, obj) in list.iter().enumerate() {
8992                    collect_render_sprite_source_for_object(
8993                        ctx, form_id, stage_idx, obj_idx, obj, layer_id, sprite_id, "object",
8994                        &mut found,
8995                    );
8996                }
8997            }
8998            if let Some(mwnds) = st.mwnd_lists.get(&stage_idx) {
8999                for (mwnd_idx, m) in mwnds.iter().enumerate() {
9000                    for (obj_idx, obj) in m.button_list.iter().enumerate() {
9001                        collect_render_sprite_source_for_object(
9002                            ctx,
9003                            form_id,
9004                            stage_idx,
9005                            obj_idx,
9006                            obj,
9007                            layer_id,
9008                            sprite_id,
9009                            &format!("mwnd{mwnd_idx}.button"),
9010                            &mut found,
9011                        );
9012                    }
9013                    for (obj_idx, obj) in m.face_list.iter().enumerate() {
9014                        collect_render_sprite_source_for_object(
9015                            ctx,
9016                            form_id,
9017                            stage_idx,
9018                            obj_idx,
9019                            obj,
9020                            layer_id,
9021                            sprite_id,
9022                            &format!("mwnd{mwnd_idx}.face"),
9023                            &mut found,
9024                        );
9025                    }
9026                    for (obj_idx, obj) in m.object_list.iter().enumerate() {
9027                        collect_render_sprite_source_for_object(
9028                            ctx,
9029                            form_id,
9030                            stage_idx,
9031                            obj_idx,
9032                            obj,
9033                            layer_id,
9034                            sprite_id,
9035                            &format!("mwnd{mwnd_idx}.object"),
9036                            &mut found,
9037                        );
9038                    }
9039                }
9040            }
9041        }
9042    }
9043    if found.is_empty() {
9044        format!("unowned:{layer_id}/{sprite_id}")
9045    } else {
9046        found.join("|")
9047    }
9048}
9049
9050fn collect_render_sprite_source_for_object(
9051    ctx: &CommandContext,
9052    form_id: u32,
9053    stage_idx: i64,
9054    obj_idx: usize,
9055    obj: &globals::ObjectState,
9056    layer_id: LayerId,
9057    sprite_id: SpriteId,
9058    source_kind: &str,
9059    found: &mut Vec<String>,
9060) {
9061    let file = obj.file_name.as_deref().unwrap_or("-");
9062    if object_backend_owns_sprite(ctx, stage_idx, obj_idx, obj, layer_id, sprite_id) {
9063        found.push(format!(
9064            "form{form_id}:stage{stage_idx}:{source_kind}[{obj_idx}]:slot{}:file{}",
9065            effective_object_slot_for_trace(obj_idx, obj),
9066            file
9067        ));
9068    }
9069    for (child_idx, child) in obj.runtime.child_objects.iter().enumerate() {
9070        collect_render_sprite_source_for_object(
9071            ctx,
9072            form_id,
9073            stage_idx,
9074            child_idx,
9075            child,
9076            layer_id,
9077            sprite_id,
9078            &format!("{source_kind}[{obj_idx}].child"),
9079            found,
9080        );
9081    }
9082}
9083
9084fn effective_object_slot_for_trace(obj_idx: usize, obj: &globals::ObjectState) -> i64 {
9085    obj.runtime_slot_or(obj_idx) as i64
9086}
9087
9088fn layer_backed_object_sprite_bindings(
9089    backend: &globals::ObjectBackend,
9090) -> Vec<(LayerId, SpriteId)> {
9091    match backend {
9092        globals::ObjectBackend::Rect {
9093            layer_id,
9094            sprite_id,
9095            ..
9096        }
9097        | globals::ObjectBackend::Movie {
9098            layer_id,
9099            sprite_id,
9100            ..
9101        } => vec![(*layer_id, *sprite_id)],
9102        globals::ObjectBackend::String {
9103            layer_id,
9104            shadow_sprite_id,
9105            fuchi_sprite_id,
9106            sprite_id,
9107            glyphs,
9108            ..
9109        } => {
9110            if glyphs.is_empty() {
9111                vec![
9112                    (*layer_id, *shadow_sprite_id),
9113                    (*layer_id, *fuchi_sprite_id),
9114                    (*layer_id, *sprite_id),
9115                ]
9116            } else {
9117                let mut bindings = Vec::with_capacity(glyphs.len() * 3);
9118                bindings.extend(
9119                    glyphs
9120                        .iter()
9121                        .map(|glyph| (*layer_id, glyph.shadow_sprite_id)),
9122                );
9123                bindings.extend(
9124                    glyphs
9125                        .iter()
9126                        .map(|glyph| (*layer_id, glyph.fuchi_sprite_id)),
9127                );
9128                bindings.extend(glyphs.iter().map(|glyph| (*layer_id, glyph.body_sprite_id)));
9129                bindings
9130            }
9131        }
9132        globals::ObjectBackend::Number {
9133            layer_id,
9134            sprite_ids,
9135        }
9136        | globals::ObjectBackend::Weather {
9137            layer_id,
9138            sprite_ids,
9139        } => sprite_ids.iter().map(|sid| (*layer_id, *sid)).collect(),
9140        globals::ObjectBackend::Gfx | globals::ObjectBackend::None => Vec::new(),
9141    }
9142}
9143
9144fn object_backend_sprite_layer_offset(
9145    ctx: &CommandContext,
9146    backend: &globals::ObjectBackend,
9147    sprite_id: Option<SpriteId>,
9148) -> i64 {
9149    let Some(sprite_id) = sprite_id else {
9150        return 0;
9151    };
9152    let globals::ObjectBackend::String {
9153        shadow_sprite_id,
9154        fuchi_sprite_id,
9155        sprite_id: body_sprite_id,
9156        glyphs,
9157        mwnd_layer_reps: true,
9158        ..
9159    } = backend
9160    else {
9161        return 0;
9162    };
9163    if glyphs
9164        .iter()
9165        .any(|glyph| glyph.shadow_sprite_id == sprite_id)
9166        || (glyphs.is_empty() && sprite_id == *shadow_sprite_id)
9167    {
9168        ctx.tables.mwnd_render.shadow_layer_rep
9169    } else if glyphs
9170        .iter()
9171        .any(|glyph| glyph.fuchi_sprite_id == sprite_id)
9172        || (glyphs.is_empty() && sprite_id == *fuchi_sprite_id)
9173    {
9174        ctx.tables.mwnd_render.fuchi_layer_rep
9175    } else if glyphs.iter().any(|glyph| glyph.body_sprite_id == sprite_id)
9176        || (glyphs.is_empty() && sprite_id == *body_sprite_id)
9177    {
9178        ctx.tables.mwnd_render.moji_layer_rep
9179    } else {
9180        0
9181    }
9182}
9183
9184fn object_backend_sprite_local_offset(
9185    obj: &globals::ObjectState,
9186    sprite_id: Option<SpriteId>,
9187) -> (i64, i64) {
9188    let Some(sprite_id) = sprite_id else {
9189        return (0, 0);
9190    };
9191    if let globals::ObjectBackend::Number { sprite_ids, .. } = &obj.backend {
9192        return sprite_ids
9193            .iter()
9194            .position(|id| *id == sprite_id)
9195            .and_then(|idx| {
9196                obj.runtime
9197                    .number_sprite_offsets
9198                    .get(idx)
9199                    .copied()
9200                    .flatten()
9201            })
9202            .map(|x| (i64::from(x), 0))
9203            .unwrap_or((0, 0));
9204    }
9205    let globals::ObjectBackend::String { glyphs, .. } = &obj.backend else {
9206        return (0, 0);
9207    };
9208    for glyph in glyphs {
9209        if glyph.shadow_sprite_id == sprite_id {
9210            return (glyph.shadow_local_x as i64, glyph.shadow_local_y as i64);
9211        }
9212        if glyph.fuchi_sprite_id == sprite_id {
9213            return (glyph.fuchi_local_x as i64, glyph.fuchi_local_y as i64);
9214        }
9215        if glyph.body_sprite_id == sprite_id {
9216            return (glyph.body_local_x as i64, glyph.body_local_y as i64);
9217        }
9218    }
9219    (0, 0)
9220}
9221
9222fn object_backend_owns_sprite(
9223    ctx: &CommandContext,
9224    stage_idx: i64,
9225    obj_idx: usize,
9226    obj: &globals::ObjectState,
9227    layer_id: LayerId,
9228    sprite_id: SpriteId,
9229) -> bool {
9230    match &obj.backend {
9231        globals::ObjectBackend::Gfx => {
9232            ctx.gfx
9233                .object_sprite_binding(stage_idx, effective_object_slot_for_trace(obj_idx, obj))
9234                == Some((layer_id, sprite_id))
9235        }
9236        globals::ObjectBackend::None => false,
9237        backend => layer_backed_object_sprite_bindings(backend)
9238            .into_iter()
9239            .any(|binding| binding == (layer_id, sprite_id)),
9240    }
9241}
9242
9243#[derive(Debug, Clone, Default)]
9244struct ObjectRenderInfo {
9245    runtime_slot: usize,
9246    used: bool,
9247    object_type: i64,
9248    disp: bool,
9249    x: i64,
9250    y: i64,
9251    x_rep: i64,
9252    y_rep: i64,
9253    z_rep: i64,
9254    order: i64,
9255    layer: i64,
9256    alpha: i64,
9257    tr: i64,
9258    tr_rep: i64,
9259    mono: i64,
9260    reverse: i64,
9261    bright: i64,
9262    dark: i64,
9263    color_rate: i64,
9264    color_add_r: i64,
9265    color_add_g: i64,
9266    color_add_b: i64,
9267    color_r: i64,
9268    color_g: i64,
9269    color_b: i64,
9270    z: i64,
9271    world_no: i64,
9272    center_x: i64,
9273    center_y: i64,
9274    center_z: i64,
9275    center_rep_x: i64,
9276    center_rep_y: i64,
9277    center_rep_z: i64,
9278    scale_x: i64,
9279    scale_y: i64,
9280    scale_z: i64,
9281    rotate_x: i64,
9282    rotate_y: i64,
9283    rotate_z: i64,
9284    culling: bool,
9285    alpha_test: bool,
9286    alpha_blend: bool,
9287    fog_use: bool,
9288    light_no: i64,
9289    blend: crate::layer::SpriteBlend,
9290    child_sort_type: i64,
9291    dst_clip: Option<ClipRect>,
9292    billboard: bool,
9293    file_name: Option<String>,
9294    mesh_animation: crate::mesh3d::MeshAnimationState,
9295}
9296
9297#[derive(Debug, Clone)]
9298struct ParentRenderState {
9299    world_no: i64,
9300    pos_x: f32,
9301    pos_y: f32,
9302    pos_z: f32,
9303    center_rep_x: f32,
9304    center_rep_y: f32,
9305    center_rep_z: f32,
9306    scale_x: f32,
9307    scale_y: f32,
9308    scale_z: f32,
9309    rotate_x: f32,
9310    rotate_y: f32,
9311    rotate_z: f32,
9312    tr: i32,
9313    mono: i32,
9314    reverse: i32,
9315    bright: i32,
9316    dark: i32,
9317    color_rate: i32,
9318    color_r: i32,
9319    color_g: i32,
9320    color_b: i32,
9321    color_add_r: i32,
9322    color_add_g: i32,
9323    color_add_b: i32,
9324    blend: crate::layer::SpriteBlend,
9325    dst_clip: Option<ClipRect>,
9326    mask_image_id: Option<ImageHandle>,
9327    mask_offset_x: i32,
9328    mask_offset_y: i32,
9329    tonecurve_image_id: Option<ImageHandle>,
9330    tonecurve_row: f32,
9331    tonecurve_sat: f32,
9332}
9333
9334fn object_runtime_slot(obj_idx: usize, obj: &globals::ObjectState) -> usize {
9335    obj.runtime_slot_or(obj_idx)
9336}
9337
9338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9339enum ButtonSeEvent {
9340    Hit,
9341    Push,
9342    Decide,
9343}
9344
9345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9346struct ButtonSortKey {
9347    order: i64,
9348    layer: i64,
9349}
9350
9351impl ButtonSortKey {
9352    fn display_tuple(self) -> String {
9353        format!("({}, {})", self.order, self.layer)
9354    }
9355}
9356
9357#[derive(Debug, Clone)]
9358struct ButtonVisualState {
9359    state: i64,
9360    action_no: i64,
9361    file_name: Option<String>,
9362    base_patno: i64,
9363    cut_no: i64,
9364}
9365
9366const TNM_BTN_STATE_NORMAL: i64 = 0;
9367const TNM_BTN_STATE_HIT: i64 = 1;
9368const TNM_BTN_STATE_PUSH: i64 = 2;
9369const TNM_BTN_STATE_SELECT: i64 = 3;
9370const TNM_BTN_STATE_DISABLE: i64 = 4;
9371
9372const TNM_SYSCOM_TYPE_NONE: i64 = 0;
9373const TNM_SYSCOM_TYPE_SAVE: i64 = 1;
9374const TNM_SYSCOM_TYPE_LOAD: i64 = 2;
9375const TNM_SYSCOM_TYPE_READ_SKIP: i64 = 3;
9376const TNM_SYSCOM_TYPE_AUTO_MODE: i64 = 4;
9377const TNM_SYSCOM_TYPE_RETURN_SEL: i64 = 5;
9378const TNM_SYSCOM_TYPE_HIDE_MWND: i64 = 6;
9379const TNM_SYSCOM_TYPE_MSG_BACK: i64 = 7;
9380const TNM_SYSCOM_TYPE_KOE_PLAY: i64 = 8;
9381const TNM_SYSCOM_TYPE_QUICK_SAVE: i64 = 9;
9382const TNM_SYSCOM_TYPE_QUICK_LOAD: i64 = 10;
9383const TNM_SYSCOM_TYPE_CONFIG: i64 = 11;
9384const TNM_SYSCOM_TYPE_LOCAL_EXTRA_SWITCH: i64 = 12;
9385const TNM_SYSCOM_TYPE_LOCAL_EXTRA_MODE: i64 = 13;
9386const TNM_SYSCOM_TYPE_GLOBAL_EXTRA_SWITCH: i64 = 14;
9387const TNM_SYSCOM_TYPE_GLOBAL_EXTRA_MODE: i64 = 15;
9388
9389#[derive(Debug, Clone, Copy)]
9390struct ButtonHitCandidate {
9391    button_no: i64,
9392    sort_key: ButtonSortKey,
9393    runtime_slot: usize,
9394    se_no: i64,
9395    was_hit: bool,
9396    event_enabled: bool,
9397}
9398
9399#[derive(Debug, Clone, Copy)]
9400struct ButtonOwnerInfo {
9401    button_no: i64,
9402    runtime_slot: usize,
9403    se_no: i64,
9404    was_hit: bool,
9405    event_enabled: bool,
9406}
9407
9408fn push_object_button_decided_action(
9409    obj: &globals::ObjectState,
9410    out: &mut Vec<globals::PendingButtonAction>,
9411) {
9412    if !obj.button.decided_action_scn_name.is_empty() {
9413        if sg_debug_enabled() {
9414            eprintln!(
9415                "[SG_DEBUG][BUTTON_TRACE][CALLBACK] enqueue user_call file={:?} button_no={} group_no={} action_no={} state={} hit={} pushed={} call={}::{}/{}",
9416                obj.file_name,
9417                obj.button.button_no,
9418                obj.button.group_no,
9419                obj.button.action_no,
9420                obj.button.state,
9421                obj.button.hit,
9422                obj.button.pushed,
9423                obj.button.decided_action_scn_name,
9424                obj.button.decided_action_cmd_name,
9425                obj.button.decided_action_z_no
9426            );
9427        }
9428        out.push(globals::PendingButtonAction {
9429            kind: globals::PendingButtonActionKind::UserCall {
9430                scn_name: obj.button.decided_action_scn_name.clone(),
9431                cmd_name: obj.button.decided_action_cmd_name.clone(),
9432                z_no: obj.button.decided_action_z_no,
9433            },
9434        });
9435    } else if obj.button.sys_type != 0 {
9436        if sg_debug_enabled() {
9437            eprintln!(
9438                "[SG_DEBUG][BUTTON_TRACE][CALLBACK] enqueue syscom file={:?} button_no={} group_no={} action_no={} state={} hit={} pushed={} sys_type={} sys_opt={} mode={}",
9439                obj.file_name,
9440                obj.button.button_no,
9441                obj.button.group_no,
9442                obj.button.action_no,
9443                obj.button.state,
9444                obj.button.hit,
9445                obj.button.pushed,
9446                obj.button.sys_type,
9447                obj.button.sys_type_opt,
9448                obj.button.mode
9449            );
9450        }
9451        out.push(globals::PendingButtonAction {
9452            kind: globals::PendingButtonActionKind::Syscom {
9453                sys_type: obj.button.sys_type,
9454                sys_type_opt: obj.button.sys_type_opt,
9455                mode: obj.button.mode,
9456            },
9457        });
9458    } else if sg_debug_enabled() {
9459        eprintln!(
9460            "[SG_DEBUG][BUTTON_TRACE][CALLBACK] no_callback file={:?} button_no={} group_no={} action_no={} state={} hit={} pushed={}",
9461            obj.file_name,
9462            obj.button.button_no,
9463            obj.button.group_no,
9464            obj.button.action_no,
9465            obj.button.state,
9466            obj.button.hit,
9467            obj.button.pushed
9468        );
9469    }
9470}
9471
9472fn syscom_feature_enabled_for_button(
9473    syscom: &globals::SyscomRuntimeState,
9474    button: &globals::ObjectButtonState,
9475    read_skip_runtime_enabled: bool,
9476) -> bool {
9477    match button.sys_type {
9478        TNM_SYSCOM_TYPE_NONE => true,
9479        TNM_SYSCOM_TYPE_SAVE => syscom.save_feature.check_enabled() != 0,
9480        TNM_SYSCOM_TYPE_LOAD => syscom.load_feature.check_enabled() != 0,
9481        // elm_object_btn.cpp::C_elm_object::get_button_real_state() calls
9482        // tnm_syscom_read_skip_is_enable(), which includes the current
9483        // committed read flag / unread-skip gate in addition to enable/exist.
9484        TNM_SYSCOM_TYPE_READ_SKIP => read_skip_runtime_enabled,
9485        TNM_SYSCOM_TYPE_AUTO_MODE => syscom.auto_mode.check_enabled() != 0,
9486        TNM_SYSCOM_TYPE_RETURN_SEL => syscom.return_to_sel.check_enabled() != 0,
9487        TNM_SYSCOM_TYPE_HIDE_MWND => syscom.hide_mwnd.check_enabled() != 0,
9488        TNM_SYSCOM_TYPE_MSG_BACK => syscom.msg_back.check_enabled() != 0,
9489        TNM_SYSCOM_TYPE_KOE_PLAY => true,
9490        TNM_SYSCOM_TYPE_QUICK_SAVE => syscom.save_feature.check_enabled() != 0,
9491        TNM_SYSCOM_TYPE_QUICK_LOAD => syscom.load_feature.check_enabled() != 0,
9492        TNM_SYSCOM_TYPE_CONFIG => true,
9493        TNM_SYSCOM_TYPE_LOCAL_EXTRA_SWITCH => syscom.local_extra_switch.check_enabled() != 0,
9494        TNM_SYSCOM_TYPE_LOCAL_EXTRA_MODE => syscom.local_extra_mode.check_enabled() != 0,
9495        TNM_SYSCOM_TYPE_GLOBAL_EXTRA_SWITCH | TNM_SYSCOM_TYPE_GLOBAL_EXTRA_MODE => true,
9496        _ => true,
9497    }
9498}
9499
9500fn syscom_mode_for_button(
9501    syscom: &globals::SyscomRuntimeState,
9502    button: &globals::ObjectButtonState,
9503) -> i64 {
9504    match button.sys_type {
9505        TNM_SYSCOM_TYPE_READ_SKIP => i64::from(syscom.read_skip.onoff),
9506        TNM_SYSCOM_TYPE_AUTO_MODE => i64::from(syscom.auto_mode.onoff),
9507        TNM_SYSCOM_TYPE_LOCAL_EXTRA_SWITCH => i64::from(syscom.local_extra_switch.onoff),
9508        TNM_SYSCOM_TYPE_LOCAL_EXTRA_MODE => syscom.local_extra_mode.value,
9509        _ => 0,
9510    }
9511}
9512
9513fn button_syscom_mode_visible(
9514    syscom: &globals::SyscomRuntimeState,
9515    button: &globals::ObjectButtonState,
9516) -> bool {
9517    button.sys_type == TNM_SYSCOM_TYPE_NONE || syscom_mode_for_button(syscom, button) == button.mode
9518}
9519
9520fn mwnd_button_forced_disabled(
9521    syscom: &globals::SyscomRuntimeState,
9522    mwnd_button_idx: Option<usize>,
9523) -> bool {
9524    // elm_mwnd_waku.cpp applies these flags only to MWND buttons.
9525    // Ordinary OBJECT buttons (including EXCALL menus) remain interactive.
9526    let Some(idx) = mwnd_button_idx else {
9527        return false;
9528    };
9529    if syscom.mwnd_btn_disable_all {
9530        return true;
9531    }
9532    syscom
9533        .mwnd_btn_disable
9534        .get(&(idx as i64))
9535        .copied()
9536        .unwrap_or(false)
9537}
9538
9539fn button_effective_disabled(
9540    syscom: &globals::SyscomRuntimeState,
9541    obj: &globals::ObjectState,
9542    mwnd_button_idx: Option<usize>,
9543    read_skip_runtime_enabled: bool,
9544) -> bool {
9545    button_disabled_reason(syscom, obj, mwnd_button_idx, read_skip_runtime_enabled).is_some()
9546}
9547
9548fn button_disabled_reason(
9549    syscom: &globals::SyscomRuntimeState,
9550    obj: &globals::ObjectState,
9551    mwnd_button_idx: Option<usize>,
9552    read_skip_runtime_enabled: bool,
9553) -> Option<&'static str> {
9554    if obj.button.is_disabled() {
9555        return Some("object_state_disable");
9556    }
9557    if mwnd_button_forced_disabled(syscom, mwnd_button_idx) {
9558        return Some("syscom_mwnd_button_disable");
9559    }
9560    if !syscom_feature_enabled_for_button(syscom, &obj.button, read_skip_runtime_enabled) {
9561        return Some("syscom_feature_disable");
9562    }
9563    None
9564}
9565
9566fn button_state_name(state: i64) -> &'static str {
9567    match state {
9568        TNM_BTN_STATE_NORMAL => "normal",
9569        TNM_BTN_STATE_HIT => "hit",
9570        TNM_BTN_STATE_PUSH => "push",
9571        TNM_BTN_STATE_SELECT => "select",
9572        TNM_BTN_STATE_DISABLE => "disable",
9573        _ => "unknown",
9574    }
9575}
9576
9577fn object_button_renderable_by_syscom(
9578    syscom: &globals::SyscomRuntimeState,
9579    obj: &globals::ObjectState,
9580) -> bool {
9581    !obj.button.enabled || button_syscom_mode_visible(syscom, &obj.button)
9582}
9583
9584fn button_real_state_for_visual(
9585    syscom: &globals::SyscomRuntimeState,
9586    st: &globals::StageFormState,
9587    stage_idx: i64,
9588    obj: &globals::ObjectState,
9589    mwnd_button_idx: Option<usize>,
9590    read_skip_runtime_enabled: bool,
9591) -> i64 {
9592    if let Some(reason) =
9593        button_disabled_reason(syscom, obj, mwnd_button_idx, read_skip_runtime_enabled)
9594    {
9595        if sg_debug_enabled() {
9596            eprintln!(
9597                "[SG_DEBUG][BUTTON_TRACE][VISUAL] real_state=disable reason={} stage={} file={:?} mwnd_button_idx={:?} button_no={} group_no={} group_idx={:?} action_no={} raw_state={} enabled={} hit={} pushed={} sys_type={} sys_opt={} mode={} touch_disable={}",
9598                reason,
9599                stage_idx,
9600                obj.file_name,
9601                mwnd_button_idx,
9602                obj.button.button_no,
9603                obj.button.group_no,
9604                obj.button.group_idx(),
9605                obj.button.action_no,
9606                obj.button.state,
9607                obj.button.enabled,
9608                obj.button.hit,
9609                obj.button.pushed,
9610                obj.button.sys_type,
9611                obj.button.sys_type_opt,
9612                obj.button.mode,
9613                syscom.mwnd_btn_touch_disable
9614            );
9615        }
9616        return TNM_BTN_STATE_DISABLE;
9617    }
9618    if obj.button.state == TNM_BTN_STATE_SELECT || obj.button.state == TNM_BTN_STATE_DISABLE {
9619        return obj.button.state;
9620    }
9621    if syscom.mwnd_btn_touch_disable {
9622        if sg_debug_enabled() && obj.button.enabled {
9623            eprintln!(
9624                "[SG_DEBUG][BUTTON_TRACE][VISUAL] real_state=normal reason=touch_disable stage={} file={:?} mwnd_button_idx={:?} button_no={} group_no={} action_no={}",
9625                stage_idx,
9626                obj.file_name,
9627                mwnd_button_idx,
9628                obj.button.button_no,
9629                obj.button.group_no,
9630                obj.button.action_no
9631            );
9632        }
9633        return TNM_BTN_STATE_NORMAL;
9634    }
9635    if let Some(gidx) = obj.button.group_idx() {
9636        if let Some(gl) = st
9637            .group_lists
9638            .get(&stage_idx)
9639            .and_then(|groups| groups.get(gidx))
9640        {
9641            if gl.decided_button_no == obj.button.button_no {
9642                return TNM_BTN_STATE_PUSH;
9643            }
9644            if gl.hit_button_no == obj.button.button_no {
9645                return TNM_BTN_STATE_HIT;
9646            }
9647            if gl.pushed_button_no == obj.button.button_no {
9648                return TNM_BTN_STATE_PUSH;
9649            }
9650        }
9651    } else if obj.button.pushed {
9652        return TNM_BTN_STATE_PUSH;
9653    } else if obj.button.hit {
9654        return TNM_BTN_STATE_HIT;
9655    }
9656    TNM_BTN_STATE_NORMAL
9657}
9658
9659fn collect_button_decided_action_by_runtime_slot_recursive(
9660    obj_idx: usize,
9661    obj: &globals::ObjectState,
9662    runtime_slot: usize,
9663    out: &mut Vec<globals::PendingButtonAction>,
9664) -> bool {
9665    if object_runtime_slot(obj_idx, obj) == runtime_slot {
9666        if obj.button.enabled && obj.button.action_no >= 0 {
9667            push_object_button_decided_action(obj, out);
9668        }
9669        return true;
9670    }
9671    for (child_idx, child) in obj.runtime.child_objects.iter().enumerate() {
9672        if collect_button_decided_action_by_runtime_slot_recursive(
9673            child_idx,
9674            child,
9675            runtime_slot,
9676            out,
9677        ) {
9678            return true;
9679        }
9680    }
9681    false
9682}
9683
9684fn button_event_enabled_by_runtime_slot_recursive(
9685    obj_idx: usize,
9686    obj: &globals::ObjectState,
9687    runtime_slot: usize,
9688    syscom: &globals::SyscomRuntimeState,
9689    read_skip_runtime_enabled: bool,
9690) -> Option<bool> {
9691    if object_runtime_slot(obj_idx, obj) == runtime_slot {
9692        return Some(button_event_enabled(
9693            syscom,
9694            obj,
9695            None,
9696            read_skip_runtime_enabled,
9697        ));
9698    }
9699    for (child_idx, child) in obj.runtime.child_objects.iter().enumerate() {
9700        if let Some(enabled) = button_event_enabled_by_runtime_slot_recursive(
9701            child_idx,
9702            child,
9703            runtime_slot,
9704            syscom,
9705            read_skip_runtime_enabled,
9706        ) {
9707            return Some(enabled);
9708        }
9709    }
9710    None
9711}
9712
9713fn button_event_enabled_in_list_by_runtime_slot(
9714    objs: &[globals::ObjectState],
9715    runtime_slot: usize,
9716    syscom: &globals::SyscomRuntimeState,
9717    read_skip_runtime_enabled: bool,
9718) -> bool {
9719    objs.iter()
9720        .enumerate()
9721        .find_map(|(obj_idx, obj)| {
9722            button_event_enabled_by_runtime_slot_recursive(
9723                obj_idx,
9724                obj,
9725                runtime_slot,
9726                syscom,
9727                read_skip_runtime_enabled,
9728            )
9729        })
9730        .unwrap_or(false)
9731}
9732
9733fn find_button_se_no_by_runtime_slot_recursive(
9734    obj_idx: usize,
9735    obj: &globals::ObjectState,
9736    runtime_slot: usize,
9737) -> Option<i64> {
9738    if object_runtime_slot(obj_idx, obj) == runtime_slot {
9739        return (obj.button.enabled && obj.button.action_no >= 0).then_some(obj.button.se_no);
9740    }
9741    for (child_idx, child) in obj.runtime.child_objects.iter().enumerate() {
9742        if let Some(se_no) =
9743            find_button_se_no_by_runtime_slot_recursive(child_idx, child, runtime_slot)
9744        {
9745            return Some(se_no);
9746        }
9747    }
9748    None
9749}
9750
9751fn find_button_se_no_in_list_by_runtime_slot(
9752    objs: &[globals::ObjectState],
9753    runtime_slot: usize,
9754) -> Option<i64> {
9755    for (obj_idx, obj) in objs.iter().enumerate() {
9756        if let Some(se_no) = find_button_se_no_by_runtime_slot_recursive(obj_idx, obj, runtime_slot)
9757        {
9758            return Some(se_no);
9759        }
9760    }
9761    None
9762}
9763
9764fn set_button_pushed_by_runtime_slot_recursive(
9765    obj_idx: usize,
9766    obj: &mut globals::ObjectState,
9767    runtime_slot: usize,
9768) -> bool {
9769    if object_runtime_slot(obj_idx, obj) == runtime_slot {
9770        if obj.button.enabled {
9771            obj.button.last_pushed = obj.button.pushed;
9772            obj.button.pushed = true;
9773        }
9774        return true;
9775    }
9776    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
9777        if set_button_pushed_by_runtime_slot_recursive(child_idx, child, runtime_slot) {
9778            return true;
9779        }
9780    }
9781    false
9782}
9783
9784fn pushed_group_button_recursive(
9785    obj_idx: usize,
9786    obj: &globals::ObjectState,
9787    group_idx: usize,
9788) -> Option<(i64, usize)> {
9789    if obj.button.enabled && obj.button.pushed && obj.button.group_idx() == Some(group_idx) {
9790        return Some((obj.button.button_no, object_runtime_slot(obj_idx, obj)));
9791    }
9792    obj.runtime
9793        .child_objects
9794        .iter()
9795        .enumerate()
9796        .find_map(|(idx, child)| pushed_group_button_recursive(idx, child, group_idx))
9797}
9798
9799fn object_button_push_keep_by_runtime_slot_recursive(
9800    obj_idx: usize,
9801    obj: &globals::ObjectState,
9802    runtime_slot: usize,
9803) -> bool {
9804    if object_runtime_slot(obj_idx, obj) == runtime_slot {
9805        return obj.button.enabled && obj.button.push_keep;
9806    }
9807    obj.runtime
9808        .child_objects
9809        .iter()
9810        .enumerate()
9811        .any(|(child_idx, child)| {
9812            object_button_push_keep_by_runtime_slot_recursive(child_idx, child, runtime_slot)
9813        })
9814}
9815
9816fn object_button_push_keep_in_list_by_runtime_slot(
9817    objs: &[globals::ObjectState],
9818    runtime_slot: usize,
9819) -> bool {
9820    objs.iter().enumerate().any(|(obj_idx, obj)| {
9821        object_button_push_keep_by_runtime_slot_recursive(obj_idx, obj, runtime_slot)
9822    })
9823}
9824
9825fn clear_button_hit_recursive(obj: &mut globals::ObjectState) {
9826    if obj.button.enabled {
9827        obj.button.last_hit = obj.button.hit;
9828        obj.button.hit = false;
9829    }
9830    for child in &mut obj.runtime.child_objects {
9831        clear_button_hit_recursive(child);
9832    }
9833}
9834
9835fn set_button_hit_by_runtime_slot_recursive(
9836    obj_idx: usize,
9837    obj: &mut globals::ObjectState,
9838    runtime_slot: usize,
9839) -> bool {
9840    if object_runtime_slot(obj_idx, obj) == runtime_slot {
9841        obj.button.hit = true;
9842        return true;
9843    }
9844    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
9845        if set_button_hit_by_runtime_slot_recursive(child_idx, child, runtime_slot) {
9846            return true;
9847        }
9848    }
9849    false
9850}
9851
9852fn set_button_pushed_recursive(obj: &mut globals::ObjectState, group_idx: usize, button_no: i64) {
9853    if obj.button.enabled
9854        && obj.button.group_idx() == Some(group_idx)
9855        && obj.button.button_no == button_no
9856    {
9857        obj.button.last_pushed = obj.button.pushed;
9858        obj.button.pushed = true;
9859    }
9860    for child in &mut obj.runtime.child_objects {
9861        set_button_pushed_recursive(child, group_idx, button_no);
9862    }
9863}
9864
9865fn mark_standalone_button_pushed_from_hit_recursive(
9866    _obj_idx: usize,
9867    obj: &mut globals::ObjectState,
9868    syscom: &globals::SyscomRuntimeState,
9869    read_skip_runtime_enabled: bool,
9870    mwnd_button_idx: Option<usize>,
9871) -> Option<i64> {
9872    if has_standalone_button_registration(obj) && obj.button.hit {
9873        let was_pushed = obj.button.pushed;
9874        obj.button.last_pushed = obj.button.pushed;
9875        obj.button.pushed = true;
9876        if !was_pushed
9877            && button_event_enabled(syscom, obj, mwnd_button_idx, read_skip_runtime_enabled)
9878        {
9879            return Some(obj.button.se_no);
9880        }
9881    }
9882    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
9883        if let Some(se_no) = mark_standalone_button_pushed_from_hit_recursive(
9884            child_idx,
9885            child,
9886            syscom,
9887            read_skip_runtime_enabled,
9888            mwnd_button_idx,
9889        ) {
9890            return Some(se_no);
9891        }
9892    }
9893    None
9894}
9895
9896fn registered_button_hit_recursive(obj: &globals::ObjectState) -> bool {
9897    if obj.button.enabled && obj.button.action_no >= 0 && obj.button.hit {
9898        return true;
9899    }
9900    obj.runtime
9901        .child_objects
9902        .iter()
9903        .any(registered_button_hit_recursive)
9904}
9905
9906fn standalone_button_hit_recursive(obj: &globals::ObjectState) -> bool {
9907    if has_standalone_button_registration(obj) && obj.button.hit {
9908        return true;
9909    }
9910    obj.runtime
9911        .child_objects
9912        .iter()
9913        .any(standalone_button_hit_recursive)
9914}
9915
9916fn standalone_button_pushed_recursive(obj: &globals::ObjectState) -> bool {
9917    if has_standalone_button_registration(obj) && obj.button.pushed {
9918        return true;
9919    }
9920    obj.runtime
9921        .child_objects
9922        .iter()
9923        .any(standalone_button_pushed_recursive)
9924}
9925
9926fn clear_button_pushed_recursive(obj: &mut globals::ObjectState) {
9927    if obj.button.enabled {
9928        obj.button.last_pushed = obj.button.pushed;
9929        obj.button.pushed = false;
9930    }
9931    for child in &mut obj.runtime.child_objects {
9932        clear_button_pushed_recursive(child);
9933    }
9934}
9935
9936fn object_button_push_keep_recursive(
9937    obj: &globals::ObjectState,
9938    group_idx: usize,
9939    button_no: i64,
9940) -> bool {
9941    if obj.button.enabled
9942        && obj.button.group_idx() == Some(group_idx)
9943        && obj.button.button_no == button_no
9944        && obj.button.push_keep
9945    {
9946        return true;
9947    }
9948    obj.runtime
9949        .child_objects
9950        .iter()
9951        .any(|child| object_button_push_keep_recursive(child, group_idx, button_no))
9952}
9953
9954fn hit_test_render_sprite(
9955    images: &mut ImageManager,
9956    sprite: &Sprite,
9957    mx: i32,
9958    my: i32,
9959    alpha_test: bool,
9960) -> bool {
9961    if !sprite.visible || sprite.tr == 0 {
9962        return false;
9963    }
9964    if let Some(clip) = sprite.dst_clip
9965        && (mx < clip.left || my < clip.top || mx >= clip.right || my >= clip.bottom)
9966    {
9967        return false;
9968    }
9969    let image = sprite
9970        .image_id
9971        .as_ref()
9972        .and_then(|img_id| images.get(img_id));
9973    let img = image.as_deref();
9974    let emote = sprite.emote_render.as_deref();
9975    if img.is_none() && emote.is_none() {
9976        return false;
9977    }
9978    let (intrinsic_w, intrinsic_h) = if let Some(img) = img {
9979        (img.width, img.height)
9980    } else if let Some(packet) = emote {
9981        (packet.width, packet.height)
9982    } else {
9983        return false;
9984    };
9985    let (w, h) = match sprite.size_mode {
9986        SpriteSizeMode::Intrinsic => (intrinsic_w as f32, intrinsic_h as f32),
9987        SpriteSizeMode::Explicit { width, height } => (width as f32, height as f32),
9988    };
9989    let (anchor_x, anchor_y) = match sprite.fit {
9990        SpriteFit::PixelRect => (sprite.x as f32, sprite.y as f32),
9991        SpriteFit::FullScreen => (0.0, 0.0),
9992    };
9993    if sprite.scale_x == 0.0 || sprite.scale_y == 0.0 {
9994        return false;
9995    }
9996    let (origin_x, origin_y) = if sprite.object_anchor {
9997        (anchor_x, anchor_y)
9998    } else {
9999        (anchor_x + sprite.pivot_x, anchor_y + sprite.pivot_y)
10000    };
10001    let mut px = mx as f32 - origin_x;
10002    let mut py = my as f32 - origin_y;
10003    if sprite.rotate != 0.0 {
10004        let (s, c) = (-sprite.rotate).sin_cos();
10005        let rx = px * c - py * s;
10006        let ry = px * s + py * c;
10007        px = rx;
10008        py = ry;
10009    }
10010    let (tex_center_x, tex_center_y) = if sprite.object_anchor {
10011        (sprite.texture_center_x, sprite.texture_center_y)
10012    } else {
10013        (0.0, 0.0)
10014    };
10015    let local_x = px / sprite.scale_x + sprite.pivot_x + tex_center_x;
10016    let local_y = py / sprite.scale_y + sprite.pivot_y + tex_center_y;
10017    if !(0.0 <= local_x && local_x < w && 0.0 <= local_y && local_y < h) {
10018        return false;
10019    }
10020    if alpha_test {
10021        let (sx, sy) = match sprite.src_clip {
10022            Some(src) => (
10023                src.left.saturating_add(local_x.floor() as i32),
10024                src.top.saturating_add(local_y.floor() as i32),
10025            ),
10026            None => (local_x.floor() as i32, local_y.floor() as i32),
10027        };
10028        let opaque = if let Some(img) = img {
10029            CommandContext::alpha_test_image(img, sx, sy)
10030        } else if let Some(packet) = emote {
10031            packet.alpha_hit_test(sx, sy)
10032        } else {
10033            false
10034        };
10035        if !opaque {
10036            return false;
10037        }
10038    }
10039    true
10040}
10041
10042fn hit_test_layer_sprite(
10043    images: &mut ImageManager,
10044    layers: &LayerManager,
10045    layer_id: LayerId,
10046    sprite_id: SpriteId,
10047    mx: i32,
10048    my: i32,
10049    alpha_test: bool,
10050) -> bool {
10051    let Some(spr) = layers.layer(layer_id).and_then(|l| l.sprite(sprite_id)) else {
10052        return false;
10053    };
10054    hit_test_render_sprite(images, spr, mx, my, alpha_test)
10055}
10056
10057fn object_button_sort_key(
10058    ids: &constants::RuntimeConstants,
10059    gfx: &graphics::GfxRuntime,
10060    stage_idx: i64,
10061    runtime_slot: usize,
10062    obj: &globals::ObjectState,
10063) -> ButtonSortKey {
10064    let embedded_tree_object = obj.nested_runtime_slot.is_some();
10065    let layer = obj
10066        .lookup_int_prop(ids, ids.obj_layer)
10067        .or_else(|| {
10068            if embedded_tree_object {
10069                None
10070            } else {
10071                gfx.object_peek_layer(stage_idx, runtime_slot as i64)
10072            }
10073        })
10074        .unwrap_or(obj.base.layer);
10075    let order = obj
10076        .lookup_int_prop(ids, ids.obj_order)
10077        .or_else(|| {
10078            if embedded_tree_object {
10079                None
10080            } else {
10081                gfx.object_peek_order(stage_idx, runtime_slot as i64)
10082            }
10083        })
10084        .unwrap_or(obj.base.order);
10085    ButtonSortKey { order, layer }
10086}
10087
10088fn button_sort_ge(lhs: ButtonSortKey, rhs: ButtonSortKey) -> bool {
10089    lhs.order > rhs.order || (lhs.order == rhs.order && lhs.layer >= rhs.layer)
10090}
10091
10092fn has_standalone_button_registration(obj: &globals::ObjectState) -> bool {
10093    // C_elm_object::regist_button() registers an action button independently
10094    // of get_button_real_state().  DISABLE is an event-layer state: the button
10095    // manager must still hit/capture it so DECIDE cannot leak to the script.
10096    obj.button.enabled && obj.button.group_idx().is_none() && obj.button.action_no >= 0
10097}
10098
10099fn has_standalone_button_action(obj: &globals::ObjectState) -> bool {
10100    has_standalone_button_registration(obj) && !obj.button.is_disabled()
10101}
10102
10103fn button_event_enabled(
10104    syscom: &globals::SyscomRuntimeState,
10105    obj: &globals::ObjectState,
10106    mwnd_button_idx: Option<usize>,
10107    read_skip_runtime_enabled: bool,
10108) -> bool {
10109    !syscom.mwnd_btn_touch_disable
10110        && !button_effective_disabled(syscom, obj, mwnd_button_idx, read_skip_runtime_enabled)
10111}
10112
10113fn merge_button_hit(
10114    best: &mut Option<ButtonHitCandidate>,
10115    tied: &mut bool,
10116    hit: ButtonHitCandidate,
10117) {
10118    match *best {
10119        None => {
10120            *best = Some(hit);
10121            *tied = false;
10122        }
10123        Some(prev) if button_sort_ge(hit.sort_key, prev.sort_key) => {
10124            // C_tnm_btn_mng::hit_test_proc uses >=, so equal order/layer means
10125            // the later registered button wins rather than producing a tie.
10126            *best = Some(hit);
10127            *tied = false;
10128        }
10129        _ => {}
10130    }
10131}
10132
10133fn object_event_value(
10134    ids: &constants::RuntimeConstants,
10135    obj: &globals::ObjectState,
10136    event_op: i32,
10137    current: i64,
10138) -> i64 {
10139    if event_op != 0 {
10140        obj.int_event_by_op(ids, event_op)
10141            .map(|ev| ev.get_total_value() as i64)
10142            .unwrap_or(current)
10143    } else {
10144        current
10145    }
10146}
10147
10148fn object_button_effective_gfx_hit(
10149    images: &mut ImageManager,
10150    layers: &LayerManager,
10151    gfx: &graphics::GfxRuntime,
10152    ids: &constants::RuntimeConstants,
10153    stage_idx: i64,
10154    runtime_slot: usize,
10155    obj: &globals::ObjectState,
10156    mx: i32,
10157    my: i32,
10158    parent_state: Option<ParentRenderState>,
10159) -> Option<ButtonSortKey> {
10160    let embedded_tree_object = obj.nested_runtime_slot.is_some();
10161    let disp = obj
10162        .lookup_int_prop(ids, ids.obj_disp)
10163        .or_else(|| {
10164            if embedded_tree_object {
10165                None
10166            } else {
10167                gfx.object_peek_disp(stage_idx, runtime_slot as i64)
10168            }
10169        })
10170        .unwrap_or(obj.base.disp);
10171    if disp == 0 {
10172        return None;
10173    }
10174
10175    let mut tr = obj.lookup_int_prop(ids, ids.obj_tr).unwrap_or(obj.base.tr);
10176    tr = object_event_value(ids, obj, ids.obj_tr_eve, tr);
10177    tr = obj
10178        .runtime
10179        .prop_event_lists
10180        .tr_rep
10181        .iter()
10182        .fold(tr, |acc, ev| {
10183            acc.saturating_mul(ev.get_total_value() as i64)
10184                .div_euclid(255)
10185        });
10186    if tr <= 0 {
10187        return None;
10188    }
10189
10190    if parent_state.is_none()
10191        && let Some((layer_id, sprite_id)) =
10192            gfx.object_sprite_binding(stage_idx, runtime_slot as i64)
10193    {
10194        if hit_test_layer_sprite(
10195            images,
10196            layers,
10197            layer_id,
10198            sprite_id,
10199            mx,
10200            my,
10201            obj.button.alpha_test,
10202        ) {
10203            return Some(object_button_sort_key(
10204                ids,
10205                gfx,
10206                stage_idx,
10207                runtime_slot,
10208                obj,
10209            ));
10210        }
10211        return None;
10212    }
10213
10214    let (base_x, base_y) = if embedded_tree_object {
10215        (obj.base.x, obj.base.y)
10216    } else {
10217        gfx.object_peek_pos(stage_idx, runtime_slot as i64)
10218            .unwrap_or((obj.base.x, obj.base.y))
10219    };
10220    let mut x = obj.lookup_int_prop(ids, ids.obj_x).unwrap_or(base_x);
10221    let mut y = obj.lookup_int_prop(ids, ids.obj_y).unwrap_or(base_y);
10222    x = object_event_value(ids, obj, ids.obj_x_eve, x);
10223    y = object_event_value(ids, obj, ids.obj_y_eve, y);
10224    x += obj
10225        .runtime
10226        .prop_event_lists
10227        .x_rep
10228        .iter()
10229        .map(|ev| ev.get_total_value() as i64)
10230        .sum::<i64>();
10231    y += obj
10232        .runtime
10233        .prop_event_lists
10234        .y_rep
10235        .iter()
10236        .map(|ev| ev.get_total_value() as i64)
10237        .sum::<i64>();
10238
10239    let mut scale_x = obj
10240        .lookup_int_prop(ids, ids.obj_scale_x)
10241        .unwrap_or(obj.base.scale_x);
10242    let mut scale_y = obj
10243        .lookup_int_prop(ids, ids.obj_scale_y)
10244        .unwrap_or(obj.base.scale_y);
10245    scale_x = object_event_value(ids, obj, ids.obj_scale_x_eve, scale_x);
10246    scale_y = object_event_value(ids, obj, ids.obj_scale_y_eve, scale_y);
10247    if scale_x == 0 || scale_y == 0 {
10248        return None;
10249    }
10250
10251    let mut patno = obj
10252        .lookup_int_prop(ids, ids.obj_patno)
10253        .or_else(|| gfx.object_peek_patno(stage_idx, runtime_slot as i64))
10254        .unwrap_or(obj.base.patno);
10255    patno = object_event_value(ids, obj, ids.obj_patno_eve, patno);
10256    patno = patno.saturating_add(obj.button.cut_no);
10257
10258    let file_name = obj.file_name.as_ref()?;
10259    let img_id = CommandContext::load_any_image_for_hit(images, file_name.as_str(), patno)?;
10260
10261    let mut sprite = Sprite {
10262        image_id: Some(img_id.clone()),
10263        ..Default::default()
10264    };
10265
10266    if let Some(img) = images.get(&img_id) {
10267        sprite.object_anchor = true;
10268        sprite.texture_center_x = img.center_x as f32;
10269        sprite.texture_center_y = img.center_y as f32;
10270    } else {
10271        sprite.object_anchor = false;
10272        sprite.texture_center_x = 0.0;
10273        sprite.texture_center_y = 0.0;
10274    }
10275    sprite.visible = true;
10276    let center_x = obj
10277        .lookup_int_prop(ids, ids.obj_center_x)
10278        .unwrap_or(obj.base.center_x);
10279    let center_y = obj
10280        .lookup_int_prop(ids, ids.obj_center_y)
10281        .unwrap_or(obj.base.center_y);
10282    let center_z = obj
10283        .lookup_int_prop(ids, ids.obj_center_z)
10284        .unwrap_or(obj.base.center_z);
10285    let center_rep_x = obj
10286        .lookup_int_prop(ids, ids.obj_center_rep_x)
10287        .unwrap_or(obj.base.center_rep_x);
10288    let center_rep_y = obj
10289        .lookup_int_prop(ids, ids.obj_center_rep_y)
10290        .unwrap_or(obj.base.center_rep_y);
10291    let center_rep_z = obj
10292        .lookup_int_prop(ids, ids.obj_center_rep_z)
10293        .unwrap_or(obj.base.center_rep_z);
10294    sprite.x = x as i32;
10295    sprite.y = y as i32;
10296    sprite.pivot_x = (center_x + center_rep_x) as f32;
10297    sprite.pivot_y = (center_y + center_rep_y) as f32;
10298    sprite.pivot_z = (center_z + center_rep_z) as f32;
10299    sprite.scale_x = scale_x as f32 / 1000.0;
10300    sprite.scale_y = scale_y as f32 / 1000.0;
10301    sprite.tr = tr.clamp(0, 255) as u8;
10302    if let Some(parent) = parent_state {
10303        let dummy = ObjectRenderInfo::default();
10304        apply_parent_render_state_to_sprite(&mut sprite, &dummy, &parent);
10305    }
10306    sprite.x = (sprite.x as i64 + center_rep_x).clamp(i32::MIN as i64, i32::MAX as i64) as i32;
10307    sprite.y = (sprite.y as i64 + center_rep_y).clamp(i32::MIN as i64, i32::MAX as i64) as i32;
10308    sprite.z += center_rep_z as f32;
10309
10310    if !hit_test_render_sprite(images, &sprite, mx, my, obj.button.alpha_test) {
10311        return None;
10312    }
10313
10314    Some(object_button_sort_key(
10315        ids,
10316        gfx,
10317        stage_idx,
10318        runtime_slot,
10319        obj,
10320    ))
10321}
10322
10323fn collect_standalone_button_decided_actions_recursive(
10324    obj: &globals::ObjectState,
10325    out: &mut Vec<globals::PendingButtonAction>,
10326    sounds: &mut Vec<i64>,
10327    syscom: &globals::SyscomRuntimeState,
10328    read_skip_runtime_enabled: bool,
10329    mwnd_button_idx: Option<usize>,
10330) {
10331    if has_standalone_button_registration(obj)
10332        && obj.button.pushed
10333        && (obj.button.hit || obj.button.push_keep)
10334        && button_event_enabled(syscom, obj, mwnd_button_idx, read_skip_runtime_enabled)
10335    {
10336        push_object_button_decided_action(obj, out);
10337        sounds.push(obj.button.se_no);
10338    }
10339    for child in &obj.runtime.child_objects {
10340        collect_standalone_button_decided_actions_recursive(
10341            child,
10342            out,
10343            sounds,
10344            syscom,
10345            read_skip_runtime_enabled,
10346            mwnd_button_idx,
10347        );
10348    }
10349}
10350
10351#[derive(Debug, Clone, Copy)]
10352struct ButtonObjectRenderInfo {
10353    disp: bool,
10354    x: i64,
10355    y: i64,
10356    z: i64,
10357    x_rep: i64,
10358    y_rep: i64,
10359    z_rep: i64,
10360    center_x: i64,
10361    center_y: i64,
10362    center_z: i64,
10363    center_rep_x: i64,
10364    center_rep_y: i64,
10365    center_rep_z: i64,
10366    scale_x: i64,
10367    scale_y: i64,
10368    scale_z: i64,
10369    rotate_x: i64,
10370    rotate_y: i64,
10371    rotate_z: i64,
10372    tr: i64,
10373    tr_rep: i64,
10374    world_no: i64,
10375    dst_clip: Option<ClipRect>,
10376}
10377
10378fn fetch_bound_render_sprites_for_hit(
10379    layers: &LayerManager,
10380    gfx: &graphics::GfxRuntime,
10381    stage_idx: i64,
10382    runtime_slot: usize,
10383    obj: &globals::ObjectState,
10384) -> Vec<RenderSprite> {
10385    fn push_one(layers: &LayerManager, lid: LayerId, sid: SpriteId, out: &mut Vec<RenderSprite>) {
10386        let Some(layer) = layers.layer(lid) else {
10387            return;
10388        };
10389        let Some(sprite) = layer.sprite(sid) else {
10390            return;
10391        };
10392        if sprite.image_id.is_none() && sprite.emote_render.is_none() {
10393            return;
10394        }
10395        out.push(RenderSprite::new(Some(lid), Some(sid), sprite.clone()));
10396    }
10397    let mut out = Vec::new();
10398    match &obj.backend {
10399        globals::ObjectBackend::Gfx => {
10400            if let Some((lid, sid)) = gfx.object_sprite_binding(stage_idx, runtime_slot as i64) {
10401                push_one(layers, lid, sid, &mut out);
10402            }
10403        }
10404        globals::ObjectBackend::None => {}
10405        backend => {
10406            for (layer_id, sprite_id) in layer_backed_object_sprite_bindings(backend) {
10407                push_one(layers, layer_id, sprite_id, &mut out);
10408            }
10409        }
10410    }
10411    out
10412}
10413
10414/// The first sprite `fetch_bound_render_sprites_for_hit` would return,
10415/// borrowed: parents only read a few of its fields, and cloning every bound
10416/// sprite of every parent on each hover update showed in profiles.
10417fn first_bound_render_sprite<'a>(
10418    layers: &'a LayerManager,
10419    gfx: &graphics::GfxRuntime,
10420    stage_idx: i64,
10421    runtime_slot: usize,
10422    obj: &globals::ObjectState,
10423) -> Option<&'a Sprite> {
10424    let pick = |lid: LayerId, sid: SpriteId| {
10425        layers
10426            .layer(lid)?
10427            .sprite(sid)
10428            .filter(|sprite| sprite.image_id.is_some() || sprite.emote_render.is_some())
10429    };
10430    match &obj.backend {
10431        globals::ObjectBackend::Gfx => gfx
10432            .object_sprite_binding(stage_idx, runtime_slot as i64)
10433            .and_then(|(lid, sid)| pick(lid, sid)),
10434        globals::ObjectBackend::None => None,
10435        backend => layer_backed_object_sprite_bindings(backend)
10436            .into_iter()
10437            .find_map(|(lid, sid)| pick(lid, sid)),
10438    }
10439}
10440
10441fn button_object_render_info(
10442    ids: &constants::RuntimeConstants,
10443    gfx: &graphics::GfxRuntime,
10444    stage_idx: i64,
10445    obj_idx: usize,
10446    obj: &globals::ObjectState,
10447) -> ButtonObjectRenderInfo {
10448    let runtime_slot = object_runtime_slot(obj_idx, obj);
10449    let embedded_tree_object = obj.nested_runtime_slot.is_some();
10450    let use_gfx_object_state =
10451        matches!(obj.backend, globals::ObjectBackend::Gfx) && !embedded_tree_object;
10452    let extra = |id: i32, default: i64| -> i64 {
10453        if id != 0 {
10454            obj.lookup_int_prop(ids, id).unwrap_or(default)
10455        } else {
10456            default
10457        }
10458    };
10459    let gfx_disp = || {
10460        if use_gfx_object_state {
10461            gfx.object_peek_disp(stage_idx, runtime_slot as i64)
10462        } else {
10463            None
10464        }
10465    };
10466    let gfx_pos = || {
10467        if use_gfx_object_state {
10468            gfx.object_peek_pos(stage_idx, runtime_slot as i64)
10469        } else {
10470            None
10471        }
10472    };
10473    let x_rep = obj
10474        .runtime
10475        .prop_event_lists
10476        .x_rep
10477        .iter()
10478        .map(|ev| ev.get_total_value() as i64)
10479        .sum::<i64>();
10480    let y_rep = obj
10481        .runtime
10482        .prop_event_lists
10483        .y_rep
10484        .iter()
10485        .map(|ev| ev.get_total_value() as i64)
10486        .sum::<i64>();
10487    let z_rep = obj
10488        .runtime
10489        .prop_event_lists
10490        .z_rep
10491        .iter()
10492        .map(|ev| ev.get_total_value() as i64)
10493        .sum::<i64>();
10494    let tr_rep = obj
10495        .runtime
10496        .prop_event_lists
10497        .tr_rep
10498        .iter()
10499        .fold(255i64, |acc, ev| {
10500            acc.saturating_mul(ev.get_total_value() as i64)
10501                .div_euclid(255)
10502        });
10503    let dst_clip = if extra(ids.obj_clip_use, obj.base.clip_use) != 0 {
10504        Some(ClipRect {
10505            left: extra(ids.obj_clip_left, obj.base.clip_left) as i32,
10506            top: extra(ids.obj_clip_top, obj.base.clip_top) as i32,
10507            right: extra(ids.obj_clip_right, obj.base.clip_right) as i32,
10508            bottom: extra(ids.obj_clip_bottom, obj.base.clip_bottom) as i32,
10509        })
10510    } else {
10511        None
10512    };
10513    ButtonObjectRenderInfo {
10514        disp: extra(ids.obj_disp, gfx_disp().unwrap_or(obj.base.disp)) != 0,
10515        x: object_event_value(
10516            ids,
10517            obj,
10518            ids.obj_x_eve,
10519            extra(ids.obj_x, gfx_pos().map(|v| v.0).unwrap_or(obj.base.x)),
10520        ),
10521        y: object_event_value(
10522            ids,
10523            obj,
10524            ids.obj_y_eve,
10525            extra(ids.obj_y, gfx_pos().map(|v| v.1).unwrap_or(obj.base.y)),
10526        ),
10527        z: object_event_value(ids, obj, ids.obj_z_eve, extra(ids.obj_z, obj.base.z)),
10528        x_rep,
10529        y_rep,
10530        z_rep,
10531        center_x: object_event_value(
10532            ids,
10533            obj,
10534            ids.obj_center_x_eve,
10535            extra(ids.obj_center_x, obj.base.center_x),
10536        ),
10537        center_y: object_event_value(
10538            ids,
10539            obj,
10540            ids.obj_center_y_eve,
10541            extra(ids.obj_center_y, obj.base.center_y),
10542        ),
10543        center_z: object_event_value(
10544            ids,
10545            obj,
10546            ids.obj_center_z_eve,
10547            extra(ids.obj_center_z, obj.base.center_z),
10548        ),
10549        center_rep_x: extra(ids.obj_center_rep_x, obj.base.center_rep_x),
10550        center_rep_y: extra(ids.obj_center_rep_y, obj.base.center_rep_y),
10551        center_rep_z: extra(ids.obj_center_rep_z, obj.base.center_rep_z),
10552        scale_x: object_event_value(
10553            ids,
10554            obj,
10555            ids.obj_scale_x_eve,
10556            extra(ids.obj_scale_x, obj.base.scale_x),
10557        ),
10558        scale_y: object_event_value(
10559            ids,
10560            obj,
10561            ids.obj_scale_y_eve,
10562            extra(ids.obj_scale_y, obj.base.scale_y),
10563        ),
10564        scale_z: object_event_value(
10565            ids,
10566            obj,
10567            ids.obj_scale_z_eve,
10568            extra(ids.obj_scale_z, obj.base.scale_z),
10569        ),
10570        rotate_x: object_event_value(
10571            ids,
10572            obj,
10573            ids.obj_rotate_x_eve,
10574            extra(ids.obj_rotate_x, obj.base.rotate_x),
10575        ),
10576        rotate_y: object_event_value(
10577            ids,
10578            obj,
10579            ids.obj_rotate_y_eve,
10580            extra(ids.obj_rotate_y, obj.base.rotate_y),
10581        ),
10582        rotate_z: object_event_value(
10583            ids,
10584            obj,
10585            ids.obj_rotate_z_eve,
10586            extra(ids.obj_rotate_z, obj.base.rotate_z),
10587        ),
10588        tr: object_event_value(ids, obj, ids.obj_tr_eve, extra(ids.obj_tr, obj.base.tr)),
10589        tr_rep,
10590        world_no: extra(ids.obj_world, obj.base.world),
10591        dst_clip,
10592    }
10593}
10594
10595fn apply_button_object_render_info_to_sprite(sprite: &mut Sprite, info: &ButtonObjectRenderInfo) {
10596    sprite.visible = info.disp;
10597    sprite.x = (info.x + info.x_rep).clamp(i32::MIN as i64, i32::MAX as i64) as i32;
10598    sprite.y = (info.y + info.y_rep).clamp(i32::MIN as i64, i32::MAX as i64) as i32;
10599    sprite.z = (info.z + info.z_rep) as f32;
10600    sprite.pivot_x = (info.center_x + info.center_rep_x) as f32;
10601    sprite.pivot_y = (info.center_y + info.center_rep_y) as f32;
10602    sprite.pivot_z = (info.center_z + info.center_rep_z) as f32;
10603    sprite.scale_x = info.scale_x as f32 / 1000.0;
10604    sprite.scale_y = info.scale_y as f32 / 1000.0;
10605    sprite.scale_z = info.scale_z as f32 / 1000.0;
10606    sprite.rotate = info.rotate_z as f32 * std::f32::consts::PI / 1800.0;
10607    sprite.rotate_x = info.rotate_x as f32 * std::f32::consts::PI / 1800.0;
10608    sprite.rotate_y = info.rotate_y as f32 * std::f32::consts::PI / 1800.0;
10609    sprite.tr = ((info.tr.clamp(0, 255) * info.tr_rep.clamp(0, 255)) / 255).clamp(0, 255) as u8;
10610    sprite.dst_clip = info.dst_clip;
10611}
10612
10613fn finalize_button_object_center_rep_to_sprite(sprite: &mut Sprite, info: &ButtonObjectRenderInfo) {
10614    let x = (sprite.x as i64 + info.center_rep_x).clamp(i32::MIN as i64, i32::MAX as i64);
10615    let y = (sprite.y as i64 + info.center_rep_y).clamp(i32::MIN as i64, i32::MAX as i64);
10616    sprite.x = x as i32;
10617    sprite.y = y as i32;
10618    sprite.z += info.center_rep_z as f32;
10619}
10620
10621fn object_button_hit_sort_key_from_render(
10622    images: &mut ImageManager,
10623    layers: &LayerManager,
10624    gfx: &graphics::GfxRuntime,
10625    ids: &constants::RuntimeConstants,
10626    syscom: &globals::SyscomRuntimeState,
10627    read_skip_runtime_enabled: bool,
10628    stage_idx: i64,
10629    obj_idx: usize,
10630    obj: &globals::ObjectState,
10631    mx: i32,
10632    my: i32,
10633    parent_state: Option<ParentRenderState>,
10634) -> Option<ButtonSortKey> {
10635    // C_tnm_btn_mng hit-tests every registered button first.  Dynamic/static
10636    // DISABLE and mwnd_btn_touch_disable are checked later by button_event();
10637    // they suppress the event, not physical input capture.
10638    if !object_button_renderable_by_syscom(syscom, obj) {
10639        if sg_debug_enabled() && obj.button.enabled {
10640            eprintln!(
10641                "[SG_DEBUG][BUTTON_TRACE][HIT] reject stage={} obj_idx={} runtime_slot={} file={:?} mx={} my={} visible={} disabled_reason={:?} touch_disable={} button_no={} group_no={} group_idx={:?} action_no={} state={} hit={} pushed={} alpha_test={} sys_type={} sys_opt={} mode={}",
10642                stage_idx,
10643                obj_idx,
10644                object_runtime_slot(obj_idx, obj),
10645                obj.file_name,
10646                mx,
10647                my,
10648                object_button_renderable_by_syscom(syscom, obj),
10649                button_disabled_reason(syscom, obj, None, read_skip_runtime_enabled),
10650                syscom.mwnd_btn_touch_disable,
10651                obj.button.button_no,
10652                obj.button.group_no,
10653                obj.button.group_idx(),
10654                obj.button.action_no,
10655                obj.button.state,
10656                obj.button.hit,
10657                obj.button.pushed,
10658                obj.button.alpha_test,
10659                obj.button.sys_type,
10660                obj.button.sys_type_opt,
10661                obj.button.mode
10662            );
10663        }
10664        return None;
10665    }
10666    let runtime_slot = object_runtime_slot(obj_idx, obj);
10667    let info = button_object_render_info(ids, gfx, stage_idx, obj_idx, obj);
10668    let mut bound = fetch_bound_render_sprites_for_hit(layers, gfx, stage_idx, runtime_slot, obj);
10669    for rs in &mut bound {
10670        apply_button_object_render_info_to_sprite(&mut rs.sprite, &info);
10671        if let Some(parent) = parent_state.clone() {
10672            let dummy = ObjectRenderInfo::default();
10673            apply_parent_render_state_to_sprite(&mut rs.sprite, &dummy, &parent);
10674        }
10675        finalize_button_object_center_rep_to_sprite(&mut rs.sprite, &info);
10676        if hit_test_render_sprite(images, &rs.sprite, mx, my, obj.button.alpha_test) {
10677            let sort_key = object_button_sort_key(ids, gfx, stage_idx, runtime_slot, obj);
10678            if sg_debug_enabled() {
10679                eprintln!(
10680                    "[SG_DEBUG][BUTTON_TRACE][HIT] success stage={} obj_idx={} runtime_slot={} file={:?} mx={} my={} button_no={} group_no={} group_idx={:?} action_no={} state={} hit={} pushed={} alpha_test={} sprite=({:?},{:?}) pos=({}, {}) size_mode={:?} sort={}",
10681                    stage_idx,
10682                    obj_idx,
10683                    runtime_slot,
10684                    obj.file_name,
10685                    mx,
10686                    my,
10687                    obj.button.button_no,
10688                    obj.button.group_no,
10689                    obj.button.group_idx(),
10690                    obj.button.action_no,
10691                    obj.button.state,
10692                    obj.button.hit,
10693                    obj.button.pushed,
10694                    obj.button.alpha_test,
10695                    rs.layer_id,
10696                    rs.sprite_id,
10697                    rs.sprite.x,
10698                    rs.sprite.y,
10699                    rs.sprite.size_mode,
10700                    sort_key.display_tuple()
10701                );
10702            }
10703            return Some(sort_key);
10704        }
10705    }
10706
10707    if bound.is_empty() {
10708        return object_button_effective_gfx_hit(
10709            images,
10710            layers,
10711            gfx,
10712            ids,
10713            stage_idx,
10714            runtime_slot,
10715            obj,
10716            mx,
10717            my,
10718            parent_state,
10719        );
10720    }
10721    None
10722}
10723
10724fn button_parent_render_state(
10725    layers: &LayerManager,
10726    gfx: &graphics::GfxRuntime,
10727    ids: &constants::RuntimeConstants,
10728    stage_idx: i64,
10729    obj_idx: usize,
10730    obj: &globals::ObjectState,
10731    parent_state: Option<ParentRenderState>,
10732) -> ParentRenderState {
10733    let runtime_slot = object_runtime_slot(obj_idx, obj);
10734    let info = button_object_render_info(ids, gfx, stage_idx, obj_idx, obj);
10735    let first = first_bound_render_sprite(layers, gfx, stage_idx, runtime_slot, obj);
10736    let mut cur = ParentRenderState {
10737        world_no: info.world_no,
10738        pos_x: (info.x + info.x_rep) as f32,
10739        pos_y: (info.y + info.y_rep) as f32,
10740        pos_z: (info.z + info.z_rep) as f32,
10741        center_rep_x: info.center_rep_x as f32,
10742        center_rep_y: info.center_rep_y as f32,
10743        center_rep_z: info.center_rep_z as f32,
10744        scale_x: info.scale_x as f32 / 1000.0,
10745        scale_y: info.scale_y as f32 / 1000.0,
10746        scale_z: info.scale_z as f32 / 1000.0,
10747        rotate_x: info.rotate_x as f32 * std::f32::consts::PI / 1800.0,
10748        rotate_y: info.rotate_y as f32 * std::f32::consts::PI / 1800.0,
10749        rotate_z: info.rotate_z as f32 * std::f32::consts::PI / 1800.0,
10750        tr: ((info.tr.clamp(0, 255) * info.tr_rep.clamp(0, 255)) / 255) as i32,
10751        mono: 0,
10752        reverse: 0,
10753        bright: 0,
10754        dark: 0,
10755        color_rate: 0,
10756        color_r: 255,
10757        color_g: 255,
10758        color_b: 255,
10759        color_add_r: 0,
10760        color_add_g: 0,
10761        color_add_b: 0,
10762        blend: crate::layer::SpriteBlend::Normal,
10763        dst_clip: info.dst_clip,
10764        mask_image_id: first.and_then(|s| s.mask_image_id.clone()),
10765        mask_offset_x: first.map(|s| s.mask_offset_x).unwrap_or(0),
10766        mask_offset_y: first.map(|s| s.mask_offset_y).unwrap_or(0),
10767        tonecurve_image_id: first.and_then(|s| s.tonecurve_image_id.clone()),
10768        tonecurve_row: first.map(|s| s.tonecurve_row).unwrap_or(0.0),
10769        tonecurve_sat: first.map(|s| s.tonecurve_sat).unwrap_or(0.0),
10770    };
10771    if let Some(parent) = parent_state {
10772        cur = compose_parent_render_state(parent, cur);
10773    }
10774    cur
10775}
10776
10777/// Whether an object or a descendant owns a standalone button (as `recurse`
10778/// in `hit_test_standalone_action_button_recursive` decides it).
10779fn subtree_has_standalone_button_owner(obj: &globals::ObjectState) -> bool {
10780    (has_standalone_button_registration(obj) && !obj.base.no_event_hint)
10781        || obj
10782            .runtime
10783            .child_objects
10784            .iter()
10785            .any(subtree_has_standalone_button_owner)
10786}
10787
10788fn hit_test_standalone_action_button_recursive(
10789    images: &mut ImageManager,
10790    layers: &LayerManager,
10791    gfx: &graphics::GfxRuntime,
10792    ids: &constants::RuntimeConstants,
10793    syscom: &globals::SyscomRuntimeState,
10794    read_skip_runtime_enabled: bool,
10795    mwnd_button_idx: Option<usize>,
10796    stage_idx: i64,
10797    mx: i32,
10798    my: i32,
10799    obj_idx: usize,
10800    obj: &mut globals::ObjectState,
10801    parent_state: Option<ParentRenderState>,
10802) -> Option<ButtonHitCandidate> {
10803    fn recurse(
10804        images: &mut ImageManager,
10805        layers: &LayerManager,
10806        gfx: &graphics::GfxRuntime,
10807        ids: &constants::RuntimeConstants,
10808        syscom: &globals::SyscomRuntimeState,
10809        read_skip_runtime_enabled: bool,
10810        mwnd_button_idx: Option<usize>,
10811        stage_idx: i64,
10812        mx: i32,
10813        my: i32,
10814        obj_idx: usize,
10815        obj: &mut globals::ObjectState,
10816        parent_state: Option<ParentRenderState>,
10817        inherited_owner: Option<ButtonOwnerInfo>,
10818    ) -> Option<ButtonHitCandidate> {
10819        let runtime_slot = object_runtime_slot(obj_idx, obj);
10820        let current_owner = if has_standalone_button_registration(obj) && !obj.base.no_event_hint {
10821            Some(ButtonOwnerInfo {
10822                button_no: obj.button.button_no,
10823                runtime_slot,
10824                se_no: obj.button.se_no,
10825                was_hit: obj.button.last_hit,
10826                event_enabled: button_event_enabled(
10827                    syscom,
10828                    obj,
10829                    mwnd_button_idx,
10830                    read_skip_runtime_enabled,
10831                ),
10832            })
10833        } else {
10834            None
10835        };
10836        let effective_owner = current_owner.or(inherited_owner);
10837
10838        let mut best = None;
10839        let mut tied = false;
10840        if let Some(owner) = effective_owner
10841            && !obj.base.no_event_hint
10842            && let Some(sort_key) = object_button_hit_sort_key_from_render(
10843                images,
10844                layers,
10845                gfx,
10846                ids,
10847                syscom,
10848                read_skip_runtime_enabled,
10849                stage_idx,
10850                obj_idx,
10851                obj,
10852                mx,
10853                my,
10854                parent_state.clone(),
10855            )
10856        {
10857            best = Some(ButtonHitCandidate {
10858                button_no: owner.button_no,
10859                sort_key,
10860                runtime_slot: owner.runtime_slot,
10861                se_no: owner.se_no,
10862                was_hit: owner.was_hit,
10863                event_enabled: owner.event_enabled,
10864            });
10865        }
10866        // Children can only hit through a button owner: an inherited one or
10867        // one in their own subtree. Without either, the render state they
10868        // would get (about forty property reads per parent, every frame) is
10869        // never used.
10870        if obj.runtime.child_objects.is_empty()
10871            || (effective_owner.is_none()
10872                && !obj
10873                    .runtime
10874                    .child_objects
10875                    .iter()
10876                    .any(subtree_has_standalone_button_owner))
10877        {
10878            return if tied { None } else { best };
10879        }
10880        let cur_parent_state =
10881            button_parent_render_state(layers, gfx, ids, stage_idx, obj_idx, obj, parent_state);
10882        let parent_sort = object_button_sort_key(ids, gfx, stage_idx, runtime_slot, obj);
10883        for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
10884            if effective_owner.is_none() && !subtree_has_standalone_button_owner(child) {
10885                continue;
10886            }
10887            if let Some(mut hit) = recurse(
10888                images,
10889                layers,
10890                gfx,
10891                ids,
10892                syscom,
10893                read_skip_runtime_enabled,
10894                mwnd_button_idx,
10895                stage_idx,
10896                mx,
10897                my,
10898                child_idx,
10899                child,
10900                Some(cur_parent_state.clone()),
10901                effective_owner,
10902            ) {
10903                // Native m_trp.sorter adds each ancestor's order and layer.
10904                hit.sort_key.order = hit.sort_key.order.saturating_add(parent_sort.order);
10905                hit.sort_key.layer = hit.sort_key.layer.saturating_add(parent_sort.layer);
10906                merge_button_hit(&mut best, &mut tied, hit);
10907            }
10908        }
10909        if tied { None } else { best }
10910    }
10911
10912    recurse(
10913        images,
10914        layers,
10915        gfx,
10916        ids,
10917        syscom,
10918        read_skip_runtime_enabled,
10919        mwnd_button_idx,
10920        stage_idx,
10921        mx,
10922        my,
10923        obj_idx,
10924        obj,
10925        parent_state,
10926        None,
10927    )
10928}
10929
10930fn hit_test_object_button_recursive(
10931    images: &mut ImageManager,
10932    layers: &LayerManager,
10933    gfx: &graphics::GfxRuntime,
10934    ids: &constants::RuntimeConstants,
10935    syscom: &globals::SyscomRuntimeState,
10936    read_skip_runtime_enabled: bool,
10937    stage_idx: i64,
10938    group_idx: usize,
10939    mx: i32,
10940    my: i32,
10941    obj_idx: usize,
10942    obj: &mut globals::ObjectState,
10943    parent_state: Option<ParentRenderState>,
10944) -> Option<ButtonHitCandidate> {
10945    fn recurse(
10946        images: &mut ImageManager,
10947        layers: &LayerManager,
10948        gfx: &graphics::GfxRuntime,
10949        ids: &constants::RuntimeConstants,
10950        syscom: &globals::SyscomRuntimeState,
10951        read_skip_runtime_enabled: bool,
10952        stage_idx: i64,
10953        group_idx: usize,
10954        mx: i32,
10955        my: i32,
10956        obj_idx: usize,
10957        obj: &mut globals::ObjectState,
10958        parent_state: Option<ParentRenderState>,
10959        inherited_owner: Option<ButtonOwnerInfo>,
10960    ) -> Option<ButtonHitCandidate> {
10961        let runtime_slot = object_runtime_slot(obj_idx, obj);
10962        let current_owner = if obj.button.enabled
10963            && !obj.base.no_event_hint
10964            && obj.button.action_no >= 0
10965            && obj.button.group_idx() == Some(group_idx)
10966        {
10967            Some(ButtonOwnerInfo {
10968                button_no: obj.button.button_no,
10969                runtime_slot,
10970                se_no: obj.button.se_no,
10971                was_hit: obj.button.last_hit,
10972                event_enabled: button_event_enabled(syscom, obj, None, read_skip_runtime_enabled),
10973            })
10974        } else {
10975            None
10976        };
10977        let effective_owner = current_owner.or(inherited_owner);
10978
10979        let mut best = None;
10980        let mut tied = false;
10981        if let Some(owner) = effective_owner
10982            && !obj.base.no_event_hint
10983            && let Some(sort_key) = object_button_hit_sort_key_from_render(
10984                images,
10985                layers,
10986                gfx,
10987                ids,
10988                syscom,
10989                read_skip_runtime_enabled,
10990                stage_idx,
10991                obj_idx,
10992                obj,
10993                mx,
10994                my,
10995                parent_state.clone(),
10996            )
10997        {
10998            best = Some(ButtonHitCandidate {
10999                button_no: owner.button_no,
11000                sort_key,
11001                runtime_slot: owner.runtime_slot,
11002                se_no: owner.se_no,
11003                was_hit: owner.was_hit,
11004                event_enabled: owner.event_enabled,
11005            });
11006        }
11007        let cur_parent_state =
11008            button_parent_render_state(layers, gfx, ids, stage_idx, obj_idx, obj, parent_state);
11009        let parent_sort = object_button_sort_key(ids, gfx, stage_idx, runtime_slot, obj);
11010        for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
11011            if let Some(mut hit) = recurse(
11012                images,
11013                layers,
11014                gfx,
11015                ids,
11016                syscom,
11017                read_skip_runtime_enabled,
11018                stage_idx,
11019                group_idx,
11020                mx,
11021                my,
11022                child_idx,
11023                child,
11024                Some(cur_parent_state.clone()),
11025                effective_owner,
11026            ) {
11027                // Include the dialog's layer before comparing its background
11028                // button with nested YES/NO buttons, just as rendering does.
11029                hit.sort_key.order = hit.sort_key.order.saturating_add(parent_sort.order);
11030                hit.sort_key.layer = hit.sort_key.layer.saturating_add(parent_sort.layer);
11031                merge_button_hit(&mut best, &mut tied, hit);
11032            }
11033        }
11034        if tied { None } else { best }
11035    }
11036
11037    recurse(
11038        images,
11039        layers,
11040        gfx,
11041        ids,
11042        syscom,
11043        read_skip_runtime_enabled,
11044        stage_idx,
11045        group_idx,
11046        mx,
11047        my,
11048        obj_idx,
11049        obj,
11050        parent_state,
11051        None,
11052    )
11053}
11054
11055fn find_object_by_runtime_slot_mut(
11056    mut objects: &mut [globals::ObjectState],
11057    runtime_slot: usize,
11058) -> Option<&mut globals::ObjectState> {
11059    let mut idx = 0usize;
11060    while let Some((obj, tail)) = objects.split_first_mut() {
11061        if obj.runtime_slot_or(idx) == runtime_slot {
11062            return Some(obj);
11063        }
11064        if let Some(found) =
11065            find_object_by_runtime_slot_mut(&mut obj.runtime.child_objects, runtime_slot)
11066        {
11067            return Some(found);
11068        }
11069        objects = tail;
11070        idx += 1;
11071    }
11072    None
11073}
11074
11075fn compose_clip_rect(
11076    parent_clip: Option<ClipRect>,
11077    child_clip: Option<ClipRect>,
11078) -> Option<ClipRect> {
11079    match (parent_clip, child_clip) {
11080        (Some(parent), Some(child)) => Some(ClipRect {
11081            // This intentionally mirrors tnm_add_parent_trp(). The original
11082            // combines TRP clip bounds with min(left/top) and max(right/bottom)
11083            // before converting to the final render parameter. It does not
11084            // transform the child rectangle by the parent matrix here.
11085            left: child.left.min(parent.left),
11086            top: child.top.min(parent.top),
11087            right: child.right.max(parent.right),
11088            bottom: child.bottom.max(parent.bottom),
11089        }),
11090        (Some(parent), None) => Some(parent),
11091        (None, Some(child)) => Some(child),
11092        (None, None) => None,
11093    }
11094}
11095
11096fn compose_parent_render_state(
11097    parent: ParentRenderState,
11098    mut cur: ParentRenderState,
11099) -> ParentRenderState {
11100    if cur.world_no < 0 {
11101        cur.world_no = parent.world_no;
11102    }
11103
11104    let child_clip = cur.dst_clip;
11105
11106    cur.pos_x = (cur.pos_x - parent.center_rep_x) * parent.scale_x + parent.center_rep_x;
11107    cur.pos_y = (cur.pos_y - parent.center_rep_y) * parent.scale_y + parent.center_rep_y;
11108    cur.pos_z = (cur.pos_z - parent.center_rep_z) * parent.scale_z + parent.center_rep_z;
11109    {
11110        let tmp_x = cur.pos_x;
11111        let tmp_y = cur.pos_y;
11112        let (s, c) = parent.rotate_z.sin_cos();
11113        cur.pos_x = (tmp_x - parent.center_rep_x) * c - (tmp_y - parent.center_rep_y) * s
11114            + parent.center_rep_x;
11115        cur.pos_y = (tmp_x - parent.center_rep_x) * s
11116            + (tmp_y - parent.center_rep_y) * c
11117            + parent.center_rep_y;
11118    }
11119    cur.pos_x += parent.pos_x;
11120    cur.pos_y += parent.pos_y;
11121    cur.pos_z += parent.pos_z;
11122    cur.scale_x *= parent.scale_x;
11123    cur.scale_y *= parent.scale_y;
11124    cur.scale_z *= parent.scale_z;
11125    cur.rotate_x += parent.rotate_x;
11126    cur.rotate_y += parent.rotate_y;
11127    cur.rotate_z += parent.rotate_z;
11128
11129    cur.dst_clip = compose_clip_rect(parent.dst_clip, child_clip);
11130
11131    cur.tr = (cur.tr * parent.tr / 255).clamp(0, 255);
11132    cur.mono = combine_lerp(cur.mono as u8, parent.mono) as i32;
11133    cur.reverse = combine_lerp(cur.reverse as u8, parent.reverse) as i32;
11134    cur.bright = combine_lerp(cur.bright as u8, parent.bright) as i32;
11135    cur.dark = combine_lerp(cur.dark as u8, parent.dark) as i32;
11136    if cur.color_rate + parent.color_rate > 0 {
11137        let parent_rate = (parent.color_rate * 255 * 255)
11138            / (255 * 255 - (255 - cur.color_rate) * (255 - parent.color_rate)).max(1);
11139        cur.color_r = blend_color(cur.color_r as u8, parent.color_r, parent_rate) as i32;
11140        cur.color_g = blend_color(cur.color_g as u8, parent.color_g, parent_rate) as i32;
11141        cur.color_b = blend_color(cur.color_b as u8, parent.color_b, parent_rate) as i32;
11142    }
11143    cur.color_rate = combine_lerp(cur.color_rate as u8, parent.color_rate) as i32;
11144    cur.color_add_r = clamp_add(cur.color_add_r as u8, parent.color_add_r) as i32;
11145    cur.color_add_g = clamp_add(cur.color_add_g as u8, parent.color_add_g) as i32;
11146    cur.color_add_b = clamp_add(cur.color_add_b as u8, parent.color_add_b) as i32;
11147    if matches!(cur.blend, crate::layer::SpriteBlend::Normal) {
11148        cur.blend = parent.blend;
11149    }
11150    if cur.mask_image_id.is_none() {
11151        cur.mask_image_id = parent.mask_image_id;
11152        cur.mask_offset_x = parent.mask_offset_x;
11153        cur.mask_offset_y = parent.mask_offset_y;
11154    }
11155    if cur.tonecurve_image_id.is_none() {
11156        cur.tonecurve_image_id = parent.tonecurve_image_id;
11157        cur.tonecurve_row = parent.tonecurve_row;
11158        cur.tonecurve_sat = parent.tonecurve_sat;
11159    }
11160    cur
11161}
11162
11163fn apply_object_event_animations_recursive(
11164    ids: &constants::RuntimeConstants,
11165    gfx: &mut graphics::GfxRuntime,
11166    images: &mut ImageManager,
11167    layers: &mut LayerManager,
11168    stage_i64: i64,
11169    obj_i64: i64,
11170    obj: &mut globals::ObjectState,
11171) {
11172    if obj.any_event_active() {
11173        let read_ev = |op_id: i32, obj: &globals::ObjectState| -> Option<i64> {
11174            if op_id == 0 {
11175                None
11176            } else {
11177                obj.int_event_by_op(ids, op_id)
11178                    .filter(|ev| ev.check_event())
11179                    .map(|ev| ev.get_total_value() as i64)
11180            }
11181        };
11182        let read_list0 = |op_id: i32, obj: &globals::ObjectState| -> Option<i64> {
11183            if op_id == 0 {
11184                None
11185            } else {
11186                obj.int_event_list_by_op(ids, op_id)
11187                    .and_then(|list| list.first())
11188                    .filter(|ev| ev.check_event())
11189                    .map(|ev| ev.get_total_value() as i64)
11190            }
11191        };
11192
11193        let x: Option<i64> = read_ev(ids.obj_x_eve, obj);
11194        let y: Option<i64> = read_ev(ids.obj_y_eve, obj);
11195        let x_rep: Option<i64> = read_list0(ids.obj_x_rep_eve, obj);
11196        let y_rep: Option<i64> = read_list0(ids.obj_y_rep_eve, obj);
11197        let z_rep: Option<i64> = read_list0(ids.obj_z_rep_eve, obj);
11198        let alpha: Option<i64> = None;
11199        let patno: Option<i64> = read_ev(ids.obj_patno_eve, obj);
11200        let order: Option<i64> = None;
11201        let layer_no: Option<i64> = None;
11202        let z: Option<i64> = read_ev(ids.obj_z_eve, obj);
11203        let center_x: Option<i64> = read_ev(ids.obj_center_x_eve, obj);
11204        let center_y: Option<i64> = read_ev(ids.obj_center_y_eve, obj);
11205        let center_z: Option<i64> = read_ev(ids.obj_center_z_eve, obj);
11206        let center_rep_x: Option<i64> = read_ev(ids.obj_center_rep_x_eve, obj);
11207        let center_rep_y: Option<i64> = read_ev(ids.obj_center_rep_y_eve, obj);
11208        let center_rep_z: Option<i64> = read_ev(ids.obj_center_rep_z_eve, obj);
11209        let scale_x: Option<i64> = read_ev(ids.obj_scale_x_eve, obj);
11210        let scale_y: Option<i64> = read_ev(ids.obj_scale_y_eve, obj);
11211        let scale_z: Option<i64> = read_ev(ids.obj_scale_z_eve, obj);
11212        let rotate_x: Option<i64> = read_ev(ids.obj_rotate_x_eve, obj);
11213        let rotate_y: Option<i64> = read_ev(ids.obj_rotate_y_eve, obj);
11214        let rotate_z: Option<i64> = read_ev(ids.obj_rotate_z_eve, obj);
11215        let clip_left: Option<i64> = read_ev(ids.obj_clip_left_eve, obj);
11216        let clip_top: Option<i64> = read_ev(ids.obj_clip_top_eve, obj);
11217        let clip_right: Option<i64> = read_ev(ids.obj_clip_right_eve, obj);
11218        let clip_bottom: Option<i64> = read_ev(ids.obj_clip_bottom_eve, obj);
11219        let src_clip_left: Option<i64> = read_ev(ids.obj_src_clip_left_eve, obj);
11220        let src_clip_top: Option<i64> = read_ev(ids.obj_src_clip_top_eve, obj);
11221        let src_clip_right: Option<i64> = read_ev(ids.obj_src_clip_right_eve, obj);
11222        let src_clip_bottom: Option<i64> = read_ev(ids.obj_src_clip_bottom_eve, obj);
11223        let tr: Option<i64> = read_ev(ids.obj_tr_eve, obj);
11224        let tr_rep: Option<i64> = read_list0(ids.obj_tr_rep_eve, obj);
11225        let mono: Option<i64> = read_ev(ids.obj_mono_eve, obj);
11226        let reverse: Option<i64> = read_ev(ids.obj_reverse_eve, obj);
11227        let bright: Option<i64> = read_ev(ids.obj_bright_eve, obj);
11228        let dark: Option<i64> = read_ev(ids.obj_dark_eve, obj);
11229        let color_rate: Option<i64> = read_ev(ids.obj_color_rate_eve, obj);
11230        let color_add_r: Option<i64> = read_ev(ids.obj_color_add_r_eve, obj);
11231        let color_add_g: Option<i64> = read_ev(ids.obj_color_add_g_eve, obj);
11232        let color_add_b: Option<i64> = read_ev(ids.obj_color_add_b_eve, obj);
11233        let color_r: Option<i64> = read_ev(ids.obj_color_r_eve, obj);
11234        let color_g: Option<i64> = read_ev(ids.obj_color_g_eve, obj);
11235        let color_b: Option<i64> = read_ev(ids.obj_color_b_eve, obj);
11236
11237        let mut set_extra_prop = |prop_id: i32, val: Option<i64>| {
11238            if prop_id != 0
11239                && let Some(v) = val
11240            {
11241                let old_value = obj.get_int_prop(ids, prop_id);
11242                trace_config_event_frame_prop_write(
11243                    ids, stage_i64, obj_i64, obj, prop_id, old_value, v,
11244                );
11245                obj.set_int_prop_from_event_frame(ids, prop_id, v);
11246            }
11247        };
11248        set_extra_prop(ids.obj_x, x);
11249        set_extra_prop(ids.obj_y, y);
11250        // REP event lists are consumed directly by ObjectRenderInfo and hit testing.
11251        // Do not write animated totals back through obj_x_rep/obj_y_rep/obj_z_rep,
11252        // because those properties alias the same event-list storage.
11253        set_extra_prop(ids.obj_alpha, alpha);
11254        set_extra_prop(ids.obj_patno, patno);
11255        set_extra_prop(ids.obj_order, order);
11256        set_extra_prop(ids.obj_layer, layer_no);
11257        set_extra_prop(ids.obj_z, z);
11258        set_extra_prop(ids.obj_center_x, center_x);
11259        set_extra_prop(ids.obj_center_y, center_y);
11260        set_extra_prop(ids.obj_center_z, center_z);
11261        set_extra_prop(ids.obj_center_rep_x, center_rep_x);
11262        set_extra_prop(ids.obj_center_rep_y, center_rep_y);
11263        set_extra_prop(ids.obj_center_rep_z, center_rep_z);
11264        set_extra_prop(ids.obj_scale_x, scale_x);
11265        set_extra_prop(ids.obj_scale_y, scale_y);
11266        set_extra_prop(ids.obj_scale_z, scale_z);
11267        set_extra_prop(ids.obj_rotate_x, rotate_x);
11268        set_extra_prop(ids.obj_rotate_y, rotate_y);
11269        set_extra_prop(ids.obj_rotate_z, rotate_z);
11270        set_extra_prop(ids.obj_clip_left, clip_left);
11271        set_extra_prop(ids.obj_clip_top, clip_top);
11272        set_extra_prop(ids.obj_clip_right, clip_right);
11273        set_extra_prop(ids.obj_clip_bottom, clip_bottom);
11274        set_extra_prop(ids.obj_src_clip_left, src_clip_left);
11275        set_extra_prop(ids.obj_src_clip_top, src_clip_top);
11276        set_extra_prop(ids.obj_src_clip_right, src_clip_right);
11277        set_extra_prop(ids.obj_src_clip_bottom, src_clip_bottom);
11278        set_extra_prop(ids.obj_tr, tr);
11279        // obj_tr_rep also aliases prop_event_lists.tr_rep and must not be overwritten here.
11280        set_extra_prop(ids.obj_mono, mono);
11281        set_extra_prop(ids.obj_reverse, reverse);
11282        set_extra_prop(ids.obj_bright, bright);
11283        set_extra_prop(ids.obj_dark, dark);
11284        set_extra_prop(ids.obj_color_rate, color_rate);
11285        set_extra_prop(ids.obj_color_add_r, color_add_r);
11286        set_extra_prop(ids.obj_color_add_g, color_add_g);
11287        set_extra_prop(ids.obj_color_add_b, color_add_b);
11288        set_extra_prop(ids.obj_color_r, color_r);
11289        set_extra_prop(ids.obj_color_g, color_g);
11290        set_extra_prop(ids.obj_color_b, color_b);
11291
11292        if !(x.is_none()
11293            && y.is_none()
11294            && x_rep.is_none()
11295            && y_rep.is_none()
11296            && z_rep.is_none()
11297            && alpha.is_none()
11298            && patno.is_none()
11299            && order.is_none()
11300            && layer_no.is_none()
11301            && z.is_none()
11302            && center_x.is_none()
11303            && center_y.is_none()
11304            && center_z.is_none()
11305            && center_rep_x.is_none()
11306            && center_rep_y.is_none()
11307            && center_rep_z.is_none()
11308            && scale_x.is_none()
11309            && scale_y.is_none()
11310            && scale_z.is_none()
11311            && rotate_x.is_none()
11312            && rotate_y.is_none()
11313            && rotate_z.is_none()
11314            && clip_left.is_none()
11315            && clip_top.is_none()
11316            && clip_right.is_none()
11317            && clip_bottom.is_none()
11318            && src_clip_left.is_none()
11319            && src_clip_top.is_none()
11320            && src_clip_right.is_none()
11321            && src_clip_bottom.is_none()
11322            && tr.is_none()
11323            && tr_rep.is_none()
11324            && mono.is_none()
11325            && reverse.is_none()
11326            && bright.is_none()
11327            && dark.is_none()
11328            && color_rate.is_none()
11329            && color_add_r.is_none()
11330            && color_add_g.is_none()
11331            && color_add_b.is_none()
11332            && color_r.is_none()
11333            && color_g.is_none()
11334            && color_b.is_none())
11335        {
11336            match &obj.backend {
11337                globals::ObjectBackend::Gfx => {
11338                    if let Some(ax) = x {
11339                        let _ = gfx.object_set_x(images, layers, stage_i64, obj_i64, ax);
11340                    }
11341                    if let Some(ay) = y {
11342                        let _ = gfx.object_set_y(images, layers, stage_i64, obj_i64, ay);
11343                    }
11344                    if let Some(a) = alpha {
11345                        let _ = gfx.object_set_alpha(images, layers, stage_i64, obj_i64, a);
11346                    }
11347                    if let Some(p) = patno {
11348                        let _ = gfx.object_set_pat_no(images, layers, stage_i64, obj_i64, p);
11349                    }
11350                    if let Some(o) = order {
11351                        let _ = gfx.object_set_order(images, layers, stage_i64, obj_i64, o);
11352                    }
11353                    if let Some(l) = layer_no {
11354                        let _ = gfx.object_set_layer(images, layers, stage_i64, obj_i64, l);
11355                    }
11356                    if let Some(zv) = z {
11357                        let _ = gfx.object_set_z(stage_i64, obj_i64, zv);
11358                    }
11359                    if center_x.is_some() || center_y.is_some() {
11360                        let cx = center_x
11361                            .or_else(|| {
11362                                (ids.obj_center_x != 0)
11363                                    .then_some(obj.get_int_prop(ids, ids.obj_center_x))
11364                            })
11365                            .unwrap_or(0);
11366                        let cy = center_y
11367                            .or_else(|| {
11368                                (ids.obj_center_y != 0)
11369                                    .then_some(obj.get_int_prop(ids, ids.obj_center_y))
11370                            })
11371                            .unwrap_or(0);
11372                        let _ = gfx.object_set_center(images, layers, stage_i64, obj_i64, cx, cy);
11373                    }
11374                    if scale_x.is_some() || scale_y.is_some() {
11375                        let sx = scale_x
11376                            .or_else(|| {
11377                                (ids.obj_scale_x != 0)
11378                                    .then_some(obj.get_int_prop(ids, ids.obj_scale_x))
11379                            })
11380                            .unwrap_or(1000);
11381                        let sy = scale_y
11382                            .or_else(|| {
11383                                (ids.obj_scale_y != 0)
11384                                    .then_some(obj.get_int_prop(ids, ids.obj_scale_y))
11385                            })
11386                            .unwrap_or(1000);
11387                        let _ = gfx.object_set_scale(images, layers, stage_i64, obj_i64, sx, sy);
11388                    }
11389                    if let Some(rz) = rotate_z {
11390                        let _ = gfx.object_set_rotate(images, layers, stage_i64, obj_i64, rz);
11391                    }
11392                    if clip_left.is_some()
11393                        || clip_top.is_some()
11394                        || clip_right.is_some()
11395                        || clip_bottom.is_some()
11396                    {
11397                        let use_flag = if ids.obj_clip_use != 0 {
11398                            obj.get_int_prop(ids, ids.obj_clip_use)
11399                        } else {
11400                            0
11401                        };
11402                        let left = clip_left
11403                            .or_else(|| {
11404                                (ids.obj_clip_left != 0)
11405                                    .then_some(obj.get_int_prop(ids, ids.obj_clip_left))
11406                            })
11407                            .unwrap_or(0);
11408                        let top = clip_top
11409                            .or_else(|| {
11410                                (ids.obj_clip_top != 0)
11411                                    .then_some(obj.get_int_prop(ids, ids.obj_clip_top))
11412                            })
11413                            .unwrap_or(0);
11414                        let right = clip_right
11415                            .or_else(|| {
11416                                (ids.obj_clip_right != 0)
11417                                    .then_some(obj.get_int_prop(ids, ids.obj_clip_right))
11418                            })
11419                            .unwrap_or(0);
11420                        let bottom = clip_bottom
11421                            .or_else(|| {
11422                                (ids.obj_clip_bottom != 0)
11423                                    .then_some(obj.get_int_prop(ids, ids.obj_clip_bottom))
11424                            })
11425                            .unwrap_or(0);
11426                        let _ = gfx.object_set_clip(
11427                            images, layers, stage_i64, obj_i64, use_flag, left, top, right, bottom,
11428                        );
11429                    }
11430                    if src_clip_left.is_some()
11431                        || src_clip_top.is_some()
11432                        || src_clip_right.is_some()
11433                        || src_clip_bottom.is_some()
11434                    {
11435                        let use_flag = if ids.obj_src_clip_use != 0 {
11436                            obj.lookup_int_prop(ids, ids.obj_src_clip_use).unwrap_or(0)
11437                        } else {
11438                            0
11439                        };
11440                        let left = src_clip_left
11441                            .or_else(|| {
11442                                if ids.obj_src_clip_left != 0 {
11443                                    obj.lookup_int_prop(ids, ids.obj_src_clip_left)
11444                                } else {
11445                                    None
11446                                }
11447                            })
11448                            .unwrap_or(0);
11449                        let top = src_clip_top
11450                            .or_else(|| {
11451                                if ids.obj_src_clip_top != 0 {
11452                                    obj.lookup_int_prop(ids, ids.obj_src_clip_top)
11453                                } else {
11454                                    None
11455                                }
11456                            })
11457                            .unwrap_or(0);
11458                        let right = src_clip_right
11459                            .or_else(|| {
11460                                if ids.obj_src_clip_right != 0 {
11461                                    obj.lookup_int_prop(ids, ids.obj_src_clip_right)
11462                                } else {
11463                                    None
11464                                }
11465                            })
11466                            .unwrap_or(0);
11467                        let bottom = src_clip_bottom
11468                            .or_else(|| {
11469                                if ids.obj_src_clip_bottom != 0 {
11470                                    obj.lookup_int_prop(ids, ids.obj_src_clip_bottom)
11471                                } else {
11472                                    None
11473                                }
11474                            })
11475                            .unwrap_or(0);
11476                        let _ = gfx.object_set_src_clip(
11477                            images, layers, stage_i64, obj_i64, use_flag, left, top, right, bottom,
11478                        );
11479                    }
11480                    if let Some(v) = tr {
11481                        let _ = gfx.object_set_tr(images, layers, stage_i64, obj_i64, v);
11482                    }
11483                    if let Some(v) = mono {
11484                        let _ = gfx.object_set_mono(images, layers, stage_i64, obj_i64, v);
11485                    }
11486                    if let Some(v) = reverse {
11487                        let _ = gfx.object_set_reverse(images, layers, stage_i64, obj_i64, v);
11488                    }
11489                    if let Some(v) = bright {
11490                        let _ = gfx.object_set_bright(images, layers, stage_i64, obj_i64, v);
11491                    }
11492                    if let Some(v) = dark {
11493                        let _ = gfx.object_set_dark(images, layers, stage_i64, obj_i64, v);
11494                    }
11495                    if let Some(v) = color_rate {
11496                        let _ = gfx.object_set_color_rate(images, layers, stage_i64, obj_i64, v);
11497                    }
11498                    if color_add_r.is_some() || color_add_g.is_some() || color_add_b.is_some() {
11499                        let r = color_add_r.unwrap_or_else(|| {
11500                            if ids.obj_color_add_r != 0 {
11501                                obj.get_int_prop(ids, ids.obj_color_add_r)
11502                            } else {
11503                                0
11504                            }
11505                        });
11506                        let g = color_add_g.unwrap_or_else(|| {
11507                            if ids.obj_color_add_g != 0 {
11508                                obj.get_int_prop(ids, ids.obj_color_add_g)
11509                            } else {
11510                                0
11511                            }
11512                        });
11513                        let b = color_add_b.unwrap_or_else(|| {
11514                            if ids.obj_color_add_b != 0 {
11515                                obj.get_int_prop(ids, ids.obj_color_add_b)
11516                            } else {
11517                                0
11518                            }
11519                        });
11520                        let _ =
11521                            gfx.object_set_color_add(images, layers, stage_i64, obj_i64, r, g, b);
11522                    }
11523                    if color_r.is_some() || color_g.is_some() || color_b.is_some() {
11524                        let r = color_r.unwrap_or_else(|| {
11525                            if ids.obj_color_r != 0 {
11526                                obj.get_int_prop(ids, ids.obj_color_r)
11527                            } else {
11528                                0
11529                            }
11530                        });
11531                        let g = color_g.unwrap_or_else(|| {
11532                            if ids.obj_color_g != 0 {
11533                                obj.get_int_prop(ids, ids.obj_color_g)
11534                            } else {
11535                                0
11536                            }
11537                        });
11538                        let b = color_b.unwrap_or_else(|| {
11539                            if ids.obj_color_b != 0 {
11540                                obj.get_int_prop(ids, ids.obj_color_b)
11541                            } else {
11542                                0
11543                            }
11544                        });
11545                        let _ = gfx.object_set_color(images, layers, stage_i64, obj_i64, r, g, b);
11546                    }
11547                }
11548                backend => {
11549                    for (layer_id, sprite_id) in layer_backed_object_sprite_bindings(backend) {
11550                        let Some(sprite) = layers
11551                            .layer_mut(layer_id)
11552                            .and_then(|layer| layer.sprite_mut(sprite_id))
11553                        else {
11554                            continue;
11555                        };
11556                        if let Some(ax) = x {
11557                            sprite.x = ax as i32;
11558                        }
11559                        if let Some(ay) = y {
11560                            sprite.y = ay as i32;
11561                        }
11562                        if let Some(v) = alpha {
11563                            sprite.alpha = v.clamp(0, 255) as u8;
11564                        }
11565                        if let Some(v) = order {
11566                            sprite.order = v as i32;
11567                        }
11568                        if let Some(v) = tr {
11569                            sprite.tr = v.clamp(0, 255) as u8;
11570                        }
11571                    }
11572                }
11573            }
11574        }
11575    }
11576
11577    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
11578        apply_object_event_animations_recursive(
11579            ids,
11580            gfx,
11581            images,
11582            layers,
11583            stage_i64,
11584            object_runtime_slot(child_idx, child) as i64,
11585            child,
11586        );
11587    }
11588}
11589
11590const WEATHER_APPEAR_MS: i64 = 1000;
11591const WEATHER_DISAPPEAR_MS: i64 = 1000;
11592const WEATHER_ANGLE_FULL: f64 = 3600.0;
11593
11594fn weather_alpha_for_state(state: i64, cur: i64, len: i64) -> u8 {
11595    match state {
11596        1 => ((cur.clamp(0, WEATHER_APPEAR_MS) * 255) / WEATHER_APPEAR_MS).clamp(0, 255) as u8,
11597        2 => 255,
11598        3 => {
11599            let len = if len <= 0 { WEATHER_DISAPPEAR_MS } else { len };
11600            ((len.saturating_sub(cur).clamp(0, len) * 255) / len).clamp(0, 255) as u8
11601        }
11602        _ => 0,
11603    }
11604}
11605
11606fn weather_wave(time: i64, period: i64, power: i64) -> i64 {
11607    if period == 0 || power == 0 {
11608        return 0;
11609    }
11610    let rad = (time as f64 / period.abs() as f64) * std::f64::consts::TAU;
11611    (rad.sin() * power as f64).round() as i64
11612}
11613
11614fn weather_pattern(obj: &mut globals::ObjectState, idx: usize) -> i64 {
11615    let p = obj.weather_param.clone();
11616    let first = p.pat_no_00.min(p.pat_no_01);
11617    let last = p.pat_no_00.max(p.pat_no_01);
11618    let span = (last - first + 1).max(1);
11619    match (p.pat_mode, p.pat_time) {
11620        (1, 1..) => {
11621            let t = obj
11622                .weather_work
11623                .sub
11624                .get(idx)
11625                .map(|s| s.move_cur_time.max(0) % p.pat_time)
11626                .unwrap_or(0);
11627            first + t.saturating_mul(span) / p.pat_time
11628        }
11629        (2, _) => first + obj.weather_work.rand_mod(span),
11630        _ => p.pat_no_00,
11631    }
11632}
11633
11634fn ensure_weather_sprites(
11635    layers: &mut LayerManager,
11636    obj: &mut globals::ObjectState,
11637) -> Option<(LayerId, Vec<SpriteId>)> {
11638    let required = obj.weather_sprite_count();
11639    let (layer_id, sprite_ids) = match &mut obj.backend {
11640        globals::ObjectBackend::Weather {
11641            layer_id,
11642            sprite_ids,
11643        } => (*layer_id, sprite_ids),
11644        _ => return None,
11645    };
11646    if let Some(layer) = layers.layer_mut(layer_id) {
11647        while sprite_ids.len() < required {
11648            let sid = layer.create_sprite();
11649            if let Some(sprite) = layer.sprite_mut(sid) {
11650                sprite.fit = SpriteFit::PixelRect;
11651                sprite.size_mode = SpriteSizeMode::Intrinsic;
11652                sprite.visible = false;
11653                sprite.image_id = None;
11654            }
11655            sprite_ids.push(sid);
11656        }
11657    }
11658    Some((layer_id, sprite_ids.clone()))
11659}
11660
11661fn set_weather_sprite(
11662    ids: &constants::RuntimeConstants,
11663    layers: &mut LayerManager,
11664    _images: &mut ImageManager,
11665    obj: &globals::ObjectState,
11666    layer_id: LayerId,
11667    sprite_id: SpriteId,
11668    image_id: Option<ImageHandle>,
11669    x: i64,
11670    y: i64,
11671    alpha: u8,
11672    scale_x: i64,
11673    scale_y: i64,
11674) {
11675    let Some(layer) = layers.layer_mut(layer_id) else {
11676        return;
11677    };
11678    let Some(sprite) = layer.sprite_mut(sprite_id) else {
11679        return;
11680    };
11681    sprite.image_id = image_id.clone();
11682    sprite.visible = image_id.is_some() && obj.get_int_prop(ids, ids.obj_disp) != 0 && alpha > 0;
11683    sprite.fit = SpriteFit::PixelRect;
11684    sprite.size_mode = SpriteSizeMode::Intrinsic;
11685    // Weather backing sprites store particle-local state.  The object-tree
11686    // collector applies OBJECT.X/Y/scale/TR and parent transforms exactly once.
11687    sprite.x = x.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
11688    sprite.y = y.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
11689    sprite.alpha = 255;
11690    sprite.tr = alpha;
11691    sprite.order = 0;
11692    sprite.scale_x = scale_x as f32 / 1000.0;
11693    sprite.scale_y = scale_y as f32 / 1000.0;
11694}
11695
11696fn weather_wrap_position(value: i64, extent: i64) -> i64 {
11697    if extent <= 0 {
11698        return value;
11699    }
11700    // Match the source's `x > 0 ? x % extent : extent - ((-x) % extent)`.
11701    if value > 0 {
11702        value % extent
11703    } else {
11704        extent - ((-value) % extent)
11705    }
11706}
11707
11708fn sync_weather_object_recursive(
11709    ids: &constants::RuntimeConstants,
11710    layers: &mut LayerManager,
11711    images: &mut ImageManager,
11712    screen_w: i64,
11713    screen_h: i64,
11714    game_delta_ms: i32,
11715    real_delta_ms: i32,
11716    obj: &mut globals::ObjectState,
11717) {
11718    if obj.object_type == 4 && matches!(obj.weather_param.weather_type, 1 | 2) {
11719        obj.update_weather_time(game_delta_ms, real_delta_ms, screen_w, screen_h);
11720        let Some((layer_id, sprite_ids)) = ensure_weather_sprites(layers, obj) else {
11721            return;
11722        };
11723
11724        let file_name = obj.file_name.clone().unwrap_or_default();
11725        let cnt_max = obj.weather_work.cnt_max.min(obj.weather_work.sub.len());
11726        let mut used = 0usize;
11727        for idx in 0..cnt_max {
11728            let sub = obj.weather_work.sub[idx].clone();
11729            if sub.state == 0 {
11730                continue;
11731            }
11732            let pat_no = weather_pattern(obj, idx).max(0) as u32;
11733            let image_id = if file_name.is_empty() {
11734                None
11735            } else {
11736                images.load_g00(&file_name, pat_no).ok()
11737            };
11738            let alpha = weather_alpha_for_state(sub.state, sub.state_cur_time, sub.state_time_len);
11739
11740            if obj.weather_param.weather_type == 1 {
11741                let move_x = if sub.move_time_x == 0 {
11742                    0
11743                } else {
11744                    (1000.0 / sub.move_time_x as f64 * sub.move_cur_time.max(0) as f64) as i64
11745                };
11746                let move_y = if sub.move_time_y == 0 {
11747                    0
11748                } else {
11749                    (1000.0 / sub.move_time_y as f64 * sub.move_cur_time.max(0) as f64) as i64
11750                };
11751                let mut x = sub.move_start_pos_x
11752                    + move_x
11753                    + weather_wave(sub.sin_cur_time, sub.sin_time_x, sub.sin_power_x);
11754                let mut y = sub.move_start_pos_y
11755                    + move_y
11756                    + weather_wave(sub.sin_cur_time, sub.sin_time_y, sub.sin_power_y);
11757                x = weather_wrap_position(x, screen_w);
11758                y = weather_wrap_position(y, screen_h);
11759
11760                let (over_l, over_r, over_u, over_d) = image_id
11761                    .as_ref()
11762                    .and_then(|id| images.get(id))
11763                    .map(|img| {
11764                        let sx = sub.scale_x as f64 / 1000.0;
11765                        let sy = sub.scale_y as f64 / 1000.0;
11766                        let left = x as f64 - img.center_x as f64 * sx;
11767                        let right = x as f64 + (img.width as i64 - img.center_x as i64) as f64 * sx;
11768                        let top = y as f64 - img.center_y as f64 * sy;
11769                        let bottom =
11770                            y as f64 + (img.height as i64 - img.center_y as i64) as f64 * sy;
11771                        (
11772                            left < 0.0,
11773                            right >= screen_w as f64,
11774                            top < 0.0,
11775                            bottom >= screen_h as f64,
11776                        )
11777                    })
11778                    .unwrap_or((false, false, false, false));
11779                let wrap_x = if over_l {
11780                    screen_w
11781                } else if over_r {
11782                    -screen_w
11783                } else {
11784                    0
11785                };
11786                let wrap_y = if over_u {
11787                    screen_h
11788                } else if over_d {
11789                    -screen_h
11790                } else {
11791                    0
11792                };
11793                let offsets = [
11794                    Some((0, 0)),
11795                    (over_l || over_r).then_some((wrap_x, 0)),
11796                    (over_u || over_d).then_some((0, wrap_y)),
11797                    ((over_l || over_r) && (over_u || over_d)).then_some((wrap_x, wrap_y)),
11798                ];
11799                for offset in offsets.into_iter().flatten() {
11800                    if let Some(&sid) = sprite_ids.get(used) {
11801                        set_weather_sprite(
11802                            ids,
11803                            layers,
11804                            images,
11805                            obj,
11806                            layer_id,
11807                            sid,
11808                            image_id.clone(),
11809                            x + offset.0,
11810                            y + offset.1,
11811                            alpha,
11812                            sub.scale_x,
11813                            sub.scale_y,
11814                        );
11815                    }
11816                    used += 1;
11817                }
11818            } else {
11819                let total_time =
11820                    (WEATHER_APPEAR_MS + sub.active_time_len + WEATHER_DISAPPEAR_MS).max(1);
11821                let move_cur_time = if sub.move_time_x > 0 {
11822                    sub.move_cur_time.max(0)
11823                } else {
11824                    total_time.saturating_sub(sub.move_cur_time.max(0))
11825                };
11826                let mut rep_x = sub.move_start_distance as f64;
11827                let mut rep_y = 0.0f64;
11828                if sub.move_time_x != 0 {
11829                    let mt = sub.move_time_x as f64;
11830                    let t = move_cur_time as f64;
11831                    rep_x += 1000.0 / mt / mt * t * t;
11832                }
11833                // The source intentionally applies sin_time_x to the orthogonal
11834                // axis and sin_time_y to the radial axis.
11835                rep_y += weather_wave(sub.sin_cur_time, sub.sin_time_x, sub.sin_power_x) as f64;
11836                rep_x += weather_wave(sub.sin_cur_time, sub.sin_time_y, sub.sin_power_y) as f64;
11837
11838                let mut rad = (sub.move_start_degree as f64 / 10.0).to_radians();
11839                let theta_deg = rep_x * (sub.center_rotate as f64 / 10.0) / 1000.0;
11840                rad += theta_deg.to_radians();
11841                let x = obj.weather_param.center_x + (rep_x * rad.cos() - rep_y * rad.sin()) as i64;
11842                let y = obj.weather_param.center_y + (rep_x * rad.sin() + rep_y * rad.cos()) as i64;
11843                let process = (move_cur_time.saturating_mul(1000) / total_time).clamp(0, 1000);
11844                let zoom =
11845                    sub.zoom_min + (sub.zoom_max - sub.zoom_min).saturating_mul(process) / 1000;
11846                if let Some(&sid) = sprite_ids.get(used) {
11847                    set_weather_sprite(
11848                        ids, layers, images, obj, layer_id, sid, image_id, x, y, alpha, zoom, zoom,
11849                    );
11850                }
11851                used += 1;
11852            }
11853        }
11854
11855        if let Some(layer) = layers.layer_mut(layer_id) {
11856            for sid in sprite_ids.into_iter().skip(used) {
11857                if let Some(sprite) = layer.sprite_mut(sid) {
11858                    sprite.visible = false;
11859                    sprite.image_id = None;
11860                }
11861            }
11862        }
11863    }
11864
11865    for child in &mut obj.runtime.child_objects {
11866        sync_weather_object_recursive(
11867            ids,
11868            layers,
11869            images,
11870            screen_w,
11871            screen_h,
11872            game_delta_ms,
11873            real_delta_ms,
11874            child,
11875        );
11876    }
11877}
11878
11879fn install_object_movie_preview_if_missing(
11880    layers: &mut LayerManager,
11881    movie_mgr: &mut MovieManager,
11882    images: &mut ImageManager,
11883    obj: &mut globals::ObjectState,
11884    stage_idx: i64,
11885    obj_idx: i64,
11886    file: &str,
11887    trace: bool,
11888) {
11889    let globals::ObjectBackend::Movie {
11890        layer_id,
11891        sprite_id,
11892        image_id,
11893        width,
11894        height,
11895    } = &mut obj.backend
11896    else {
11897        return;
11898    };
11899
11900    if image_id.is_some() {
11901        return;
11902    }
11903
11904    match movie_mgr.ensure_omv_preview_frame(file) {
11905        Ok(frame) => {
11906            let img_id = images.insert_image_arc(frame.clone());
11907            *image_id = Some(img_id.clone());
11908            *width = frame.width;
11909            *height = frame.height;
11910            obj.movie.frame_image_ids[0] = Some(img_id.clone());
11911            obj.movie.frame_image_cursor = 0;
11912            if let Some(layer) = layers.layer_mut(*layer_id)
11913                && let Some(sprite) = layer.sprite_mut(*sprite_id)
11914            {
11915                sprite.image_id = Some(img_id.clone());
11916                sprite.object_anchor = true;
11917                sprite.texture_center_x = 0.0;
11918                sprite.texture_center_y = 0.0;
11919            }
11920            if trace || sg_debug_enabled() {
11921                eprintln!(
11922                    "[SG_DEBUG][MOV] object_movie.preview_installed stage={} obj={} file={} image={:?} size={}x{}",
11923                    stage_idx, obj_idx, file, img_id, frame.width, frame.height
11924                );
11925            }
11926        }
11927        Err(err) => {
11928            if trace || sg_debug_enabled() {
11929                eprintln!(
11930                    "[SG_DEBUG][MOV] object_movie.preview_failed stage={} obj={} file={} err={:#}",
11931                    stage_idx, obj_idx, file, err
11932                );
11933            }
11934        }
11935    }
11936}
11937
11938fn install_object_movie_stream_frame(
11939    layers: &mut LayerManager,
11940    images: &mut ImageManager,
11941    obj: &mut globals::ObjectState,
11942    stage_idx: i64,
11943    obj_idx: i64,
11944    file: &str,
11945    frame_idx: usize,
11946    frame: std::sync::Arc<crate::assets::RgbaImage>,
11947    source_size: Option<(u32, u32)>,
11948    trace: bool,
11949) {
11950    let globals::ObjectBackend::Movie {
11951        layer_id,
11952        sprite_id,
11953        image_id,
11954        width,
11955        height,
11956    } = &mut obj.backend
11957    else {
11958        return;
11959    };
11960
11961    // C_elm_object::restruct_movie() creates one D3DUSAGE_DYNAMIC texture and
11962    // C_elm_object::movie_frame() updates that same texture with
11963    // D3DLOCK_DISCARD. Keep the ImageHandle stable for the entire OBJECT movie
11964    // lifetime so the renderer can update one GPU texture in place without
11965    // invalidating/rebuilding the sprite bind group on every decoded frame.
11966    let img_id = if let Some(ref id) = obj.movie.frame_image_ids[0] {
11967        let _ = images.replace_image_arc(id, frame.clone());
11968        id.clone()
11969    } else {
11970        let id = images.insert_image_arc(frame.clone());
11971        obj.movie.frame_image_ids[0] = Some(id.clone());
11972        id
11973    };
11974    obj.movie.frame_image_cursor = 0;
11975
11976    // The object is as large as the video, even when the decoded picture is
11977    // smaller (Vita): the sprite then stretches it to that size.
11978    let (video_w, video_h) = source_size
11979        .filter(|&(w, h)| w > 0 && h > 0)
11980        .unwrap_or((frame.width, frame.height));
11981    *image_id = Some(img_id.clone());
11982    *width = video_w;
11983    *height = video_h;
11984    if let Some(layer) = layers.layer_mut(*layer_id)
11985        && let Some(sprite) = layer.sprite_mut(*sprite_id)
11986    {
11987        sprite.size_mode = if (video_w, video_h) == (frame.width, frame.height) {
11988            SpriteSizeMode::Intrinsic
11989        } else {
11990            SpriteSizeMode::Explicit {
11991                width: video_w,
11992                height: video_h,
11993            }
11994        };
11995        sprite.image_id = Some(img_id.clone());
11996        sprite.object_anchor = true;
11997        sprite.texture_center_x = 0.0;
11998        sprite.texture_center_y = 0.0;
11999    }
12000    if trace || sg_debug_enabled() {
12001        eprintln!(
12002            "[SG_DEBUG][MOV] object_movie.frame stage={} obj={} file={} frame={} image={:?} size={}x{} timer_ms={}",
12003            stage_idx,
12004            obj_idx,
12005            file,
12006            frame_idx,
12007            img_id,
12008            frame.width,
12009            frame.height,
12010            obj.movie.timer_ms
12011        );
12012    }
12013}
12014
12015fn sync_emote_object_recursive(
12016    layers: &mut LayerManager,
12017    obj: &mut globals::ObjectState,
12018    mouth_stop: bool,
12019    koe_playing: bool,
12020    koe_ex: bool,
12021    koe_chara_no: i64,
12022    live_mouth: f32,
12023) {
12024    if obj.object_type == 12 {
12025        let fallback = obj.emote.koe_mouth_volume as f32 / 1000.0;
12026        let mouth = if !mouth_stop
12027            && obj.emote.koe_chara_no >= 0
12028            && obj.emote.koe_chara_no == koe_chara_no
12029            && !koe_ex
12030            && koe_playing
12031        {
12032            live_mouth
12033        } else {
12034            fallback
12035        };
12036        if let Some(runtime) = obj.emote.runtime.as_mut()
12037            && let Err(err) = runtime.set_face_talk(mouth)
12038        {
12039            log::error!("Emote face_talk update failed: {err:#}");
12040        }
12041        if let globals::ObjectBackend::Rect {
12042            layer_id,
12043            sprite_id,
12044            width,
12045            height,
12046        } = obj.backend
12047            && let Some(sprite) = layers
12048                .layer_mut(layer_id)
12049                .and_then(|layer| layer.sprite_mut(sprite_id))
12050        {
12051            sprite.emote_render = obj.emote.runtime.as_ref().map(|runtime| {
12052                runtime.packet(
12053                    obj.emote.width,
12054                    obj.emote.height,
12055                    obj.emote.rep_x,
12056                    obj.emote.rep_y,
12057                    obj.button.alpha_test,
12058                )
12059            });
12060            sprite.size_mode = SpriteSizeMode::Explicit { width, height };
12061            sprite.alpha_test = true;
12062            sprite.alpha_blend = true;
12063        }
12064    }
12065    for child in &mut obj.runtime.child_objects {
12066        sync_emote_object_recursive(
12067            layers,
12068            child,
12069            mouth_stop,
12070            koe_playing,
12071            koe_ex,
12072            koe_chara_no,
12073            live_mouth,
12074        );
12075    }
12076}
12077
12078fn sync_movie_object_recursive(
12079    ids: &constants::RuntimeConstants,
12080    layers: &mut LayerManager,
12081    movie_mgr: &mut MovieManager,
12082    audio: &mut AudioHub,
12083    gfx: &mut graphics::GfxRuntime,
12084    images: &mut ImageManager,
12085    stage_idx: i64,
12086    obj_idx: i64,
12087    obj: &mut globals::ObjectState,
12088    _decoded_any: &mut bool,
12089) {
12090    let trace = env_is_set!("SG_MOVIE_TRACE");
12091    if obj.object_type == 9
12092        && let Some(file_name) = obj.file_name.clone()
12093    {
12094        if trace {
12095            eprintln!(
12096                "[SG_MOVIE_TRACE] enter stage={} obj={} file={} playing={} pause={} backend={:?} children={}",
12097                stage_idx,
12098                obj_idx,
12099                file_name,
12100                obj.movie.playing,
12101                obj.movie.pause_flag,
12102                obj.backend,
12103                obj.runtime.child_objects.len()
12104            );
12105        }
12106        let file = file_name.as_str();
12107        if obj.movie.just_finished {
12108            if let Some(id) = obj.movie.audio_id.take() {
12109                movie_mgr.stop_audio(id);
12110            }
12111            obj.movie.just_finished = false;
12112            if obj.movie.auto_free_flag {
12113                // C_elm_object::update_movie calls init_type(true) at EOS when
12114                // auto-free is enabled, regardless of whether a visible frame was
12115                // installed. Decoder failure must not leave a dead movie object.
12116                if let globals::ObjectBackend::Movie {
12117                    layer_id,
12118                    sprite_id,
12119                    ..
12120                } = obj.backend
12121                    && let Some(layer) = layers.layer_mut(layer_id)
12122                    && let Some(sprite) = layer.sprite_mut(sprite_id)
12123                {
12124                    sprite.visible = false;
12125                    sprite.image_id = None;
12126                }
12127                obj.init_type_like();
12128            }
12129        } else if !obj.movie.playing
12130            && let Some(id) = obj.movie.audio_id.take()
12131        {
12132            movie_mgr.stop_audio(id);
12133        }
12134
12135        if obj.object_type == 9 {
12136            let (layer_id, sprite_id) = if let globals::ObjectBackend::Movie {
12137                layer_id,
12138                sprite_id,
12139                ..
12140            } = &obj.backend
12141            {
12142                (*layer_id, *sprite_id)
12143            } else {
12144                let Some(layer_id) = gfx.ensure_stage_layer_id(layers, stage_idx) else {
12145                    return;
12146                };
12147                let Some(layer) = layers.layer_mut(layer_id) else {
12148                    return;
12149                };
12150                let sid = layer.create_sprite();
12151                if let Some(sprite) = layer.sprite_mut(sid) {
12152                    sprite.visible = true;
12153                    sprite.alpha = 255;
12154                    sprite.fit = SpriteFit::PixelRect;
12155                    sprite.size_mode = SpriteSizeMode::Intrinsic;
12156                    sprite.object_anchor = true;
12157                    sprite.texture_center_x = 0.0;
12158                    sprite.texture_center_y = 0.0;
12159                    sprite.x = 0;
12160                    sprite.y = 0;
12161                    sprite.order = 0;
12162                }
12163                obj.backend = globals::ObjectBackend::Movie {
12164                    layer_id,
12165                    sprite_id: sid,
12166                    image_id: None,
12167                    width: obj.movie.width,
12168                    height: obj.movie.height,
12169                };
12170                (layer_id, sid)
12171            };
12172
12173            if let Some(layer) = layers.layer_mut(layer_id)
12174                && let Some(sprite) = layer.sprite_mut(sprite_id)
12175            {
12176                let render_info =
12177                    button_object_render_info(ids, gfx, stage_idx, obj_idx as usize, obj);
12178                apply_button_object_render_info_to_sprite(sprite, &render_info);
12179                finalize_button_object_center_rep_to_sprite(sprite, &render_info);
12180                if ids.obj_alpha != 0 {
12181                    sprite.alpha = obj
12182                        .lookup_int_prop(ids, ids.obj_alpha)
12183                        .unwrap_or(255)
12184                        .clamp(0, 255) as u8;
12185                }
12186                if ids.obj_order != 0 {
12187                    sprite.order = obj.lookup_int_prop(ids, ids.obj_order).unwrap_or(0) as i32;
12188                }
12189                sprite.blend = crate::layer::SpriteBlend::from_i64(
12190                    obj.lookup_int_prop(ids, ids.obj_blend).unwrap_or(0),
12191                );
12192                // C++ OBJECT movie uses movie_frame() and trp_to_rp(), so OBJECT.CENTER
12193                // must shift the OMV quad just like a texture object. The dynamic OMV
12194                // texture itself keeps the C_d3d_texture default center of (0, 0).
12195                sprite.object_anchor = true;
12196                sprite.texture_center_x = 0.0;
12197                sprite.texture_center_y = 0.0;
12198            }
12199
12200            // Object movie sprites need a texture immediately after CREATE_MOVIE.
12201            // The streaming decoder can return None while its worker is warming up;
12202            // without this preview surface the object stays as image_id=None/0x0 and
12203            // is filtered out by render submission. The stream path below replaces it.
12204            install_object_movie_preview_if_missing(
12205                layers, movie_mgr, images, obj, stage_idx, obj_idx, file, trace,
12206            );
12207
12208            if obj.movie.seeked
12209                && let Some(id) = obj.movie.audio_id.take()
12210            {
12211                movie_mgr.stop_audio(id);
12212            }
12213            obj.movie.seeked = false;
12214            obj.movie.just_looped = false;
12215
12216            if let Some(id) = obj.movie.audio_id {
12217                if let Some(position_ms) = movie_mgr.audio_playback_position_ms(id) {
12218                    obj.movie.timer_ms = position_ms;
12219                    if movie_mgr.audio_playback_finished(id) {
12220                        obj.movie.audio_id = None;
12221                        if !obj.movie.loop_flag {
12222                            if let Some(total_ms) = obj.movie.total_ms {
12223                                obj.movie.timer_ms = total_ms;
12224                            }
12225                            obj.movie.playing = false;
12226                            obj.movie.just_finished = true;
12227                        }
12228                    }
12229                } else {
12230                    // Keep object-movie video alive if only its streaming audio
12231                    // decoder failed.  This mirrors GLOBAL.MOV handling above.
12232                    obj.movie.audio_id = None;
12233                    obj.movie.audio_started_once = true;
12234                }
12235            }
12236
12237            // Pause/resume is edge-triggered by the OBJECT commands in
12238            // forms/stage.rs. Reissuing Kira resume every native tick can
12239            // continuously restart its transition state and produce choppy
12240            // movie audio even while video rendering remains smooth.
12241
12242            if obj.movie.pause_flag {
12243                if let globals::ObjectBackend::Movie {
12244                    layer_id,
12245                    sprite_id,
12246                    image_id,
12247                    width,
12248                    height,
12249                } = &mut obj.backend
12250                    && image_id.is_none()
12251                {
12252                    match movie_mgr.ensure_preview_frame(file) {
12253                        Ok(frame) => {
12254                            let img_id = images.insert_image_arc(frame.clone());
12255                            *image_id = Some(img_id.clone());
12256                            *width = frame.width;
12257                            *height = frame.height;
12258                            if let Some(layer) = layers.layer_mut(*layer_id)
12259                                && let Some(sprite) = layer.sprite_mut(*sprite_id)
12260                            {
12261                                sprite.image_id = Some(img_id);
12262                                sprite.object_anchor = true;
12263                                sprite.texture_center_x = 0.0;
12264                                sprite.texture_center_y = 0.0;
12265                            }
12266                            if trace {
12267                                eprintln!(
12268                                    "[SG_MOVIE_TRACE] installed paused preview stage={} obj={} file={} size={}x{}",
12269                                    stage_idx, obj_idx, file, frame.width, frame.height,
12270                                );
12271                            }
12272                        }
12273                        Err(err) => {
12274                            if trace {
12275                                eprintln!(
12276                                    "[SG_MOVIE_TRACE] paused preview decode failed stage={} obj={} file={} err={:#}",
12277                                    stage_idx, obj_idx, file, err,
12278                                );
12279                            }
12280                        }
12281                    }
12282                }
12283                for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
12284                    sync_movie_object_recursive(
12285                        ids,
12286                        layers,
12287                        movie_mgr,
12288                        audio,
12289                        gfx,
12290                        images,
12291                        stage_idx,
12292                        object_runtime_slot(child_idx, child) as i64,
12293                        child,
12294                        _decoded_any,
12295                    );
12296                }
12297                return;
12298            }
12299
12300            if trace {
12301                eprintln!(
12302                    "[SG_MOVIE_TRACE] poll_stream stage={} obj={} file={}",
12303                    stage_idx, obj_idx, file
12304                );
12305            }
12306            let polled = match movie_mgr.poll_global_movie_frame_with_loop(
12307                file,
12308                obj.movie.timer_ms,
12309                obj.movie.loop_flag,
12310            ) {
12311                Ok(Some(frame)) => frame,
12312                Ok(None) => {
12313                    if obj.movie.last_frame_idx.is_none() {
12314                        obj.movie.timer_ms = 0;
12315                        obj.movie.last_tick = Some(crate::platform_time::Instant::now());
12316                    }
12317                    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
12318                        sync_movie_object_recursive(
12319                            ids,
12320                            layers,
12321                            movie_mgr,
12322                            audio,
12323                            gfx,
12324                            images,
12325                            stage_idx,
12326                            object_runtime_slot(child_idx, child) as i64,
12327                            child,
12328                            _decoded_any,
12329                        );
12330                    }
12331                    return;
12332                }
12333                Err(err) => {
12334                    eprintln!(
12335                        "[SG_MOVIE] object movie error stage={} obj={} file={}: {:#}",
12336                        stage_idx, obj_idx, file, err
12337                    );
12338                    obj.movie.playing = false;
12339                    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
12340                        sync_movie_object_recursive(
12341                            ids,
12342                            layers,
12343                            movie_mgr,
12344                            audio,
12345                            gfx,
12346                            images,
12347                            stage_idx,
12348                            object_runtime_slot(child_idx, child) as i64,
12349                            child,
12350                            _decoded_any,
12351                        );
12352                    }
12353                    return;
12354                }
12355            };
12356            if obj.movie.total_ms.is_none() || polled.total_ms.is_some() {
12357                obj.movie.total_ms = polled.total_ms.or(obj.movie.total_ms);
12358            }
12359            let frame_idx = polled.frame_idx;
12360            let last_idx = obj.movie.last_frame_idx;
12361            let small_rewind = last_idx
12362                .is_some_and(|last| frame_idx < last && frame_idx.saturating_add(1) >= last);
12363            if last_idx != Some(frame_idx) && !small_rewind {
12364                obj.movie.last_frame_idx = Some(frame_idx);
12365                let frame = polled.frame.clone();
12366                install_object_movie_stream_frame(
12367                    layers,
12368                    images,
12369                    obj,
12370                    stage_idx,
12371                    obj_idx,
12372                    file,
12373                    frame_idx,
12374                    frame,
12375                    polled.source_size,
12376                    trace,
12377                );
12378            }
12379            // Keep OBJECT movie video on the same independent media clock as
12380            // GLOBAL.MOV while ASF/WMA audio is still being decoded.  The old
12381            // path forced timer_ms back to zero until `polled.audio` became
12382            // ready, freezing WMV video on its first/preview frame for the
12383            // entire background audio decode.  When audio becomes available,
12384            // start it at the current movie time; from then on the audio handle
12385            // becomes the master clock through audio_playback_position_ms().
12386            if obj.movie.playing && obj.movie.audio_id.is_none() {
12387                if let Some(track) = polled.audio.as_ref() {
12388                    if let Ok(id) =
12389                        movie_mgr.start_audio(audio, track, obj.movie.timer_ms, obj.movie.loop_flag)
12390                    {
12391                        obj.movie.audio_id = Some(id);
12392                        obj.movie.audio_started_once = true;
12393                    }
12394                } else if polled.audio_ready {
12395                    // No soundtrack (or audio decode failed): do not retry a
12396                    // completed probe forever, and never hold video at t=0.
12397                    obj.movie.audio_started_once = true;
12398                }
12399            }
12400        }
12401    }
12402
12403    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
12404        sync_movie_object_recursive(
12405            ids,
12406            layers,
12407            movie_mgr,
12408            audio,
12409            gfx,
12410            images,
12411            stage_idx,
12412            object_runtime_slot(child_idx, child) as i64,
12413            child,
12414            _decoded_any,
12415        );
12416    }
12417}
12418
12419fn apply_object_masks_recursive(
12420    ids: &constants::RuntimeConstants,
12421    gfx: &mut graphics::GfxRuntime,
12422    layers: &mut LayerManager,
12423    stage_i64: i64,
12424    obj_i64: i64,
12425    obj: &mut globals::ObjectState,
12426    mask_info: &[Option<(String, i32, i32)>],
12427    resolved_masks: &HashMap<String, ImageHandle>,
12428) {
12429    let mask_no = if ids.obj_mask_no != 0 {
12430        obj.lookup_int_prop(ids, ids.obj_mask_no).unwrap_or(-1)
12431    } else {
12432        -1
12433    };
12434    let mask_binding = usize::try_from(mask_no)
12435        .ok()
12436        .and_then(|mask_idx| mask_info.get(mask_idx))
12437        .and_then(|entry| entry.as_ref())
12438        .and_then(|(mask_name, mask_x, mask_y)| {
12439            resolved_masks
12440                .get(mask_name)
12441                .cloned()
12442                .map(|mask_image_id| (mask_image_id, *mask_x, *mask_y))
12443        });
12444
12445    let targets: Vec<(LayerId, SpriteId)> = match &obj.backend {
12446        globals::ObjectBackend::Gfx => gfx
12447            .object_sprite_binding(stage_i64, obj_i64)
12448            .into_iter()
12449            .collect(),
12450        backend => layer_backed_object_sprite_bindings(backend),
12451    };
12452
12453    for (layer_id, sprite_id) in targets {
12454        let Some(sprite) = layers
12455            .layer_mut(layer_id)
12456            .and_then(|layer| layer.sprite_mut(sprite_id))
12457        else {
12458            continue;
12459        };
12460        if let Some((mask_image_id, mask_x, mask_y)) = mask_binding.clone() {
12461            sprite.mask_image_id = Some(mask_image_id);
12462            sprite.mask_offset_x = mask_x;
12463            sprite.mask_offset_y = mask_y;
12464        } else {
12465            sprite.mask_image_id = None;
12466            sprite.mask_offset_x = 0;
12467            sprite.mask_offset_y = 0;
12468        }
12469    }
12470
12471    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
12472        apply_object_masks_recursive(
12473            ids,
12474            gfx,
12475            layers,
12476            stage_i64,
12477            object_runtime_slot(child_idx, child) as i64,
12478            child,
12479            mask_info,
12480            resolved_masks,
12481        );
12482    }
12483}
12484
12485fn apply_object_tonecurves_recursive(
12486    ids: &constants::RuntimeConstants,
12487    gfx: &mut graphics::GfxRuntime,
12488    images: &mut ImageManager,
12489    layers: &mut LayerManager,
12490    tonecurve: &mut tonecurve::ToneCurveRuntime,
12491    stage_i64: i64,
12492    obj_i64: i64,
12493    obj: &mut globals::ObjectState,
12494) {
12495    let tonecurve_no = if ids.obj_tonecurve_no != 0 {
12496        obj.lookup_int_prop(ids, ids.obj_tonecurve_no).unwrap_or(-1)
12497    } else {
12498        -1
12499    };
12500    if tonecurve_no >= 0
12501        && let Some((tonecurve_image_id, tonecurve_row, tonecurve_sat)) =
12502            tonecurve.shader_binding(images, tonecurve_no as i32)
12503    {
12504        let targets: Vec<(LayerId, SpriteId)> = match &obj.backend {
12505            globals::ObjectBackend::Gfx => gfx
12506                .object_sprite_binding(stage_i64, obj_i64)
12507                .into_iter()
12508                .collect(),
12509            backend => layer_backed_object_sprite_bindings(backend),
12510        };
12511        for (layer_id, sprite_id) in targets {
12512            if let Some(sprite) = layers
12513                .layer_mut(layer_id)
12514                .and_then(|l| l.sprite_mut(sprite_id))
12515            {
12516                sprite.tonecurve_image_id = Some(tonecurve_image_id.clone());
12517                sprite.tonecurve_row = tonecurve_row;
12518                sprite.tonecurve_sat = tonecurve_sat;
12519            }
12520        }
12521    }
12522
12523    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
12524        apply_object_tonecurves_recursive(
12525            ids,
12526            gfx,
12527            images,
12528            layers,
12529            tonecurve,
12530            stage_i64,
12531            object_runtime_slot(child_idx, child) as i64,
12532            child,
12533        );
12534    }
12535}
12536
12537fn apply_gan_effects_recursive(
12538    gfx: &mut graphics::GfxRuntime,
12539    images: &mut ImageManager,
12540    sprites: &mut Vec<RenderSprite>,
12541    index: &HashMap<(Option<LayerId>, Option<SpriteId>), usize>,
12542    stage_i64: i64,
12543    obj_i64: i64,
12544    obj: &mut globals::ObjectState,
12545) {
12546    if let Some(pat) = obj.gan.current_pat()
12547        && !(pat.pat_no == 0 && pat.x == 0 && pat.y == 0 && pat.tr == 255)
12548    {
12549        let keys: Vec<(LayerId, SpriteId)> = match &obj.backend {
12550            globals::ObjectBackend::Gfx => gfx
12551                .object_sprite_binding(stage_i64, obj_i64)
12552                .into_iter()
12553                .collect(),
12554            backend => layer_backed_object_sprite_bindings(backend),
12555        };
12556        let replacement_image = if pat.pat_no != 0 {
12557            gfx.object_peek_file(stage_i64, obj_i64).and_then(|file| {
12558                let base_pat = gfx.object_peek_patno(stage_i64, obj_i64).unwrap_or(0);
12559                let pat_no = (base_pat + pat.pat_no as i64).max(0) as u32;
12560                images.load_g00(&file, pat_no).ok()
12561            })
12562        } else {
12563            None
12564        };
12565        for (layer_id, sprite_id) in keys {
12566            let Some(&idx) = index.get(&(Some(layer_id), Some(sprite_id))) else {
12567                continue;
12568            };
12569            let sprite = &mut sprites[idx].sprite;
12570            if pat.x != 0 {
12571                sprite.x = sprite.x.saturating_add(pat.x);
12572            }
12573            if pat.y != 0 {
12574                sprite.y = sprite.y.saturating_add(pat.y);
12575            }
12576            if pat.tr != 255 {
12577                let tr = (sprite.tr as i64 * pat.tr as i64 / 255).clamp(0, 255) as u8;
12578                sprite.tr = tr;
12579            }
12580            if let Some(id) = replacement_image.clone() {
12581                sprite.image_id = Some(id);
12582            }
12583        }
12584    }
12585
12586    for (child_idx, child) in obj.runtime.child_objects.iter_mut().enumerate() {
12587        apply_gan_effects_recursive(
12588            gfx,
12589            images,
12590            sprites,
12591            index,
12592            stage_i64,
12593            object_runtime_slot(child_idx, child) as i64,
12594            child,
12595        );
12596    }
12597}
12598
12599fn build_parent_render_state(
12600    info: &ObjectRenderInfo,
12601    first_sprite: Option<&Sprite>,
12602) -> ParentRenderState {
12603    ParentRenderState {
12604        world_no: info.world_no,
12605        pos_x: (info.x + info.x_rep) as f32,
12606        pos_y: (info.y + info.y_rep) as f32,
12607        pos_z: (info.z + info.z_rep) as f32,
12608        center_rep_x: info.center_rep_x as f32,
12609        center_rep_y: info.center_rep_y as f32,
12610        center_rep_z: info.center_rep_z as f32,
12611        scale_x: info.scale_x as f32 / 1000.0,
12612        scale_y: info.scale_y as f32 / 1000.0,
12613        scale_z: info.scale_z as f32 / 1000.0,
12614        rotate_x: info.rotate_x as f32 * std::f32::consts::PI / 1800.0,
12615        rotate_y: info.rotate_y as f32 * std::f32::consts::PI / 1800.0,
12616        rotate_z: info.rotate_z as f32 * std::f32::consts::PI / 1800.0,
12617        tr: ((info.tr.clamp(0, 255) * info.tr_rep.clamp(0, 255)) / 255) as i32,
12618        mono: info.mono.clamp(0, 255) as i32,
12619        reverse: info.reverse.clamp(0, 255) as i32,
12620        bright: info.bright.clamp(0, 255) as i32,
12621        dark: info.dark.clamp(0, 255) as i32,
12622        color_rate: info.color_rate.clamp(0, 255) as i32,
12623        color_r: info.color_r.clamp(0, 255) as i32,
12624        color_g: info.color_g.clamp(0, 255) as i32,
12625        color_b: info.color_b.clamp(0, 255) as i32,
12626        color_add_r: info.color_add_r.clamp(0, 255) as i32,
12627        color_add_g: info.color_add_g.clamp(0, 255) as i32,
12628        color_add_b: info.color_add_b.clamp(0, 255) as i32,
12629        blend: info.blend,
12630        dst_clip: info.dst_clip,
12631        mask_image_id: first_sprite.and_then(|s| s.mask_image_id.clone()),
12632        mask_offset_x: first_sprite.map(|s| s.mask_offset_x).unwrap_or(0),
12633        mask_offset_y: first_sprite.map(|s| s.mask_offset_y).unwrap_or(0),
12634        tonecurve_image_id: first_sprite.and_then(|s| s.tonecurve_image_id.clone()),
12635        tonecurve_row: first_sprite.map(|s| s.tonecurve_row).unwrap_or(0.0),
12636        tonecurve_sat: first_sprite.map(|s| s.tonecurve_sat).unwrap_or(0.0),
12637    }
12638}
12639
12640fn apply_parent_render_state_to_sprite(
12641    sprite: &mut Sprite,
12642    _info: &ObjectRenderInfo,
12643    state: &ParentRenderState,
12644) {
12645    let local_x = sprite.x as f32;
12646    let local_y = sprite.y as f32;
12647    let local_z = sprite.z;
12648
12649    let mut rel_x = local_x - state.center_rep_x;
12650    let mut rel_y = local_y - state.center_rep_y;
12651    rel_x *= state.scale_x;
12652    rel_y *= state.scale_y;
12653    let (sin_z, cos_z) = state.rotate_z.sin_cos();
12654    let rot_x = rel_x * cos_z - rel_y * sin_z;
12655    let rot_y = rel_x * sin_z + rel_y * cos_z;
12656
12657    sprite.x = (state.pos_x + state.center_rep_x + rot_x).round() as i32;
12658    sprite.y = (state.pos_y + state.center_rep_y + rot_y).round() as i32;
12659    sprite.z = state.pos_z + state.center_rep_z + local_z * state.scale_z;
12660    sprite.scale_x *= state.scale_x;
12661    sprite.scale_y *= state.scale_y;
12662    sprite.scale_z *= state.scale_z;
12663    sprite.rotate_x += state.rotate_x;
12664    sprite.rotate_y += state.rotate_y;
12665    sprite.rotate += state.rotate_z;
12666
12667    sprite.tr = ((sprite.tr as i32 * state.tr.clamp(0, 255)) / 255).clamp(0, 255) as u8;
12668    sprite.mono = combine_lerp(sprite.mono, state.mono);
12669    sprite.reverse = combine_lerp(sprite.reverse, state.reverse);
12670    sprite.bright = combine_lerp(sprite.bright, state.bright);
12671    sprite.dark = combine_lerp(sprite.dark, state.dark);
12672    if (sprite.color_rate as i32) + state.color_rate > 0 {
12673        let parent_rate = (state.color_rate * 255 * 255)
12674            / (255 * 255 - (255 - sprite.color_rate as i32) * (255 - state.color_rate)).max(1);
12675        sprite.color_r = blend_color(sprite.color_r, state.color_r, parent_rate);
12676        sprite.color_g = blend_color(sprite.color_g, state.color_g, parent_rate);
12677        sprite.color_b = blend_color(sprite.color_b, state.color_b, parent_rate);
12678        sprite.color_rate = combine_lerp(sprite.color_rate, state.color_rate);
12679    }
12680    sprite.color_add_r = sprite
12681        .color_add_r
12682        .saturating_add(state.color_add_r.clamp(0, 255) as u8);
12683    sprite.color_add_g = sprite
12684        .color_add_g
12685        .saturating_add(state.color_add_g.clamp(0, 255) as u8);
12686    sprite.color_add_b = sprite
12687        .color_add_b
12688        .saturating_add(state.color_add_b.clamp(0, 255) as u8);
12689    if matches!(sprite.blend, crate::layer::SpriteBlend::Normal) {
12690        sprite.blend = state.blend;
12691    }
12692    let child_clip = sprite.dst_clip;
12693    sprite.dst_clip = compose_clip_rect(state.dst_clip, child_clip);
12694    if state.dst_clip.is_some() && child_clip.is_some() && sprite.dst_clip.is_none() {
12695        sprite.tr = 0;
12696    }
12697    if sprite.mask_image_id.is_none() {
12698        sprite.mask_image_id = state.mask_image_id.clone();
12699        sprite.mask_offset_x = state.mask_offset_x;
12700        sprite.mask_offset_y = state.mask_offset_y;
12701    }
12702    if sprite.tonecurve_image_id.is_none() {
12703        sprite.tonecurve_image_id = state.tonecurve_image_id.clone();
12704        sprite.tonecurve_row = state.tonecurve_row;
12705        sprite.tonecurve_sat = state.tonecurve_sat;
12706    }
12707
12708    if state.world_no >= 0 {
12709        sprite.world_no = state.world_no as i32;
12710    }
12711}
12712
12713fn apply_world_camera_mode(
12714    sprite: &mut Sprite,
12715    worlds: Option<&Vec<globals::WorldState>>,
12716    screen_w: u32,
12717    screen_h: u32,
12718) {
12719    if sprite.world_no < 0 {
12720        return;
12721    }
12722    let Some(worlds) = worlds else {
12723        return;
12724    };
12725    let Some(world) = worlds.get(sprite.world_no as usize) else {
12726        return;
12727    };
12728
12729    let cam_eye = [
12730        world.camera_eye_x.get_total_value() as f32,
12731        world.camera_eye_y.get_total_value() as f32,
12732        world.camera_eye_z.get_total_value() as f32,
12733    ];
12734    let cam_target = [
12735        world.camera_pint_x.get_total_value() as f32,
12736        world.camera_pint_y.get_total_value() as f32,
12737        world.camera_pint_z.get_total_value() as f32,
12738    ];
12739    let cam_up = [
12740        world.camera_up_x.get_total_value() as f32,
12741        world.camera_up_y.get_total_value() as f32,
12742        world.camera_up_z.get_total_value() as f32,
12743    ];
12744    sprite.camera_view_angle_deg = (world.camera_view_angle as f32) / 10.0;
12745    if world.mono != 0 {
12746        let base = sprite.mono as i32;
12747        let parent = world.mono.clamp(0, 255);
12748        sprite.mono = (255 - (255 - base) * (255 - parent) / 255) as u8;
12749    }
12750
12751    if world.mode == 0 {
12752        let dz = sprite.z - cam_eye[2];
12753        if dz <= 0.0 {
12754            sprite.visible = false;
12755            return;
12756        }
12757        let camera_scale = 1000.0 / dz;
12758        let sw = screen_w as f32;
12759        let sh = screen_h as f32;
12760        sprite.x = (((sprite.x as f32) - cam_eye[0]) * camera_scale + sw * 0.5)
12761            .round()
12762            .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
12763        sprite.y = (((sprite.y as f32) - cam_eye[1]) * camera_scale + sh * 0.5)
12764            .round()
12765            .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
12766        sprite.scale_x *= camera_scale;
12767        sprite.scale_y *= camera_scale;
12768        sprite.z = 0.0;
12769        sprite.pivot_z = 0.0;
12770        sprite.scale_z = 1.0;
12771        sprite.rotate_x = 0.0;
12772        sprite.rotate_y = 0.0;
12773        sprite.billboard = false;
12774        sprite.culling = false;
12775        sprite.fog_use = false;
12776        sprite.light_no = -1;
12777        sprite.light_enabled = false;
12778        sprite.light_diffuse = [1.0, 1.0, 1.0, 1.0];
12779        sprite.light_ambient = [0.0, 0.0, 0.0, 1.0];
12780        sprite.light_specular = [0.0, 0.0, 0.0, 1.0];
12781        sprite.light_factor = 0.0;
12782        sprite.light_kind = -1;
12783        sprite.light_pos = [0.0, 0.0, 0.0, 0.0];
12784        sprite.light_dir = [0.0, 0.0, -1.0, 0.0];
12785        sprite.light_atten = [1.0, 0.0, 0.0, 5000.0];
12786        sprite.light_cone = [0.0, 0.0, 1.0, 0.0];
12787        sprite.fog_enabled = false;
12788        sprite.fog_color = [0.0, 0.0, 0.0, 1.0];
12789        sprite.fog_near = 0.0;
12790        sprite.fog_far = 0.0;
12791        sprite.fog_scroll_x = 0.0;
12792        sprite.fog_texture_image_id = None;
12793        sprite.camera_enabled = false;
12794        sprite.camera_eye = [0.0, 0.0, -1000.0];
12795        sprite.camera_target = [0.0, 0.0, 0.0];
12796        sprite.camera_up = [0.0, 1.0, 0.0];
12797        return;
12798    }
12799
12800    sprite.camera_enabled = true;
12801    sprite.camera_eye = cam_eye;
12802    sprite.camera_target = cam_target;
12803    sprite.camera_up = cam_up;
12804}
12805
12806fn fetch_bound_render_sprites(
12807    ctx: &CommandContext,
12808    stage_idx: i64,
12809    runtime_slot: usize,
12810    obj: &globals::ObjectState,
12811) -> Vec<RenderSprite> {
12812    // Object tree visibility is driven by C_elm_object::disp and parent visibility.
12813    // The backing layer sprite visible bit is only a cached render backend state and
12814    // can be stale for object-owned sprites. Fetch the sprite payload unconditionally;
12815    // append_object_tree_nodes() applies the original object visibility gate.
12816    fetch_bound_render_sprites_impl(ctx, stage_idx, runtime_slot, obj, false)
12817}
12818
12819fn fetch_bound_render_sprites_any(
12820    ctx: &CommandContext,
12821    stage_idx: i64,
12822    runtime_slot: usize,
12823    obj: &globals::ObjectState,
12824) -> Vec<RenderSprite> {
12825    fetch_bound_render_sprites_impl(ctx, stage_idx, runtime_slot, obj, false)
12826}
12827
12828fn fetch_bound_render_sprites_impl(
12829    ctx: &CommandContext,
12830    stage_idx: i64,
12831    runtime_slot: usize,
12832    obj: &globals::ObjectState,
12833    visible_only: bool,
12834) -> Vec<RenderSprite> {
12835    fn push_one(
12836        ctx: &CommandContext,
12837        lid: LayerId,
12838        sid: SpriteId,
12839        visible_only: bool,
12840        out: &mut Vec<RenderSprite>,
12841    ) {
12842        let Some(layer) = ctx.layers.layer(lid) else {
12843            return;
12844        };
12845        let Some(sprite) = layer.sprite(sid) else {
12846            return;
12847        };
12848        if visible_only && !sprite.visible {
12849            return;
12850        }
12851        if !sprite_has_render_payload(sprite) {
12852            return;
12853        }
12854        out.push(RenderSprite::new(Some(lid), Some(sid), sprite.clone()));
12855    }
12856
12857    let mut out = Vec::new();
12858    match &obj.backend {
12859        globals::ObjectBackend::Gfx => {
12860            if let Some((lid, sid)) = ctx
12861                .gfx
12862                .object_sprite_binding(stage_idx, runtime_slot as i64)
12863            {
12864                push_one(ctx, lid, sid, visible_only, &mut out);
12865            }
12866        }
12867        globals::ObjectBackend::None => {}
12868        globals::ObjectBackend::Number {
12869            layer_id,
12870            sprite_ids,
12871        } => {
12872            // Backend visibility may be suppressed for tree-owned sprites;
12873            // the number layout separately selects digits and padding.
12874            for (&sprite_id, offset) in sprite_ids.iter().zip(&obj.runtime.number_sprite_offsets) {
12875                if offset.is_some() {
12876                    push_one(ctx, *layer_id, sprite_id, visible_only, &mut out);
12877                }
12878            }
12879        }
12880        backend => {
12881            for (layer_id, sprite_id) in layer_backed_object_sprite_bindings(backend) {
12882                push_one(ctx, layer_id, sprite_id, visible_only, &mut out);
12883            }
12884        }
12885    }
12886    out
12887}
12888
12889fn effective_object_info(
12890    ctx: &CommandContext,
12891    _stage_idx: i64,
12892    obj_idx: usize,
12893    obj: &globals::ObjectState,
12894) -> ObjectRenderInfo {
12895    let runtime_slot = object_runtime_slot(obj_idx, obj);
12896    let ids = &ctx.ids;
12897    let base = &obj.base;
12898    let ev = &obj.runtime.prop_events;
12899
12900    macro_rules! event_or_base {
12901        ($prop_id:expr, $event_id:expr, $field:ident) => {{
12902            if $prop_id != 0 || $event_id != 0 {
12903                ev.$field.get_total_value() as i64
12904            } else {
12905                base.$field
12906            }
12907        }};
12908    }
12909
12910    // C_elm_object::frame consumes the object's parameter block directly.  The
12911    // scripting property dispatcher is intentionally not used here: for
12912    // event-backed properties lookup_int_prop() already resolves to the same
12913    // IntEvent::get_total_value(), and routing every render read through the
12914    // generic form/property lookup only repeats opcode-id comparisons.
12915    let dst_clip = if base.clip_use != 0 {
12916        Some(ClipRect {
12917            left: event_or_base!(ids.obj_clip_left, ids.obj_clip_left_eve, clip_left) as i32,
12918            top: event_or_base!(ids.obj_clip_top, ids.obj_clip_top_eve, clip_top) as i32,
12919            right: event_or_base!(ids.obj_clip_right, ids.obj_clip_right_eve, clip_right) as i32,
12920            bottom: event_or_base!(ids.obj_clip_bottom, ids.obj_clip_bottom_eve, clip_bottom)
12921                as i32,
12922        })
12923    } else {
12924        None
12925    };
12926
12927    let x_rep_total = obj
12928        .runtime
12929        .prop_event_lists
12930        .x_rep
12931        .iter()
12932        .map(|ev| ev.get_total_value() as i64)
12933        .sum::<i64>();
12934    let y_rep_total = obj
12935        .runtime
12936        .prop_event_lists
12937        .y_rep
12938        .iter()
12939        .map(|ev| ev.get_total_value() as i64)
12940        .sum::<i64>();
12941    let z_rep_total = obj
12942        .runtime
12943        .prop_event_lists
12944        .z_rep
12945        .iter()
12946        .map(|ev| ev.get_total_value() as i64)
12947        .sum::<i64>();
12948    let tr_rep_total = obj
12949        .runtime
12950        .prop_event_lists
12951        .tr_rep
12952        .iter()
12953        .fold(255i64, |acc, ev| {
12954            acc.saturating_mul(ev.get_total_value() as i64)
12955                .div_euclid(255)
12956        });
12957
12958    let info = ObjectRenderInfo {
12959        runtime_slot,
12960        used: obj.used,
12961        object_type: obj.object_type,
12962        disp: base.disp != 0,
12963        x: event_or_base!(ids.obj_x, ids.obj_x_eve, x),
12964        y: event_or_base!(ids.obj_y, ids.obj_y_eve, y),
12965        x_rep: x_rep_total,
12966        y_rep: y_rep_total,
12967        z_rep: z_rep_total,
12968        order: base.order,
12969        layer: base.layer,
12970        alpha: base.alpha,
12971        tr: event_or_base!(ids.obj_tr, ids.obj_tr_eve, tr),
12972        tr_rep: tr_rep_total,
12973        mono: event_or_base!(ids.obj_mono, ids.obj_mono_eve, mono),
12974        reverse: event_or_base!(ids.obj_reverse, ids.obj_reverse_eve, reverse),
12975        bright: event_or_base!(ids.obj_bright, ids.obj_bright_eve, bright),
12976        dark: event_or_base!(ids.obj_dark, ids.obj_dark_eve, dark),
12977        color_rate: event_or_base!(ids.obj_color_rate, ids.obj_color_rate_eve, color_rate),
12978        color_add_r: event_or_base!(ids.obj_color_add_r, ids.obj_color_add_r_eve, color_add_r),
12979        color_add_g: event_or_base!(ids.obj_color_add_g, ids.obj_color_add_g_eve, color_add_g),
12980        color_add_b: event_or_base!(ids.obj_color_add_b, ids.obj_color_add_b_eve, color_add_b),
12981        color_r: event_or_base!(ids.obj_color_r, ids.obj_color_r_eve, color_r),
12982        color_g: event_or_base!(ids.obj_color_g, ids.obj_color_g_eve, color_g),
12983        color_b: event_or_base!(ids.obj_color_b, ids.obj_color_b_eve, color_b),
12984        z: event_or_base!(ids.obj_z, ids.obj_z_eve, z),
12985        world_no: base.world,
12986        center_x: event_or_base!(ids.obj_center_x, ids.obj_center_x_eve, center_x),
12987        center_y: event_or_base!(ids.obj_center_y, ids.obj_center_y_eve, center_y),
12988        center_z: event_or_base!(ids.obj_center_z, ids.obj_center_z_eve, center_z),
12989        center_rep_x: event_or_base!(ids.obj_center_rep_x, ids.obj_center_rep_x_eve, center_rep_x),
12990        center_rep_y: event_or_base!(ids.obj_center_rep_y, ids.obj_center_rep_y_eve, center_rep_y),
12991        center_rep_z: event_or_base!(ids.obj_center_rep_z, ids.obj_center_rep_z_eve, center_rep_z),
12992        scale_x: event_or_base!(ids.obj_scale_x, ids.obj_scale_x_eve, scale_x),
12993        scale_y: event_or_base!(ids.obj_scale_y, ids.obj_scale_y_eve, scale_y),
12994        scale_z: event_or_base!(ids.obj_scale_z, ids.obj_scale_z_eve, scale_z),
12995        rotate_x: event_or_base!(ids.obj_rotate_x, ids.obj_rotate_x_eve, rotate_x),
12996        rotate_y: event_or_base!(ids.obj_rotate_y, ids.obj_rotate_y_eve, rotate_y),
12997        rotate_z: event_or_base!(ids.obj_rotate_z, ids.obj_rotate_z_eve, rotate_z),
12998        culling: base.culling != 0,
12999        alpha_test: base.alpha_test != 0,
13000        alpha_blend: base.alpha_blend != 0,
13001        fog_use: base.fog_use != 0,
13002        light_no: base.light_no,
13003        blend: crate::layer::SpriteBlend::from_i64(base.blend),
13004        child_sort_type: base.child_sort_type,
13005        dst_clip,
13006        billboard: obj.object_type == 7,
13007        file_name: obj.file_name.clone(),
13008        mesh_animation: obj.mesh_animation_state.clone(),
13009    };
13010
13011    // The original C_elm_object keeps render parameters exclusively in m_op.obp.
13012    // copy() may rebuild the type-owned resource first, but it then copies the
13013    // complete parameter block from the source object; create_trp() reads that
13014    // block directly every frame.  GfxRuntime is therefore only backing storage
13015    // for the leaf resource and must never override the copied logical state.
13016    // In particular, OBJECT stage-copy reconstruction calls object_create(),
13017    // whose backing layer/order defaults are not the script-visible sorter.
13018    info
13019}
13020
13021fn configure_sprite_3d(
13022    sprite: &mut crate::layer::Sprite,
13023    info: &ObjectRenderInfo,
13024    _worlds: Option<&Vec<globals::WorldState>>,
13025    _screen_w: u32,
13026    _screen_h: u32,
13027) {
13028    sprite.z = info.z as f32;
13029    sprite.pivot_z = info.center_z as f32;
13030    sprite.scale_z = info.scale_z as f32 / 1000.0;
13031    sprite.rotate_x = info.rotate_x as f32 * std::f32::consts::PI / 1800.0;
13032    sprite.rotate_y = info.rotate_y as f32 * std::f32::consts::PI / 1800.0;
13033    sprite.culling = info.culling;
13034    sprite.alpha_test = info.alpha_test;
13035    // C_elm_object::restruct_type overrides the generic object flag for MESH:
13036    // mesh subsets own their material pass and the object sprite itself is
13037    // always submitted as opaque.
13038    sprite.alpha_blend = object_alpha_blend_for_render(info.object_type, info.alpha_blend);
13039    sprite.fog_use = info.fog_use;
13040    sprite.light_no = info.light_no as i32;
13041    sprite.world_no = info.world_no as i32;
13042    sprite.billboard = info.billboard;
13043    sprite.mesh_file_name = if info.object_type == 6 {
13044        info.file_name.clone()
13045    } else {
13046        None
13047    };
13048    sprite.mesh_kind = if info.object_type == 6 { 1 } else { 0 };
13049    sprite.shadow_cast = sprite.mesh_kind != 0;
13050    sprite.shadow_receive = sprite.mesh_kind != 0;
13051    sprite.mesh_animation = info.mesh_animation.clone();
13052
13053    // Screen objects can rotate on all axes without entering world coordinates.
13054    // An assigned WORLD supplies its camera later in apply_world_camera_mode.
13055    sprite.camera_enabled = matches!(info.object_type, 6 | 7) || info.billboard;
13056    sprite.camera_eye = [0.0, 0.0, -1000.0];
13057    sprite.camera_target = [0.0, 0.0, 0.0];
13058    sprite.camera_up = [0.0, 1.0, 0.0];
13059    sprite.camera_view_angle_deg = 45.0;
13060}
13061
13062fn object_alpha_blend_for_render(object_type: i64, requested: bool) -> bool {
13063    object_type != 6 && requested
13064}
13065
13066#[derive(Debug)]
13067enum ObjectMotionTraceFilter {
13068    Disabled,
13069    All,
13070    Files(Vec<String>),
13071}
13072
13073fn object_motion_trace_filter() -> &'static ObjectMotionTraceFilter {
13074    static FILTER: std::sync::OnceLock<ObjectMotionTraceFilter> = std::sync::OnceLock::new();
13075    FILTER.get_or_init(|| {
13076        let Some(raw) = std::env::var_os("SG_OBJECT_MOTION_TRACE") else {
13077            return ObjectMotionTraceFilter::Disabled;
13078        };
13079        let raw = raw.to_string_lossy();
13080        let raw = raw.trim();
13081        if raw.is_empty()
13082            || raw == "1"
13083            || raw.eq_ignore_ascii_case("true")
13084            || raw.eq_ignore_ascii_case("all")
13085            || raw == "*"
13086        {
13087            return ObjectMotionTraceFilter::All;
13088        }
13089
13090        let files = raw
13091            .split(',')
13092            .map(str::trim)
13093            .filter(|item| !item.is_empty())
13094            .map(|item| item.to_ascii_lowercase())
13095            .collect::<Vec<_>>();
13096        if files.is_empty() {
13097            ObjectMotionTraceFilter::All
13098        } else {
13099            ObjectMotionTraceFilter::Files(files)
13100        }
13101    })
13102}
13103
13104fn object_motion_trace_enabled() -> bool {
13105    !matches!(
13106        object_motion_trace_filter(),
13107        ObjectMotionTraceFilter::Disabled
13108    )
13109}
13110
13111fn object_motion_trace_object(obj: &globals::ObjectState) -> bool {
13112    match object_motion_trace_filter() {
13113        ObjectMotionTraceFilter::Disabled => false,
13114        ObjectMotionTraceFilter::All => true,
13115        ObjectMotionTraceFilter::Files(filters) => {
13116            let Some(file_name) = obj.file_name.as_deref() else {
13117                return false;
13118            };
13119            let normalized = file_name.replace('\\', "/").to_ascii_lowercase();
13120            let base = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
13121            let stem = base.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(base);
13122            filters
13123                .iter()
13124                .any(|filter| filter == &normalized || filter == base || filter == stem)
13125        }
13126    }
13127}
13128
13129fn object_motion_trace_bind(
13130    ctx: &CommandContext,
13131    stage_idx: i64,
13132    info: &ObjectRenderInfo,
13133    obj: &globals::ObjectState,
13134) -> Option<(LayerId, SpriteId)> {
13135    match &obj.backend {
13136        globals::ObjectBackend::Gfx => ctx
13137            .gfx
13138            .object_sprite_binding(stage_idx, info.runtime_slot as i64),
13139        globals::ObjectBackend::Rect {
13140            layer_id,
13141            sprite_id,
13142            ..
13143        }
13144        | globals::ObjectBackend::String {
13145            layer_id,
13146            sprite_id,
13147            ..
13148        }
13149        | globals::ObjectBackend::Movie {
13150            layer_id,
13151            sprite_id,
13152            ..
13153        } => Some((*layer_id, *sprite_id)),
13154        globals::ObjectBackend::Number {
13155            layer_id,
13156            sprite_ids,
13157        }
13158        | globals::ObjectBackend::Weather {
13159            layer_id,
13160            sprite_ids,
13161        } => sprite_ids.first().copied().map(|sid| (*layer_id, sid)),
13162        globals::ObjectBackend::None => None,
13163    }
13164}
13165
13166fn object_motion_trace_emit(
13167    ctx: &CommandContext,
13168    stage_idx: i64,
13169    obj_idx: usize,
13170    obj: &globals::ObjectState,
13171    info: &ObjectRenderInfo,
13172    parent_visible: bool,
13173    visible: bool,
13174    local_tr: i64,
13175    total_order: i64,
13176    total_layer: i64,
13177    bound_len: usize,
13178    bind_dbg: Option<(LayerId, SpriteId)>,
13179    emitted: bool,
13180    sprite: Option<&Sprite>,
13181) {
13182    let ev = &obj.runtime.prop_events;
13183    let sprite_desc = sprite
13184        .map(|s| {
13185            format!(
13186                "sprite_pos=({}, {}, {:.3}) sprite_order={} sprite_sorter=({}, {}) sprite_alpha={} sprite_tr={} image={:?}",
13187                s.x,
13188                s.y,
13189                s.z,
13190                s.order,
13191                unpack_legacy_sorter_key(s.order).0,
13192                unpack_legacy_sorter_key(s.order).1,
13193                s.alpha,
13194                s.tr,
13195                s.image_id
13196            )
13197        })
13198        .unwrap_or_else(|| "sprite_pos=(none) sprite_order=(none) sprite_sorter=(none) sprite_alpha=(none) sprite_tr=(none) image=None".to_string());
13199    eprintln!(
13200        "[SG_OBJECT_MOTION_TRACE][RENDER] frame={} stage={} obj_idx={} slot={} file={} used={} backend={:?} parent_visible={} visible={} emitted={} disp={} local=({}, {}, {}) rep=({}, {}, {}) center=({}, {}, {}) center_rep=({}, {}, {}) prop_final=({}, {}, {}) order={} layer={} total_order={} total_layer={} alpha={} tr={} tr_rep={} local_tr={} bound_len={} bind={:?} x_eve=active:{} total:{} value:{} time:{}/{} y_eve=active:{} total:{} value:{} time:{}/{} tr_eve=active:{} total:{} value:{} time:{}/{} {}",
13201        ctx.globals.render_frame,
13202        stage_idx,
13203        obj_idx,
13204        info.runtime_slot,
13205        obj.file_name.as_deref().unwrap_or("-"),
13206        obj.used,
13207        obj.backend,
13208        parent_visible,
13209        visible,
13210        emitted,
13211        info.disp,
13212        info.x,
13213        info.y,
13214        info.z,
13215        info.x_rep,
13216        info.y_rep,
13217        info.z_rep,
13218        info.center_x,
13219        info.center_y,
13220        info.center_z,
13221        info.center_rep_x,
13222        info.center_rep_y,
13223        info.center_rep_z,
13224        info.x + info.x_rep + info.center_rep_x,
13225        info.y + info.y_rep + info.center_rep_y,
13226        info.z + info.z_rep + info.center_rep_z,
13227        info.order,
13228        info.layer,
13229        total_order,
13230        total_layer,
13231        info.alpha,
13232        info.tr,
13233        info.tr_rep,
13234        local_tr,
13235        bound_len,
13236        bind_dbg,
13237        ev.x.check_event(),
13238        ev.x.get_total_value(),
13239        ev.x.get_value(),
13240        ev.x.cur_time,
13241        ev.x.end_time,
13242        ev.y.check_event(),
13243        ev.y.get_total_value(),
13244        ev.y.get_value(),
13245        ev.y.cur_time,
13246        ev.y.end_time,
13247        ev.tr.check_event(),
13248        ev.tr.get_total_value(),
13249        ev.tr.get_value(),
13250        ev.tr.cur_time,
13251        ev.tr.end_time,
13252        sprite_desc,
13253    );
13254}
13255
13256fn object_tree_texture_key(
13257    ctx: &CommandContext,
13258    stage_idx: i64,
13259    obj_idx: usize,
13260    obj: &globals::ObjectState,
13261) -> (bool, u32) {
13262    let slot = object_runtime_slot(obj_idx, obj);
13263    let image = fetch_bound_render_sprites_any(ctx, stage_idx, slot, obj)
13264        .first()
13265        .and_then(|rs| rs.sprite.image_id.clone());
13266    (image.is_some(), image.map(|id| id.key().0).unwrap_or(0))
13267}
13268
13269fn object_tree_stored_axis(obj: &globals::ObjectState, axis: u8) -> i32 {
13270    // C_elm_object::get_pos_x/y/z() uses IntEvent::get_value(), not the
13271    // animated get_total_value(). Custom child sorting therefore follows the
13272    // stored destination value and does not reshuffle while an event is moving.
13273    match axis {
13274        0 => obj.runtime.prop_events.x.get_value(),
13275        1 => obj.runtime.prop_events.y.get_value(),
13276        _ => obj.runtime.prop_events.z.get_value(),
13277    }
13278}
13279
13280fn append_object_tree_nodes(
13281    ctx: &CommandContext,
13282    worlds: Option<&Vec<globals::WorldState>>,
13283    stage_idx: i64,
13284    obj_idx: usize,
13285    obj: &globals::ObjectState,
13286    precomputed_info: Option<ObjectRenderInfo>,
13287    parent_visible: bool,
13288    parent_order: i64,
13289    parent_layer: i64,
13290    parent_state: Option<ParentRenderState>,
13291    out: &mut Vec<SiglusRenderNode>,
13292    object_keys: &mut HashSet<(LayerId, SpriteId)>,
13293    debug_lines: &mut Vec<String>,
13294) {
13295    if !object_participates_in_tree(obj) {
13296        return;
13297    }
13298
13299    let debug_enabled = sg_render_tree_debug_enabled();
13300    let info =
13301        precomputed_info.unwrap_or_else(|| effective_object_info(ctx, stage_idx, obj_idx, obj));
13302    let local_tr = ((info.tr.clamp(0, 255) * info.tr_rep.clamp(0, 255)) / 255).clamp(0, 255);
13303    let visible = parent_visible
13304        && info.disp
13305        && local_tr > 0
13306        && object_button_renderable_by_syscom(&ctx.globals.syscom, obj);
13307    let total_order = parent_order.saturating_add(info.order);
13308    let total_layer = parent_layer.saturating_add(info.layer);
13309    let mut own_sprites: Vec<RenderSprite> = Vec::new();
13310
13311    if debug_enabled {
13312        let bind_dbg = match &obj.backend {
13313            globals::ObjectBackend::Gfx => ctx
13314                .gfx
13315                .object_sprite_binding(stage_idx, info.runtime_slot as i64),
13316            globals::ObjectBackend::Rect {
13317                layer_id,
13318                sprite_id,
13319                ..
13320            }
13321            | globals::ObjectBackend::String {
13322                layer_id,
13323                sprite_id,
13324                ..
13325            }
13326            | globals::ObjectBackend::Movie {
13327                layer_id,
13328                sprite_id,
13329                ..
13330            } => Some((*layer_id, *sprite_id)),
13331            globals::ObjectBackend::Number {
13332                layer_id,
13333                sprite_ids,
13334            }
13335            | globals::ObjectBackend::Weather {
13336                layer_id,
13337                sprite_ids,
13338            } => sprite_ids.first().copied().map(|sid| (*layer_id, sid)),
13339            globals::ObjectBackend::None => None,
13340        };
13341        debug_lines.push(format!(
13342            "[SG_DEBUG]     obj[{obj_idx}] slot={} used={} type={} backend={:?} file={} disp={} pos=({}, {}) center=({}, {}, {}) center_rep=({}, {}, {}) final_pos=({}, {}, {}) order={} layer={} alpha={} tr={} z={} child_sort={} wipe_copy={} wipe_erase={} bind={:?}",
13343            info.runtime_slot,
13344            obj.used,
13345            obj.object_type,
13346            obj.backend,
13347            obj.file_name.as_deref().unwrap_or("-"),
13348            info.disp,
13349            info.x,
13350            info.y,
13351            info.center_x,
13352            info.center_y,
13353            info.center_z,
13354            info.center_rep_x,
13355            info.center_rep_y,
13356            info.center_rep_z,
13357            info.x + info.x_rep + info.center_rep_x,
13358            info.y + info.y_rep + info.center_rep_y,
13359            info.z + info.z_rep + info.center_rep_z,
13360            info.order,
13361            info.layer,
13362            info.alpha,
13363            info.tr,
13364            info.z,
13365            info.child_sort_type,
13366            obj.get_int_prop(&ctx.ids, ctx.ids.obj_wipe_copy),
13367            obj.get_int_prop(&ctx.ids, ctx.ids.obj_wipe_erase),
13368            bind_dbg,
13369        ));
13370    }
13371
13372    if debug_enabled && obj.button.enabled {
13373        debug_lines.push(format!(
13374            "[SG_DEBUG]       button enabled=true no={} group_no={} group_idx={:?} cut={} action={} se={} state={} hit={} pushed={} alpha_test={} call={}::{}/{}",
13375            obj.button.button_no,
13376            obj.button.group_no,
13377            obj.button.group_idx(),
13378            obj.button.cut_no,
13379            obj.button.action_no,
13380            obj.button.se_no,
13381            obj.button.state,
13382            obj.button.hit,
13383            obj.button.pushed,
13384            obj.button.alpha_test,
13385            obj.button.decided_action_scn_name,
13386            obj.button.decided_action_cmd_name,
13387            obj.button.decided_action_z_no,
13388        ));
13389    }
13390    if debug_enabled && (!obj.frame_action.cmd_name.is_empty() || obj.frame_action.end_flag) {
13391        debug_lines.push(format!(
13392            "[SG_DEBUG]       frame_action cmd={}::{} count={} end_time={} real={} end_flag={} args={:?}",
13393            obj.frame_action.scn_name,
13394            obj.frame_action.cmd_name,
13395            obj.frame_action.counter.get_count(),
13396            obj.frame_action.end_time,
13397            obj.frame_action.real_time_flag,
13398            obj.frame_action.end_flag,
13399            obj.frame_action.args,
13400        ));
13401    }
13402    for (fa_idx, fa) in obj.frame_action_ch.iter().enumerate() {
13403        if debug_enabled && (!fa.cmd_name.is_empty() || fa.end_flag) {
13404            debug_lines.push(format!(
13405                "[SG_DEBUG]       frame_action_ch[{}] cmd={}::{} count={} end_time={} real={} end_flag={} args={:?}",
13406                fa_idx,
13407                fa.scn_name,
13408                fa.cmd_name,
13409                fa.counter.get_count(),
13410                fa.end_time,
13411                fa.real_time_flag,
13412                fa.end_flag,
13413                fa.args,
13414            ));
13415        }
13416    }
13417    let ev = &obj.runtime.prop_events;
13418    if debug_enabled
13419        && (ev.color_rate.check_event()
13420            || ev.tr.check_event()
13421            || ev.x.check_event()
13422            || ev.y.check_event())
13423    {
13424        debug_lines.push(format!(
13425            "[SG_DEBUG]       active_events x={}/{} t={}/{} y={}/{} t={}/{} tr={}/{} t={}/{} color_rate={}/{} t={}/{}",
13426            ev.x.get_total_value(), ev.x.get_value(), ev.x.cur_time, ev.x.end_time,
13427            ev.y.get_total_value(), ev.y.get_value(), ev.y.cur_time, ev.y.end_time,
13428            ev.tr.get_total_value(), ev.tr.get_value(), ev.tr.cur_time, ev.tr.end_time,
13429            ev.color_rate.get_total_value(), ev.color_rate.get_value(), ev.color_rate.cur_time, ev.color_rate.end_time,
13430        ));
13431    }
13432    if debug_enabled
13433        && (!obj.runtime.prop_event_lists.x_rep.is_empty()
13434            || !obj.runtime.prop_event_lists.y_rep.is_empty()
13435            || !obj.runtime.prop_event_lists.tr_rep.is_empty())
13436    {
13437        let fmt_list = |list: &Vec<crate::runtime::int_event::IntEvent>| -> Vec<String> {
13438            list.iter()
13439                .enumerate()
13440                .filter(|(_, ev)| {
13441                    ev.check_event()
13442                        || ev.get_total_value() != ev.def_value
13443                        || ev.get_value() != ev.def_value
13444                })
13445                .map(|(idx, ev)| {
13446                    format!(
13447                        "{}:{}/{} t={}/{} active={}",
13448                        idx,
13449                        ev.get_total_value(),
13450                        ev.get_value(),
13451                        ev.cur_time,
13452                        ev.end_time,
13453                        ev.check_event()
13454                    )
13455                })
13456                .collect()
13457        };
13458        let x_rep = fmt_list(&obj.runtime.prop_event_lists.x_rep);
13459        let y_rep = fmt_list(&obj.runtime.prop_event_lists.y_rep);
13460        let tr_rep = fmt_list(&obj.runtime.prop_event_lists.tr_rep);
13461        if !x_rep.is_empty() || !y_rep.is_empty() || !tr_rep.is_empty() {
13462            debug_lines.push(format!(
13463                "[SG_DEBUG]       rep_events x={:?} y={:?} tr={:?}",
13464                x_rep, y_rep, tr_rep,
13465            ));
13466        }
13467    }
13468
13469    let mut bound = fetch_bound_render_sprites(ctx, stage_idx, info.runtime_slot, obj);
13470    let motion_trace = object_motion_trace_enabled() && object_motion_trace_object(obj);
13471    let motion_bind_dbg = if motion_trace {
13472        object_motion_trace_bind(ctx, stage_idx, &info, obj)
13473    } else {
13474        None
13475    };
13476    if motion_trace && (!visible || bound.is_empty()) {
13477        object_motion_trace_emit(
13478            ctx,
13479            stage_idx,
13480            obj_idx,
13481            obj,
13482            &info,
13483            parent_visible,
13484            visible,
13485            local_tr,
13486            total_order,
13487            total_layer,
13488            bound.len(),
13489            motion_bind_dbg,
13490            false,
13491            None,
13492        );
13493    }
13494    if config_button_trace_enabled() && config_button_trace_object(obj) {
13495        let bind_dbg = match &obj.backend {
13496            globals::ObjectBackend::Gfx => ctx
13497                .gfx
13498                .object_sprite_binding(stage_idx, info.runtime_slot as i64),
13499            globals::ObjectBackend::Rect {
13500                layer_id,
13501                sprite_id,
13502                ..
13503            }
13504            | globals::ObjectBackend::String {
13505                layer_id,
13506                sprite_id,
13507                ..
13508            }
13509            | globals::ObjectBackend::Movie {
13510                layer_id,
13511                sprite_id,
13512                ..
13513            } => Some((*layer_id, *sprite_id)),
13514            globals::ObjectBackend::Number {
13515                layer_id,
13516                sprite_ids,
13517            }
13518            | globals::ObjectBackend::Weather {
13519                layer_id,
13520                sprite_ids,
13521            } => sprite_ids.first().copied().map(|sid| (*layer_id, sid)),
13522            globals::ObjectBackend::None => None,
13523        };
13524        let syscom_renderable = object_button_renderable_by_syscom(&ctx.globals.syscom, obj);
13525        debug_lines.push(format!(
13526            "[SG_DEBUG][CONFIG_BUTTON_TRACE][COLLECT] stage={} obj_idx={} runtime_slot={} file={} backend={:?} participates={} parent_visible={} disp={} local_tr={} tr={} tr_rep={} syscom_renderable={} visible={} bound_len={} bind={:?} order={} layer={} total_order={} total_layer={} button_enabled={} button_state={} button_no={} group_no={} action_no={} hit={} pushed={} disabled_reason={:?} parent_state={}",
13527            stage_idx,
13528            obj_idx,
13529            info.runtime_slot,
13530            obj.file_name.as_deref().unwrap_or("-"),
13531            obj.backend,
13532            object_participates_in_tree(obj),
13533            parent_visible,
13534            info.disp,
13535            local_tr,
13536            info.tr,
13537            info.tr_rep,
13538            syscom_renderable,
13539            visible,
13540            bound.len(),
13541            bind_dbg,
13542            info.order,
13543            info.layer,
13544            total_order,
13545            total_layer,
13546            obj.button.enabled,
13547            obj.button.state,
13548            obj.button.button_no,
13549            obj.button.group_no,
13550            obj.button.action_no,
13551            obj.button.hit,
13552            obj.button.pushed,
13553            button_disabled_reason(&ctx.globals.syscom, obj, None, ctx.runtime_read_skip_is_enable()),
13554            parent_state.is_some()
13555        ));
13556    }
13557    for rs in &bound {
13558        if let (Some(lid), Some(sid)) = (rs.layer_id, rs.sprite_id) {
13559            object_keys.insert((lid, sid));
13560        }
13561    }
13562    let mut cur_parent_state = build_parent_render_state(&info, bound.first().map(|rs| &rs.sprite));
13563    if let Some(parent) = parent_state.clone() {
13564        cur_parent_state = compose_parent_render_state(parent, cur_parent_state);
13565    }
13566
13567    if visible {
13568        if obj.object_type == 4 {
13569            let out_len_before = own_sprites.len();
13570            append_weather_sprites(
13571                ctx,
13572                worlds,
13573                obj,
13574                &info,
13575                total_order,
13576                total_layer,
13577                &bound,
13578                &mut own_sprites,
13579            );
13580            for rs in own_sprites[out_len_before..].iter_mut() {
13581                if let Some(parent) = parent_state.clone() {
13582                    apply_parent_render_state_to_sprite(&mut rs.sprite, &info, &parent);
13583                }
13584                finalize_object_center_rep_to_sprite(&mut rs.sprite, &info);
13585                apply_world_camera_mode(&mut rs.sprite, worlds, ctx.screen_w, ctx.screen_h);
13586                apply_runtime_light_and_fog(ctx, &mut rs.sprite);
13587                if motion_trace {
13588                    object_motion_trace_emit(
13589                        ctx,
13590                        stage_idx,
13591                        obj_idx,
13592                        obj,
13593                        &info,
13594                        parent_visible,
13595                        visible,
13596                        local_tr,
13597                        total_order,
13598                        total_layer,
13599                        bound.len(),
13600                        motion_bind_dbg,
13601                        rs.sprite.tr > 0,
13602                        Some(&rs.sprite),
13603                    );
13604                }
13605            }
13606        } else {
13607            let bound_len_for_trace = bound.len();
13608            for mut rs in bound.drain(..) {
13609                apply_object_render_info_to_sprite(&mut rs.sprite, &info);
13610                let (local_x, local_y) = object_backend_sprite_local_offset(obj, rs.sprite_id);
13611                if local_x != 0 || local_y != 0 {
13612                    rs.sprite.x = (rs.sprite.x as i64 + local_x)
13613                        .clamp(i32::MIN as i64, i32::MAX as i64)
13614                        as i32;
13615                    rs.sprite.y = (rs.sprite.y as i64 + local_y)
13616                        .clamp(i32::MIN as i64, i32::MAX as i64)
13617                        as i32;
13618                    // All glyph and digit quads share the object's transform origin.
13619                    // Moving the individual quad requires the inverse pivot
13620                    // adjustment, matching C++ rp.center -= glyph.pos.
13621                    rs.sprite.pivot_x -= local_x as f32;
13622                    rs.sprite.pivot_y -= local_y as f32;
13623                }
13624                let sprite_layer = total_layer.saturating_add(object_backend_sprite_layer_offset(
13625                    ctx,
13626                    &obj.backend,
13627                    rs.sprite_id,
13628                ));
13629                rs.set_sorter(total_order, sprite_layer);
13630                rs.sprite.order = legacy_packed_sorter_key(total_order, sprite_layer);
13631                configure_sprite_3d(&mut rs.sprite, &info, worlds, ctx.screen_w, ctx.screen_h);
13632                if let Some(parent) = parent_state.clone() {
13633                    apply_parent_render_state_to_sprite(&mut rs.sprite, &info, &parent);
13634                }
13635                finalize_object_center_rep_to_sprite(&mut rs.sprite, &info);
13636                apply_world_camera_mode(&mut rs.sprite, worlds, ctx.screen_w, ctx.screen_h);
13637                apply_runtime_light_and_fog(ctx, &mut rs.sprite);
13638                if motion_trace {
13639                    object_motion_trace_emit(
13640                        ctx,
13641                        stage_idx,
13642                        obj_idx,
13643                        obj,
13644                        &info,
13645                        parent_visible,
13646                        visible,
13647                        local_tr,
13648                        total_order,
13649                        total_layer,
13650                        bound_len_for_trace,
13651                        motion_bind_dbg,
13652                        rs.sprite.tr > 0,
13653                        Some(&rs.sprite),
13654                    );
13655                }
13656                if rs.sprite.tr > 0 {
13657                    own_sprites.push(rs);
13658                }
13659            }
13660        }
13661    }
13662
13663    if config_button_trace_enabled() && config_button_trace_object(obj) {
13664        debug_lines.push(format!(
13665            "[SG_DEBUG][CONFIG_BUTTON_TRACE][EMIT_DONE] stage={} obj_idx={} runtime_slot={} file={} out_len_now={} visible={} child_count={}",
13666            stage_idx,
13667            obj_idx,
13668            info.runtime_slot,
13669            obj.file_name.as_deref().unwrap_or("-"),
13670            own_sprites.len(),
13671            visible,
13672            obj.runtime.child_objects.len()
13673        ));
13674    }
13675
13676    if debug_enabled && !obj.runtime.child_objects.is_empty() {
13677        debug_lines.push(format!(
13678            "[SG_DEBUG]       child_list op=93 len={}",
13679            obj.runtime.child_objects.len()
13680        ));
13681    }
13682    if sg_mwnd_object_trace_enabled() && !obj.runtime.child_objects.is_empty() {
13683        for (child_idx, child) in obj.runtime.child_objects.iter().enumerate() {
13684            let child_info = effective_object_info(ctx, stage_idx, child_idx, child);
13685            let child_bind = match &child.backend {
13686                globals::ObjectBackend::Gfx => ctx
13687                    .gfx
13688                    .object_sprite_binding(stage_idx, child_info.runtime_slot as i64),
13689                globals::ObjectBackend::Rect {
13690                    layer_id,
13691                    sprite_id,
13692                    ..
13693                }
13694                | globals::ObjectBackend::String {
13695                    layer_id,
13696                    sprite_id,
13697                    ..
13698                }
13699                | globals::ObjectBackend::Movie {
13700                    layer_id,
13701                    sprite_id,
13702                    ..
13703                } => Some((*layer_id, *sprite_id)),
13704                globals::ObjectBackend::Number {
13705                    layer_id,
13706                    sprite_ids,
13707                }
13708                | globals::ObjectBackend::Weather {
13709                    layer_id,
13710                    sprite_ids,
13711                } => sprite_ids.first().copied().map(|sid| (*layer_id, sid)),
13712                globals::ObjectBackend::None => None,
13713            };
13714            debug_lines.push(format!(
13715                "[SG_DEBUG][MWND_OBJECT_TRACE]       child parent_slot={} parent_obj_idx={} child[{}] slot={} participates={} used={} type={} backend={:?} file={} disp={} pos=({}, {}) center=({}, {}, {}) center_rep=({}, {}, {}) final_pos=({}, {}, {}) order={} layer={} alpha={} tr={} nested_slot={:?} bind={:?} grandchildren={}",
13716                info.runtime_slot,
13717                obj_idx,
13718                child_idx,
13719                child_info.runtime_slot,
13720                object_participates_in_tree(child),
13721                child.used,
13722                child.object_type,
13723                child.backend,
13724                child.file_name.as_deref().unwrap_or("-"),
13725                child_info.disp,
13726                child_info.x,
13727                child_info.y,
13728                child_info.center_x,
13729                child_info.center_y,
13730                child_info.center_z,
13731                child_info.center_rep_x,
13732                child_info.center_rep_y,
13733                child_info.center_rep_z,
13734                child_info.x + child_info.x_rep + child_info.center_rep_x,
13735                child_info.y + child_info.y_rep + child_info.center_rep_y,
13736                child_info.z + child_info.z_rep + child_info.center_rep_z,
13737                child_info.order,
13738                child_info.layer,
13739                child_info.alpha,
13740                child_info.tr,
13741                child.nested_runtime_slot,
13742                child_bind,
13743                child.runtime.child_objects.len()
13744            ));
13745        }
13746    }
13747
13748    let mut children: Vec<(usize, &globals::ObjectState)> = obj
13749        .runtime
13750        .child_objects
13751        .iter()
13752        .enumerate()
13753        .filter(|(_, child)| object_participates_in_tree(child))
13754        .collect();
13755
13756    let tree_sort_type = if matches!(obj.object_type, 3..=5) {
13757        // STRING/NUMBER/WEATHER create a fresh dummy sprite for CHILD, whose
13758        // child-sort mode remains DEFAULT regardless of the owning object.
13759        0
13760    } else {
13761        info.child_sort_type
13762    };
13763    match (tree_sort_type, obj.object_type) {
13764        (0 | 1, _) => {
13765            // DEFAULT is sorted at tree-flatten time because one child object
13766            // may expand to several sibling nodes (STRING/NUMBER/WEATHER).
13767            // NONE preserves insertion order.
13768        }
13769        (2, _) => {
13770            children.sort_by(|(lhs_idx, lhs), (rhs_idx, rhs)| {
13771                object_tree_texture_key(ctx, stage_idx, *lhs_idx, lhs)
13772                    .cmp(&object_tree_texture_key(ctx, stage_idx, *rhs_idx, rhs))
13773            });
13774        }
13775        (3, 0) => {
13776            children.sort_by_key(|(_, child)| object_tree_stored_axis(child, 0));
13777        }
13778        (4, 0) => {
13779            children.sort_by_key(|(_, child)| std::cmp::Reverse(object_tree_stored_axis(child, 0)));
13780        }
13781        (5, 0) => {
13782            children.sort_by_key(|(_, child)| object_tree_stored_axis(child, 1));
13783        }
13784        (6, 0) => {
13785            children.sort_by_key(|(_, child)| std::cmp::Reverse(object_tree_stored_axis(child, 1)));
13786        }
13787        (7, 0) => {
13788            children.sort_by_key(|(_, child)| object_tree_stored_axis(child, 2));
13789        }
13790        (8, 0) => {
13791            children.sort_by_key(|(_, child)| std::cmp::Reverse(object_tree_stored_axis(child, 2)));
13792        }
13793        (3..=8, _) => {
13794            // In the original source, X/Y/Z child sorting is implemented only
13795            // for TYPE_NONE. Other object types append no children for these
13796            // modes.
13797            children.clear();
13798        }
13799        _ => {}
13800    }
13801
13802    // C_elm_object::frame passes the composed TRP to every child. A hidden or
13803    // fully transparent parent therefore suppresses the complete descendant
13804    // tree, even when the parent node itself is only a dummy/container.
13805    let recurse_children = visible;
13806    let mut child_nodes = Vec::new();
13807    for (child_idx, child) in children {
13808        append_object_tree_nodes(
13809            ctx,
13810            worlds,
13811            stage_idx,
13812            child_idx,
13813            child,
13814            None,
13815            recurse_children,
13816            total_order,
13817            total_layer,
13818            Some(cur_parent_state.clone()),
13819            &mut child_nodes,
13820            object_keys,
13821            debug_lines,
13822        );
13823    }
13824
13825    if !visible {
13826        return;
13827    }
13828
13829    match obj.object_type {
13830        3..=5 => {
13831            // STRING/NUMBER/WEATHER add every generated sprite directly to the
13832            // current parent, then add a separate dummy node for CHILD. This is
13833            // why they must not be collapsed into one contiguous object group.
13834            for rs in own_sprites {
13835                out.push(SiglusRenderNode::from_single_sprite(rs));
13836            }
13837            if !child_nodes.is_empty() {
13838                out.push(SiglusRenderNode::dummy(
13839                    total_order,
13840                    total_layer,
13841                    true,
13842                    child_nodes,
13843                ));
13844            }
13845        }
13846        0 => {
13847            // TYPE_NONE contributes only a dummy tree node and only when it has
13848            // children.
13849            if !child_nodes.is_empty() {
13850                out.push(SiglusRenderNode::dummy(
13851                    total_order,
13852                    total_layer,
13853                    tree_sort_type == 0,
13854                    child_nodes,
13855                ));
13856            }
13857        }
13858        _ => {
13859            if own_sprites.is_empty() && child_nodes.is_empty() {
13860                return;
13861            }
13862            let (sorter_order, sorter_layer) = own_sprites
13863                .first()
13864                .map(|rs| (rs.sorter_order, rs.sorter_layer))
13865                .unwrap_or((
13866                    total_order.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
13867                    total_layer.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
13868                ));
13869            out.push(SiglusRenderNode {
13870                sorter_order,
13871                sorter_layer,
13872                sprites: own_sprites,
13873                sort_children_default: tree_sort_type == 0,
13874                children: child_nodes,
13875            });
13876        }
13877    }
13878}
13879
13880fn append_weather_sprites(
13881    ctx: &CommandContext,
13882    worlds: Option<&Vec<globals::WorldState>>,
13883    _obj: &globals::ObjectState,
13884    info: &ObjectRenderInfo,
13885    total_order: i64,
13886    total_layer: i64,
13887    bound: &[RenderSprite],
13888    out: &mut Vec<RenderSprite>,
13889) {
13890    // `sync_weather_objects()` has already evaluated C_elm_object::weather_frame
13891    // into layer-backed particle sprites.  Preserve that local particle state
13892    // and apply the owning object's transform exactly once.
13893    for template in bound {
13894        if !template.sprite.visible || template.sprite.image_id.is_none() {
13895            continue;
13896        }
13897        let mut rs = template.clone();
13898        let local_x = rs.sprite.x;
13899        let local_y = rs.sprite.y;
13900        let local_scale_x = rs.sprite.scale_x;
13901        let local_scale_y = rs.sprite.scale_y;
13902        let weather_tr = rs.sprite.tr;
13903
13904        apply_object_render_info_to_sprite(&mut rs.sprite, info);
13905        rs.sprite.x =
13906            (rs.sprite.x as i64 + local_x as i64).clamp(i32::MIN as i64, i32::MAX as i64) as i32;
13907        rs.sprite.y =
13908            (rs.sprite.y as i64 + local_y as i64).clamp(i32::MIN as i64, i32::MAX as i64) as i32;
13909        rs.sprite.scale_x *= local_scale_x;
13910        rs.sprite.scale_y *= local_scale_y;
13911        rs.sprite.tr = ((info.tr.clamp(0, 255) * weather_tr as i64) / 255).clamp(0, 255) as u8;
13912        rs.set_sorter(total_order, total_layer);
13913        rs.sprite.order = legacy_packed_sorter_key(total_order, total_layer);
13914        configure_sprite_3d(&mut rs.sprite, info, worlds, ctx.screen_w, ctx.screen_h);
13915        if rs.sprite.tr > 0 {
13916            out.push(rs);
13917        }
13918    }
13919}
13920
13921fn apply_object_render_info_to_sprite(sprite: &mut Sprite, info: &ObjectRenderInfo) {
13922    sprite.visible = info.disp;
13923    sprite.x = (info.x + info.x_rep).clamp(i32::MIN as i64, i32::MAX as i64) as i32;
13924    sprite.y = (info.y + info.y_rep).clamp(i32::MIN as i64, i32::MAX as i64) as i32;
13925    sprite.z = (info.z + info.z_rep) as f32;
13926    sprite.pivot_x = (info.center_x + info.center_rep_x) as f32;
13927    sprite.pivot_y = (info.center_y + info.center_rep_y) as f32;
13928    sprite.pivot_z = (info.center_z + info.center_rep_z) as f32;
13929    sprite.scale_x = info.scale_x as f32 / 1000.0;
13930    sprite.scale_y = info.scale_y as f32 / 1000.0;
13931    sprite.scale_z = info.scale_z as f32 / 1000.0;
13932    sprite.rotate = info.rotate_z as f32 * std::f32::consts::PI / 1800.0;
13933    sprite.rotate_x = info.rotate_x as f32 * std::f32::consts::PI / 1800.0;
13934    sprite.rotate_y = info.rotate_y as f32 * std::f32::consts::PI / 1800.0;
13935    sprite.alpha = info.alpha.clamp(0, 255) as u8;
13936    sprite.tr = ((info.tr.clamp(0, 255) * info.tr_rep.clamp(0, 255)) / 255).clamp(0, 255) as u8;
13937    sprite.mono = info.mono.clamp(0, 255) as u8;
13938    sprite.reverse = info.reverse.clamp(0, 255) as u8;
13939    sprite.bright = info.bright.clamp(0, 255) as u8;
13940    sprite.dark = info.dark.clamp(0, 255) as u8;
13941    sprite.color_rate = info.color_rate.clamp(0, 255) as u8;
13942    sprite.color_add_r = info.color_add_r.clamp(0, 255) as u8;
13943    sprite.color_add_g = info.color_add_g.clamp(0, 255) as u8;
13944    sprite.color_add_b = info.color_add_b.clamp(0, 255) as u8;
13945    sprite.color_r = info.color_r.clamp(0, 255) as u8;
13946    sprite.color_g = info.color_g.clamp(0, 255) as u8;
13947    sprite.color_b = info.color_b.clamp(0, 255) as u8;
13948    sprite.blend = info.blend;
13949    sprite.dst_clip = info.dst_clip;
13950}
13951
13952fn finalize_object_center_rep_to_sprite(sprite: &mut Sprite, info: &ObjectRenderInfo) {
13953    let x = (sprite.x as i64 + info.center_rep_x).clamp(i32::MIN as i64, i32::MAX as i64);
13954    let y = (sprite.y as i64 + info.center_rep_y).clamp(i32::MIN as i64, i32::MAX as i64);
13955    sprite.x = x as i32;
13956    sprite.y = y as i32;
13957    sprite.z += info.center_rep_z as f32;
13958}
13959
13960fn object_participates_in_tree(obj: &globals::ObjectState) -> bool {
13961    // C++ does not have a mutable "used" lifetime flag.  Top-level list slots
13962    // are gated by C_elm_object::is_use() outside this helper; once an object is
13963    // admitted, participation is determined by its payload/tree state.
13964    if obj.object_type != 0 {
13965        return true;
13966    }
13967    if !obj.runtime.child_objects.is_empty() {
13968        return true;
13969    }
13970    !matches!(obj.backend, globals::ObjectBackend::None)
13971}
13972
13973fn mark_object_tree_sprite_keys(
13974    ctx: &CommandContext,
13975    stage_idx: i64,
13976    obj_idx: usize,
13977    obj: &globals::ObjectState,
13978    object_keys: &mut HashSet<(LayerId, SpriteId)>,
13979) {
13980    let runtime_slot = object_runtime_slot(obj_idx, obj);
13981    match &obj.backend {
13982        globals::ObjectBackend::Gfx => {
13983            if let Some(binding) = ctx
13984                .gfx
13985                .object_sprite_binding(stage_idx, runtime_slot as i64)
13986            {
13987                object_keys.insert(binding);
13988            }
13989        }
13990        globals::ObjectBackend::None => {}
13991        globals::ObjectBackend::Rect {
13992            layer_id,
13993            sprite_id,
13994            ..
13995        }
13996        | globals::ObjectBackend::Movie {
13997            layer_id,
13998            sprite_id,
13999            ..
14000        } => {
14001            object_keys.insert((*layer_id, *sprite_id));
14002        }
14003        globals::ObjectBackend::String {
14004            layer_id,
14005            shadow_sprite_id,
14006            fuchi_sprite_id,
14007            sprite_id,
14008            glyphs,
14009            ..
14010        } => {
14011            if glyphs.is_empty() {
14012                object_keys.insert((*layer_id, *shadow_sprite_id));
14013                object_keys.insert((*layer_id, *fuchi_sprite_id));
14014                object_keys.insert((*layer_id, *sprite_id));
14015            } else {
14016                for glyph in glyphs {
14017                    object_keys.insert((*layer_id, glyph.shadow_sprite_id));
14018                    object_keys.insert((*layer_id, glyph.fuchi_sprite_id));
14019                    object_keys.insert((*layer_id, glyph.body_sprite_id));
14020                }
14021            }
14022        }
14023        globals::ObjectBackend::Number {
14024            layer_id,
14025            sprite_ids,
14026        }
14027        | globals::ObjectBackend::Weather {
14028            layer_id,
14029            sprite_ids,
14030        } => {
14031            for &sprite_id in sprite_ids {
14032                object_keys.insert((*layer_id, sprite_id));
14033            }
14034        }
14035    }
14036    for (child_idx, child) in obj.runtime.child_objects.iter().enumerate() {
14037        mark_object_tree_sprite_keys(ctx, stage_idx, child_idx, child, object_keys);
14038    }
14039}
14040
14041fn mark_mwnd_owned_sprite_keys(
14042    ctx: &CommandContext,
14043    stage_idx: i64,
14044    m: &globals::MwndState,
14045    object_keys: &mut HashSet<(LayerId, SpriteId)>,
14046) {
14047    for (idx, obj) in m.button_list.iter().enumerate() {
14048        mark_object_tree_sprite_keys(ctx, stage_idx, idx, obj, object_keys);
14049    }
14050    for (idx, obj) in m.face_list.iter().enumerate() {
14051        mark_object_tree_sprite_keys(ctx, stage_idx, idx, obj, object_keys);
14052    }
14053    for (idx, obj) in m.object_list.iter().enumerate() {
14054        mark_object_tree_sprite_keys(ctx, stage_idx, idx, obj, object_keys);
14055    }
14056}
14057
14058#[derive(Debug)]
14059struct SiglusRenderNode {
14060    sorter_order: i32,
14061    sorter_layer: i32,
14062    sprites: Vec<RenderSprite>,
14063    sort_children_default: bool,
14064    children: Vec<SiglusRenderNode>,
14065}
14066
14067impl SiglusRenderNode {
14068    fn from_single_sprite(rs: RenderSprite) -> Self {
14069        Self {
14070            sorter_order: rs.sorter_order,
14071            sorter_layer: rs.sorter_layer,
14072            sprites: vec![rs],
14073            sort_children_default: true,
14074            children: Vec::new(),
14075        }
14076    }
14077
14078    fn dummy(
14079        sorter_order: i64,
14080        sorter_layer: i64,
14081        sort_children_default: bool,
14082        children: Vec<SiglusRenderNode>,
14083    ) -> Self {
14084        Self {
14085            sorter_order: sorter_order.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
14086            sorter_layer: sorter_layer.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
14087            sprites: Vec::new(),
14088            sort_children_default,
14089            children,
14090        }
14091    }
14092
14093    fn set_stage_metadata(&mut self, stage_form_id: u32, wipe_order: i64, wipe_layer: i64) {
14094        for rs in &mut self.sprites {
14095            rs.set_wipe_sorter(wipe_order, wipe_layer);
14096            rs.set_stage_form_owner(stage_form_id);
14097        }
14098        for child in &mut self.children {
14099            child.set_stage_metadata(stage_form_id, wipe_order, wipe_layer);
14100        }
14101    }
14102
14103    fn sprite_count(&self) -> usize {
14104        self.sprites.len()
14105            + self
14106                .children
14107                .iter()
14108                .map(SiglusRenderNode::sprite_count)
14109                .sum::<usize>()
14110    }
14111
14112    fn flatten(mut self, out: &mut Vec<RenderSprite>) {
14113        out.extend(self.sprites);
14114        if self.sort_children_default {
14115            self.children.sort_by(siglus_render_node_cmp);
14116        }
14117        for child in self.children {
14118            child.flatten(out);
14119        }
14120    }
14121}
14122
14123fn siglus_render_node_cmp(lhs: &SiglusRenderNode, rhs: &SiglusRenderNode) -> std::cmp::Ordering {
14124    (lhs.sorter_order, lhs.sorter_layer).cmp(&(rhs.sorter_order, rhs.sorter_layer))
14125}
14126
14127fn collect_object_render_nodes(
14128    ctx: &CommandContext,
14129    stage_form_id: u32,
14130    worlds: Option<&Vec<globals::WorldState>>,
14131    stage_idx: i64,
14132    obj_idx: usize,
14133    obj: &globals::ObjectState,
14134    precomputed_info: Option<ObjectRenderInfo>,
14135    parent_visible: bool,
14136    parent_order: i64,
14137    parent_layer: i64,
14138    parent_state: Option<ParentRenderState>,
14139    wipe_order: i64,
14140    wipe_layer: i64,
14141    object_keys: &mut HashSet<(LayerId, SpriteId)>,
14142    debug: &mut Vec<String>,
14143) -> Vec<SiglusRenderNode> {
14144    if !object_participates_in_tree(obj) {
14145        return Vec::new();
14146    }
14147
14148    let mut nodes = Vec::new();
14149    append_object_tree_nodes(
14150        ctx,
14151        worlds,
14152        stage_idx,
14153        obj_idx,
14154        obj,
14155        precomputed_info,
14156        parent_visible,
14157        parent_order,
14158        parent_layer,
14159        parent_state,
14160        &mut nodes,
14161        object_keys,
14162        debug,
14163    );
14164    for node in &mut nodes {
14165        node.set_stage_metadata(stage_form_id, wipe_order, wipe_layer);
14166    }
14167    nodes
14168}
14169
14170fn mwnd_parent_render_state_at(
14171    m: &globals::MwndState,
14172    window_x: i64,
14173    window_y: i64,
14174) -> ParentRenderState {
14175    ParentRenderState {
14176        // C++ C_elm_mwnd::frame builds the MWND render parent from a fresh
14177        // S_tnm_render_param and never assigns p_world before passing it to
14178        // C_elm_mwnd_waku::frame.  Waku buttons therefore remain 2D UI sprites
14179        // even if the MWND form has a WORLD value.  Do not inherit m.world here:
14180        // doing so routes message-window buttons through the 3D/depth path, which
14181        // makes their textures appear in the renderer chain while the final frame
14182        // can depth-test them away behind earlier quads.
14183        world_no: -1,
14184        pos_x: window_x as f32,
14185        pos_y: window_y as f32,
14186        pos_z: 0.0,
14187        center_rep_x: 0.0,
14188        center_rep_y: 0.0,
14189        center_rep_z: 0.0,
14190        scale_x: 1.0,
14191        scale_y: 1.0,
14192        scale_z: 1.0,
14193        rotate_x: 0.0,
14194        rotate_y: 0.0,
14195        rotate_z: 0.0,
14196        tr: 255,
14197        mono: 0,
14198        reverse: 0,
14199        bright: 0,
14200        dark: 0,
14201        color_rate: 0,
14202        color_r: 0,
14203        color_g: 0,
14204        color_b: 0,
14205        color_add_r: 0,
14206        color_add_g: 0,
14207        color_add_b: 0,
14208        blend: crate::layer::SpriteBlend::Normal,
14209        dst_clip: None,
14210        mask_image_id: None,
14211        mask_offset_x: 0,
14212        mask_offset_y: 0,
14213        tonecurve_image_id: None,
14214        tonecurve_row: 0.0,
14215        tonecurve_sat: 0.0,
14216    }
14217}
14218
14219fn mwnd_parent_render_state(m: &globals::MwndState) -> ParentRenderState {
14220    let (x, y) = m.window_pos.unwrap_or((0, 0));
14221    mwnd_parent_render_state_at(m, x, y)
14222}
14223
14224fn mwnd_window_rect_for_embedded(
14225    ctx: &CommandContext,
14226    m: &globals::MwndState,
14227) -> Option<(
14228    i64,
14229    i64,
14230    i64,
14231    i64,
14232    Option<crate::runtime::ui::MwndWindowRenderState>,
14233)> {
14234    let (x, y) = m.window_pos?;
14235    let (w, h) = m.window_size?;
14236    if w <= 0 || h <= 0 {
14237        return None;
14238    }
14239    let ui_state = ctx
14240        .ui
14241        .current_mwnd_window_render_state(ctx.screen_w, ctx.screen_h)
14242        .filter(|ui| ui.x as i64 == x && ui.y as i64 == y && ui.w as i64 == w && ui.h as i64 == h);
14243    Some((x, y, w, h, ui_state))
14244}
14245
14246fn mwnd_anim_parent_from_ui_state(
14247    m: &globals::MwndState,
14248    ui: crate::runtime::ui::MwndWindowRenderState,
14249) -> ParentRenderState {
14250    let mut parent = mwnd_parent_render_state_at(m, 0, 0);
14251    parent.pos_x = ui.dx as f32;
14252    parent.pos_y = ui.dy as f32;
14253    parent.center_rep_x = ui.pivot_abs_x - ui.dx as f32;
14254    parent.center_rep_y = ui.pivot_abs_y - ui.dy as f32;
14255    parent.scale_x = ui.scale_x;
14256    parent.scale_y = ui.scale_y;
14257    parent.rotate_z = ui.rotate;
14258    parent.tr = ui.alpha as i32;
14259    parent
14260}
14261
14262fn apply_mwnd_window_anim_parent(
14263    parent: ParentRenderState,
14264    anim_parent: Option<ParentRenderState>,
14265) -> ParentRenderState {
14266    match anim_parent {
14267        Some(anim) => compose_parent_render_state(anim, parent),
14268        None => parent,
14269    }
14270}
14271
14272fn append_mwnd_embedded_object_list_groups(
14273    ctx: &CommandContext,
14274    stage_form_id: u32,
14275    worlds: Option<&Vec<globals::WorldState>>,
14276    stage_idx: i64,
14277    list: &[globals::ObjectState],
14278    parent: ParentRenderState,
14279    parent_order: i64,
14280    parent_layer: i64,
14281    wipe_order: i64,
14282    wipe_layer: i64,
14283    groups: &mut Vec<SiglusRenderNode>,
14284    object_keys: &mut HashSet<(LayerId, SpriteId)>,
14285    debug: &mut Vec<String>,
14286) {
14287    for (obj_idx, obj) in list.iter().enumerate() {
14288        groups.extend(collect_object_render_nodes(
14289            ctx,
14290            stage_form_id,
14291            worlds,
14292            stage_idx,
14293            obj_idx,
14294            obj,
14295            None,
14296            true,
14297            parent_order,
14298            parent_layer,
14299            Some(parent.clone()),
14300            wipe_order,
14301            wipe_layer,
14302            object_keys,
14303            debug,
14304        ));
14305    }
14306}
14307
14308fn mwnd_button_parent_render_state(
14309    m: &globals::MwndState,
14310    button_idx: usize,
14311    window_x: i64,
14312    window_y: i64,
14313    window_w: i64,
14314    window_h: i64,
14315) -> ParentRenderState {
14316    let mut parent = mwnd_parent_render_state_at(m, window_x, window_y);
14317    let Some(&(pos_base, x, y)) = m.waku_button_layout.get(button_idx) else {
14318        return parent;
14319    };
14320    match pos_base {
14321        1 => {
14322            parent.pos_x += (window_w - x) as f32;
14323            parent.pos_y += y as f32;
14324        }
14325        2 => {
14326            parent.pos_x += x as f32;
14327            parent.pos_y += (window_h - y) as f32;
14328        }
14329        3 => {
14330            parent.pos_x += (window_w - x) as f32;
14331            parent.pos_y += (window_h - y) as f32;
14332        }
14333        _ => {
14334            parent.pos_x += x as f32;
14335            parent.pos_y += y as f32;
14336        }
14337    }
14338    parent
14339}
14340
14341fn mwnd_face_parent_render_state(
14342    m: &globals::MwndState,
14343    face_idx: usize,
14344    window_x: i64,
14345    window_y: i64,
14346) -> ParentRenderState {
14347    let mut parent = mwnd_parent_render_state_at(m, window_x, window_y);
14348    if let Some(&(x, y)) = m.waku_face_pos.get(face_idx) {
14349        parent.pos_x += x as f32;
14350        parent.pos_y += y as f32;
14351    }
14352    parent
14353}
14354
14355fn btnselitem_parent_render_state(item: &globals::BtnSelItemState) -> ParentRenderState {
14356    ParentRenderState {
14357        world_no: -1,
14358        pos_x: item.pos.0.saturating_add(item.animation_offset.0) as f32,
14359        pos_y: item.pos.1.saturating_add(item.animation_offset.1) as f32,
14360        pos_z: 0.0,
14361        center_rep_x: 0.0,
14362        center_rep_y: 0.0,
14363        center_rep_z: 0.0,
14364        scale_x: 1.0,
14365        scale_y: 1.0,
14366        scale_z: 1.0,
14367        rotate_x: 0.0,
14368        rotate_y: 0.0,
14369        rotate_z: 0.0,
14370        tr: if item.visible {
14371            item.animation_tr.unwrap_or(255).clamp(0, 255) as i32
14372        } else {
14373            0
14374        },
14375        mono: 0,
14376        reverse: 0,
14377        bright: 0,
14378        dark: 0,
14379        color_rate: 0,
14380        color_r: 0,
14381        color_g: 0,
14382        color_b: 0,
14383        color_add_r: 0,
14384        color_add_g: 0,
14385        color_add_b: 0,
14386        blend: crate::layer::SpriteBlend::Normal,
14387        dst_clip: None,
14388        mask_image_id: None,
14389        mask_offset_x: 0,
14390        mask_offset_y: 0,
14391        tonecurve_image_id: None,
14392        tonecurve_row: 0.0,
14393        tonecurve_sat: 0.0,
14394    }
14395}
14396
14397fn append_btnselitem_groups(
14398    ctx: &CommandContext,
14399    stage_form_id: u32,
14400    worlds: Option<&Vec<globals::WorldState>>,
14401    stage_idx: i64,
14402    items: &[globals::BtnSelItemState],
14403    groups: &mut Vec<SiglusRenderNode>,
14404    object_keys: &mut HashSet<(LayerId, SpriteId)>,
14405    debug: &mut Vec<String>,
14406) {
14407    let wipe_order = ctx.tables.mwnd_render.order;
14408    let wipe_layer = 0;
14409    for (item_idx, item) in items.iter().enumerate() {
14410        if !item.visible {
14411            continue;
14412        }
14413        let parent = btnselitem_parent_render_state(item);
14414        for (obj_idx, obj) in item.generated_objects.iter().enumerate() {
14415            groups.extend(collect_object_render_nodes(
14416                ctx,
14417                stage_form_id,
14418                worlds,
14419                stage_idx,
14420                obj_idx,
14421                obj,
14422                None,
14423                true,
14424                wipe_order,
14425                0,
14426                Some(parent.clone()),
14427                wipe_order,
14428                wipe_layer,
14429                object_keys,
14430                debug,
14431            ));
14432        }
14433        for (obj_idx, obj) in item.object_list.iter().enumerate() {
14434            groups.extend(collect_object_render_nodes(
14435                ctx,
14436                stage_form_id,
14437                worlds,
14438                stage_idx,
14439                obj_idx,
14440                obj,
14441                None,
14442                true,
14443                wipe_order,
14444                0,
14445                Some(parent.clone()),
14446                wipe_order,
14447                wipe_layer,
14448                object_keys,
14449                debug,
14450            ));
14451        }
14452        if sg_render_tree_debug_enabled()
14453            && (item.generated_objects.len() + item.object_list.len()) == 0
14454        {
14455            debug.push(format!(
14456                "[SG_DEBUG]     btnselitem[{item_idx}] text_len={} visible={} pos=({}, {}) size=({}, {}) no_objects",
14457                item.text.chars().count(),
14458                item.visible,
14459                item.pos.0,
14460                item.pos.1,
14461                item.size.0,
14462                item.size.1,
14463            ));
14464        }
14465    }
14466}
14467
14468#[derive(Clone)]
14469struct SelBtnSpriteVisual {
14470    component: u8,
14471    action_no: i64,
14472    state: i64,
14473    base_file: Option<String>,
14474    base_patno: i64,
14475    text_color: Option<(u8, u8, u8)>,
14476}
14477
14478fn collect_selbtn_sprite_visuals_recursive(
14479    obj: &globals::ObjectState,
14480    component: u8,
14481    action_no: i64,
14482    state: i64,
14483    text_color: Option<(u8, u8, u8)>,
14484    map: &mut HashMap<(LayerId, SpriteId), SelBtnSpriteVisual>,
14485) {
14486    match (&obj.backend, component) {
14487        (globals::ObjectBackend::Gfx | globals::ObjectBackend::None, _) => {}
14488        (
14489            globals::ObjectBackend::String {
14490                layer_id,
14491                shadow_sprite_id,
14492                fuchi_sprite_id,
14493                sprite_id,
14494                glyphs,
14495                ..
14496            },
14497            2,
14498        ) => {
14499            // The original text item owns three sprites per glyph.  Only body
14500            // sprites switch between normal/hit colours; shadow and fuchi
14501            // retain their configured colours.
14502            if glyphs.is_empty() {
14503                for (sid, text_component, color) in [
14504                    (*shadow_sprite_id, 4u8, None),
14505                    (*fuchi_sprite_id, 5u8, None),
14506                    (*sprite_id, 2u8, text_color),
14507                ] {
14508                    map.insert(
14509                        (*layer_id, sid),
14510                        SelBtnSpriteVisual {
14511                            component: text_component,
14512                            action_no,
14513                            state,
14514                            base_file: obj.file_name.clone(),
14515                            base_patno: obj.base.patno,
14516                            text_color: color,
14517                        },
14518                    );
14519                }
14520            } else {
14521                for glyph in glyphs {
14522                    for (sid, text_component, color) in [
14523                        (glyph.shadow_sprite_id, 4u8, None),
14524                        (glyph.fuchi_sprite_id, 5u8, None),
14525                        (glyph.body_sprite_id, 2u8, text_color),
14526                    ] {
14527                        map.insert(
14528                            (*layer_id, sid),
14529                            SelBtnSpriteVisual {
14530                                component: text_component,
14531                                action_no,
14532                                state,
14533                                base_file: obj.file_name.clone(),
14534                                base_patno: obj.base.patno,
14535                                text_color: color,
14536                            },
14537                        );
14538                    }
14539                }
14540            }
14541        }
14542        _ => {
14543            for (layer_id, sprite_id) in layer_backed_object_sprite_bindings(&obj.backend) {
14544                map.insert(
14545                    (layer_id, sprite_id),
14546                    SelBtnSpriteVisual {
14547                        component,
14548                        action_no,
14549                        state,
14550                        base_file: obj.file_name.clone(),
14551                        base_patno: obj.base.patno,
14552                        text_color,
14553                    },
14554                );
14555            }
14556        }
14557    }
14558
14559    for child in &obj.runtime.child_objects {
14560        collect_selbtn_sprite_visuals_recursive(
14561            child, component, action_no, state, text_color, map,
14562        );
14563    }
14564}
14565
14566fn apply_selbtn_item_visuals(ctx: &mut CommandContext, sprites: &mut [RenderSprite]) {
14567    let mut map: HashMap<(LayerId, SpriteId), SelBtnSpriteVisual> = HashMap::default();
14568    let template_no = ctx.globals.selbtn.template_no.max(0) as usize;
14569    let hit_color_no = ctx
14570        .globals
14571        .selbtn
14572        .saved_cur_param
14573        .map(|param| param[17])
14574        .or_else(|| {
14575            ctx.tables
14576                .sel_btn_templates
14577                .get(template_no)
14578                .map(|tmpl| tmpl.moji_hit_color)
14579        })
14580        .unwrap_or(-1);
14581    for st in ctx.globals.stage_forms.values() {
14582        for items in st.btnselitem_lists.values() {
14583            for item in items {
14584                if !item.visible {
14585                    continue;
14586                }
14587                for (obj_idx, obj) in item.generated_objects.iter().enumerate() {
14588                    let component = match obj_idx {
14589                        0 => 0,
14590                        1 => 1,
14591                        _ => 2,
14592                    };
14593                    let text_color_no = if component == 2
14594                        && item.button_state != TNM_BTN_STATE_NORMAL
14595                        && item.button_state != TNM_BTN_STATE_DISABLE
14596                        && hit_color_no >= 0
14597                    {
14598                        hit_color_no
14599                    } else {
14600                        item.color
14601                    };
14602                    let text_color = if component == 2 {
14603                        Some(ctx.gameexe_color(text_color_no))
14604                    } else {
14605                        None
14606                    };
14607                    collect_selbtn_sprite_visuals_recursive(
14608                        obj,
14609                        component,
14610                        item.button_action_no,
14611                        item.button_state,
14612                        text_color,
14613                        &mut map,
14614                    );
14615                }
14616                for obj in &item.object_list {
14617                    collect_selbtn_sprite_visuals_recursive(
14618                        obj,
14619                        3,
14620                        item.button_action_no,
14621                        item.button_state,
14622                        None,
14623                        &mut map,
14624                    );
14625                }
14626            }
14627        }
14628    }
14629
14630    if map.is_empty() {
14631        return;
14632    }
14633
14634    for rs in sprites.iter_mut() {
14635        let (Some(lid), Some(sid)) = (rs.layer_id, rs.sprite_id) else {
14636            continue;
14637        };
14638        let Some(visual) = map.get(&(lid, sid)).cloned() else {
14639            continue;
14640        };
14641        let pat = button_action_pattern(&ctx.tables, visual.action_no, visual.state);
14642
14643        rs.sprite.x = rs.sprite.x.saturating_add(pat.rep_pos_x as i32);
14644        rs.sprite.y = rs.sprite.y.saturating_add(pat.rep_pos_y as i32);
14645
14646        match visual.component {
14647            0 => {
14648                if let Some(file_name) = visual.base_file.as_deref().filter(|s| !s.is_empty()) {
14649                    let patno = visual.base_patno.saturating_add(pat.rep_pat_no).max(0) as u32;
14650                    let image_id = match ctx.images.load_g00(file_name, patno) {
14651                        Ok(id) => Some(id),
14652                        Err(_) => ctx.images.load_bg_frame(file_name, patno as usize).ok(),
14653                    };
14654                    if let Some(image_id) = image_id {
14655                        rs.sprite.image_id = Some(image_id.clone());
14656                        if let Some(img) = ctx.images.get(&image_id) {
14657                            rs.sprite.object_anchor = true;
14658                            rs.sprite.texture_center_x = img.center_x as f32;
14659                            rs.sprite.texture_center_y = img.center_y as f32;
14660                        }
14661                    }
14662                }
14663                rs.sprite.tr =
14664                    ((rs.sprite.tr as i64 * pat.rep_tr.clamp(0, 255)) / 255).clamp(0, 255) as u8;
14665                rs.sprite.bright = (rs.sprite.bright as i64 + pat.rep_bright).clamp(0, 255) as u8;
14666                rs.sprite.dark = (rs.sprite.dark as i64 + pat.rep_dark).clamp(0, 255) as u8;
14667            }
14668            1 => {
14669                let (cfg_r, cfg_g, cfg_b, cfg_a) = ctx.syscom_filter_config_rgba();
14670                rs.sprite.alpha = 255;
14671                rs.sprite.tr =
14672                    ((cfg_a as i64 * pat.rep_tr.clamp(0, 255)) / 255).clamp(0, 255) as u8;
14673                rs.sprite.alpha_test = true;
14674                rs.sprite.alpha_blend = true;
14675                rs.sprite.color_rate = 0;
14676                rs.sprite.color_add_r = cfg_r;
14677                rs.sprite.color_add_g = cfg_g;
14678                rs.sprite.color_add_b = cfg_b;
14679                rs.sprite.color_r = 0;
14680                rs.sprite.color_g = 0;
14681                rs.sprite.color_b = 0;
14682                rs.sprite.bright = 0;
14683                rs.sprite.dark = 0;
14684                rs.sprite.mask_mode = 0;
14685            }
14686            2 => {
14687                if let Some((r, g, b)) = visual.text_color {
14688                    // C_elm_btn_select_item::frame() supplies the selected
14689                    // colour to the body glyphs every frame.  Replace the
14690                    // cached normal RGB while preserving its coverage alpha.
14691                    rs.sprite.color_rate = 255;
14692                    rs.sprite.color_r = r;
14693                    rs.sprite.color_g = g;
14694                    rs.sprite.color_b = b;
14695                    rs.sprite.color_add_r = 0;
14696                    rs.sprite.color_add_g = 0;
14697                    rs.sprite.color_add_b = 0;
14698                    rs.sprite.bright = 0;
14699                    rs.sprite.dark = 0;
14700                }
14701            }
14702            _ => {}
14703        }
14704    }
14705}
14706
14707fn append_mwnd_embedded_groups(
14708    ctx: &CommandContext,
14709    stage_form_id: u32,
14710    worlds: Option<&Vec<globals::WorldState>>,
14711    stage_idx: i64,
14712    m: &globals::MwndState,
14713    groups: &mut Vec<SiglusRenderNode>,
14714    object_keys: &mut HashSet<(LayerId, SpriteId)>,
14715    debug: &mut Vec<String>,
14716) {
14717    if ctx.globals.script.mwnd_disp_off_flag
14718        || ctx.globals.syscom.hide_mwnd.onoff
14719        || ctx.globals.syscom.msg_back_open
14720    {
14721        if config_button_trace_enabled() {
14722            debug.push(format!(
14723                "[SG_DEBUG][CONFIG_BUTTON_TRACE][MWND_SKIP] stage={} reason=hidden script_off={} sys_hide={} open={} buttons={} objects={} waku={} filter={} pos={:?} size={:?}",
14724                stage_idx,
14725                ctx.globals.script.mwnd_disp_off_flag,
14726                ctx.globals.syscom.hide_mwnd.onoff,
14727                m.open,
14728                m.button_list.len(),
14729                m.object_list.len(),
14730                if m.waku_file.is_empty() { "-" } else { m.waku_file.as_str() },
14731                if m.filter_file.is_empty() { "-" } else { m.filter_file.as_str() },
14732                m.window_pos,
14733                m.window_size
14734            ));
14735        }
14736        return;
14737    }
14738    let Some((window_x, window_y, window_w, window_h, ui_state)) =
14739        mwnd_window_rect_for_embedded(ctx, m)
14740    else {
14741        if config_button_trace_enabled() {
14742            debug.push(format!(
14743                "[SG_DEBUG][CONFIG_BUTTON_TRACE][MWND_SKIP] stage={} reason=no_window_rect open={} buttons={} objects={} waku={} filter={} pos={:?} size={:?}",
14744                stage_idx,
14745                m.open,
14746                m.button_list.len(),
14747                m.object_list.len(),
14748                if m.waku_file.is_empty() { "-" } else { m.waku_file.as_str() },
14749                if m.filter_file.is_empty() { "-" } else { m.filter_file.as_str() },
14750                m.window_pos,
14751                m.window_size
14752            ));
14753        }
14754        return;
14755    };
14756    if !m.open && ui_state.is_none() {
14757        if config_button_trace_enabled() {
14758            debug.push(format!(
14759                "[SG_DEBUG][CONFIG_BUTTON_TRACE][MWND_SKIP] stage={} reason=closed_no_anim open={} buttons={} objects={} waku={} filter={} rect=({}, {}, {}, {})",
14760                stage_idx,
14761                m.open,
14762                m.button_list.len(),
14763                m.object_list.len(),
14764                if m.waku_file.is_empty() { "-" } else { m.waku_file.as_str() },
14765                if m.filter_file.is_empty() { "-" } else { m.filter_file.as_str() },
14766                window_x, window_y, window_w, window_h
14767            ));
14768        }
14769        return;
14770    }
14771    let mwnd_order_source = if m.order <= 0 {
14772        ctx.tables.mwnd_render.order.max(1)
14773    } else {
14774        m.order
14775    };
14776    let mwnd_order = mwnd_order_source;
14777    let mwnd_layer = m.layer;
14778    let anim_parent = ui_state.map(|ui| mwnd_anim_parent_from_ui_state(m, ui));
14779    if config_button_trace_enabled() {
14780        debug.push(format!(
14781            "[SG_DEBUG][CONFIG_BUTTON_TRACE][MWND_COLLECT] stage={} open={} buttons={} faces={} objects={} waku={} filter={} rect=({}, {}, {}, {}) ui_anim={} order={} layer={} hide_flags=(script:{},sys:{})",
14782            stage_idx,
14783            m.open,
14784            m.button_list.len(),
14785            m.face_list.len(),
14786            m.object_list.len(),
14787            if m.waku_file.is_empty() { "-" } else { m.waku_file.as_str() },
14788            if m.filter_file.is_empty() { "-" } else { m.filter_file.as_str() },
14789            window_x, window_y, window_w, window_h,
14790            anim_parent.is_some(),
14791            mwnd_order,
14792            mwnd_layer,
14793            ctx.globals.script.mwnd_disp_off_flag,
14794            ctx.globals.syscom.hide_mwnd.onoff
14795        ));
14796    }
14797    for (button_idx, obj) in m.button_list.iter().enumerate() {
14798        if !object_participates_in_tree(obj) {
14799            if config_button_trace_enabled() {
14800                debug.push(format!(
14801                    "[SG_DEBUG][CONFIG_BUTTON_TRACE][MWND_BUTTON_SKIP] stage={} button_idx={} reason=not_participating file={} used={} type={} disp={} backend={:?}",
14802                    stage_idx,
14803                    button_idx,
14804                    obj.file_name.as_deref().unwrap_or("-"),
14805                    obj.used,
14806                    obj.object_type,
14807                    obj.base.disp,
14808                    obj.backend
14809                ));
14810            }
14811            continue;
14812        }
14813        let local_parent =
14814            mwnd_button_parent_render_state(m, button_idx, window_x, window_y, window_w, window_h);
14815        let parent = apply_mwnd_window_anim_parent(local_parent, anim_parent.clone());
14816        if sg_render_tree_debug_enabled() {
14817            debug.push(format!(
14818                "[SG_DEBUG]       mwnd_button_parent[{}] file={} pos=({}, {}) local_base={:?} order={} layer={}",
14819                button_idx,
14820                obj.file_name.as_deref().unwrap_or("-"),
14821                parent.pos_x,
14822                parent.pos_y,
14823                m.waku_button_layout.get(button_idx),
14824                mwnd_order,
14825                mwnd_layer.saturating_add(ctx.tables.mwnd_render.waku_layer_rep),
14826            ));
14827        }
14828        groups.extend(collect_object_render_nodes(
14829            ctx,
14830            stage_form_id,
14831            worlds,
14832            stage_idx,
14833            button_idx,
14834            obj,
14835            None,
14836            true,
14837            mwnd_order,
14838            mwnd_layer.saturating_add(ctx.tables.mwnd_render.waku_layer_rep),
14839            Some(parent),
14840            mwnd_order,
14841            mwnd_layer,
14842            object_keys,
14843            debug,
14844        ));
14845    }
14846    for (face_idx, obj) in m.face_list.iter().enumerate() {
14847        if !object_participates_in_tree(obj) {
14848            continue;
14849        }
14850        let parent = apply_mwnd_window_anim_parent(
14851            mwnd_face_parent_render_state(m, face_idx, window_x, window_y),
14852            anim_parent.clone(),
14853        );
14854        groups.extend(collect_object_render_nodes(
14855            ctx,
14856            stage_form_id,
14857            worlds,
14858            stage_idx,
14859            face_idx,
14860            obj,
14861            None,
14862            true,
14863            mwnd_order,
14864            mwnd_layer.saturating_add(ctx.tables.mwnd_render.face_layer_rep),
14865            Some(parent),
14866            mwnd_order,
14867            mwnd_layer,
14868            object_keys,
14869            debug,
14870        ));
14871    }
14872    let parent = apply_mwnd_window_anim_parent(
14873        mwnd_parent_render_state_at(m, window_x, window_y),
14874        anim_parent,
14875    );
14876    append_mwnd_embedded_object_list_groups(
14877        ctx,
14878        stage_form_id,
14879        worlds,
14880        stage_idx,
14881        &m.object_list,
14882        parent,
14883        mwnd_order,
14884        mwnd_layer,
14885        mwnd_order,
14886        mwnd_layer,
14887        groups,
14888        object_keys,
14889        debug,
14890    );
14891}
14892
14893fn mwnd_sort_base(ctx: &CommandContext, m: &globals::MwndState) -> (i64, i64) {
14894    let order = if m.order <= 0 {
14895        ctx.tables.mwnd_render.order.max(1)
14896    } else {
14897        m.order
14898    };
14899    (order, m.layer)
14900}
14901
14902fn selected_mwnd_sort_base(ctx: &CommandContext) -> Option<(i64, i64)> {
14903    if let Some((focused_form, focused_stage, focused_idx)) = ctx.globals.focused_stage_mwnd
14904        && let Some(m) = ctx
14905            .globals
14906            .stage_forms
14907            .get(&focused_form)
14908            .and_then(|st| st.mwnd_lists.get(&focused_stage))
14909            .and_then(|list| list.get(focused_idx))
14910            .filter(|m| m.open)
14911    {
14912        return Some(mwnd_sort_base(ctx, m));
14913    }
14914
14915    let mut form_ids: Vec<u32> = ctx.globals.stage_forms.keys().copied().collect();
14916    form_ids.sort_unstable();
14917    for form_id in form_ids {
14918        let Some(st) = ctx.globals.stage_forms.get(&form_id) else {
14919            continue;
14920        };
14921        let mut stage_ids: Vec<i64> = st.mwnd_lists.keys().copied().collect();
14922        stage_ids.sort_unstable();
14923        for stage_idx in stage_ids {
14924            let Some(list) = st.mwnd_lists.get(&stage_idx) else {
14925                continue;
14926            };
14927            for m in list {
14928                if m.open {
14929                    return Some(mwnd_sort_base(ctx, m));
14930                }
14931            }
14932        }
14933    }
14934    None
14935}
14936
14937fn normalize_mwnd_ui_sprite_sorter(
14938    ctx: &CommandContext,
14939    layer_id: Option<LayerId>,
14940    order: i32,
14941) -> (i32, i32) {
14942    if let Some(sorter) = ctx.ui.mwnd_sorter_for_ui_layer(
14943        layer_id,
14944        order,
14945        ctx.tables.mwnd_render.waku_layer_rep,
14946        ctx.tables.mwnd_render.filter_layer_rep,
14947        ctx.tables.mwnd_render.face_layer_rep,
14948        ctx.tables.mwnd_render.shadow_layer_rep,
14949        ctx.tables.mwnd_render.fuchi_layer_rep,
14950        ctx.tables.mwnd_render.moji_layer_rep,
14951    ) {
14952        return sorter;
14953    }
14954    let Some((mwnd_order, mwnd_layer)) = selected_mwnd_sort_base(ctx) else {
14955        return unpack_legacy_sorter_key(order);
14956    };
14957    let layer = match order {
14958        1_000_000 | 1_000_030 => mwnd_layer.saturating_add(ctx.tables.mwnd_render.waku_layer_rep),
14959        1_000_005 => mwnd_layer.saturating_add(ctx.tables.mwnd_render.filter_layer_rep),
14960        1_000_008 => mwnd_layer.saturating_add(ctx.tables.mwnd_render.face_layer_rep),
14961        1_000_010 | 1_000_020 => mwnd_layer.saturating_add(ctx.tables.mwnd_render.shadow_layer_rep),
14962        1_000_011 | 1_000_021 => mwnd_layer.saturating_add(ctx.tables.mwnd_render.fuchi_layer_rep),
14963        1_000_012 | 1_000_013 | 1_000_022 => {
14964            mwnd_layer.saturating_add(ctx.tables.mwnd_render.moji_layer_rep)
14965        }
14966        _ => return unpack_legacy_sorter_key(order),
14967    };
14968    (
14969        mwnd_order.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
14970        layer.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
14971    )
14972}
14973
14974const TNM_STAGE_FRONT_I64: i64 = 1;
14975const TNM_SEL_ITEM_TYPE_ON_I64: i64 = 1;
14976const TNM_SEL_ITEM_TYPE_READ_I64: i64 = 2;
14977const TNM_STAGE_NEXT_I64: i64 = 2;
14978const EXCALL_LOCAL_NS_XOR: u32 = 0x4000;
14979const INIDEF_EXCALL_ORDER: i64 = 20_000;
14980
14981fn excall_stage_form_id(ctx: &CommandContext) -> u32 {
14982    let normal = if ctx.ids.form_global_stage != 0 {
14983        ctx.ids.form_global_stage
14984    } else {
14985        crate::runtime::forms::codes::FORM_GLOBAL_STAGE
14986    };
14987    normal ^ EXCALL_LOCAL_NS_XOR
14988}
14989
14990fn is_excall_stage_form(ctx: &CommandContext, form_id: u32) -> bool {
14991    ctx.excall_state.ready && form_id == excall_stage_form_id(ctx)
14992}
14993
14994fn configured_excall_order(ctx: &CommandContext) -> i64 {
14995    ctx.tables
14996        .gameexe
14997        .as_ref()
14998        .and_then(|cfg| {
14999            cfg.get_i64("#EXCALL.ORDER")
15000                .or_else(|| cfg.get_i64("EXCALL.ORDER"))
15001        })
15002        .unwrap_or(INIDEF_EXCALL_ORDER)
15003}
15004
15005fn mark_all_stage_owned_sprite_keys(
15006    ctx: &CommandContext,
15007    object_keys: &mut HashSet<(LayerId, SpriteId)>,
15008) {
15009    // Ownership collection has no ordering semantics.  Walk each backing map
15010    // directly instead of allocating/sorting temporary form/stage id vectors.
15011    for st in ctx.globals.stage_forms.values() {
15012        for (&stage_idx, list) in &st.object_lists {
15013            for (obj_idx, obj) in list.iter().enumerate() {
15014                mark_object_tree_sprite_keys(ctx, stage_idx, obj_idx, obj, object_keys);
15015            }
15016        }
15017        for (&stage_idx, mwnds) in &st.mwnd_lists {
15018            for m in mwnds {
15019                mark_mwnd_owned_sprite_keys(ctx, stage_idx, m, object_keys);
15020            }
15021        }
15022        for (&stage_idx, items) in &st.btnselitem_lists {
15023            for item in items {
15024                for (obj_idx, obj) in item.generated_objects.iter().enumerate() {
15025                    mark_object_tree_sprite_keys(ctx, stage_idx, obj_idx, obj, object_keys);
15026                }
15027                for (obj_idx, obj) in item.object_list.iter().enumerate() {
15028                    mark_object_tree_sprite_keys(ctx, stage_idx, obj_idx, obj, object_keys);
15029                }
15030            }
15031        }
15032    }
15033}
15034
15035fn build_siglus_object_render_list(
15036    ctx: &CommandContext,
15037    base: &[RenderSprite],
15038    selected_stage: i64,
15039    object_keys: &mut HashSet<(LayerId, SpriteId)>,
15040) -> (Vec<RenderSprite>, Vec<String>) {
15041    let debug_enabled = sg_render_tree_debug_enabled();
15042    let focused_mwnd = ctx.globals.focused_stage_mwnd;
15043    let mut render_nodes: Vec<SiglusRenderNode> = Vec::new();
15044    let mut debug = Vec::new();
15045    if config_button_trace_enabled() {
15046        debug.push(format!(
15047            "[SG_DEBUG][CONFIG_BUTTON_TRACE][BUILD] selected_stage={} focused_mwnd={:?} base_len={} wipe_active={}",
15048            selected_stage,
15049            ctx.globals.focused_stage_mwnd,
15050            base.len(),
15051            ctx.globals.wipe.is_some()
15052        ));
15053    }
15054
15055    let mut form_ids: Vec<u32> = ctx.globals.stage_forms.keys().copied().collect();
15056    form_ids.sort_unstable();
15057    for form_id in form_ids {
15058        let Some(st) = ctx.globals.stage_forms.get(&form_id) else {
15059            continue;
15060        };
15061        if debug_enabled {
15062            debug.push(format!("[SG_DEBUG] stage_form {}", form_id));
15063        }
15064        let mut stage_ids: Vec<i64> = st
15065            .object_lists
15066            .keys()
15067            .chain(st.mwnd_lists.keys())
15068            .chain(st.group_lists.keys())
15069            .chain(st.btnselitem_lists.keys())
15070            .chain(st.world_lists.keys())
15071            .chain(st.effect_lists.keys())
15072            .chain(st.quake_lists.keys())
15073            .copied()
15074            .collect();
15075        stage_ids.sort_unstable();
15076        stage_ids.dedup();
15077        for stage_idx in stage_ids {
15078            let worlds = st.world_lists.get(&stage_idx);
15079            if let Some(mwnds) = st.mwnd_lists.get(&stage_idx) {
15080                for m in mwnds {
15081                    mark_mwnd_owned_sprite_keys(ctx, stage_idx, m, object_keys);
15082                }
15083            }
15084
15085            let active_cnt = st
15086                .object_lists
15087                .get(&stage_idx)
15088                .map(|list| {
15089                    list.iter()
15090                        .enumerate()
15091                        .filter(|(obj_idx, o)| {
15092                            !st.is_embedded_object_slot(stage_idx, *obj_idx)
15093                                && object_participates_in_tree(o)
15094                        })
15095                        .count()
15096                })
15097                .unwrap_or(0);
15098            let mwnd_embedded_cnt = st
15099                .mwnd_lists
15100                .get(&stage_idx)
15101                .map(|mwnds| {
15102                    mwnds
15103                        .iter()
15104                        .map(|m| m.button_list.len() + m.face_list.len() + m.object_list.len())
15105                        .sum::<usize>()
15106                })
15107                .unwrap_or(0);
15108            let group_cnt = st.group_lists.get(&stage_idx).map(|v| v.len()).unwrap_or(0);
15109            let btnselitem_cnt = st
15110                .btnselitem_lists
15111                .get(&stage_idx)
15112                .map(|v| v.len())
15113                .unwrap_or(0);
15114            let world_cnt = st.world_lists.get(&stage_idx).map(|v| v.len()).unwrap_or(0);
15115            let effect_cnt = st
15116                .effect_lists
15117                .get(&stage_idx)
15118                .map(|v| v.len())
15119                .unwrap_or(0);
15120            let quake_cnt = st.quake_lists.get(&stage_idx).map(|v| v.len()).unwrap_or(0);
15121            if active_cnt == 0
15122                && mwnd_embedded_cnt == 0
15123                && group_cnt == 0
15124                && btnselitem_cnt == 0
15125                && world_cnt == 0
15126                && effect_cnt == 0
15127                && quake_cnt == 0
15128            {
15129                continue;
15130            }
15131            if debug_enabled {
15132                debug.push(format!(
15133                    "[SG_DEBUG]   stage {} active_objects={} mwnd_embedded={} groups={} btnselitems={} worlds={} effects={} quakes={}",
15134                    stage_idx, active_cnt, mwnd_embedded_cnt, group_cnt, btnselitem_cnt, world_cnt, effect_cnt, quake_cnt
15135                ));
15136                if let Some(effects) = st.effect_lists.get(&stage_idx) {
15137                    for (effect_idx, effect) in effects.iter().enumerate() {
15138                        debug.push(format!(
15139                            "[SG_DEBUG]     effect[{}] range=({},{})->({},{}) wipe_copy={} wipe_erase={} xy=({}, {}) color_rate={} bright={} dark={} tr-like-mono={}",
15140                            effect_idx,
15141                            effect.begin_order,
15142                            effect.begin_layer,
15143                            effect.end_order,
15144                            effect.end_layer,
15145                            effect.wipe_copy,
15146                            effect.wipe_erase,
15147                            effect.x.get_total_value(),
15148                            effect.y.get_total_value(),
15149                            effect.color_rate.get_total_value(),
15150                            effect.bright.get_total_value(),
15151                            effect.dark.get_total_value(),
15152                            effect.mono.get_total_value(),
15153                        ));
15154                    }
15155                }
15156                if let Some(quakes) = st.quake_lists.get(&stage_idx) {
15157                    for (quake_idx, quake) in quakes.iter().enumerate() {
15158                        debug.push(format!(
15159                            "[SG_DEBUG]     quake[{}] type={} power={} vec={} center=({}, {}) order_range={}..{} active={}",
15160                            quake_idx,
15161                            quake.quake_type,
15162                            quake.power,
15163                            quake.vec,
15164                            quake.center_x,
15165                            quake.center_y,
15166                            quake.begin_order,
15167                            quake.end_order,
15168                            quake.is_active(),
15169                        ));
15170                    }
15171                }
15172            }
15173            if stage_idx != selected_stage {
15174                if config_button_trace_enabled() {
15175                    let mwnd_summary = st.mwnd_lists.get(&stage_idx).map(|mwnds| {
15176                        mwnds.iter().enumerate().map(|(idx, m)| {
15177                            format!(
15178                                "{}:open={} buttons={} objects={} waku={} filter={} pos={:?} size={:?}",
15179                                idx,
15180                                m.open,
15181                                m.button_list.len(),
15182                                m.object_list.len(),
15183                                if m.waku_file.is_empty() { "-" } else { m.waku_file.as_str() },
15184                                if m.filter_file.is_empty() { "-" } else { m.filter_file.as_str() },
15185                                m.window_pos,
15186                                m.window_size
15187                            )
15188                        }).collect::<Vec<_>>()
15189                    }).unwrap_or_default();
15190                    debug.push(format!(
15191                        "[SG_DEBUG][CONFIG_BUTTON_TRACE][STAGE_SKIP] form={} stage={} selected_stage={} active_objects={} mwnd_embedded={} mwnds={:?} focused_mwnd={:?}",
15192                        form_id,
15193                        stage_idx,
15194                        selected_stage,
15195                        active_cnt,
15196                        mwnd_embedded_cnt,
15197                        mwnd_summary,
15198                        focused_mwnd
15199                    ));
15200                }
15201                // C_elm_stage::get_sprite_tree() only returns objects owned by
15202                // that concrete stage.  Focus controls input/message routing;
15203                // it must never inject FRONT MWND children into a NEXT render
15204                // tree (or vice versa), otherwise the wipe textures contain
15205                // two copies of the same window/face/button sprites.
15206                continue;
15207            }
15208            if let Some(list) = st.object_lists.get(&stage_idx) {
15209                // C_elm_stage::frame(..., order) passes Gp_ini->excall_order
15210                // only to top-level OBJECTs of the EXCALL stage. Children inherit
15211                // that parent sorter through C_elm_object::frame(). MWND/BTNSEL
15212                // are intentionally not offset here because the original stage
15213                // frame calls their frame methods without the EXCALL order value.
15214                let stage_parent_order = if is_excall_stage_form(ctx, form_id) {
15215                    configured_excall_order(ctx)
15216                } else {
15217                    0
15218                };
15219                for (obj_idx, obj) in list.iter().enumerate() {
15220                    if st.is_embedded_object_slot(stage_idx, obj_idx)
15221                        || !st.object_slot_is_used(stage_idx, obj_idx)
15222                        || !object_participates_in_tree(obj)
15223                    {
15224                        continue;
15225                    }
15226                    let info = effective_object_info(ctx, stage_idx, obj_idx, obj);
15227                    let top_order = stage_parent_order.saturating_add(info.order);
15228                    let top_layer = info.layer;
15229                    render_nodes.extend(collect_object_render_nodes(
15230                        ctx,
15231                        form_id,
15232                        worlds,
15233                        stage_idx,
15234                        obj_idx,
15235                        obj,
15236                        Some(info),
15237                        true,
15238                        stage_parent_order,
15239                        0,
15240                        None,
15241                        top_order,
15242                        top_layer,
15243                        object_keys,
15244                        &mut debug,
15245                    ));
15246                }
15247            }
15248            if let Some(mwnds) = st.mwnd_lists.get(&stage_idx) {
15249                for (mwnd_idx, m) in mwnds.iter().enumerate() {
15250                    if debug_enabled {
15251                        let embedded_cnt =
15252                            m.button_list.len() + m.face_list.len() + m.object_list.len();
15253                        if m.open
15254                            || embedded_cnt != 0
15255                            || !m.msg_text.is_empty()
15256                            || !m.name_text.is_empty()
15257                            || m.selection.is_some()
15258                        {
15259                            debug.push(format!(
15260                                "[SG_DEBUG]     mwnd[{mwnd_idx}] open={} order={} layer={} world={} msg_len={} name_len={} embedded={} button={} face={} object={} waku={} filter={} face_file={} open_anim=({}, {}) close_anim=({}, {}) selection={} hide_flags=(script:{},sys:{})",
15261                                m.open,
15262                                m.order,
15263                                m.layer,
15264                                m.world,
15265                                m.msg_text.chars().count(),
15266                                m.name_text.chars().count(),
15267                                embedded_cnt,
15268                                m.button_list.len(),
15269                                m.face_list.len(),
15270                                m.object_list.len(),
15271                                if m.waku_file.is_empty() { "-" } else { m.waku_file.as_str() },
15272                                if m.filter_file.is_empty() { "-" } else { m.filter_file.as_str() },
15273                                if m.face_file.is_empty() { "-" } else { m.face_file.as_str() },
15274                                m.open_anime_type,
15275                                m.open_anime_time,
15276                                m.close_anime_type,
15277                                m.close_anime_time,
15278                                m.selection.is_some(),
15279                                ctx.globals.script.mwnd_disp_off_flag,
15280                                ctx.globals.syscom.hide_mwnd.onoff,
15281                            ));
15282                        }
15283                    }
15284                    append_mwnd_embedded_groups(
15285                        ctx,
15286                        form_id,
15287                        worlds,
15288                        stage_idx,
15289                        m,
15290                        &mut render_nodes,
15291                        object_keys,
15292                        &mut debug,
15293                    );
15294                }
15295            }
15296            if let Some(items) = st.btnselitem_lists.get(&stage_idx) {
15297                append_btnselitem_groups(
15298                    ctx,
15299                    form_id,
15300                    worlds,
15301                    stage_idx,
15302                    items,
15303                    &mut render_nodes,
15304                    object_keys,
15305                    &mut debug,
15306                );
15307            }
15308        }
15309    }
15310
15311    let mut bg = Vec::new();
15312    let mut rest = Vec::new();
15313    for rs in base {
15314        if let Some(mwnd_stage) = ctx.ui.mwnd_stage_for_ui_layer(rs.layer_id)
15315            && mwnd_stage != selected_stage
15316        {
15317            continue;
15318        }
15319        match (rs.layer_id, rs.sprite_id) {
15320            (Some(lid), Some(sid)) if object_keys.contains(&(lid, sid)) => {}
15321            (None, None) if render_sprite_visible_for_submit(rs) => bg.push(rs.clone()),
15322            (None, None) => {}
15323            _ if render_sprite_visible_for_submit(rs) => rest.push(rs.clone()),
15324            _ => {}
15325        }
15326    }
15327
15328    for mut rs in rest {
15329        // LayerManager ids are storage handles. They are not Siglus script-layer
15330        // values. For MWND UI-runtime sprites, translate the sentinel order into
15331        // the same C++ S_tnm_sorter(order, layer) pair that C_elm_mwnd_waku uses.
15332        let (order, layer) = normalize_mwnd_ui_sprite_sorter(ctx, rs.layer_id, rs.sprite.order);
15333        rs.set_sorter(order as i64, layer as i64);
15334        rs.sprite.order = legacy_packed_sorter_key(order as i64, layer as i64);
15335        if let Some((wipe_order, wipe_layer)) = ctx.ui.mwnd_base_sorter_for_ui_layer(rs.layer_id) {
15336            rs.set_wipe_sorter(wipe_order as i64, wipe_layer as i64);
15337        }
15338        if let Some((form_id, _)) = ctx.ui.mwnd_owner_for_ui_layer(rs.layer_id) {
15339            rs.set_stage_form_owner(form_id);
15340        }
15341        render_nodes.push(SiglusRenderNode::from_single_sprite(rs));
15342    }
15343
15344    // C_tnm_wnd::disp_proc_sprite_tree_to_sprite_list() stable-sorts only the
15345    // root node's immediate children, then recursively flattens each subtree.
15346    // Keep every object subtree contiguous; globally sorting all descendant
15347    // sprites changes CHILD_SORT_TYPE_NONE/custom ordering and lets descendants
15348    // cross unrelated top-level objects.
15349    render_nodes.sort_by(siglus_render_node_cmp);
15350    let sprite_count = render_nodes
15351        .iter()
15352        .map(SiglusRenderNode::sprite_count)
15353        .sum::<usize>();
15354    let mut final_list = Vec::with_capacity(bg.len() + sprite_count);
15355    final_list.extend(bg);
15356    for node in render_nodes {
15357        node.flatten(&mut final_list);
15358    }
15359    (final_list, debug)
15360}
15361
15362fn trace_codes_enabled() -> bool {
15363    static ENABLED: OnceLock<bool> = OnceLock::new();
15364    *ENABLED.get_or_init(|| env_is_set!("SIGLUS_TRACE_CODES"))
15365}
15366
15367pub fn dispatch_form_code(ctx: &mut CommandContext, form_id: u32, args: &[Value]) -> Result<bool> {
15368    let append_dir = ctx.globals.append_dir.as_str();
15369    ctx.images.set_current_append_dir_ref(append_dir);
15370    ctx.movie.set_current_append_dir_ref(append_dir);
15371    ctx.bgm.set_current_append_dir_ref(append_dir);
15372
15373    let code = opcode::OpCode::form(form_id);
15374    if trace_codes_enabled() {
15375        let chain = ctx
15376            .vm_call
15377            .as_ref()
15378            .map(|call| call.element.clone())
15379            .unwrap_or_default();
15380        eprintln!(
15381            "[TRACE code] form={} chain={chain:?} argc={} args={args:?}",
15382            form_id,
15383            args.len()
15384        );
15385    }
15386
15387    opcode::dispatch_code(ctx, code, args)
15388}
15389
15390/// Dispatch a decoded numeric form command.
15391pub fn dispatch(ctx: &mut CommandContext, cmd: &Command) -> Result<()> {
15392    let Some(code) = cmd.code else {
15393        anyhow::bail!(
15394            "name-only command dispatch is invalid: {}; Siglus user commands must run through SceneVm",
15395            cmd.name
15396        );
15397    };
15398
15399    if !dispatch_form_code(ctx, code.id, &cmd.args)? {
15400        anyhow::bail!("unhandled form code {}", code.id);
15401    }
15402    Ok(())
15403}
15404
15405fn apply_button_visuals(ctx: &mut CommandContext, sprites: &mut [RenderSprite]) {
15406    let mut map: HashMap<(LayerId, SpriteId), ButtonVisualState> = HashMap::default();
15407
15408    let mut form_ids: Vec<u32> = ctx.globals.stage_forms.keys().copied().collect();
15409    form_ids.sort_unstable();
15410    for form_id in form_ids {
15411        let Some(st) = ctx.globals.stage_forms.get(&form_id) else {
15412            continue;
15413        };
15414        let mut stage_ids: Vec<i64> = st
15415            .object_lists
15416            .keys()
15417            .chain(st.mwnd_lists.keys())
15418            .chain(st.btnselitem_lists.keys())
15419            .copied()
15420            .collect();
15421        stage_ids.sort_unstable();
15422        stage_ids.dedup();
15423        for stage_idx in stage_ids {
15424            if let Some(objs) = st.object_lists.get(&stage_idx) {
15425                for (obj_idx, obj) in objs.iter().enumerate() {
15426                    collect_button_visuals_recursive(
15427                        ctx, st, stage_idx, obj_idx, obj, &mut map, None,
15428                    );
15429                }
15430            }
15431            if let Some(mwnds) = st.mwnd_lists.get(&stage_idx) {
15432                for m in mwnds {
15433                    for (obj_idx, obj) in m.button_list.iter().enumerate() {
15434                        collect_button_visuals_recursive(
15435                            ctx,
15436                            st,
15437                            stage_idx,
15438                            obj_idx,
15439                            obj,
15440                            &mut map,
15441                            Some(obj_idx),
15442                        );
15443                    }
15444                    for (obj_idx, obj) in m.face_list.iter().enumerate() {
15445                        collect_button_visuals_recursive(
15446                            ctx, st, stage_idx, obj_idx, obj, &mut map, None,
15447                        );
15448                    }
15449                    for (obj_idx, obj) in m.object_list.iter().enumerate() {
15450                        collect_button_visuals_recursive(
15451                            ctx, st, stage_idx, obj_idx, obj, &mut map, None,
15452                        );
15453                    }
15454                }
15455            }
15456            if let Some(items) = st.btnselitem_lists.get(&stage_idx) {
15457                for item in items {
15458                    for (obj_idx, obj) in item.generated_objects.iter().enumerate() {
15459                        collect_button_visuals_recursive(
15460                            ctx, st, stage_idx, obj_idx, obj, &mut map, None,
15461                        );
15462                    }
15463                    for (obj_idx, obj) in item.object_list.iter().enumerate() {
15464                        collect_button_visuals_recursive(
15465                            ctx, st, stage_idx, obj_idx, obj, &mut map, None,
15466                        );
15467                    }
15468                }
15469            }
15470        }
15471    }
15472
15473    if map.is_empty() {
15474        return;
15475    }
15476
15477    for rs in sprites.iter_mut() {
15478        let (Some(lid), Some(sid)) = (rs.layer_id, rs.sprite_id) else {
15479            continue;
15480        };
15481        let Some(visual) = map.get(&(lid, sid)).cloned() else {
15482            continue;
15483        };
15484        apply_button_state_visual(&ctx.tables, &mut ctx.images, &mut rs.sprite, visual);
15485    }
15486}
15487
15488fn collect_button_visuals_recursive(
15489    ctx: &CommandContext,
15490    st: &globals::StageFormState,
15491    stage_idx: i64,
15492    obj_idx: usize,
15493    obj: &globals::ObjectState,
15494    map: &mut HashMap<(LayerId, SpriteId), ButtonVisualState>,
15495    mwnd_button_idx: Option<usize>,
15496) {
15497    use globals::ObjectBackend;
15498
15499    // C_elm_object::frame() processes children before applying this object's
15500    // button action. Its texture, cut and action corrections belong only to
15501    // its own sprites; passing them to children replaces glyphs and icons
15502    // with copies of the parent button's image.
15503    let mut effective_visual = None;
15504    if obj.button.enabled || obj.button.state == TNM_BTN_STATE_DISABLE {
15505        if !button_syscom_mode_visible(&ctx.globals.syscom, &obj.button) {
15506            effective_visual = None;
15507        } else {
15508            let state = button_real_state_for_visual(
15509                &ctx.globals.syscom,
15510                st,
15511                stage_idx,
15512                obj,
15513                mwnd_button_idx,
15514                ctx.runtime_read_skip_is_enable(),
15515            );
15516            if sg_debug_enabled() {
15517                let runtime_slot = object_runtime_slot(obj_idx, obj);
15518                eprintln!(
15519                    "[SG_DEBUG][BUTTON_TRACE][VISUAL] collect stage={} obj_idx={} runtime_slot={} file={:?} mwnd_button_idx={:?} state={}({}) raw_state={} enabled={} visible={} disabled_reason={:?} button_no={} group_no={} group_idx={:?} action_no={} cut_no={} hit={} pushed={} sys_type={} sys_opt={} mode={} call={}::{}/{}",
15520                    stage_idx,
15521                    obj_idx,
15522                    runtime_slot,
15523                    obj.file_name,
15524                    mwnd_button_idx,
15525                    state,
15526                    button_state_name(state),
15527                    obj.button.state,
15528                    obj.button.enabled,
15529                    button_syscom_mode_visible(&ctx.globals.syscom, &obj.button),
15530                    button_disabled_reason(
15531                        &ctx.globals.syscom,
15532                        obj,
15533                        mwnd_button_idx,
15534                        ctx.runtime_read_skip_is_enable()
15535                    ),
15536                    obj.button.button_no,
15537                    obj.button.group_no,
15538                    obj.button.group_idx(),
15539                    obj.button.action_no,
15540                    obj.button.cut_no,
15541                    obj.button.hit,
15542                    obj.button.pushed,
15543                    obj.button.sys_type,
15544                    obj.button.sys_type_opt,
15545                    obj.button.mode,
15546                    obj.button.decided_action_scn_name,
15547                    obj.button.decided_action_cmd_name,
15548                    obj.button.decided_action_z_no
15549                );
15550            }
15551            let base_patno = obj
15552                .lookup_int_prop(&ctx.ids, ctx.ids.obj_patno)
15553                .unwrap_or(obj.base.patno);
15554            effective_visual = Some(ButtonVisualState {
15555                state,
15556                action_no: obj.button.action_no,
15557                file_name: obj.file_name.clone(),
15558                base_patno,
15559                cut_no: obj.button.cut_no,
15560            });
15561        }
15562    }
15563
15564    if let Some(visual) = effective_visual.clone() {
15565        let runtime_slot = object_runtime_slot(obj_idx, obj);
15566        match &obj.backend {
15567            ObjectBackend::Gfx => {
15568                if let Some((lid, sid)) = ctx
15569                    .gfx
15570                    .object_sprite_binding(stage_idx, runtime_slot as i64)
15571                {
15572                    map.insert((lid, sid), visual.clone());
15573                }
15574            }
15575            ObjectBackend::Rect {
15576                layer_id,
15577                sprite_id,
15578                ..
15579            } => {
15580                map.insert((*layer_id, *sprite_id), visual.clone());
15581            }
15582            ObjectBackend::String { .. } => {
15583                for binding in layer_backed_object_sprite_bindings(&obj.backend) {
15584                    map.insert(binding, visual.clone());
15585                }
15586            }
15587            ObjectBackend::Movie {
15588                layer_id,
15589                sprite_id,
15590                ..
15591            } => {
15592                map.insert((*layer_id, *sprite_id), visual.clone());
15593            }
15594            ObjectBackend::Number {
15595                layer_id,
15596                sprite_ids,
15597            }
15598            | ObjectBackend::Weather {
15599                layer_id,
15600                sprite_ids,
15601            } => {
15602                for sid in sprite_ids {
15603                    map.insert((*layer_id, *sid), visual.clone());
15604                }
15605            }
15606            ObjectBackend::None => {}
15607        }
15608    }
15609
15610    for (child_idx, child) in obj.runtime.child_objects.iter().enumerate() {
15611        collect_button_visuals_recursive(ctx, st, stage_idx, child_idx, child, map, None);
15612    }
15613}
15614
15615#[cfg(test)]
15616mod button_visual_ownership_tests {
15617    use super::*;
15618
15619    fn picture(sprite_id: SpriteId, file: &str) -> globals::ObjectState {
15620        globals::ObjectState {
15621            used: true,
15622            file_name: Some(file.to_owned()),
15623            backend: globals::ObjectBackend::Rect {
15624                layer_id: 0,
15625                sprite_id,
15626                width: 360,
15627                height: 48,
15628            },
15629            ..Default::default()
15630        }
15631    }
15632
15633    fn visuals(obj: &globals::ObjectState) -> HashMap<(LayerId, SpriteId), ButtonVisualState> {
15634        let ctx = CommandContext::new(PathBuf::from("."));
15635        let stage = globals::StageFormState::default();
15636        let mut map = HashMap::default();
15637        collect_button_visuals_recursive(&ctx, &stage, 0, 0, obj, &mut map, None);
15638        map
15639    }
15640
15641    #[test]
15642    fn save_slot_button_does_not_replace_child_text_digits_or_icons() {
15643        let mut slot = picture(0, "_s_data");
15644        slot.button.enabled = true;
15645        slot.button.action_no = 9;
15646
15647        let mut text = globals::ObjectState {
15648            used: true,
15649            backend: globals::ObjectBackend::String {
15650                layer_id: 0,
15651                shadow_sprite_id: 1,
15652                fuchi_sprite_id: 2,
15653                sprite_id: 3,
15654                shadow_image_id: None,
15655                fuchi_image_id: None,
15656                image_id: None,
15657                glyphs: Vec::new(),
15658                mwnd_layer_reps: false,
15659                width: 200,
15660                height: 22,
15661            },
15662            ..Default::default()
15663        };
15664
15665        let mut number = globals::ObjectState {
15666            used: true,
15667            backend: globals::ObjectBackend::Number {
15668                layer_id: 0,
15669                sprite_ids: vec![4, 5],
15670            },
15671            ..Default::default()
15672        };
15673
15674        let mut container = globals::ObjectState::default();
15675        container.runtime.child_objects.push(picture(6, "_sl_new"));
15676        slot.runtime.child_objects = vec![text, number, container];
15677
15678        for state in 0..=TNM_BTN_STATE_DISABLE {
15679            slot.button.state = state;
15680            slot.button.hit = state == TNM_BTN_STATE_HIT;
15681            slot.button.pushed = state == TNM_BTN_STATE_PUSH;
15682            let map = visuals(&slot);
15683            assert_eq!(map.len(), 1, "parent button state {state}");
15684            assert_eq!(map[&(0, 0)].file_name.as_deref(), Some("_s_data"));
15685        }
15686    }
15687
15688    #[test]
15689    fn nested_button_keeps_its_own_action_texture_and_cut() {
15690        let ctx = CommandContext::new(PathBuf::from("."));
15691        let mut slot = picture(0, "_l_data");
15692        slot.button.enabled = true;
15693        slot.button.action_no = 9;
15694        slot.button.state = TNM_BTN_STATE_DISABLE;
15695
15696        let mut icon = picture(1, "_sl_new");
15697        icon.button.enabled = true;
15698        icon.button.action_no = 2;
15699        icon.button.hit = true;
15700        icon.button.cut_no = 3;
15701        icon.set_int_prop(&ctx.ids, ctx.ids.obj_patno, 4);
15702        icon.runtime.child_objects.push(picture(2, "child_label"));
15703        slot.runtime.child_objects.push(icon);
15704
15705        let map = visuals(&slot);
15706        assert_eq!(map.len(), 2);
15707        let icon_visual = &map[&(0, 1)];
15708        assert_eq!(icon_visual.state, TNM_BTN_STATE_HIT);
15709        assert_eq!(icon_visual.action_no, 2);
15710        assert_eq!(icon_visual.file_name.as_deref(), Some("_sl_new"));
15711        assert_eq!(icon_visual.base_patno, 4);
15712        assert_eq!(icon_visual.cut_no, 3);
15713    }
15714}
15715
15716fn button_action_pattern(
15717    tables: &tables::AssetTables,
15718    action_no: i64,
15719    state: i64,
15720) -> tables::ButtonActionPattern {
15721    let state_idx = state.clamp(0, 4) as usize;
15722    if action_no >= 0
15723        && let Some(tpl) = tables.button_action_templates.get(action_no as usize)
15724    {
15725        return tpl.state[state_idx];
15726    }
15727    tables::ButtonActionTemplate::default().state[state_idx]
15728}
15729
15730fn apply_button_state_visual(
15731    tables: &tables::AssetTables,
15732    images: &mut ImageManager,
15733    sprite: &mut Sprite,
15734    visual: ButtonVisualState,
15735) {
15736    let pat = button_action_pattern(tables, visual.action_no, visual.state);
15737
15738    if let Some(file_name) = visual.file_name.as_deref().filter(|s| !s.is_empty()) {
15739        let patno = visual
15740            .base_patno
15741            .saturating_add(visual.cut_no)
15742            .saturating_add(pat.rep_pat_no)
15743            .max(0) as u32;
15744        let image_id = match images.load_g00(file_name, patno) {
15745            Ok(id) => Some(id),
15746            Err(_) => images.load_bg_frame(file_name, patno as usize).ok(),
15747        };
15748        if let Some(image_id) = image_id {
15749            sprite.image_id = Some(image_id.clone());
15750            if let Some(img) = images.get(&image_id) {
15751                sprite.object_anchor = true;
15752                sprite.texture_center_x = img.center_x as f32;
15753                sprite.texture_center_y = img.center_y as f32;
15754            } else {
15755                sprite.object_anchor = false;
15756                sprite.texture_center_x = 0.0;
15757                sprite.texture_center_y = 0.0;
15758            }
15759        }
15760    }
15761    sprite.x = sprite.x.saturating_add(pat.rep_pos_x as i32);
15762    sprite.y = sprite.y.saturating_add(pat.rep_pos_y as i32);
15763    sprite.tr = ((sprite.tr as i64 * pat.rep_tr.clamp(0, 255)) / 255).clamp(0, 255) as u8;
15764    sprite.bright = (sprite.bright as i64 + pat.rep_bright).clamp(0, 255) as u8;
15765    sprite.dark = (sprite.dark as i64 + pat.rep_dark).clamp(0, 255) as u8;
15766}
15767
15768fn unpack_legacy_sorter_key(order: i32) -> (i32, i32) {
15769    if order.abs() >= 1024 {
15770        (order.div_euclid(1024), order.rem_euclid(1024))
15771    } else {
15772        (0, order)
15773    }
15774}
15775
15776fn legacy_packed_sorter_key(order: i64, layer: i64) -> i32 {
15777    order
15778        .clamp(i32::MIN as i64 / 1024, i32::MAX as i64 / 1024)
15779        .saturating_mul(1024)
15780        .saturating_add(layer.clamp(-1023, 1023)) as i32
15781}
15782
15783fn sorter_key(order: i32, layer: i32) -> (i32, i32) {
15784    (order, layer)
15785}
15786
15787fn sprite_sorter_key(rs: &RenderSprite) -> (i32, i32) {
15788    (rs.sorter_order, rs.sorter_layer)
15789}
15790
15791fn quake_order_affects_sprite(quake: &globals::ScreenQuakeState, rs: &RenderSprite) -> bool {
15792    let order = rs.sorter_order;
15793    let (lo, hi) = if quake.begin_order <= quake.end_order {
15794        (quake.begin_order, quake.end_order)
15795    } else {
15796        (quake.end_order, quake.begin_order)
15797    };
15798    lo <= order && order <= hi
15799}
15800
15801fn apply_quake_transform(sprite: &mut Sprite, tr: globals::ScreenQuakeTransform) {
15802    sprite.x = sprite.x.saturating_add(tr.x);
15803    sprite.y = sprite.y.saturating_add(tr.y);
15804    sprite.pivot_x += tr.center_x as f32;
15805    sprite.pivot_y += tr.center_y as f32;
15806    sprite.scale_x *= tr.scale;
15807    sprite.scale_y *= tr.scale;
15808    sprite.rotate += tr.rotate_degrees * std::f32::consts::PI / 180.0;
15809}
15810
15811fn render_sprite_owned_by(rs: &RenderSprite, stage_form_id: u32) -> bool {
15812    rs.stage_form_id == Some(stage_form_id)
15813}
15814
15815fn collect_screen_shake(globals: &globals::GlobalState) -> (i32, i32) {
15816    let mut shake_x = 0i32;
15817    let mut shake_y = 0i32;
15818    let mut screen_form_ids: Vec<u32> = globals.screen_forms.keys().copied().collect();
15819    screen_form_ids.sort_unstable();
15820    for form_id in screen_form_ids {
15821        let Some(st) = globals.screen_forms.get(&form_id) else {
15822            continue;
15823        };
15824        if st.shake.is_active() {
15825            shake_x = shake_x.saturating_add(st.shake.cur_x);
15826            shake_y = shake_y.saturating_add(st.shake.cur_y);
15827        }
15828    }
15829    (shake_x, shake_y)
15830}
15831
15832fn collect_screen_quakes(globals: &globals::GlobalState) -> Vec<&globals::ScreenQuakeState> {
15833    if globals.script.quake_stop_flag {
15834        return Vec::new();
15835    }
15836    let mut out = Vec::new();
15837    let mut screen_form_ids: Vec<u32> = globals.screen_forms.keys().copied().collect();
15838    screen_form_ids.sort_unstable();
15839    for form_id in screen_form_ids {
15840        let Some(st) = globals.screen_forms.get(&form_id) else {
15841            continue;
15842        };
15843        out.extend(st.quake_list.iter().filter(|q| q.is_active()));
15844    }
15845    out
15846}
15847
15848fn collect_stage_quakes(
15849    globals: &globals::GlobalState,
15850    stage_form_id: u32,
15851    stage_idx: i64,
15852) -> Vec<&globals::ScreenQuakeState> {
15853    if globals.script.quake_stop_flag {
15854        return Vec::new();
15855    }
15856    globals
15857        .stage_forms
15858        .get(&stage_form_id)
15859        .and_then(|st| st.quake_lists.get(&stage_idx))
15860        .map(|quakes| quakes.iter().filter(|q| q.is_active()).collect())
15861        .unwrap_or_default()
15862}
15863
15864fn apply_quakes_to_owner(
15865    sprites: &mut [RenderSprite],
15866    owner_form_id: u32,
15867    quakes: &[&globals::ScreenQuakeState],
15868) {
15869    if quakes.is_empty() {
15870        return;
15871    }
15872    for rs in sprites {
15873        if !render_sprite_owned_by(rs, owner_form_id) {
15874            continue;
15875        }
15876        for quake in quakes {
15877            if quake_order_affects_sprite(quake, rs) {
15878                apply_quake_transform(&mut rs.sprite, quake.transform());
15879            }
15880        }
15881    }
15882}
15883
15884#[derive(Debug, Clone, Copy)]
15885struct EffectParam {
15886    x: i32,
15887    y: i32,
15888    z: i32,
15889    mono: i32,
15890    reverse: i32,
15891    bright: i32,
15892    dark: i32,
15893    color_r: i32,
15894    color_g: i32,
15895    color_b: i32,
15896    color_rate: i32,
15897    color_add_r: i32,
15898    color_add_g: i32,
15899    color_add_b: i32,
15900    begin_order: i32,
15901    begin_layer: i32,
15902    end_order: i32,
15903    end_layer: i32,
15904}
15905
15906fn apply_effects_to_owner(
15907    sprites: &mut [RenderSprite],
15908    owner_form_id: u32,
15909    effects: &[EffectParam],
15910) {
15911    if effects.is_empty() {
15912        return;
15913    }
15914    for effect in effects {
15915        for rs in sprites.iter_mut() {
15916            if !render_sprite_owned_by(rs, owner_form_id) || !in_sorter_range(rs, effect) {
15917                continue;
15918            }
15919            apply_effect_to_sprite(&mut rs.sprite, effect);
15920        }
15921    }
15922}
15923
15924fn read_effect_event(ev: &crate::runtime::int_event::IntEvent) -> i32 {
15925    ev.get_total_value()
15926}
15927
15928fn effect_param_from_state(effect: &globals::ScreenEffectState) -> EffectParam {
15929    EffectParam {
15930        x: read_effect_event(&effect.x),
15931        y: read_effect_event(&effect.y),
15932        z: read_effect_event(&effect.z),
15933        mono: read_effect_event(&effect.mono),
15934        reverse: read_effect_event(&effect.reverse),
15935        bright: read_effect_event(&effect.bright),
15936        dark: read_effect_event(&effect.dark),
15937        color_r: read_effect_event(&effect.color_r),
15938        color_g: read_effect_event(&effect.color_g),
15939        color_b: read_effect_event(&effect.color_b),
15940        color_rate: read_effect_event(&effect.color_rate),
15941        color_add_r: read_effect_event(&effect.color_add_r),
15942        color_add_g: read_effect_event(&effect.color_add_g),
15943        color_add_b: read_effect_event(&effect.color_add_b),
15944        begin_order: effect.begin_order,
15945        begin_layer: effect.begin_layer,
15946        end_order: effect.end_order,
15947        end_layer: effect.end_layer,
15948    }
15949}
15950
15951fn collect_global_screen_effects(globals: &globals::GlobalState) -> Vec<EffectParam> {
15952    let mut out = Vec::new();
15953    let mut screen_form_ids: Vec<u32> = globals.screen_forms.keys().copied().collect();
15954    screen_form_ids.sort_unstable();
15955    for form_id in screen_form_ids {
15956        let Some(st) = globals.screen_forms.get(&form_id) else {
15957            continue;
15958        };
15959        for effect in &st.effect_list {
15960            let rp = effect_param_from_state(effect);
15961            if effect_is_use(&rp) {
15962                out.push(rp);
15963            }
15964        }
15965    }
15966    out
15967}
15968
15969fn collect_stage_effects(
15970    globals: &globals::GlobalState,
15971    stage_form_id: u32,
15972    stage_idx: i64,
15973) -> Vec<EffectParam> {
15974    globals
15975        .stage_forms
15976        .get(&stage_form_id)
15977        .and_then(|st| st.effect_lists.get(&stage_idx))
15978        .map(|effects| {
15979            effects
15980                .iter()
15981                .map(effect_param_from_state)
15982                .filter(effect_is_use)
15983                .collect()
15984        })
15985        .unwrap_or_default()
15986}
15987
15988fn apply_stage_render_effects(
15989    globals: &globals::GlobalState,
15990    ids: &constants::RuntimeConstants,
15991    stage_idx: i64,
15992    sprites: &mut [RenderSprite],
15993) {
15994    // C_tnm_wnd::disp_proc_stage_ready() applies render parameters in this
15995    // exact sequence. SCREEN effects/quakes belong only to the normal global
15996    // stage; each EXCALL stage receives only its own stage effects/quakes.
15997    let normal_stage_form = ids.form_global_stage;
15998    if normal_stage_form != 0 {
15999        let screen_effects = collect_global_screen_effects(globals);
16000        apply_effects_to_owner(sprites, normal_stage_form, &screen_effects);
16001
16002        let screen_quakes = collect_screen_quakes(globals);
16003        apply_quakes_to_owner(sprites, normal_stage_form, &screen_quakes);
16004    }
16005
16006    let mut stage_form_ids: Vec<u32> = globals.stage_forms.keys().copied().collect();
16007    stage_form_ids.sort_unstable();
16008    for stage_form_id in stage_form_ids {
16009        let effects = collect_stage_effects(globals, stage_form_id, stage_idx);
16010        apply_effects_to_owner(sprites, stage_form_id, &effects);
16011
16012        let quakes = collect_stage_quakes(globals, stage_form_id, stage_idx);
16013        apply_quakes_to_owner(sprites, stage_form_id, &quakes);
16014    }
16015
16016    // SCREEN.SHAKE is implemented by the original as a final game-buffer
16017    // offset, after stage effects and quakes. Until the backend carries a
16018    // separate presentation transform, applying the same final translation to
16019    // every game sprite is the closest equivalent and, importantly, does not
16020    // let the shake translation participate in quake scaling/rotation.
16021    let (shake_x, shake_y) = collect_screen_shake(globals);
16022    if shake_x != 0 || shake_y != 0 {
16023        for rs in sprites {
16024            rs.sprite.x = rs.sprite.x.saturating_add(shake_x);
16025            rs.sprite.y = rs.sprite.y.saturating_add(shake_y);
16026        }
16027    }
16028}
16029
16030fn effect_is_use(effect: &EffectParam) -> bool {
16031    effect.x != 0
16032        || effect.y != 0
16033        || effect.z != 0
16034        || effect.mono != 0
16035        || effect.reverse != 0
16036        || effect.bright != 0
16037        || effect.dark != 0
16038        || effect.color_r != 0
16039        || effect.color_g != 0
16040        || effect.color_b != 0
16041        || effect.color_rate != 0
16042        || effect.color_add_r != 0
16043        || effect.color_add_g != 0
16044        || effect.color_add_b != 0
16045}
16046
16047fn in_sorter_range(rs: &RenderSprite, effect: &EffectParam) -> bool {
16048    let key = sprite_sorter_key(rs);
16049    let begin = sorter_key(effect.begin_order, effect.begin_layer);
16050    let end = sorter_key(effect.end_order, effect.end_layer);
16051    if begin <= end {
16052        begin <= key && key <= end
16053    } else {
16054        end <= key && key <= begin
16055    }
16056}
16057
16058fn apply_effect_to_sprite(sprite: &mut Sprite, effect: &EffectParam) {
16059    sprite.x = sprite.x.saturating_add(effect.x);
16060    sprite.y = sprite.y.saturating_add(effect.y);
16061    sprite.z += effect.z as f32;
16062
16063    sprite.mono = combine_lerp(sprite.mono, effect.mono);
16064    sprite.reverse = combine_lerp(sprite.reverse, effect.reverse);
16065    sprite.bright = combine_lerp(sprite.bright, effect.bright);
16066    sprite.dark = combine_lerp(sprite.dark, effect.dark);
16067
16068    // Color rate uses the original blend formula.
16069    let sr = sprite.color_rate as i32;
16070    let pr = clamp_u8(effect.color_rate);
16071    if sr + pr > 0 {
16072        let parent_rate = (pr * 255 * 255) / (255 * 255 - (255 - sr) * (255 - pr));
16073        sprite.color_r = blend_color(sprite.color_r, effect.color_r, parent_rate);
16074        sprite.color_g = blend_color(sprite.color_g, effect.color_g, parent_rate);
16075        sprite.color_b = blend_color(sprite.color_b, effect.color_b, parent_rate);
16076    }
16077    sprite.color_rate = (255 - (255 - sr) * (255 - pr) / 255) as u8;
16078
16079    sprite.color_add_r = clamp_add(sprite.color_add_r, effect.color_add_r);
16080    sprite.color_add_g = clamp_add(sprite.color_add_g, effect.color_add_g);
16081    sprite.color_add_b = clamp_add(sprite.color_add_b, effect.color_add_b);
16082}
16083
16084fn combine_lerp(base: u8, parent: i32) -> u8 {
16085    let parent = clamp_u8(parent);
16086    (255 - (255 - base as i32) * (255 - parent) / 255) as u8
16087}
16088
16089fn blend_color(base: u8, parent: i32, rate: i32) -> u8 {
16090    let parent = clamp_u8(parent);
16091    let base = base as i32;
16092    ((base * (255 - rate) + parent * rate) / 255) as u8
16093}
16094
16095fn clamp_u8(v: i32) -> i32 {
16096    v.clamp(0, 255)
16097}
16098
16099fn clamp_add(base: u8, add: i32) -> u8 {
16100    let v = base as i32 + add;
16101    v.clamp(0, 255) as u8
16102}
16103
16104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16105enum WipePartition {
16106    Under,
16107    Target,
16108    Over,
16109}
16110
16111fn compose_basic_wipe_scene_inputs<T: Clone>(
16112    wipe_type: i32,
16113    under: &mut Vec<T>,
16114    front: &mut Vec<T>,
16115    next: &mut Vec<T>,
16116) {
16117    if !matches!(wipe_type, 0..=2) {
16118        return;
16119    }
16120
16121    let mut front_scene = Vec::with_capacity(under.len() + front.len());
16122    front_scene.extend(under.iter().cloned());
16123    front_scene.append(front);
16124
16125    let mut next_scene = Vec::with_capacity(under.len() + next.len());
16126    next_scene.extend(under.iter().cloned());
16127    next_scene.append(next);
16128
16129    under.clear();
16130    *front = front_scene;
16131    *next = next_scene;
16132}
16133
16134fn effective_wipe_render_order_bounds(
16135    ctx: &CommandContext,
16136    wipe: &globals::WipeState,
16137) -> (i32, i32) {
16138    if wipe.stage_form_id != excall_stage_form_id(ctx) {
16139        return (wipe.begin_order, wipe.end_order);
16140    }
16141
16142    // eng_disp.cpp offsets EXCALL wipe sorters by Gp_ini->excall_order before
16143    // slicing the combined normal+EXCALL sprite tree. Preserve the unbounded
16144    // sentinels; finite script orders live in the EXCALL order band.
16145    let offset = configured_excall_order(ctx);
16146    let shift = |value: i32| -> i32 {
16147        if value == i32::MIN || value == i32::MAX {
16148            value
16149        } else {
16150            (value as i64)
16151                .saturating_add(offset)
16152                .clamp(i32::MIN as i64, i32::MAX as i64) as i32
16153        }
16154    };
16155    (shift(wipe.begin_order), shift(wipe.end_order))
16156}
16157
16158fn render_sprite_wipe_sorter(rs: &RenderSprite) -> (i32, i32) {
16159    // C_elm_stage::get_sprite_tree(begin, end) tests only the top-level
16160    // OBJECT/MWND/BTNSEL sorter and includes the complete selected subtree.
16161    // Effects and quakes still use each sprite's own sorter elsewhere.
16162    (rs.wipe_sorter_order, rs.wipe_sorter_layer)
16163}
16164
16165fn classify_wipe_partition(
16166    rs: &RenderSprite,
16167    begin_layer: i32,
16168    end_layer: i32,
16169    begin_order: i32,
16170    end_order: i32,
16171    with_low: bool,
16172) -> WipePartition {
16173    let (order, layer) = render_sprite_wipe_sorter(rs);
16174    if layer < begin_layer {
16175        return WipePartition::Under;
16176    }
16177    if layer > end_layer {
16178        return WipePartition::Over;
16179    }
16180    let affected = if with_low {
16181        order <= end_order
16182    } else {
16183        order >= begin_order && order <= end_order
16184    };
16185    if affected {
16186        WipePartition::Target
16187    } else if order < begin_order {
16188        WipePartition::Under
16189    } else {
16190        WipePartition::Over
16191    }
16192}
16193
16194fn sprite_forward_dir(sprite: &Sprite) -> [f32; 3] {
16195    let (sx, cx) = sprite.rotate_x.sin_cos();
16196    let (sy, cy) = sprite.rotate_y.sin_cos();
16197
16198    // tona3 C_d3d_sprite::set_d2_vertex_param() writes a d3-rect/PCT quad's
16199    // local normal as (0, 0, +1). D3DXMatrixRotationYawPitchRoll then applies
16200    // roll, pitch and yaw; roll does not change this local +Z normal. The old
16201    // expression was the exact negation and therefore made lit wall quads face
16202    // away from the camera light.
16203    let x = sy * cx;
16204    let y = -sx;
16205    let z = cy * cx;
16206    let len = (x * x + y * y + z * z).sqrt().max(1e-6);
16207    [x / len, y / len, z / len]
16208}
16209
16210fn apply_runtime_light_and_fog(ctx: &CommandContext, sprite: &mut Sprite) {
16211    sprite.light_enabled = false;
16212    sprite.mesh_runtime_lights.clear();
16213    sprite.light_diffuse = [1.0, 1.0, 1.0, 1.0];
16214    sprite.light_ambient = [0.0, 0.0, 0.0, 1.0];
16215    sprite.light_specular = [0.0, 0.0, 0.0, 1.0];
16216    sprite.light_factor = 0.0;
16217    sprite.light_kind = -1;
16218    sprite.light_pos = [0.0, 0.0, 0.0, 0.0];
16219    sprite.light_dir = [0.0, 0.0, -1.0, 0.0];
16220    sprite.light_atten = [1.0, 0.0, 0.0, 5000.0];
16221    sprite.light_cone = [0.0, 0.0, 1.0, 0.0];
16222    sprite.shadow_cast = sprite.mesh_kind != 0;
16223    sprite.shadow_receive = sprite.mesh_kind != 0;
16224    sprite.fog_enabled = false;
16225    sprite.fog_color = [0.0, 0.0, 0.0, 1.0];
16226    sprite.fog_near = 0.0;
16227    sprite.fog_far = 0.0;
16228    sprite.fog_scroll_x = 0.0;
16229    sprite.fog_texture_image_id = None;
16230
16231    if sprite.light_no >= 0 {
16232        let camera_default_light;
16233        let light = if let Some(light) = ctx.globals.lights.get(&sprite.light_no) {
16234            Some(light)
16235        } else if sprite.light_no == 0 {
16236            camera_default_light = siglus_default_camera_light(sprite);
16237            Some(&camera_default_light)
16238        } else {
16239            None
16240        };
16241        if let Some(light) = light {
16242            let n = if sprite.billboard {
16243                [0.0, 0.0, -1.0]
16244            } else {
16245                sprite_forward_dir(sprite)
16246            };
16247            let pos = [sprite.x as f32, sprite.y as f32, sprite.z];
16248            let mut ndotl = 0.0f32;
16249            let mut attenuation = 1.0f32;
16250            match light.kind {
16251                globals::LightType::Directional => {
16252                    let l = [-light.dir[0], -light.dir[1], -light.dir[2]];
16253                    let ll = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt().max(1e-6);
16254                    ndotl = (n[0] * (l[0] / ll) + n[1] * (l[1] / ll) + n[2] * (l[2] / ll)).max(0.0);
16255                }
16256                globals::LightType::Point
16257                | globals::LightType::Spot
16258                | globals::LightType::ShadowMapSpot => {
16259                    let mut l = [
16260                        light.pos[0] - pos[0],
16261                        light.pos[1] - pos[1],
16262                        light.pos[2] - pos[2],
16263                    ];
16264                    let dist = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt().max(1e-6);
16265                    l = [l[0] / dist, l[1] / dist, l[2] / dist];
16266                    ndotl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0);
16267                    attenuation = 1.0
16268                        / (light.attenuation0
16269                            + light.attenuation1 * dist
16270                            + light.attenuation2 * dist * dist)
16271                            .max(1.0);
16272                    if light.range > 0.0 {
16273                        attenuation *= (1.0 - dist / light.range).clamp(0.0, 1.0);
16274                    }
16275                    if matches!(
16276                        light.kind,
16277                        globals::LightType::Spot | globals::LightType::ShadowMapSpot
16278                    ) {
16279                        let spot_dir = [light.dir[0], light.dir[1], light.dir[2]];
16280                        let sll = (spot_dir[0] * spot_dir[0]
16281                            + spot_dir[1] * spot_dir[1]
16282                            + spot_dir[2] * spot_dir[2])
16283                            .sqrt()
16284                            .max(1e-6);
16285                        let cosang = (l[0] * (-spot_dir[0] / sll)
16286                            + l[1] * (-spot_dir[1] / sll)
16287                            + l[2] * (-spot_dir[2] / sll))
16288                            .clamp(-1.0, 1.0);
16289                        let cos_theta = (light.theta_deg.to_radians() * 0.5).cos();
16290                        let cos_phi = (light.phi_deg.to_radians() * 0.5).cos();
16291                        let spot = if cosang >= cos_theta {
16292                            1.0
16293                        } else if cosang <= cos_phi {
16294                            0.0
16295                        } else {
16296                            ((cosang - cos_phi) / (cos_theta - cos_phi).max(1e-6))
16297                                .powf(light.falloff.max(0.01))
16298                        };
16299                        attenuation *= spot;
16300                    }
16301                }
16302                globals::LightType::None => {}
16303            }
16304            sprite.light_enabled = !matches!(light.kind, globals::LightType::None);
16305            sprite.light_diffuse = light.diffuse;
16306            sprite.light_ambient = light.ambient;
16307            sprite.light_specular = light.specular;
16308            sprite.light_factor = (ndotl * attenuation).clamp(0.0, 1.0);
16309            sprite.light_kind = light.kind as i32;
16310            sprite.light_pos = [light.pos[0], light.pos[1], light.pos[2], 1.0];
16311            let dir_len = (light.dir[0] * light.dir[0]
16312                + light.dir[1] * light.dir[1]
16313                + light.dir[2] * light.dir[2])
16314                .sqrt()
16315                .max(1e-6);
16316            sprite.light_dir = [
16317                light.dir[0] / dir_len,
16318                light.dir[1] / dir_len,
16319                light.dir[2] / dir_len,
16320                0.0,
16321            ];
16322            sprite.light_atten = [
16323                light.attenuation0,
16324                light.attenuation1,
16325                light.attenuation2,
16326                light.range,
16327            ];
16328            sprite.light_cone = [
16329                (light.theta_deg.to_radians() * 0.5).cos(),
16330                (light.phi_deg.to_radians() * 0.5).cos(),
16331                light.falloff,
16332                if matches!(light.kind, globals::LightType::ShadowMapSpot) {
16333                    1.0
16334                } else {
16335                    0.0
16336                },
16337            ];
16338            if matches!(light.kind, globals::LightType::ShadowMapSpot) {
16339                sprite.shadow_cast = sprite.mesh_kind != 0;
16340                sprite.shadow_receive = sprite.camera_enabled;
16341            }
16342        }
16343    }
16344
16345    if sprite.mesh_kind != 0 || sprite.camera_enabled {
16346        let mut ids: Vec<i32> = ctx.globals.lights.keys().copied().collect();
16347        if !ctx.globals.lights.contains_key(&0) {
16348            ids.push(0);
16349        }
16350        ids.sort_unstable();
16351        ids.dedup();
16352        for light_id in ids {
16353            let camera_default_light;
16354            let light = if let Some(light) = ctx.globals.lights.get(&light_id) {
16355                light
16356            } else if light_id == 0 {
16357                camera_default_light = siglus_default_camera_light(sprite);
16358                &camera_default_light
16359            } else {
16360                continue;
16361            };
16362            if matches!(light.kind, globals::LightType::None) {
16363                continue;
16364            }
16365            let dir_len = (light.dir[0] * light.dir[0]
16366                + light.dir[1] * light.dir[1]
16367                + light.dir[2] * light.dir[2])
16368                .sqrt()
16369                .max(1e-6);
16370            sprite.mesh_runtime_lights.push(SpriteRuntimeLight {
16371                id: light_id,
16372                kind: light.kind as i32,
16373                diffuse: light.diffuse,
16374                ambient: light.ambient,
16375                specular: light.specular,
16376                pos: [light.pos[0], light.pos[1], light.pos[2], 1.0],
16377                dir: [
16378                    light.dir[0] / dir_len,
16379                    light.dir[1] / dir_len,
16380                    light.dir[2] / dir_len,
16381                    0.0,
16382                ],
16383                atten: [
16384                    light.attenuation0,
16385                    light.attenuation1,
16386                    light.attenuation2,
16387                    light.range,
16388                ],
16389                cone: [
16390                    (light.theta_deg.to_radians() * 0.5).cos(),
16391                    (light.phi_deg.to_radians() * 0.5).cos(),
16392                    light.falloff,
16393                    if matches!(light.kind, globals::LightType::ShadowMapSpot) {
16394                        1.0
16395                    } else {
16396                        0.0
16397                    },
16398                ],
16399            });
16400        }
16401    }
16402
16403    if sprite.fog_use && sprite.camera_enabled && ctx.globals.fog_global.enabled {
16404        let fog = &ctx.globals.fog_global;
16405        sprite.fog_enabled = true;
16406        sprite.fog_color = fog.color;
16407        sprite.fog_near = fog.near;
16408        sprite.fog_far = fog.far;
16409        sprite.fog_scroll_x = fog.scroll_x;
16410        sprite.fog_texture_image_id = fog.texture_image_id.clone();
16411    }
16412}
16413
16414fn siglus_default_camera_light(sprite: &Sprite) -> globals::LightState {
16415    let mut light = globals::LightState::directional(0, [0.0, 1.0, 0.0]);
16416    light.pos = sprite.camera_eye;
16417    light.diffuse = [1.0, 1.0, 1.0, 1.0];
16418    light.ambient = [3.0, 3.0, 3.0, 1.0];
16419    light.specular = [0.0, 0.0, 0.0, 1.0];
16420    light
16421}
16422
16423fn render_sprite_visible_for_submit(rs: &RenderSprite) -> bool {
16424    rs.sprite.visible
16425        && sprite_has_render_payload(&rs.sprite)
16426        && rs.sprite.alpha > 0
16427        && rs.sprite.tr > 0
16428}
16429
16430fn sprite_has_render_payload(sprite: &Sprite) -> bool {
16431    sprite.image_id.is_some()
16432        || sprite.emote_render.is_some()
16433        || (sprite.mesh_kind != 0 && sprite.mesh_file_name.is_some())
16434}
16435
16436fn resolve_mask_path(project_dir: &Path, raw: &str) -> Option<PathBuf> {
16437    let norm = raw.replace('\\', "/");
16438    let mut candidates = Vec::new();
16439
16440    if !norm.contains('.') {
16441        for ext in ["png", "bmp", "jpg", "jpeg", "g00"] {
16442            candidates.push(project_dir.join(format!("{}.{}", norm, ext)));
16443            candidates.push(project_dir.join("dat").join(format!("{}.{}", norm, ext)));
16444            candidates.push(project_dir.join("mask").join(format!("{}.{}", norm, ext)));
16445        }
16446    }
16447
16448    candidates.push(project_dir.join(&norm));
16449    candidates.push(project_dir.join("dat").join(&norm));
16450    candidates.push(project_dir.join("mask").join(&norm));
16451
16452    for c in candidates {
16453        if let Some(path) = crate::resource::resolve_game_file(&c).ok().flatten() {
16454            return Some(path);
16455        }
16456    }
16457    None
16458}
16459
16460fn ensure_font_list(syscom: &mut globals::SyscomRuntimeState, project_dir: &Path) {
16461    if !syscom.font_list.is_empty() {
16462        return;
16463    }
16464
16465    let mut seen = HashSet::default();
16466    for dir in [project_dir.join("font"), project_dir.join("fonts")] {
16467        let Some(dir) = crate::resource::resolve_game_path(&dir).ok().flatten() else {
16468            continue;
16469        };
16470        let Ok(entries) = fs::read_dir(dir) else {
16471            continue;
16472        };
16473        for entry in entries.flatten() {
16474            let path = entry.path();
16475            if !path.is_file() {
16476                continue;
16477            }
16478            let ext = path
16479                .extension()
16480                .and_then(|s| s.to_str())
16481                .unwrap_or("")
16482                .to_ascii_lowercase();
16483            if (ext == "ttf" || ext == "otf" || ext == "ttc")
16484                && let Some(name) = path.file_stem().and_then(|s| s.to_str())
16485                && seen.insert(name.to_string())
16486            {
16487                syscom.font_list.push(name.to_string());
16488            }
16489        }
16490    }
16491
16492    for name in embedded_default_font_names() {
16493        if seen.insert((*name).to_string()) {
16494            syscom.font_list.push((*name).to_string());
16495        }
16496    }
16497    syscom.font_list.sort();
16498}
16499
16500#[cfg(test)]
16501mod skip_state_parity_tests {
16502    use super::*;
16503
16504    #[test]
16505    fn read_skip_and_script_trigger_obey_local_skip_disable() {
16506        let mut ctx = CommandContext::new(std::path::PathBuf::from("."));
16507
16508        ctx.globals.syscom.read_skip.onoff = true;
16509        assert!(ctx.runtime_is_skipping());
16510
16511        ctx.globals.script.skip_disable = true;
16512        assert!(!ctx.runtime_is_skipping());
16513
16514        ctx.globals.syscom.read_skip.onoff = false;
16515        ctx.skip_because_skip_trigger = true;
16516        assert!(!ctx.runtime_is_skipping());
16517
16518        ctx.globals.script.skip_disable = false;
16519        assert!(ctx.runtime_is_skipping());
16520    }
16521
16522    #[test]
16523    fn decide_down_that_stops_read_skip_cannot_advance_message_on_release() {
16524        let mut ctx = CommandContext::new(std::path::PathBuf::from("."));
16525        ctx.globals.syscom.read_skip.onoff = true;
16526        ctx.ui.begin_wait_message();
16527        ctx.wait.wait_message_reveal_then_key();
16528        assert!(ctx.wait.finish_message_reveal());
16529        assert!(ctx.wait.message_key_waiting());
16530
16531        ctx.on_mouse_down(input::VmMouseButton::Left);
16532        assert!(!ctx.globals.syscom.read_skip.onoff);
16533        ctx.on_mouse_up(input::VmMouseButton::Left);
16534
16535        // eng_frame.cpp consumed DECIDE-DOWN while stopping read-skip, so the
16536        // release is not a MESSAGE_KEY_WAIT decision.
16537        assert!(ctx.wait.message_key_waiting());
16538        assert!(ctx.ui.message_waiting());
16539    }
16540
16541    #[test]
16542    fn active_read_skip_releases_message_key_wait_without_another_click() {
16543        let mut ctx = CommandContext::new(std::path::PathBuf::from("."));
16544        ctx.ui.begin_wait_message();
16545        ctx.wait.wait_message_reveal_then_key();
16546        assert!(ctx.wait.finish_message_reveal());
16547        ctx.globals.syscom.read_skip.onoff = true;
16548
16549        let generation = ctx.proc_generation();
16550        assert!(!ctx.wait_poll());
16551        assert!(!ctx.wait.message_key_waiting());
16552        assert!(!ctx.ui.message_waiting());
16553        // flow_proc.cpp::tnm_message_key_wait_proc() initially returns false
16554        // after each skipped message so frame_main_proc reaches drawing instead
16555        // of draining the whole scene to the next selection.
16556        assert_ne!(ctx.proc_generation(), generation);
16557        assert_eq!(ctx.last_proc_kind(), ProcKind::Disp);
16558    }
16559
16560    #[test]
16561    fn message_skip_without_vsync_wait_always_requests_a_draw() {
16562        let mut ctx = CommandContext::new(std::path::PathBuf::from("."));
16563        ctx.globals.script.wait_display_vsync_off_flag = true;
16564        ctx.globals.syscom.read_skip.onoff = true;
16565        // A high adaptive maximum must not suppress drawing when script-level
16566        // vsync waiting is disabled; this is the explicit else branch in
16567        // tnm_message_key_wait_proc().
16568        ctx.disp_because_msg_wait_cnt_max = 3000;
16569        ctx.ui.begin_wait_message();
16570        ctx.wait.wait_message_reveal_then_key();
16571        assert!(ctx.wait.finish_message_reveal());
16572
16573        let generation = ctx.proc_generation();
16574        assert!(!ctx.wait_poll());
16575        assert_ne!(ctx.proc_generation(), generation);
16576        assert_eq!(ctx.last_proc_kind(), ProcKind::Disp);
16577    }
16578
16579    #[test]
16580    fn skip_draw_throttle_matches_original_adaptive_bounds() {
16581        let mut ctx = CommandContext::new(std::path::PathBuf::from("."));
16582        ctx.frame_rate_100msec_total = 60;
16583        ctx.disp_because_msg_wait_cnt = 0;
16584        ctx.disp_because_msg_wait_cnt_max = 0;
16585
16586        assert!(ctx.should_draw_after_message_key_wait_advance());
16587        assert_eq!(ctx.disp_because_msg_wait_cnt, 0);
16588        assert_eq!(ctx.disp_because_msg_wait_cnt_max, 31);
16589
16590        ctx.disp_because_msg_wait_cnt_max = 3000;
16591        ctx.disp_because_msg_wait_cnt = 29;
16592        assert!(ctx.should_draw_after_message_key_wait_advance());
16593        assert_eq!(ctx.disp_because_msg_wait_cnt, 0);
16594        assert_eq!(ctx.disp_because_msg_wait_cnt_max, 3000);
16595
16596        ctx.frame_rate_100msec_total = 20;
16597        ctx.disp_because_msg_wait_cnt_max = 50;
16598        assert!(ctx.should_draw_after_message_key_wait_advance());
16599        assert_eq!(ctx.disp_because_msg_wait_cnt_max, 0);
16600    }
16601
16602    #[test]
16603    fn read_skip_button_uses_full_runtime_eligibility() {
16604        let mut syscom = globals::SyscomRuntimeState::default();
16605        syscom.read_skip.enable = true;
16606        syscom.read_skip.exist = true;
16607        let mut button = globals::ObjectButtonState {
16608            sys_type: TNM_SYSCOM_TYPE_READ_SKIP,
16609            ..Default::default()
16610        };
16611
16612        assert!(!syscom_feature_enabled_for_button(&syscom, &button, false));
16613        assert!(syscom_feature_enabled_for_button(&syscom, &button, true));
16614    }
16615
16616    #[test]
16617    fn ctrl_precedes_skip_disable_but_ctrl_shift_is_message_only() {
16618        let mut ctx = CommandContext::new(std::path::PathBuf::from("."));
16619        ctx.globals.script.skip_disable = true;
16620
16621        ctx.input.on_key_down(input::VmKey::Control);
16622        assert!(ctx.runtime_is_skipping());
16623        assert!(ctx.runtime_is_skipping_msg());
16624
16625        ctx.input.on_key_down(input::VmKey::Shift);
16626        assert!(!ctx.runtime_is_skipping());
16627        assert!(ctx.runtime_is_skipping_msg());
16628
16629        ctx.globals.script.ctrl_disable = true;
16630        assert!(!ctx.runtime_is_skipping());
16631        assert!(!ctx.runtime_is_skipping_msg());
16632    }
16633}
16634
16635#[cfg(test)]
16636mod basic_wipe_scene_input_tests {
16637    use super::compose_basic_wipe_scene_inputs;
16638
16639    #[test]
16640    fn cross_fade_inputs_are_complete_front_and_next_scenes() {
16641        let mut under = vec![1, 2];
16642        let mut front = vec![3];
16643        let mut next = vec![4];
16644
16645        compose_basic_wipe_scene_inputs(0, &mut under, &mut front, &mut next);
16646
16647        assert!(under.is_empty());
16648        assert_eq!(front, vec![1, 2, 3]);
16649        assert_eq!(next, vec![1, 2, 4]);
16650    }
16651
16652    #[test]
16653    fn processed_wipes_keep_their_separate_under_input() {
16654        let mut under = vec![1, 2];
16655        let mut front = vec![3];
16656        let mut next = vec![4];
16657
16658        compose_basic_wipe_scene_inputs(50, &mut under, &mut front, &mut next);
16659
16660        assert_eq!(under, vec![1, 2]);
16661        assert_eq!(front, vec![3]);
16662        assert_eq!(next, vec![4]);
16663    }
16664}
16665
16666#[cfg(test)]
16667mod render_tree_fidelity_tests {
16668    use super::{
16669        EffectParam, SiglusRenderNode, WipePartition, apply_effects_to_owner,
16670        classify_wipe_partition, compose_clip_rect, object_alpha_blend_for_render,
16671        sprite_has_render_payload,
16672    };
16673    use crate::layer::{ClipRect, RenderSprite, Sprite};
16674
16675    fn sprite(order: i32, layer: i32, marker: i32) -> RenderSprite {
16676        let mut sprite = Sprite {
16677            x: marker,
16678            ..Default::default()
16679        };
16680
16681        RenderSprite::with_sorter(None, None, order, layer, sprite)
16682    }
16683
16684    #[test]
16685    fn mesh_payload_does_not_require_a_pct_image() {
16686        let mut mesh = Sprite {
16687            mesh_kind: 1,
16688            mesh_file_name: Some("room.x".to_owned()),
16689            ..Default::default()
16690        };
16691
16692        assert!(mesh.image_id.is_none());
16693        assert!(sprite_has_render_payload(&mesh));
16694    }
16695
16696    #[test]
16697    fn mesh_object_forces_opaque_submission_like_restruct_mesh() {
16698        assert!(!object_alpha_blend_for_render(6, true));
16699        assert!(object_alpha_blend_for_render(7, true));
16700    }
16701
16702    #[test]
16703    fn screen_icon_rotation_keeps_screen_coordinates() {
16704        let ctx = super::CommandContext::new(std::path::PathBuf::from("."));
16705        let mut obj = super::globals::ObjectState::default();
16706        obj.init_param_like();
16707        obj.object_type = 2;
16708        obj.set_int_prop(&ctx.ids, ctx.ids.obj_rotate_x, 3600);
16709        let info = super::effective_object_info(&ctx, 1, 0, &obj);
16710        let mut sprite = Sprite::default();
16711        super::configure_sprite_3d(&mut sprite, &info, None, 1920, 1080);
16712        assert!(!sprite.camera_enabled);
16713        let quad = crate::render_math::sprite_quad_points(
16714            &sprite, 950.0, 800.0, 132.0, 132.0, 1920.0, 1080.0,
16715        )
16716        .expect("rotated icon quad");
16717        assert!((quad[0].x - 950.0).abs() < 0.01);
16718        assert!((quad[0].y - 800.0).abs() < 0.01);
16719        assert!((quad[2].x - 1082.0).abs() < 0.01);
16720        assert!((quad[2].y - 932.0).abs() < 0.01);
16721    }
16722
16723    #[test]
16724    fn number_tree_preserves_digit_offsets_and_omits_unused_slots() {
16725        let mut ctx = super::CommandContext::new(std::path::PathBuf::from("."));
16726        let image_id = ctx.images.solid_rgba((255, 255, 255, 255));
16727        let layer_id = ctx.layers.create_layer();
16728        let layer = ctx.layers.layer_mut(layer_id).unwrap();
16729        let sprite_ids: Vec<_> = (0..3)
16730            .map(|_| {
16731                let id = layer.create_sprite();
16732                let sprite = layer.sprite_mut(id).unwrap();
16733                sprite.image_id = Some(image_id.clone());
16734                // Tree-owned backend sprites can be hidden independently of digits.
16735                sprite.visible = false;
16736                id
16737            })
16738            .collect();
16739        let mut obj = super::globals::ObjectState::default();
16740        obj.init_param_like();
16741        obj.used = true;
16742        obj.object_type = 5;
16743        obj.backend = super::globals::ObjectBackend::Number {
16744            layer_id,
16745            sprite_ids,
16746        };
16747        obj.runtime.number_sprite_offsets = vec![Some(0), Some(30), None];
16748        obj.set_int_prop(&ctx.ids, ctx.ids.obj_disp, 1);
16749        obj.set_int_prop(&ctx.ids, ctx.ids.obj_y, 206);
16750        for x in [106, 200] {
16751            obj.set_int_prop(&ctx.ids, ctx.ids.obj_x, x);
16752            let mut nodes = Vec::new();
16753            super::append_object_tree_nodes(
16754                &ctx,
16755                None,
16756                1,
16757                0,
16758                &obj,
16759                None,
16760                true,
16761                0,
16762                0,
16763                None,
16764                &mut nodes,
16765                &mut std::collections::HashSet::default(),
16766                &mut Vec::new(),
16767            );
16768            let mut sprites = Vec::new();
16769            for node in nodes {
16770                node.flatten(&mut sprites);
16771            }
16772            assert_eq!(sprites.len(), 2);
16773            assert_eq!(sprites[0].sprite.x, x as i32);
16774            assert_eq!(sprites[1].sprite.x, x as i32 + 30);
16775            assert_eq!(sprites[1].sprite.y, 206);
16776        }
16777    }
16778
16779    #[test]
16780    fn root_sort_keeps_each_subtree_contiguous() {
16781        let child = SiglusRenderNode::from_single_sprite(sprite(100, 0, 11));
16782        let first = SiglusRenderNode {
16783            sorter_order: 10,
16784            sorter_layer: 0,
16785            sprites: vec![sprite(10, 0, 10)],
16786            sort_children_default: true,
16787            children: vec![child],
16788        };
16789        let second = SiglusRenderNode::from_single_sprite(sprite(20, 0, 20));
16790        let mut roots = vec![second, first];
16791        roots.sort_by(super::siglus_render_node_cmp);
16792
16793        let mut flattened = Vec::new();
16794        for root in roots {
16795            root.flatten(&mut flattened);
16796        }
16797        assert_eq!(
16798            flattened.iter().map(|rs| rs.sprite.x).collect::<Vec<_>>(),
16799            vec![10, 11, 20]
16800        );
16801    }
16802
16803    #[test]
16804    fn default_child_sort_sorts_expanded_root_nodes() {
16805        let parent = SiglusRenderNode {
16806            sorter_order: 0,
16807            sorter_layer: 0,
16808            sprites: vec![sprite(0, 0, 0)],
16809            sort_children_default: true,
16810            children: vec![
16811                SiglusRenderNode::from_single_sprite(sprite(30, 0, 30)),
16812                SiglusRenderNode::from_single_sprite(sprite(10, 0, 10)),
16813            ],
16814        };
16815        let mut flattened = Vec::new();
16816        parent.flatten(&mut flattened);
16817        assert_eq!(
16818            flattened.iter().map(|rs| rs.sprite.x).collect::<Vec<_>>(),
16819            vec![0, 10, 30]
16820        );
16821    }
16822
16823    #[test]
16824    fn none_child_sort_preserves_insertion_order() {
16825        let parent = SiglusRenderNode {
16826            sorter_order: 0,
16827            sorter_layer: 0,
16828            sprites: vec![sprite(0, 0, 0)],
16829            sort_children_default: false,
16830            children: vec![
16831                SiglusRenderNode::from_single_sprite(sprite(30, 0, 30)),
16832                SiglusRenderNode::from_single_sprite(sprite(10, 0, 10)),
16833            ],
16834        };
16835        let mut flattened = Vec::new();
16836        parent.flatten(&mut flattened);
16837        assert_eq!(
16838            flattened.iter().map(|rs| rs.sprite.x).collect::<Vec<_>>(),
16839            vec![0, 30, 10]
16840        );
16841    }
16842
16843    #[test]
16844    fn parent_trp_clip_composition_uses_original_union_rule() {
16845        let parent = ClipRect {
16846            left: 20,
16847            top: 30,
16848            right: 80,
16849            bottom: 90,
16850        };
16851        let child = ClipRect {
16852            left: 10,
16853            top: 40,
16854            right: 70,
16855            bottom: 100,
16856        };
16857        assert_eq!(
16858            compose_clip_rect(Some(parent), Some(child)),
16859            Some(ClipRect {
16860                left: 10,
16861                top: 30,
16862                right: 80,
16863                bottom: 100,
16864            })
16865        );
16866    }
16867
16868    #[test]
16869    fn stage_effects_do_not_cross_stage_form_owners() {
16870        let mut normal = sprite(0, 0, 0);
16871        normal.set_stage_form_owner(49);
16872        let mut excall = sprite(0, 0, 0);
16873        excall.set_stage_form_owner(65);
16874        let mut sprites = vec![normal, excall];
16875        let effect = EffectParam {
16876            x: 12,
16877            y: 0,
16878            z: 0,
16879            mono: 0,
16880            reverse: 0,
16881            bright: 0,
16882            dark: 0,
16883            color_r: 0,
16884            color_g: 0,
16885            color_b: 0,
16886            color_rate: 0,
16887            color_add_r: 0,
16888            color_add_g: 0,
16889            color_add_b: 0,
16890            begin_order: i32::MIN,
16891            begin_layer: i32::MIN,
16892            end_order: i32::MAX,
16893            end_layer: i32::MAX,
16894        };
16895        apply_effects_to_owner(&mut sprites, 49, &[effect]);
16896        assert_eq!(sprites[0].sprite.x, 12);
16897        assert_eq!(sprites[1].sprite.x, 0);
16898    }
16899
16900    #[test]
16901    fn wipe_range_uses_top_level_entity_sorter() {
16902        let mut child = sprite(900, 900, 0);
16903        child.set_wipe_sorter(10, 5);
16904        assert_eq!(
16905            classify_wipe_partition(&child, 0, 10, 0, 20, false),
16906            WipePartition::Target
16907        );
16908    }
16909}
16910
16911#[cfg(test)]
16912mod scene_metadata_cache_tests {
16913    use super::*;
16914
16915    #[test]
16916    fn active_append_change_invalidates_resident_scene_metadata() {
16917        let mut ctx =
16918            CommandContext::new(std::env::temp_dir().join("siglus-scene-metadata-cache-test"));
16919        ctx.set_active_append("append_a".to_string(), "A".to_string());
16920        *ctx.scene_metadata.get_mut() = Some((
16921            "append_a".to_string(),
16922            Arc::new(SceneMetadata::from_rows(vec![("scene_a".to_string(), 3)])),
16923        ));
16924        assert!(ctx.scene_metadata.get_mut().is_some());
16925
16926        ctx.set_active_append("append_b".to_string(), "B".to_string());
16927        assert!(ctx.scene_metadata.get_mut().is_none());
16928    }
16929}
16930
16931#[cfg(test)]
16932mod movie_menu_wait_tests {
16933    use super::*;
16934    use input::{VmKey, VmMouseButton};
16935
16936    #[test]
16937    fn menu_group_ignores_background_clicks_and_skip_until_a_decision() {
16938        let mut ctx = CommandContext::new(PathBuf::from("."));
16939        let form = ctx.ids.form_global_stage;
16940        let stage = ctx.globals.stage_forms.entry(form).or_default();
16941        stage.ensure_group_list(1, 1);
16942        let group = &mut stage.group_lists.get_mut(&1).unwrap()[0];
16943        group.start();
16944        group.wait_flag = true;
16945        ctx.globals.focused_stage_group = Some((form, 1, 0));
16946        ctx.wait.wait_group_selection(form, 1, 0);
16947        assert!(ctx.wait_poll());
16948
16949        ctx.on_mouse_move(900, 100);
16950        ctx.on_mouse_down(VmMouseButton::Left);
16951        ctx.input.next_frame();
16952        ctx.on_mouse_up(VmMouseButton::Left);
16953        assert!(
16954            ctx.wait_poll(),
16955            "a background click must not choose New Game"
16956        );
16957        assert!(ctx.stack.is_empty());
16958        ctx.on_key_down(VmKey::Control);
16959        assert!(ctx.wait_poll(), "Ctrl skip must not accept a menu choice");
16960        ctx.on_key_up(VmKey::Control);
16961        ctx.globals.syscom.read_skip.onoff = true;
16962        ctx.globals.syscom.auto_mode.onoff = true;
16963        assert!(ctx.wait_poll());
16964        ctx.globals.syscom.read_skip.onoff = false;
16965
16966        ctx.globals
16967            .stage_forms
16968            .get_mut(&form)
16969            .unwrap()
16970            .group_lists
16971            .get_mut(&1)
16972            .unwrap()[0]
16973            .hit_button_no = 5;
16974        ctx.on_key_down(VmKey::Enter);
16975        assert!(
16976            !ctx.wait_poll(),
16977            "a real decision must release the selection wait"
16978        );
16979        assert_eq!(ctx.stack.pop().and_then(|v| v.as_i64()), Some(5));
16980    }
16981
16982    #[test]
16983    fn movie_natural_finish_returns_zero_without_an_extra_click() {
16984        let mut ctx = CommandContext::new(PathBuf::from("."));
16985        ctx.globals.mov.playing = true;
16986        ctx.wait.wait_global_movie(true, true);
16987        assert!(ctx.wait_poll());
16988        ctx.globals.mov.playing = false;
16989        assert!(!ctx.wait_poll());
16990        assert_eq!(ctx.stack.pop().and_then(|v| v.as_i64()), Some(0));
16991        assert!(!ctx.wait_poll());
16992        assert!(ctx.stack.is_empty(), "completion must return exactly once");
16993    }
16994
16995    #[test]
16996    fn movie_click_skip_obeys_script_flag_and_requires_down_up() {
16997        for key_skip in [false, true] {
16998            let mut ctx = CommandContext::new(PathBuf::from("."));
16999            ctx.globals.mov.playing = true;
17000            ctx.wait.wait_global_movie(key_skip, key_skip);
17001            ctx.on_mouse_up(VmMouseButton::Left);
17002            assert!(ctx.wait_poll(), "a release alone must not skip the OP");
17003            ctx.on_mouse_down(VmMouseButton::Left);
17004            ctx.input.next_frame();
17005            assert!(ctx.wait_poll());
17006            ctx.on_mouse_up(VmMouseButton::Left);
17007            assert_eq!(ctx.wait_poll(), !key_skip);
17008            assert_eq!(ctx.globals.mov.playing, !key_skip);
17009            assert_eq!(
17010                ctx.stack.pop().and_then(|v| v.as_i64()),
17011                key_skip.then_some(1)
17012            );
17013        }
17014    }
17015
17016    #[test]
17017    fn object_movie_natural_finish_does_not_leave_an_input_wait() {
17018        let mut ctx = CommandContext::new(PathBuf::from("."));
17019        let form = ctx.ids.form_global_stage;
17020        // Missing/closed movie objects complete the wait in the reference engine.
17021        ctx.wait.wait_object_movie(form, 1, 0, true, true);
17022        assert!(!ctx.wait_poll());
17023        assert_eq!(ctx.stack.pop().and_then(|v| v.as_i64()), Some(0));
17024    }
17025}
17026
17027#[cfg(test)]
17028mod msg_back_voice_tests {
17029    use super::*;
17030
17031    fn backlog_with_button(project: PathBuf, voices: &[(i64, i64)]) -> CommandContext {
17032        let mut ctx = CommandContext::new(project);
17033        let mut history = globals::MsgBackState::default();
17034        for &(voice, character) in voices {
17035            history.add_koe(voice, character, 0, 0);
17036        }
17037        history.add_msg("Backlog voice", "", 0, 0);
17038        ctx.globals
17039            .msgbk_forms
17040            .insert(ctx.ids.form_global_msgbk, history);
17041        ctx.globals.syscom.msg_back_open = true;
17042        let mut projection = ctx.build_msg_back_projection().unwrap();
17043        projection.window_x = 100;
17044        projection.window_y = 50;
17045        projection.window_w = 320;
17046        projection.window_h = 200;
17047        projection.disp_margin = (10, 10, 10, 10);
17048        projection.koe_buttons = vec![ui::MsgBackEntryButtonProjection {
17049            history_index: 0,
17050            file: None,
17051            x: 30,
17052            y: 5,
17053        }];
17054        ctx.ui.set_msg_back_projection(Some(projection));
17055        let image = ctx.images.solid_rgba((255, 255, 255, 255));
17056        ctx.ui.msg_back.koe_buttons = vec![ui::MsgBackButtonRuntime {
17057            image: Some(image),
17058            size: Some((20, 20)),
17059            center: Some((4, 3)),
17060            ..Default::default()
17061        }];
17062        ctx
17063    }
17064
17065    #[test]
17066    fn backlog_voice_click_cycles_clips_and_preserves_message_voice() {
17067        let mut ctx = backlog_with_button(
17068            std::env::temp_dir().join("siglus-backlog-click-test"),
17069            &[(10, 2), (11, 3)],
17070        );
17071        ctx.globals.script.cur_koe_no = 99;
17072        ctx.globals.script.cur_chr_no = 7;
17073        ctx.globals.syscom.replay_koe = Some((99, 7));
17074        assert_eq!(
17075            ctx.ui.msg_back_hit_action(128, 55),
17076            None,
17077            "clipped part of icon"
17078        );
17079        assert_eq!(
17080            ctx.ui.msg_back_hit_action(128, 62),
17081            Some(ui::MsgBackHitAction::ReplayKoe(0))
17082        );
17083        for (voice, character, next) in [(10, 2, 1), (11, 3, 2), (10, 2, 1)] {
17084            ctx.on_mouse_move(128, 62);
17085            ctx.on_mouse_down(input::VmMouseButton::Left);
17086            ctx.on_mouse_up(input::VmMouseButton::Left);
17087            assert!(
17088                ctx.koe.is_playing_any(),
17089                "click must request voice decoding"
17090            );
17091            assert_eq!(ctx.globals.sound_routing.koe_chara_no, character);
17092            assert!(ctx.globals.sound_routing.koe_ex_flag);
17093            assert_eq!(
17094                ctx.globals.int_props[&(constants::fm::GLOBAL as u32)]
17095                    [&constants::elm_value::GLOBAL_KOE_CHECK_GET_KOE_NO],
17096                voice
17097            );
17098            assert_eq!(ctx.msg_back_state().unwrap().history[0].koe_play_no, next);
17099            assert_eq!(ctx.globals.script.cur_koe_no, 99);
17100            assert_eq!(ctx.globals.script.cur_chr_no, 7);
17101            assert_eq!(ctx.globals.syscom.replay_koe, Some((99, 7)));
17102            assert!(!ctx.globals.syscom.msg_back_content_dragging);
17103        }
17104        // A scrolled-out entry can retain its cached texture but not its hit area.
17105        ctx.ui
17106            .msg_back
17107            .projection
17108            .as_mut()
17109            .unwrap()
17110            .koe_buttons
17111            .clear();
17112        assert_eq!(ctx.ui.msg_back_hit_action(128, 62), None);
17113    }
17114
17115    #[test]
17116    #[ignore = "requires audio output, SIGLUS_BACKLOG_TEST_GAME and SIGLUS_BACKLOG_TEST_KOE"]
17117    fn backlog_voice_click_reaches_audio_output() {
17118        let project = PathBuf::from(std::env::var("SIGLUS_BACKLOG_TEST_GAME").unwrap());
17119        let voice: i64 = std::env::var("SIGLUS_BACKLOG_TEST_KOE")
17120            .unwrap()
17121            .parse()
17122            .unwrap();
17123        let mut ctx = backlog_with_button(project, &[(voice, 2)]);
17124        ctx.on_mouse_move(128, 62);
17125        ctx.on_mouse_down(input::VmMouseButton::Left);
17126        ctx.on_mouse_up(input::VmMouseButton::Left);
17127        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
17128        while ctx.koe.current_koe_no() != voice && std::time::Instant::now() < deadline {
17129            ctx.koe.tick(&mut ctx.audio);
17130            std::thread::sleep(std::time::Duration::from_millis(10));
17131        }
17132        assert_eq!(
17133            ctx.koe.current_koe_no(),
17134            voice,
17135            "voice must decode and start"
17136        );
17137        assert!(
17138            ctx.audio.is_enabled(),
17139            "requires a working audio output device"
17140        );
17141        assert!(ctx.koe.is_playing_any());
17142        std::thread::sleep(std::time::Duration::from_millis(150));
17143        assert!(
17144            ctx.koe.current_play_pos_ms() > 0,
17145            "audio playback must advance"
17146        );
17147    }
17148}
17149
17150#[cfg(test)]
17151mod selbtn_continuous_frame_tests {
17152    use super::*;
17153    use std::path::PathBuf;
17154
17155    #[test]
17156    fn selector_animation_keeps_redraws_alive_during_selbtn_wait() {
17157        let mut ctx = CommandContext::new(PathBuf::from("."));
17158
17159        // SELBTN has its own TNM_PROC_TYPE_SEL_BTN-style wait. The wait itself
17160        // is idle-friendly; only selector animation needs continuous frames.
17161        ctx.globals.selbtn.processing_flag_0 = true;
17162        ctx.globals.selbtn.sync_type = 0;
17163        ctx.wait.wait_selbtn();
17164        assert!(!ctx.wait.needs_continuous_frame());
17165        assert!(!ctx.needs_continuous_frame());
17166
17167        // Rewrite's SELBTN.000 uses OPEN_ANIME=006,500.  While that animation
17168        // is active, C_elm_stage::update_time() must keep advancing it even
17169        // though the script is blocked waiting for a selection.
17170        ctx.globals.selbtn.started = true;
17171        ctx.globals.selbtn.appear_flag = true;
17172        ctx.globals.selbtn.open_anime_type = 6;
17173        assert!(ctx.needs_continuous_frame());
17174
17175        // Once fully open, a static selector can sleep until input arrives.
17176        ctx.globals.selbtn.open_anime_type = 0;
17177        assert!(!ctx.needs_continuous_frame());
17178
17179        // Decide and close animations continue after input and therefore also
17180        // have to keep the frame loop alive until their completion callbacks.
17181        ctx.globals.selbtn.started = false;
17182        ctx.globals.selbtn.decide_anime_type = 2;
17183        assert!(ctx.needs_continuous_frame());
17184        ctx.globals.selbtn.decide_anime_type = 0;
17185        ctx.globals.selbtn.close_anime_type = 6;
17186        assert!(ctx.needs_continuous_frame());
17187        ctx.globals.selbtn.close_anime_type = 0;
17188
17189        // Capture is a one-frame selector state processed from
17190        // update_selbtn_animation(); it must be scheduled as well.
17191        ctx.globals.selbtn.capture_now_flag = true;
17192        assert!(ctx.needs_continuous_frame());
17193        ctx.globals.selbtn.capture_now_flag = false;
17194        assert!(!ctx.needs_continuous_frame());
17195    }
17196}
17197
17198#[cfg(test)]
17199mod emote_continuous_frame_tests {
17200    use super::*;
17201
17202    #[test]
17203    #[ignore = "requires SIGLUS_EMOTE_TEST_PROJECT pointing to 神待少女纱波"]
17204    fn live_emote_keeps_idle_dialogue_and_child_objects_ticking() {
17205        let project = PathBuf::from(std::env::var_os("SIGLUS_EMOTE_TEST_PROJECT").unwrap());
17206        let sources = [
17207            "bup_sn01_03制服+エプロン.psb",
17208            "bup_sn01_頭部.psb",
17209            "bup_共通tl.psb",
17210        ]
17211        .map(|name| std::fs::read(project.join("dat").join(name)).unwrap());
17212        let key = siglus_assets::key_toml::load_emote_key_from_project_dir(&project).unwrap();
17213        let runtime = crate::emote::SiglusEmoteRuntime::from_psb_sources(
17214            &sources.iter().map(Vec::as_slice).collect::<Vec<_>>(),
17215            key,
17216        )
17217        .unwrap();
17218        // No PlayTimeline: procedural controllers alone still need Progress.
17219        let mut ctx = CommandContext::new(project);
17220        let form = ctx.ids.form_global_stage;
17221        assert!(!ctx.needs_continuous_frame());
17222        let mut obj = globals::ObjectState::default();
17223        obj.used = true;
17224        obj.object_type = 12;
17225        obj.emote.runtime = Some(runtime);
17226        ctx.globals
17227            .stage_forms
17228            .entry(form)
17229            .or_default()
17230            .object_lists
17231            .insert(1, vec![obj]);
17232        assert!(ctx.needs_continuous_frame());
17233
17234        let child = ctx
17235            .globals
17236            .stage_forms
17237            .get_mut(&form)
17238            .unwrap()
17239            .object_lists
17240            .get_mut(&1)
17241            .unwrap()
17242            .pop()
17243            .unwrap();
17244        let mut parent = globals::ObjectState::default();
17245        parent.used = true;
17246        parent.runtime.child_objects.push(child);
17247        ctx.globals
17248            .stage_forms
17249            .get_mut(&form)
17250            .unwrap()
17251            .object_lists
17252            .insert(1, vec![parent]);
17253        assert!(ctx.needs_continuous_frame());
17254        ctx.globals
17255            .stage_forms
17256            .get_mut(&form)
17257            .unwrap()
17258            .object_lists
17259            .get_mut(&1)
17260            .unwrap()[0]
17261            .runtime
17262            .child_objects[0]
17263            .emote
17264            .clear_runtime();
17265        assert!(!ctx.needs_continuous_frame());
17266    }
17267}