pinready 0.27.7

Cross-platform configurator and launcher supporting the adoption of Visual Pinball 10.8.1 — for testers and early adopters, not a replacement for a stable production setup
use super::*;

impl App {
    pub(super) fn save_current_page(&mut self) {
        self.save_page(self.page);
    }

    pub(super) fn save_page(&mut self, page: WizardPage) {
        match page {
            WizardPage::Screens => self.save_screens(),
            WizardPage::Rendering => self.save_rendering(),
            WizardPage::ThreeD => self.save_threed(),
            WizardPage::Inputs => self.save_inputs(),
            WizardPage::Outputs => {
                // The one thing this page owns: whether head tracking runs.
                // The installer already writes it, but the checkbox has to
                // be able to turn it back off.
                if let Some(enabled) = self.ht_enabled {
                    self.config
                        .set_i32("Plugin.HeadTracking", "Enable", enabled as i32);
                    // Head tracking only shows up through the Window
                    // projection — under Legacy or Camera the plugin runs and
                    // the picture never moves. Turning it back off leaves
                    // Window in place: it is the cabinet default anyway, and
                    // changing the view a second time behind the user's back
                    // is worse than leaving it.
                    if enabled {
                        self.config.set_window_projection(self.view_mode);
                    }
                }
            }
            WizardPage::Tilt => self.save_tilt(),
            WizardPage::Audio => self.save_audio(),
            WizardPage::TablesDir => self.save_tables_dir(),
            WizardPage::System => {} // autostart/desktop_integration applied in finalize_wizard
        }
    }

    pub(super) fn save_screens(&mut self) {
        // Save VPX install mode and path
        let mode_str = match self.vpx_install_mode {
            VpxInstallMode::Auto => "auto",
            VpxInstallMode::Manual => "manual",
        };
        let _ = self.db.set_config("vpx_install_mode", mode_str);
        let _ = self.db.set_config("vpx_fork_repo", &self.vpx_fork_repo);
        let _ = self.db.set_config("vpx_install_dir", &self.vpx_install_dir);
        let _ = self.db.set_config(
            "external_dmd",
            if self.external_dmd { "true" } else { "false" },
        );
        if let Err(e) = self.db.set_config("vpx_exe_path", &self.vpx_exe_path) {
            log::error!("Failed to save VPX exe path: {e}");
        }

        self.config.set_view_mode(self.view_mode);

        if self.disable_touch {
            self.config.set_i32("Player", "TouchOverlay", 0);
            self.config
                .set_i32("Player", "NumberOfTimesToShowTouchMessage", 0);
        }

        // Disable outputs that are not used based on screen count
        let has_backglass = self
            .displays
            .iter()
            .any(|d| d.role == DisplayRole::Backglass);
        let has_dmd = self.displays.iter().any(|d| d.role == DisplayRole::Dmd);
        let has_topper = self.displays.iter().any(|d| d.role == DisplayRole::Topper);
        // B2SLegacy's overlay settings only mean something to the VPX builds
        // that still carry that plugin
        let b2s_legacy = crate::vpx_display::revision(&self.config, &self.db)
            .is_some_and(|r| r < crate::vpx_display::FIRST_REVISION_WITHOUT_B2S_LEGACY);

        if !has_backglass {
            self.config.set_i32("Backglass", "BackglassOutput", 0);
        }
        if !has_dmd {
            self.config.set_i32("ScoreView", "ScoreViewOutput", 0);
            if !b2s_legacy {
                // Nothing to set: the plugin reads the DMD area from the backglass
                // file's <Displays> element, which the DMD fix (b2s_dmd) writes
            } else if self.external_dmd {
                // External DMD device (ZeDMD, PinDMD...) — no overlay needed,
                // VPX auto-detects the device at runtime
                self.config
                    .set_i32("Plugin.B2SLegacy", "BackglassDMDOverlay", 0);
            } else if has_backglass {
                // No DMD screen, no external DMD — overlay DMD on backglass
                self.config
                    .set_i32("Plugin.B2SLegacy", "BackglassDMDOverlay", 1);
                self.config
                    .set_i32("Plugin.B2SLegacy", "BackglassDMDAutoPos", 1);
            }
        } else if b2s_legacy {
            self.config.set_i32("Plugin.B2SLegacy", "B2SHideGrill", 1);
            self.config
                .set_i32("Plugin.B2SLegacy", "ScoreViewDMDOverlay", 1);
            self.config
                .set_i32("Plugin.B2SLegacy", "ScoreViewDMDAutoPos", 1);
            self.config
                .set_i32("Plugin.B2SLegacy", "BackglassDMDOverlay", 0);
            self.config
                .set_i32("ScoreView", "Priority.B2SLegacyDMD", 10);
            self.config.set_i32("ScoreView", "Priority.ScoreView", 1);
        }
        if !has_topper {
            self.config.set_i32("Topper", "TopperOutput", 0);
        }

        // VPX from rev 5515 takes "<name> [x, y]" rather than the bare name
        let ids = crate::vpx_display::uses_ids(&self.config, &self.db);
        for display in self.displays.iter() {
            let value = crate::vpx_display::value(&display.name, display.x, display.y, ids);
            match display.role {
                DisplayRole::Playfield => {
                    self.config.set_display(
                        "Player",
                        "Playfield",
                        &value,
                        display.width,
                        display.height,
                        false,
                    );
                    self.config
                        .set_f32("Player", "PlayfieldRefreshRate", display.refresh_rate);
                    let w_cm = display.width_mm as f32 / 10.0;
                    let h_cm = display.height_mm as f32 / 10.0;
                    if w_cm > 0.0 && h_cm > 0.0 {
                        let (screen_w, screen_h) = if w_cm >= h_cm {
                            (w_cm, h_cm)
                        } else {
                            (h_cm, w_cm)
                        };
                        self.config.set_f32("Player", "ScreenWidth", screen_w);
                        self.config.set_f32("Player", "ScreenHeight", screen_h);
                    }
                }
                DisplayRole::Backglass => {
                    self.config.set_display(
                        "Backglass",
                        "Backglass",
                        &value,
                        display.width,
                        display.height,
                        true,
                    );
                }
                DisplayRole::Dmd => {
                    self.config.set_display(
                        "ScoreView",
                        "ScoreView",
                        &value,
                        display.width,
                        display.height,
                        true,
                    );
                }
                DisplayRole::Topper => {
                    self.config.set_display(
                        "Topper",
                        "Topper",
                        &value,
                        display.width,
                        display.height,
                        true,
                    );
                }
                DisplayRole::Unused => {}
            }
        }

        // Cabinet physical dimensions
        self.config
            .set_f32("Player", "ScreenInclination", self.screen_inclination);
        self.config
            .set_f32("Player", "LockbarWidth", self.lockbar_width);
        self.config
            .set_f32("Player", "LockbarHeight", self.lockbar_height);
        self.config
            .set_f32("Player", "ScreenPlayerX", self.player_x);
        self.config
            .set_f32("Player", "ScreenPlayerY", self.player_y);
        self.config
            .set_f32("Player", "ScreenPlayerZ", self.player_z);

        // Persist a driver-independent anchor (layout position + EDID
        // fingerprint) per assigned role, so the launcher can re-resolve the
        // `*Display=` names under whatever SDL driver VPX ends up using.
        crate::display_reconcile::persist_anchors(&self.db, &self.displays);
        // Remember a corrected physical size so the next launch does not
        // overwrite it with what detection reports.
        crate::display_reconcile::persist_sizes(&self.db, &self.displays);
    }

