1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
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 B2S plugin places the DMD itself
} 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}");
}
}
}