proof_engine/config/mod.rs
1//! Engine configuration — hierarchical, hot-reloadable, command-line overridable.
2//!
3//! Loads from `engine.toml` with sensible defaults for all settings. Supports:
4//! - TOML serialization/deserialization
5//! - Profile system (default, debug, release, steam_deck, low_end)
6//! - Command-line argument overrides (`--width`, `--height`, `--no-audio`, etc.)
7//! - Hot reload: watch `engine.toml` for changes and re-apply at runtime
8//! - Validation with clamped/sanitized values
9//! - Diff-based change detection for per-subsystem notifications
10
11#![warn(missing_docs)]
12
13use serde::{Deserialize, Serialize};
14
15// ── Top-level ─────────────────────────────────────────────────────────────────
16
17/// Top-level engine configuration, loadable from a TOML file.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct EngineConfig {
20 /// Text shown in the window title bar.
21 pub window_title: String,
22 /// Window width in pixels.
23 pub window_width: u32,
24 /// Window height in pixels.
25 pub window_height: u32,
26 /// Frame-rate cap in frames per second; 0 means uncapped, otherwise clamped to 15 to 360 by `validate`.
27 pub target_fps: u32,
28 /// Wait for the display refresh before presenting each frame.
29 pub vsync: bool,
30 /// Sound settings.
31 pub audio: AudioConfig,
32 /// Rendering and post-processing settings.
33 pub render: RenderConfig,
34 /// Physics simulation settings.
35 pub physics: PhysicsConfig,
36 /// Mouse, keyboard and gamepad settings.
37 pub input: InputConfig,
38 /// Debug overlays and logging settings.
39 pub debug: DebugConfig,
40 /// Game rules such as seed, difficulty and autosave.
41 pub gameplay: GameplayConfig,
42 /// Accessibility options such as colourblind mode and reduced motion.
43 pub accessibility: AccessibilityConfig,
44}
45
46impl Default for EngineConfig {
47 fn default() -> Self {
48 Self {
49 window_title: "Proof Engine".to_string(),
50 window_width: 1280,
51 window_height: 800,
52 target_fps: 60,
53 vsync: true,
54 audio: AudioConfig::default(),
55 render: RenderConfig::default(),
56 physics: PhysicsConfig::default(),
57 input: InputConfig::default(),
58 debug: DebugConfig::default(),
59 gameplay: GameplayConfig::default(),
60 accessibility: AccessibilityConfig::default(),
61 }
62 }
63}
64
65impl EngineConfig {
66 // ── Load/Save ──────────────────────────────────────────────────────────────
67
68 /// Load from a TOML file, falling back to defaults on error.
69 pub fn load(path: &str) -> Self {
70 let mut cfg: Self = std::fs::read_to_string(path)
71 .ok()
72 .and_then(|s| toml::from_str(&s).ok())
73 .unwrap_or_default();
74 cfg.validate();
75 cfg
76 }
77
78 /// Save to a TOML file.
79 pub fn save(&self, path: &str) -> bool {
80 toml::to_string_pretty(self)
81 .ok()
82 .and_then(|s| std::fs::write(path, s).ok())
83 .is_some()
84 }
85
86 // ── Profiles ──────────────────────────────────────────────────────────────
87
88 /// Low-end PC profile: reduced resolution, no post-fx, minimal effects.
89 pub fn profile_low_end() -> Self {
90 Self {
91 window_width: 960,
92 window_height: 540,
93 target_fps: 30,
94 vsync: true,
95 render: RenderConfig {
96 bloom_enabled: false,
97 motion_blur_enabled: false,
98 chromatic_aberration: 0.0,
99 film_grain: 0.0,
100 scanlines_enabled: false,
101 particle_multiplier: 0.5,
102 shadow_quality: ShadowQuality::Off,
103 ..RenderConfig::default()
104 },
105 physics: PhysicsConfig {
106 fluid_grid_size: 16,
107 soft_body_iters: 2,
108 ..PhysicsConfig::default()
109 },
110 ..Self::default()
111 }
112 }
113
114 /// Steam Deck profile: 1280×800 locked 60, moderate effects.
115 pub fn profile_steam_deck() -> Self {
116 Self {
117 window_width: 1280,
118 window_height: 800,
119 target_fps: 60,
120 vsync: true,
121 render: RenderConfig {
122 bloom_intensity: 0.6,
123 chromatic_aberration: 0.001,
124 particle_multiplier: 0.75,
125 shadow_quality: ShadowQuality::Low,
126 ..RenderConfig::default()
127 },
128 ..Self::default()
129 }
130 }
131
132 /// Ultra profile: max everything.
133 pub fn profile_ultra() -> Self {
134 Self {
135 window_width: 2560,
136 window_height: 1440,
137 target_fps: 144,
138 vsync: false,
139 render: RenderConfig {
140 bloom_intensity: 1.5,
141 chromatic_aberration: 0.003,
142 film_grain: 0.03,
143 particle_multiplier: 2.0,
144 shadow_quality: ShadowQuality::Ultra,
145 ..RenderConfig::default()
146 },
147 physics: PhysicsConfig {
148 fluid_grid_size: 128,
149 soft_body_iters: 8,
150 ..PhysicsConfig::default()
151 },
152 ..Self::default()
153 }
154 }
155
156 /// Debug profile: all overlays on, no vsync, fast timestep.
157 pub fn profile_debug() -> Self {
158 Self {
159 vsync: false,
160 target_fps: 0, // uncapped
161 debug: DebugConfig {
162 show_fps: true,
163 show_frame_graph: true,
164 show_physics: true,
165 show_spawn_zones: true,
166 show_entity_ids: true,
167 log_level: LogLevel::Debug,
168 ..DebugConfig::default()
169 },
170 ..Self::default()
171 }
172 }
173
174 // ── Command-line override ──────────────────────────────────────────────────
175
176 /// Parse and apply command-line arguments as overrides.
177 /// Supported flags: `--width N`, `--height N`, `--fps N`, `--no-audio`,
178 /// `--no-vsync`, `--no-bloom`, `--fullscreen`, `--windowed`.
179 pub fn apply_args(&mut self, args: &[String]) {
180 let mut i = 0;
181 while i < args.len() {
182 match args[i].as_str() {
183 "--width" => {
184 if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
185 self.window_width = v;
186 i += 1;
187 }
188 }
189 "--height" => {
190 if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
191 self.window_height = v;
192 i += 1;
193 }
194 }
195 "--fps" => {
196 if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
197 self.target_fps = v;
198 i += 1;
199 }
200 }
201 "--no-audio" => self.audio.enabled = false,
202 "--no-vsync" => self.vsync = false,
203 "--no-bloom" => self.render.bloom_enabled = false,
204 "--no-postfx" => {
205 self.render.bloom_enabled = false;
206 self.render.distortion_enabled = false;
207 self.render.motion_blur_enabled = false;
208 self.render.chromatic_aberration = 0.0;
209 self.render.film_grain = 0.0;
210 }
211 "--fullscreen" => self.render.fullscreen = true,
212 "--windowed" => self.render.fullscreen = false,
213 "--low-end" => *self = Self::profile_low_end(),
214 "--ultra" => *self = Self::profile_ultra(),
215 "--debug" => *self = Self::profile_debug(),
216 _ => {}
217 }
218 i += 1;
219 }
220 self.validate();
221 }
222
223 // ── Validation ─────────────────────────────────────────────────────────────
224
225 /// Clamp and sanitize all values to valid ranges.
226 pub fn validate(&mut self) {
227 self.window_width = self.window_width.clamp(320, 7680);
228 self.window_height = self.window_height.clamp(240, 4320);
229 self.target_fps = if self.target_fps == 0 { 0 } else { self.target_fps.clamp(15, 360) };
230 self.audio.validate();
231 self.render.validate();
232 self.physics.validate();
233 }
234
235 // ── Aspect ratio ──────────────────────────────────────────────────────────
236
237 /// Window width divided by height.
238 pub fn aspect_ratio(&self) -> f32 {
239 self.window_width as f32 / self.window_height as f32
240 }
241
242 /// Check if resolution is standard 16:9.
243 pub fn is_widescreen(&self) -> bool {
244 let ar = self.aspect_ratio();
245 (ar - 16.0 / 9.0).abs() < 0.05
246 }
247
248 /// Check if the config differs from another (for hot-reload diffs).
249 pub fn diff(&self, other: &Self) -> ConfigDiff {
250 ConfigDiff {
251 window_changed: self.window_width != other.window_width
252 || self.window_height != other.window_height,
253 audio_changed: self.audio.enabled != other.audio.enabled
254 || (self.audio.master_volume - other.audio.master_volume).abs() > 0.01,
255 render_changed: self.render.bloom_enabled != other.render.bloom_enabled
256 || self.render.bloom_intensity != other.render.bloom_intensity,
257 physics_changed: self.physics.fluid_grid_size != other.physics.fluid_grid_size,
258 }
259 }
260}
261
262/// Bitmask of which subsystems changed in a hot-reload diff.
263#[derive(Debug, Default)]
264pub struct ConfigDiff {
265 /// Window width or height differs.
266 pub window_changed: bool,
267 /// Audio enabled flag or master volume (by more than 0.01) differs.
268 pub audio_changed: bool,
269 /// Bloom enabled flag or bloom intensity differs.
270 pub render_changed: bool,
271 /// Fluid grid size differs.
272 pub physics_changed: bool,
273}
274
275impl ConfigDiff {
276 /// True if any subsystem changed.
277 pub fn any_changed(&self) -> bool {
278 self.window_changed || self.audio_changed || self.render_changed || self.physics_changed
279 }
280}
281
282// ── AudioConfig ───────────────────────────────────────────────────────────────
283
284/// Sound settings.
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct AudioConfig {
287 /// Play sound at all.
288 pub enabled: bool,
289 /// Overall volume, 0.0 to 1.0.
290 pub master_volume: f32,
291 /// Music volume, 0.0 to 1.0, applied on top of the master volume.
292 pub music_volume: f32,
293 /// Sound-effect volume, 0.0 to 1.0, applied on top of the master volume.
294 pub sfx_volume: f32,
295 /// Sample rate override (0 = use device default).
296 pub sample_rate: u32,
297 /// Buffer size in frames (0 = auto).
298 pub buffer_size: u32,
299 /// Enable spatialized 3D audio.
300 pub spatial_audio: bool,
301 /// Reverb room size (0.0 = dry, 1.0 = large hall).
302 pub reverb_room: f32,
303 /// Which audio host API to prefer.
304 pub audio_backend: AudioBackend,
305}
306
307impl Default for AudioConfig {
308 fn default() -> Self {
309 Self {
310 enabled: true,
311 master_volume: 1.0,
312 music_volume: 0.6,
313 sfx_volume: 0.8,
314 sample_rate: 0,
315 buffer_size: 0,
316 spatial_audio: true,
317 reverb_room: 0.2,
318 audio_backend: AudioBackend::Default,
319 }
320 }
321}
322
323impl AudioConfig {
324 /// Clamp the volumes and reverb room size to 0.0 to 1.0.
325 pub fn validate(&mut self) {
326 self.master_volume = self.master_volume.clamp(0.0, 1.0);
327 self.music_volume = self.music_volume.clamp(0.0, 1.0);
328 self.sfx_volume = self.sfx_volume.clamp(0.0, 1.0);
329 self.reverb_room = self.reverb_room.clamp(0.0, 1.0);
330 }
331}
332
333/// Audio host API preference.
334#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
335pub enum AudioBackend {
336 /// The platform's default host.
337 Default,
338 /// Windows Audio Session API.
339 Wasapi,
340 /// Steinberg ASIO (Windows, low latency).
341 Asio,
342 /// PulseAudio (Linux).
343 PulseAudio,
344 /// ALSA (Linux).
345 Alsa,
346 /// Core Audio (macOS).
347 CoreAudio,
348}
349
350// ── RenderConfig ──────────────────────────────────────────────────────────────
351
352/// Rendering and post-processing settings.
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct RenderConfig {
355 /// Turn the bloom (glow) pass on.
356 pub bloom_enabled: bool,
357 /// Bloom strength, clamped to 0.0 to 20.0.
358 pub bloom_intensity: f32,
359 /// Bloom radius in pixels (higher = wider glow).
360 pub bloom_radius: f32,
361 /// Turn the screen distortion pass on.
362 pub distortion_enabled: bool,
363 /// Turn motion blur on.
364 pub motion_blur_enabled: bool,
365 /// Motion blur sample count (2-16).
366 pub motion_blur_samples: u32,
367 /// Colour fringing strength, clamped to 0.0 to 0.2.
368 pub chromatic_aberration: f32,
369 /// Film grain strength, clamped to 0.0 to 0.5.
370 pub film_grain: f32,
371 /// Draw CRT-style scanlines.
372 pub scanlines_enabled: bool,
373 /// Scanline darkness, clamped to 0.0 to 1.0.
374 pub scanline_intensity: f32,
375
376 // ── Grade ────────────────────────────────────────────────────────────────
377 //
378 // The composite shader has had tint, saturation, contrast and a vignette
379 // since it was written, and every one of them was hard-coded in the
380 // pipeline where nothing could reach it. A game could turn bloom on and
381 // that was the whole of its control over how the picture looked.
382 /// Linear exposure, applied before the tonemap. 1.0 is neutral.
383 pub exposure: f32,
384 /// How much of the filmic curve to apply, 0.0 to 1.0.
385 ///
386 /// Without it the image is clipped rather than rolled off, which is what
387 /// makes a rendered frame look rendered.
388 pub tonemap: f32,
389 /// Warm bleed around bright edges, the way film halates. 0.0 to ~0.5.
390 pub halation: f32,
391 /// Colour saturation multiplier; 1.0 is neutral.
392 pub saturation: f32,
393 /// Contrast multiplier; 1.0 is neutral.
394 pub contrast: f32,
395 /// Brightness offset; 0.0 is neutral.
396 pub brightness: f32,
397 /// Strength of the corner darkening, 0.0 to 1.0.
398 pub vignette: f32,
399 /// Where the vignette starts, as a fraction of the way to the corner.
400 pub vignette_softness: f32,
401 /// Colour cast, multiplied over everything.
402 pub tint: [f32; 3],
403 /// Shadows, lifted toward this colour.
404 pub lift: [f32; 3],
405 /// Highlights, multiplied by this colour.
406 pub gain: [f32; 3],
407 /// Nothing dimmer than this blooms. 0.0 blooms everything.
408 pub bloom_threshold: f32,
409 /// How soft the edge of that threshold is.
410 pub bloom_knee: f32,
411 /// Horizontal light streaking, the way anamorphic glass flares. 0.0 off.
412 pub anamorphic: f32,
413 /// Unsharp mask after the tonemap, to put back the edge bloom takes off.
414 pub sharpen: f32,
415 /// Ordered dither before output, in 8-bit steps.
416 ///
417 /// This engine renders very dark scenes, and a dark gradient in eight bits
418 /// per channel bands visibly. A quarter-step of ordered noise costs
419 /// nothing and removes it.
420 pub dither: f32,
421 /// Barrel distortion, as a fraction of the frame. Small values only.
422 pub barrel: f32,
423 /// Glyph font size in pixels, clamped to 8 to 64.
424 pub font_size: u32,
425 /// Start in fullscreen.
426 pub fullscreen: bool,
427 /// Render scale (1.0 = native, 0.5 = half res).
428 pub render_scale: f32,
429 /// Particle system multiplier (1.0 = full, 0.5 = half particles).
430 pub particle_multiplier: f32,
431 /// Shadow map quality level.
432 pub shadow_quality: ShadowQuality,
433 /// Enable anti-aliasing (FXAA approximation for terminal renderer).
434 pub antialiasing: bool,
435 /// Color depth per channel for dithering: 8, 16, or 32.
436 pub color_depth: u8,
437 /// Voxel-cone global illumination: voxelise the scene, inject light,
438 /// propagate. Worth it for a lit 3D scene and pure cost for anything else.
439 ///
440 /// This used to run unconditionally, every frame, whatever was on screen —
441 /// which meant a game drawing its whole interface in the screen-space UI
442 /// layer paid for a global illumination solve it could never see.
443 pub global_illumination: bool,
444 /// Volumetric fog. Same story: a real effect, and a real cost, that a
445 /// scene with nothing in it cannot benefit from.
446 pub volumetric_fog: bool,
447
448 // ── Light ────────────────────────────────────────────────────────────────
449 //
450 // Everything above shapes the picture. These add light to it.
451 /// Screen-space indirect light: how strongly matter near something
452 /// bright is lit by it, in that thing's colour. 0.0 off; 0.5 to 1.5 is
453 /// a lit room.
454 pub indirect_light: f32,
455 /// Radial light shafts from the source the game sets on `engine.fx`.
456 /// 0.0 off; the source's own strength scales it further.
457 pub light_shafts: f32,
458 /// Lens flare ghosts and halo off anything that blooms. 0.0 off; 0.1 to
459 /// 0.4 is a visible lens without being a screensaver.
460 pub lens_flare: f32,
461 /// How far, in pixels, the world pass moves at full camera trauma. The
462 /// HUD does not move. 0.0 turns screen shake off for the world pass.
463 pub shake_pixels: f32,
464 /// Anti-aliasing on the finished frame, between the composite and the
465 /// HUD. A real FXAA pass, not the terminal approximation `antialiasing`
466 /// names.
467 pub fxaa: bool,
468 /// Motion trails. The fraction of the previous frame's scene that
469 /// survives into this one, 0.0 to about 0.9. Applies to the scene and
470 /// the UI world pass, never to the HUD.
471 pub persistence: f32,
472 /// Wait for the display's vertical refresh before presenting. Off, the
473 /// loop runs as fast as the GPU allows, which burns power and makes
474 /// every time-based effect run at whatever rate the machine happens to.
475 pub vsync: bool,
476 /// Whether the UI layer's world pass is painted into the scene buffer.
477 /// Off, every UI command paints in the HUD pass after post-processing,
478 /// which is how the layer behaved before the world pass existed.
479 pub world_ui_in_scene: bool,
480}
481
482impl Default for RenderConfig {
483 fn default() -> Self {
484 Self {
485 bloom_enabled: true,
486 bloom_intensity: 0.0,
487 bloom_radius: 0.0,
488 distortion_enabled: false,
489 motion_blur_enabled: false,
490 motion_blur_samples: 2,
491 chromatic_aberration: 0.0,
492 film_grain: 0.0,
493 scanlines_enabled: false,
494 scanline_intensity: 0.15,
495 // Neutral: the same picture the pipeline produced before the grade
496 // was exposed, so nothing that already existed changes look.
497 exposure: 1.0,
498 tonemap: 0.0,
499 halation: 0.0,
500 saturation: 1.0,
501 contrast: 1.05,
502 brightness: 0.0,
503 vignette: 0.25,
504 vignette_softness: 0.35,
505 tint: [1.0, 1.0, 1.0],
506 lift: [0.0, 0.0, 0.0],
507 gain: [1.0, 1.0, 1.0],
508 bloom_threshold: 0.0,
509 bloom_knee: 0.1,
510 anamorphic: 0.0,
511 sharpen: 0.0,
512 dither: 0.0,
513 barrel: 0.0,
514 font_size: 32,
515 fullscreen: false,
516 render_scale: 1.0,
517 particle_multiplier: 1.0,
518 shadow_quality: ShadowQuality::Medium,
519 antialiasing: true,
520 color_depth: 8,
521 // On by default, because turning them off silently would change
522 // how every existing scene looks. A caller that draws in the UI
523 // layer should turn them off deliberately.
524 global_illumination: true,
525 volumetric_fog: true,
526 indirect_light: 0.0,
527 light_shafts: 0.0,
528 lens_flare: 0.0,
529 shake_pixels: 0.0,
530 fxaa: false,
531 persistence: 0.0,
532 vsync: true,
533 world_ui_in_scene: true,
534 }
535 }
536}
537
538impl RenderConfig {
539 /// Clamp every setting to its supported range.
540 pub fn validate(&mut self) {
541 self.bloom_intensity = self.bloom_intensity.clamp(0.0, 20.0);
542 self.bloom_radius = self.bloom_radius.clamp(1.0, 128.0);
543 self.chromatic_aberration = self.chromatic_aberration.clamp(0.0, 0.2);
544 self.film_grain = self.film_grain.clamp(0.0, 0.5);
545 self.scanline_intensity = self.scanline_intensity.clamp(0.0, 1.0);
546 self.font_size = self.font_size.clamp(8, 64);
547 self.render_scale = self.render_scale.clamp(0.25, 8.0);
548 self.particle_multiplier = self.particle_multiplier.max(0.0);
549 self.motion_blur_samples = self.motion_blur_samples.clamp(1, 16);
550 self.indirect_light = self.indirect_light.clamp(0.0, 8.0);
551 self.light_shafts = self.light_shafts.clamp(0.0, 8.0);
552 self.lens_flare = self.lens_flare.clamp(0.0, 4.0);
553 self.shake_pixels = self.shake_pixels.clamp(0.0, 200.0);
554 self.persistence = self.persistence.clamp(0.0, 0.98);
555 }
556}
557
558/// Shadow map quality level.
559#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
560pub enum ShadowQuality {
561 /// No shadows.
562 Off,
563 /// Lowest resolution shadows.
564 Low,
565 /// Medium resolution shadows.
566 Medium,
567 /// High resolution shadows.
568 High,
569 /// Highest resolution shadows.
570 Ultra,
571}
572
573// ── PhysicsConfig ─────────────────────────────────────────────────────────────
574
575/// Physics simulation settings.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct PhysicsConfig {
578 /// Fixed timestep for physics simulation (seconds).
579 pub fixed_dt: f32,
580 /// Maximum physics sub-steps per frame.
581 pub max_sub_steps: u32,
582 /// Fluid simulation grid size (cells per axis).
583 pub fluid_grid_size: usize,
584 /// Soft body position correction iterations.
585 pub soft_body_iters: usize,
586 /// Gravity vector (Y-down = negative).
587 pub gravity_y: f32,
588 /// Collision detection broadphase strategy.
589 pub broadphase: BroadphaseStrategy,
590 /// Enable sleeping for idle bodies.
591 pub sleep_enabled: bool,
592 /// Velocity threshold below which bodies can sleep.
593 pub sleep_threshold: f32,
594}
595
596impl Default for PhysicsConfig {
597 fn default() -> Self {
598 Self {
599 fixed_dt: 1.0 / 60.0,
600 max_sub_steps: 4,
601 fluid_grid_size: 64,
602 soft_body_iters: 4,
603 gravity_y: -9.8,
604 broadphase: BroadphaseStrategy::Grid,
605 sleep_enabled: true,
606 sleep_threshold: 0.01,
607 }
608 }
609}
610
611impl PhysicsConfig {
612 /// Clamp the timestep, sub-steps, grid size, iterations and sleep threshold.
613 pub fn validate(&mut self) {
614 self.fixed_dt = self.fixed_dt.clamp(1.0 / 240.0, 1.0 / 15.0);
615 self.max_sub_steps = self.max_sub_steps.clamp(1, 16);
616 self.fluid_grid_size = self.fluid_grid_size.clamp(8, 256);
617 self.soft_body_iters = self.soft_body_iters.clamp(1, 32);
618 self.sleep_threshold = self.sleep_threshold.clamp(0.0, 1.0);
619 }
620}
621
622/// Collision broadphase strategy.
623#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
624pub enum BroadphaseStrategy {
625 /// Test every pair of bodies.
626 BruteForce,
627 /// Uniform spatial grid.
628 Grid,
629 /// Bounding volume hierarchy.
630 BvhTree,
631}
632
633// ── InputConfig ───────────────────────────────────────────────────────────────
634
635/// Mouse, keyboard and gamepad settings.
636#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct InputConfig {
638 /// Mouse sensitivity multiplier.
639 pub mouse_sensitivity: f32,
640 /// Mouse Y-axis invert.
641 pub mouse_invert_y: bool,
642 /// Deadzone for analog gamepad axes.
643 pub gamepad_deadzone: f32,
644 /// Rumble intensity (0.0-1.0).
645 pub gamepad_rumble: f32,
646 /// Enable key repeat for held keys.
647 pub key_repeat: bool,
648 /// Key repeat initial delay (seconds).
649 pub key_repeat_delay: f32,
650 /// Key repeat rate (repeats/second).
651 pub key_repeat_rate: f32,
652}
653
654impl Default for InputConfig {
655 fn default() -> Self {
656 Self {
657 mouse_sensitivity: 1.0,
658 mouse_invert_y: false,
659 gamepad_deadzone: 0.15,
660 gamepad_rumble: 0.7,
661 key_repeat: true,
662 key_repeat_delay: 0.4,
663 key_repeat_rate: 30.0,
664 }
665 }
666}
667
668// ── DebugConfig ───────────────────────────────────────────────────────────────
669
670/// Debug overlays and logging settings.
671#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct DebugConfig {
673 /// Show the frames-per-second counter.
674 pub show_fps: bool,
675 /// Show the frame-time graph.
676 pub show_frame_graph: bool,
677 /// Draw physics debug shapes.
678 pub show_physics: bool,
679 /// Draw spawn zones.
680 pub show_spawn_zones: bool,
681 /// Label entities with their ids.
682 pub show_entity_ids: bool,
683 /// Draw force field extents.
684 pub show_force_fields: bool,
685 /// Show the live particle count.
686 pub show_particle_count: bool,
687 /// Most verbose log level that is printed.
688 pub log_level: LogLevel,
689 /// Cap the log output to this many bytes per second to avoid spam.
690 pub log_rate_limit: usize,
691 /// Enable Tracy/puffin profiler integration.
692 pub profiler_enabled: bool,
693}
694
695impl Default for DebugConfig {
696 fn default() -> Self {
697 Self {
698 show_fps: false,
699 show_frame_graph: false,
700 show_physics: false,
701 show_spawn_zones: false,
702 show_entity_ids: false,
703 show_force_fields: false,
704 show_particle_count: false,
705 log_level: LogLevel::Info,
706 log_rate_limit: 1_000_000,
707 profiler_enabled: false,
708 }
709 }
710}
711
712/// Log verbosity, from `Off` (nothing) to `Trace` (everything).
713#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
714pub enum LogLevel {
715 /// No logging.
716 Off,
717 /// Errors only.
718 Error,
719 /// Errors and warnings.
720 Warn,
721 /// Normal information messages and above.
722 Info,
723 /// Debug messages and above.
724 Debug,
725 /// Everything.
726 Trace,
727}
728
729// ── GameplayConfig ────────────────────────────────────────────────────────────
730
731/// Game rules such as seed, difficulty and autosave.
732#[derive(Debug, Clone, Serialize, Deserialize)]
733pub struct GameplayConfig {
734 /// Seed for the world generator (0 = random).
735 pub world_seed: u64,
736 /// Starting dungeon depth.
737 pub start_depth: u32,
738 /// Global difficulty multiplier (0.5 = easy, 1.0 = normal, 2.0 = brutal).
739 pub difficulty: f32,
740 /// Enable permadeath.
741 pub permadeath: bool,
742 /// Auto-save every N seconds (0 = disabled).
743 pub autosave_interval: u32,
744 /// Enable developer cheats.
745 pub cheats_enabled: bool,
746 /// Entity tick budget per frame (max entities updated per frame).
747 pub entity_tick_budget: usize,
748 /// Maximum number of active enemies.
749 pub max_enemies: usize,
750}
751
752impl Default for GameplayConfig {
753 fn default() -> Self {
754 Self {
755 world_seed: 0,
756 start_depth: 1,
757 difficulty: 1.0,
758 permadeath: false,
759 autosave_interval: 60,
760 cheats_enabled: false,
761 entity_tick_budget: 256,
762 max_enemies: 64,
763 }
764 }
765}
766
767// ── AccessibilityConfig ───────────────────────────────────────────────────────
768
769/// Accessibility options.
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct AccessibilityConfig {
772 /// Enable colorblind compensation mode.
773 pub colorblind_mode: ColorblindMode,
774 /// Global text size multiplier.
775 pub text_size: f32,
776 /// Reduce motion (disables screen shake, reduces particles).
777 pub reduce_motion: bool,
778 /// High contrast mode (brighter UI, bolder outlines).
779 pub high_contrast: bool,
780 /// Screen flash warning suppression (warn when flash > this intensity).
781 pub flash_warning: f32,
782 /// Enable subtitles / audio cues for deaf accessibility.
783 pub audio_cues_visual: bool,
784}
785
786impl Default for AccessibilityConfig {
787 fn default() -> Self {
788 Self {
789 colorblind_mode: ColorblindMode::None,
790 text_size: 1.0,
791 reduce_motion: false,
792 high_contrast: false,
793 flash_warning: 0.5,
794 audio_cues_visual: false,
795 }
796 }
797}
798
799/// Colourblind compensation mode.
800#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
801pub enum ColorblindMode {
802 /// No compensation.
803 None,
804 /// Green-weak vision.
805 Deuteranopia,
806 /// Red-weak vision.
807 Protanopia,
808 /// Blue-weak vision.
809 Tritanopia,
810 /// No colour vision.
811 Achromatopsia,
812}
813
814// ── Unit tests ─────────────────────────────────────────────────────────────────
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819
820 #[test]
821 fn test_default_config() {
822 let c = EngineConfig::default();
823 assert_eq!(c.window_width, 1280);
824 assert_eq!(c.window_height, 800);
825 assert_eq!(c.target_fps, 60);
826 assert!(c.audio.enabled);
827 assert!(c.render.bloom_enabled);
828 }
829
830 #[test]
831 fn test_validate_clamps() {
832 let mut c = EngineConfig::default();
833 c.window_width = 99999;
834 c.window_height = 0;
835 c.render.bloom_intensity = 100.0;
836 c.validate();
837 assert_eq!(c.window_width, 7680);
838 assert_eq!(c.window_height, 240);
839 assert!(c.render.bloom_intensity <= 20.0);
840 }
841
842 #[test]
843 fn test_apply_args_width_height() {
844 let mut c = EngineConfig::default();
845 c.apply_args(&["--width".to_string(), "1920".to_string(),
846 "--height".to_string(), "1080".to_string()]);
847 assert_eq!(c.window_width, 1920);
848 assert_eq!(c.window_height, 1080);
849 }
850
851 #[test]
852 fn test_apply_args_no_audio() {
853 let mut c = EngineConfig::default();
854 c.apply_args(&["--no-audio".to_string()]);
855 assert!(!c.audio.enabled);
856 }
857
858 #[test]
859 fn test_apply_args_no_postfx() {
860 let mut c = EngineConfig::default();
861 c.apply_args(&["--no-postfx".to_string()]);
862 assert!(!c.render.bloom_enabled);
863 assert_eq!(c.render.chromatic_aberration, 0.0);
864 }
865
866 #[test]
867 fn test_profile_low_end() {
868 let c = EngineConfig::profile_low_end();
869 assert!(!c.render.bloom_enabled);
870 assert_eq!(c.target_fps, 30);
871 }
872
873 #[test]
874 fn test_profile_steam_deck() {
875 let c = EngineConfig::profile_steam_deck();
876 assert_eq!(c.window_width, 1280);
877 assert_eq!(c.window_height, 800);
878 }
879
880 #[test]
881 fn test_aspect_ratio() {
882 let c = EngineConfig::default(); // 1280x800
883 let ar = c.aspect_ratio();
884 assert!((ar - 1.6).abs() < 0.01);
885 }
886
887 #[test]
888 fn test_diff_detects_changes() {
889 let a = EngineConfig::default();
890 let mut b = a.clone();
891 b.window_width = 1920;
892 let diff = a.diff(&b);
893 assert!(diff.window_changed);
894 assert!(!diff.audio_changed);
895 }
896
897 #[test]
898 fn test_round_trip_toml() {
899 let c = EngineConfig::default();
900 let toml_str = toml::to_string_pretty(&c).expect("serialize");
901 let c2: EngineConfig = toml::from_str(&toml_str).expect("deserialize");
902 assert_eq!(c.window_width, c2.window_width);
903 assert_eq!(c.target_fps, c2.target_fps);
904 }
905}