Skip to content

Commit 6fd1d2f

Browse files
authored
Native CRT video v2: DDR contract, analog video settings, calibration screen (#225)
* Add native CRT video v2: DDR contract, analog settings, calibration Adopt the Menu fork's v2 DDR writer contract and move CRT ownership into the launcher now that Main_MiSTer has no OSD video pages: - Rewrite native_video_writer for the v2 control block (3 MB region, word1 magic/mode/offsets, buffers at +0x1000/+0x180000) with fb0 geometry as the mode selector: 352x240 NTSC, 352x288 PAL, and 720x480i supported in the writer but not yet exposed in the UI. - New Browse.CrtVideo singleton: video standard and centering trims persisted through state.toml + frontend.toml, live word1 pokes for calibration, and the zaparoo_launcher_crt.bin enable flag. - MiSTer-only "Analog video" settings section: confirm-gated CRT toggle (writes the enable flag and exits 42 so Main respawns the frontend under the new mode), NTSC/PAL picker, and a screen-position calibration screen with a test pattern addressing true framebuffer pixels. - Central 5% safe-area inset on the scene in CRT mode; backgrounds and screensaver overscan to the true edge. - Fix the exit-1000 restart dropping --crt: execvp now reuses the unfiltered argv. Verified on hardware: NTSC picture, live offset nudges, exit-42 toggle round trip, and writer self-disable against a stale core. * Fix PAL mode switching and modal scrim coverage in CRT mode PAL never took effect: the launcher set the framebuffer mode through vmode, whose fb_cmd path goes through Main's /dev/MiSTer_cmd loop and is not serviced while the alt launcher owns video - and Main hardcoded 352x240 anyway, re-asserting it ~1 s after spawn. - Write the fb mode directly via /sys/module/MiSTer_fb/parameters/mode in the CRT path (the mechanism Main itself uses), skipping the write when the geometry already matches. - Extend zaparoo_launcher_crt.bin to [enabled, mode] so Main programs the per-standard geometry on spawn and on its re-assert; new crt_mode_id() maps standards to DDR word1 ids. - Route video-standard changes through exit 42 (Main-owned respawn) on MiSTer instead of the in-process execvp restart; the desktop preview keeps the execvp restart. - Cover the CRT overscan band with Theme.scrim strips while a modal is open: modal scrims fill the safe-area-inset scene, so the full-bleed background previously glowed undimmed around every dialog. Pairs with Main_MiSTer a0c1bae (per-standard fb geometry + OSD CRT toggle). Verified on hardware: PAL 50 Hz picture, NTSC<->PAL round trips, modal scrims reach the framebuffer edge. * Add help-bar mapping for CRT calibration modal The CRT calibration modal had no helpEntries branch, so opening it fell through to the active-screen branch and advertised the wrong actions. Add a crtCalibrationModalVisible branch with Dpad: Adjust and ButtonA: Save, matching the modal's controls (arrows adjust H/V offset, accept/cancel commit and close). Regenerate translation catalogs for the new strings. * fix: handle CRT-mode toggle on keyboard accept path The crtEnabled field is classified as a toggle control, so keyboard/ gamepad accept enters the toggle block and returns early. That block had no crtEnabled branch, while the handling sat in fieldCommit's deferred path where toggles never reach it. Move the request into the toggle block so accept actually flips CRT mode.
1 parent 944d537 commit 6fd1d2f

32 files changed

Lines changed: 5386 additions & 3264 deletions

rust/frontend/build.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use cxx_qt_build::{CxxQtBuilder, QmlModule};
77
const MODEL_FILES: &[&str] = &[
88
"src/models/alternate_versions.rs",
99
"src/models/categories.rs",
10+
"src/models/crt_video.rs",
1011
"src/models/systems.rs",
1112
"src/models/game_info.rs",
1213
"src/models/games.rs",

rust/frontend/src/lib.rs

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ static LANGUAGE_CODE: OnceLock<CString> = OnceLock::new();
116116
static CRT_NATIVE_PATH_ENABLED: OnceLock<bool> = OnceLock::new();
117117
static VIDEO_WIDTH: OnceLock<u32> = OnceLock::new();
118118
static VIDEO_HEIGHT: OnceLock<u32> = OnceLock::new();
119+
static CRT_H_OFFSET: OnceLock<i32> = OnceLock::new();
120+
static CRT_V_OFFSET: OnceLock<i32> = OnceLock::new();
119121
static DEBUG_LOGGING_ENABLED: OnceLock<bool> = OnceLock::new();
120122
static STARTUP_TRACE_ORIGIN: OnceLock<Instant> = OnceLock::new();
121123

@@ -166,6 +168,23 @@ pub extern "C" fn zaparoo_rust_video_height() -> u32 {
166168
VIDEO_HEIGHT.get().copied().unwrap_or(0)
167169
}
168170

171+
/// Persisted native CRT horizontal centering trim (pixels, + = right),
172+
/// already clamped to the core's honored range. Pulled by
173+
/// `native_video_writer.cpp` at init so word1 carries the saved
174+
/// calibration from the first published frame. Reads before
175+
/// [`zaparoo_rust_init`] return `0`.
176+
#[no_mangle]
177+
pub extern "C" fn zaparoo_rust_crt_h_offset() -> i32 {
178+
CRT_H_OFFSET.get().copied().unwrap_or(0)
179+
}
180+
181+
/// Persisted native CRT vertical centering trim (lines, + = down). See
182+
/// [`zaparoo_rust_crt_h_offset`].
183+
#[no_mangle]
184+
pub extern "C" fn zaparoo_rust_crt_v_offset() -> i32 {
185+
CRT_V_OFFSET.get().copied().unwrap_or(0)
186+
}
187+
169188
#[no_mangle]
170189
pub extern "C" fn zaparoo_rust_debug_logging_enabled() -> bool {
171190
DEBUG_LOGGING_ENABLED.get().copied().unwrap_or(false)
@@ -355,13 +374,27 @@ pub extern "C" fn zaparoo_rust_init(crt_native_path_forced: bool) -> c_int {
355374
let _ = DEBUG_LOGGING_ENABLED.set(debug_logging);
356375
startup_trace("rust:init config loaded");
357376

358-
// CRT path always renders to the native writer's fixed 320x240 RGB8888
359-
// linuxfb surface. User-configured [video] dimensions still apply to the
360-
// normal MiSTer path, but `--crt` overrides them so startup `vmode`, the
361-
// desktop preview canvas, and the writer's fb0 validation all agree.
377+
// CRT path always renders to one of the native writer's mode
378+
// geometries (352x240 NTSC, 352x288 PAL, 720x480 480i), selected by
379+
// the persisted video standard. User-configured [video] dimensions
380+
// still apply to the normal MiSTer path, but `--crt` overrides them
381+
// so startup `vmode`, the desktop preview canvas, and the writer's
382+
// fb0 validation all agree. frontend.toml is the durable source for
383+
// the standard and offsets (state.toml lives on tmpfs on MiSTer and
384+
// mirrors it).
362385
if crt_native_path_forced {
363-
config.video_width = 320;
364-
config.video_height = 240;
386+
let standard = zaparoo_core::config::normalize_crt_video_standard(
387+
config.settings.crt_video_standard.as_deref().unwrap_or(""),
388+
);
389+
let (width, height) = zaparoo_core::config::crt_video_dimensions(standard);
390+
config.video_width = width;
391+
config.video_height = height;
392+
let (h_offset, v_offset) = zaparoo_core::config::clamp_crt_offsets(
393+
config.settings.crt_h_offset.unwrap_or(0),
394+
config.settings.crt_v_offset.unwrap_or(0),
395+
);
396+
let _ = CRT_H_OFFSET.set(h_offset);
397+
let _ = CRT_V_OFFSET.set(v_offset);
365398
}
366399

367400
// Cache the language override so `zaparoo_rust_language_code` (called

rust/frontend/src/mister_runtime.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,16 @@ pub fn apply_pre_qt_setup(config: &zaparoo_core::config::Config, crt_native_path
2323
"--crt: applying linuxfb mode {}x{} rgb32",
2424
config.video_width, config.video_height
2525
);
26-
run_vmode_with_format(config.video_width, config.video_height, "rgb32");
26+
// The CRT path cannot use `vmode`: its fb_cmd goes through
27+
// Main's /dev/MiSTer_cmd loop, which is not serviced while
28+
// the alt launcher owns video. Main itself programs the
29+
// framebuffer through the MiSTer_fb sysfs param (and is the
30+
// authority for it on spawn, including a one-shot re-assert
31+
// ~1 s in, reading the geometry from the mode byte in
32+
// zaparoo_launcher_crt.bin). This direct write covers the
33+
// execvp self-restart and bare dev runs where Main is not
34+
// involved; it is skipped when the geometry already matches.
35+
set_fb_mode_sysfs(config.video_width, config.video_height);
2736
} else {
2837
info!(
2938
"applying linuxfb mode {}x{} rgb32",
@@ -36,6 +45,27 @@ pub fn apply_pre_qt_setup(config: &zaparoo_core::config::Config, crt_native_path
3645
let _ = (config, crt_native_path_forced);
3746
}
3847

48+
#[cfg(zaparoo_runtime = "mister")]
49+
fn set_fb_mode_sysfs(width: u32, height: u32) {
50+
use tracing::{info, warn};
51+
const FB_MODE_PATH: &str = "/sys/module/MiSTer_fb/parameters/mode";
52+
let stride = width * 4;
53+
let mode = format!("8888 1 {width} {height} {stride}");
54+
match std::fs::read_to_string(FB_MODE_PATH) {
55+
Ok(current) if current.trim() == mode => {
56+
// Reconfiguring the fb bumps the kernel module's res_count
57+
// and blanks for a frame; skip when nothing would change.
58+
return;
59+
}
60+
Ok(_) => {}
61+
Err(e) => warn!("could not read {FB_MODE_PATH}: {e}"),
62+
}
63+
match std::fs::write(FB_MODE_PATH, format!("{mode}\n")) {
64+
Ok(()) => info!("fb mode set via sysfs: {mode}"),
65+
Err(e) => warn!("could not set fb mode via {FB_MODE_PATH}: {e}"),
66+
}
67+
}
68+
3969
#[cfg(zaparoo_runtime = "mister")]
4070
fn run_vmode_with_format(width: u32, height: u32, pixel_format: &str) {
4171
use tracing::warn;
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
}

rust/frontend/src/models/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub mod app_status;
2929
pub mod browse;
3030
pub mod build_info;
3131
pub mod categories;
32+
pub mod crt_video;
3233
pub mod favorites;
3334
pub mod favorites_state;
3435
pub mod game_info;

0 commit comments

Comments
 (0)