|
| 1 | +// Zaparoo Frontend |
| 2 | +// Copyright (c) 2026 Wizzo Pty Ltd and the Zaparoo Project contributors. |
| 3 | +// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 |
| 4 | +// |
| 5 | +// `Browse.CrtVideo` - native CRT video state for the MiSTer `--crt` |
| 6 | +// path. Owns the settings the Analog video section and the calibration |
| 7 | +// screen read and write: |
| 8 | +// |
| 9 | +// * `crt_enabled` - CONSTANT. Whether this process is running the |
| 10 | +// native CRT path (`--crt`). The durable truth lives in |
| 11 | +// `config/zaparoo_launcher_crt.bin` on the MiSTer SD card, which |
| 12 | +// Main_MiSTer reads to decide how to spawn us; toggling goes |
| 13 | +// through `write_crt_enable_file` + exit code 42 so Main respawns |
| 14 | +// the frontend under the new mode. |
| 15 | +// * `available_video_standards` - CONSTANT. `ntsc` / `pal` picker |
| 16 | +// keys. `480i` is a valid persisted value (hand-set in |
| 17 | +// frontend.toml for hardware smoke tests) but is deliberately not |
| 18 | +// offered until the 480i flicker-discipline UI pass lands. |
| 19 | +// * `current_video_standard` - READ + NOTIFY, persisted. Restart- |
| 20 | +// applied: the next `--crt` boot sizes the framebuffer from it |
| 21 | +// (352x240 NTSC, 352x288 PAL) and the Menu fork core derives its |
| 22 | +// mode from that geometry. |
| 23 | +// * `h_offset` / `v_offset` - READ + NOTIFY. Centering trims within |
| 24 | +// the core's honored ranges. `set_offsets` updates them live (the |
| 25 | +// calibration screen pokes the DDR control word per keypress) but |
| 26 | +// does NOT persist - arrow hold-repeat must not hammer the SD card |
| 27 | +// through the frontend.toml mirror. `commit_offsets` persists the |
| 28 | +// current values; the calibration screen calls it on exit. |
| 29 | + |
| 30 | +use crate::models::settings::{mirror_settings_to_config, persist_settings}; |
| 31 | +use crate::models::with_persist_read; |
| 32 | +use cxx_qt::{CxxQtType, Initialize}; |
| 33 | +use cxx_qt_lib::{QString, QStringList}; |
| 34 | +use std::pin::Pin; |
| 35 | +use zaparoo_core::config::{ |
| 36 | + clamp_crt_offsets, load_config, normalize_crt_video_standard, CRT_H_OFFSET_MAX, |
| 37 | + CRT_H_OFFSET_MIN, CRT_V_OFFSET_MAX, CRT_V_OFFSET_MIN, |
| 38 | +}; |
| 39 | +use zaparoo_core::platform_paths::config_file_path; |
| 40 | + |
| 41 | +/// Picker order for the Analog video section's standard picker. |
| 42 | +const VIDEO_STANDARDS: &[&str] = &["ntsc", "pal"]; |
| 43 | + |
| 44 | +/// The CRT state file Main_MiSTer reads when the menu core loads |
| 45 | +/// (`zaparoo_alt_launcher_init_for_menu`) and on every CRT spawn: |
| 46 | +/// byte 0 = enabled, byte 1 = DDR mode id (`crt_mode_id`). Main needs |
| 47 | +/// the mode byte because it programs the framebuffer geometry before |
| 48 | +/// the spawn AND re-asserts it ~1 s after - without it, a PAL fb would |
| 49 | +/// be stomped back to 352x240. The frontend writes the file and exits |
| 50 | +/// with code 42; Main re-reads it and respawns us with or without |
| 51 | +/// `--crt`. Legacy 1-byte files read as mode 0 (NTSC) on Main's side. |
| 52 | +#[cfg(zaparoo_runtime = "mister")] |
| 53 | +const CRT_ENABLE_FILE: &str = "/media/fat/config/zaparoo_launcher_crt.bin"; |
| 54 | + |
| 55 | +// Live word1 poke in the C++ writer (`native_video_writer.cpp`). Only |
| 56 | +// referenced on MiSTer builds: desktop `cargo test` and the QML test |
| 57 | +// harness link this crate without the writer object, so an ungated |
| 58 | +// extern would fail to link there. |
| 59 | +#[cfg(zaparoo_runtime = "mister")] |
| 60 | +extern "C" { |
| 61 | + fn zaparoo_native_video_set_offsets(h_offset: i32, v_offset: i32); |
| 62 | +} |
| 63 | + |
| 64 | +#[derive(Default)] |
| 65 | +pub struct CrtVideoRust { |
| 66 | + crt_enabled: bool, |
| 67 | + available_video_standards: QStringList, |
| 68 | + current_video_standard: QString, |
| 69 | + h_offset: i32, |
| 70 | + v_offset: i32, |
| 71 | +} |
| 72 | + |
| 73 | +#[cxx_qt::bridge] |
| 74 | +pub mod ffi { |
| 75 | + unsafe extern "C++" { |
| 76 | + include!("model_includes.h"); |
| 77 | + |
| 78 | + type QString = cxx_qt_lib::QString; |
| 79 | + type QStringList = cxx_qt_lib::QStringList; |
| 80 | + } |
| 81 | + |
| 82 | + unsafe extern "RustQt" { |
| 83 | + #[qobject] |
| 84 | + #[qml_element] |
| 85 | + #[qml_singleton] |
| 86 | + #[qproperty(bool, crt_enabled, READ, CONSTANT)] |
| 87 | + #[qproperty(QStringList, available_video_standards, READ, CONSTANT)] |
| 88 | + #[qproperty(QString, current_video_standard, READ, WRITE = set_video_standard, NOTIFY)] |
| 89 | + #[qproperty(i32, h_offset, READ, NOTIFY)] |
| 90 | + #[qproperty(i32, v_offset, READ, NOTIFY)] |
| 91 | + type CrtVideo = super::CrtVideoRust; |
| 92 | + |
| 93 | + #[qinvokable] |
| 94 | + fn set_video_standard(self: Pin<&mut CrtVideo>, value: QString); |
| 95 | + |
| 96 | + /// Clamp and apply centering trims live (updates the DDR |
| 97 | + /// control word on MiSTer); does not persist. |
| 98 | + #[qinvokable] |
| 99 | + fn set_offsets(self: Pin<&mut CrtVideo>, h_offset: i32, v_offset: i32); |
| 100 | + |
| 101 | + /// Persist the current trims to state.toml + frontend.toml. |
| 102 | + #[qinvokable] |
| 103 | + fn commit_offsets(self: Pin<&mut CrtVideo>); |
| 104 | + |
| 105 | + /// Write the CRT state file Main_MiSTer reads on respawn: |
| 106 | + /// [enabled, mode id of the current video standard]. Returns |
| 107 | + /// false if the write failed (caller should not exit-42 in |
| 108 | + /// that case). No-op true off MiSTer. |
| 109 | + #[qinvokable] |
| 110 | + fn write_crt_enable_file(self: Pin<&mut CrtVideo>, enabled: bool) -> bool; |
| 111 | + } |
| 112 | + |
| 113 | + impl cxx_qt::Initialize for CrtVideo {} |
| 114 | +} |
| 115 | + |
| 116 | +impl Initialize for ffi::CrtVideo { |
| 117 | + fn initialize(mut self: Pin<&mut Self>) { |
| 118 | + // Self-contained merge: config wins over the persisted snapshot |
| 119 | + // (same precedence as `settings::merge_settings`) so this model |
| 120 | + // does not depend on Browse.Settings having initialised first. |
| 121 | + let snapshot = with_persist_read(|s| s.settings.clone()); |
| 122 | + let config = load_config(&config_file_path()); |
| 123 | + let standard = normalize_crt_video_standard( |
| 124 | + config |
| 125 | + .settings |
| 126 | + .crt_video_standard |
| 127 | + .as_deref() |
| 128 | + .unwrap_or(snapshot.crt_video_standard.as_str()), |
| 129 | + ); |
| 130 | + let (h_offset, v_offset) = clamp_crt_offsets( |
| 131 | + config |
| 132 | + .settings |
| 133 | + .crt_h_offset |
| 134 | + .unwrap_or(snapshot.crt_h_offset), |
| 135 | + config |
| 136 | + .settings |
| 137 | + .crt_v_offset |
| 138 | + .unwrap_or(snapshot.crt_v_offset), |
| 139 | + ); |
| 140 | + let mut standards = QStringList::default(); |
| 141 | + for s in VIDEO_STANDARDS { |
| 142 | + standards.append(QString::from(*s)); |
| 143 | + } |
| 144 | + self.as_mut().rust_mut().crt_enabled = crate::zaparoo_rust_crt_native_path_enabled(); |
| 145 | + self.as_mut().rust_mut().available_video_standards = standards; |
| 146 | + self.as_mut().rust_mut().current_video_standard = QString::from(standard); |
| 147 | + self.as_mut().rust_mut().h_offset = h_offset; |
| 148 | + self.as_mut().rust_mut().v_offset = v_offset; |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +impl ffi::CrtVideo { |
| 153 | + #[allow( |
| 154 | + clippy::needless_pass_by_value, |
| 155 | + reason = "cxx-qt qinvokable signature requires QString by value" |
| 156 | + )] |
| 157 | + fn set_video_standard(mut self: Pin<&mut Self>, value: QString) { |
| 158 | + let value_str = normalize_crt_video_standard(&value.to_string()).to_string(); |
| 159 | + if self.current_video_standard.to_string() == value_str { |
| 160 | + return; |
| 161 | + } |
| 162 | + // Restart-applied: the next `--crt` boot reads it from |
| 163 | + // frontend.toml when sizing the framebuffer. |
| 164 | + let snapshot = persist_settings(|s| s.crt_video_standard.clone_from(&value_str)); |
| 165 | + mirror_settings_to_config(&config_file_path(), &snapshot.settings); |
| 166 | + self.as_mut().rust_mut().current_video_standard = QString::from(value_str.as_str()); |
| 167 | + self.as_mut().current_video_standard_changed(); |
| 168 | + } |
| 169 | + |
| 170 | + fn set_offsets(mut self: Pin<&mut Self>, h_offset: i32, v_offset: i32) { |
| 171 | + let (h_offset, v_offset) = clamp_crt_offsets(h_offset, v_offset); |
| 172 | + let changed_h = self.h_offset != h_offset; |
| 173 | + let changed_v = self.v_offset != v_offset; |
| 174 | + if !changed_h && !changed_v { |
| 175 | + return; |
| 176 | + } |
| 177 | + self.as_mut().rust_mut().h_offset = h_offset; |
| 178 | + self.as_mut().rust_mut().v_offset = v_offset; |
| 179 | + if changed_h { |
| 180 | + self.as_mut().h_offset_changed(); |
| 181 | + } |
| 182 | + if changed_v { |
| 183 | + self.as_mut().v_offset_changed(); |
| 184 | + } |
| 185 | + #[cfg(zaparoo_runtime = "mister")] |
| 186 | + // SAFETY: plain int arguments into a C function that only |
| 187 | + // rewrites a mapped control word (and no-ops when the writer |
| 188 | + // is not initialised). |
| 189 | + unsafe { |
| 190 | + zaparoo_native_video_set_offsets(h_offset, v_offset); |
| 191 | + } |
| 192 | + } |
| 193 | + |
| 194 | + fn commit_offsets(self: Pin<&mut Self>) { |
| 195 | + let h_offset = self.h_offset.clamp(CRT_H_OFFSET_MIN, CRT_H_OFFSET_MAX); |
| 196 | + let v_offset = self.v_offset.clamp(CRT_V_OFFSET_MIN, CRT_V_OFFSET_MAX); |
| 197 | + let snapshot = persist_settings(|s| { |
| 198 | + s.crt_h_offset = h_offset; |
| 199 | + s.crt_v_offset = v_offset; |
| 200 | + }); |
| 201 | + mirror_settings_to_config(&config_file_path(), &snapshot.settings); |
| 202 | + } |
| 203 | + |
| 204 | + #[cfg(zaparoo_runtime = "mister")] |
| 205 | + fn write_crt_enable_file(self: Pin<&mut Self>, enabled: bool) -> bool { |
| 206 | + let mode = zaparoo_core::config::crt_mode_id(&self.current_video_standard.to_string()); |
| 207 | + match std::fs::write(CRT_ENABLE_FILE, [u8::from(enabled), mode]) { |
| 208 | + Ok(()) => true, |
| 209 | + Err(e) => { |
| 210 | + tracing::warn!("could not write {CRT_ENABLE_FILE}: {e}"); |
| 211 | + false |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + #[cfg(not(zaparoo_runtime = "mister"))] |
| 217 | + fn write_crt_enable_file(self: Pin<&mut Self>, enabled: bool) -> bool { |
| 218 | + let _ = enabled; |
| 219 | + true |
| 220 | + } |
| 221 | +} |
0 commit comments