    pub(super) fn save_rendering(&mut self) {
        self.config.set_f32("Player", "AAFactor", self.aa_factor);
        self.config.set_i32("Player", "MSAASamples", self.msaa);
        self.config.set_i32("Player", "FXAA", self.fxaa);
        self.config.set_i32("Player", "Sharpen", self.sharpen);
        self.config
            .set_i32("Player", "PFReflection", self.pf_reflection);
        self.config
            .set_i32("Player", "MaxTexDimension", self.max_tex_dim);
        self.config.set_i32("Player", "SyncMode", self.sync_mode);
        self.config.set_i32(
            "Player",
            "CompressTextures",
            if self.compress_textures { 1 } else { 0 },
        );
        self.config
            .set_f32("Player", "MaxFramerate", self.max_framerate);
        self.effects.save(&mut self.config);
        // 0 = off, 1 = FPS overlay (2 = full perf stats, not offered here).
        self.config
            .set_i32("Player", "ShowFPS", if self.show_fps { 1 } else { 0 });
        // Keep the ball round (compensate render stretch).
        self.config.set_i32(
            "Player",
            "BallAntiStretch",
            if self.ball_antistretch { 1 } else { 0 },
        );
    }

    pub(super) fn save_threed(&mut self) {
        self.threed.save(&mut self.config);
    }

    pub(super) fn save_inputs(&mut self) {
        let _ = self
            .db
            .set_config("pinscape_profile", &self.pinscape_profile.to_string());

        if let Some(psc_id) = &self.pinscape_id {
            let psc_id = psc_id.clone();
            // Device declarations — leave values empty, VPX fills them on first launch.
            // Only NoAutoLayout = 1 matters (prevents VPX from re-prompting mapping).
            self.config.set("Input", "Devices", "");
            self.config.set("Input", "Device.Key.Type", "");
            self.config.set("Input", "Device.Key.NoAutoLayout", "");
            self.config.set("Input", "Device.Key.Name", "");
            self.config.set("Input", "Device.Mouse.Type", "");
            self.config.set("Input", "Device.Mouse.NoAutoLayout", "");
            self.config
                .set("Input", &format!("Device.{psc_id}.Type"), "");
            self.config
                .set("Input", &format!("Device.{psc_id}.NoAutoLayout"), "1");
            self.config
                .set("Input", &format!("Device.{psc_id}.Name"), "");

            // Axes: always write them so VPX doesn't need to auto-detect.
            // Combined with NoAutoLayout=1, VPX won't prompt and axes work.
            //
            // VPX 10.8.1 rev 5277+ ("Rewrite nudge & plunger sensor handlers")
            // replaced the old Mapping.PlungerPos / Mapping.NudgeX1 keys with a
            // "sensor" schema: VPX now reads PlungerSensorCount / NudgeSensorCount
            // plus per-sensor mappings, and ignores the legacy keys entirely.
            // All supported controllers expose accelerometers (MPU-6050, ADXL345…)
            // delivered as SDL joystick axes.
            //
            // Plunger sensor 0 — position axis 514, velocity axis 517.
            //
            // Declared only when a plunger actually exists. A Pinscape board
            // states its plunger sensor type in its own configuration (type 0
            // = none), so a cabinet wired without one no longer gets a
            // phantom sensor mapped to an axis that never moves. When no
            // board answers, assume there is one — that was the behaviour
            // before we could ask.
            // `None` means the board didn't say — keep declaring a plunger,
            // which is what we did before we could ask.
            let has_plunger = self
                .pinscape_cfg
                .and_then(|cfg| cfg.has_plunger())
                .unwrap_or(true);
            if has_plunger {
                self.config.set("Input", "PlungerSensorCount", "1");
                self.config.set(
                    "Input",
                    "Mapping.Plunger0.Position",
                    &format!("{psc_id};514;P;0.000000;1.000000;1.000000"),
                );
                // The velocity scale is a unit conversion, not a sensitivity: VPX
                // wants per-unit/s (one unit = the plunger travel length), and its
                // own sensor page offers 12.5 as the Pinscape reading. Left at 1.0,
                // the launch impulse comes out two orders of magnitude short.
                self.config.set(
                    "Input",
                    "Mapping.Plunger0.Velocity",
                    &format!("{psc_id};517;V;0.000000;12.500000;1.000000"),
                );
            } else {
                log::info!("Board reports no plunger sensor — leaving the plunger unmapped");
                self.config.set("Input", "PlungerSensorCount", "0");
            }

            // Nudge sensor 0 — accelerometer axes 512 (X) / 513 (Y). Sensitivity
            // (scale) and deadzone come from the tilt page; the sensor type is
            // user-selectable there (default Intent Sensor, best for HID boards).
            self.config.set("Input", "NudgeSensorCount", "1");
            self.config
                .set_i32("Input", "Mapping.Nudge0.Type", self.tilt.nudge_sensor_type);
            self.config.set(
                "Input",
                "Mapping.Nudge0.AccX",
                &format!(
                    "{psc_id};512;A;{:.6};{:.6};1.000000",
                    self.tilt.nudge_deadzone_pct / 100.0,
                    self.tilt.nudge_scale_pct / 100.0
                ),
            );
            self.config.set(
                "Input",
                "Mapping.Nudge0.AccY",
                &format!(
                    "{psc_id};513;A;{:.6};{:.6};1.000000",
                    self.tilt.nudge_deadzone_pct / 100.0,
                    self.tilt.nudge_scale_pct / 100.0
                ),
            );
        }

        if let Some(gp_id) = &self.gamepad_id {
            let gp_id = gp_id.clone();
            self.config
                .set("Input", &format!("Device.{gp_id}.Type"), "2");
            self.config
                .set("Input", &format!("Device.{gp_id}.Name"), "Gamepad");
            if self.use_gamepad {
                self.config
                    .set("Input", &format!("Device.{gp_id}.NoAutoLayout"), "");
            } else {
                self.config
                    .set("Input", &format!("Device.{gp_id}.NoAutoLayout"), "1");
            }
            // Add gamepad to Devices list if not already there
            if let Some(devices) = self.config.get("Input", "Devices")
                && !devices.contains(&gp_id)
            {
                self.config
                    .set("Input", "Devices", &format!("{devices};{gp_id}"));
            }
        }

        for action in &self.actions {
            if let Some(mapping) = inputs::mapping_for_save(action) {
                self.config.set_input_mapping(action.setting_id, &mapping);
            }
        }
    }

    pub(super) fn save_tilt(&mut self) {
        self.tilt.save_to_config(&mut self.config);
    }

    pub(super) fn save_audio(&mut self) {
        self.audio.save_to_config(&mut self.config);
    }

    pub(super) fn flush_config(&mut self) {
        if let Err(e) = self.config.save() {
            log::error!("Failed to save config: {e}");
        }
    }

    pub(super) fn save_tables_dir(&mut self) {
        if let Err(e) = self.db.set_tables_dir(&self.tables_dir) {
            log::error!("Failed to save tables dir: {e}");
        }
    }
}