Skip to main content

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
11use serde::{Deserialize, Serialize};
12
13// ── Top-level ─────────────────────────────────────────────────────────────────
14
15/// Top-level engine configuration, loadable from a TOML file.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct EngineConfig {
18    pub window_title:  String,
19    pub window_width:  u32,
20    pub window_height: u32,
21    pub target_fps:    u32,
22    pub vsync:         bool,
23    pub audio:         AudioConfig,
24    pub render:        RenderConfig,
25    pub physics:       PhysicsConfig,
26    pub input:         InputConfig,
27    pub debug:         DebugConfig,
28    pub gameplay:      GameplayConfig,
29    pub accessibility: AccessibilityConfig,
30}
31
32impl Default for EngineConfig {
33    fn default() -> Self {
34        Self {
35            window_title:  "Proof Engine".to_string(),
36            window_width:  1280,
37            window_height: 800,
38            target_fps:    60,
39            vsync:         true,
40            audio:         AudioConfig::default(),
41            render:        RenderConfig::default(),
42            physics:       PhysicsConfig::default(),
43            input:         InputConfig::default(),
44            debug:         DebugConfig::default(),
45            gameplay:      GameplayConfig::default(),
46            accessibility: AccessibilityConfig::default(),
47        }
48    }
49}
50
51impl EngineConfig {
52    // ── Load/Save ──────────────────────────────────────────────────────────────
53
54    /// Load from a TOML file, falling back to defaults on error.
55    pub fn load(path: &str) -> Self {
56        let mut cfg: Self = std::fs::read_to_string(path)
57            .ok()
58            .and_then(|s| toml::from_str(&s).ok())
59            .unwrap_or_default();
60        cfg.validate();
61        cfg
62    }
63
64    /// Save to a TOML file.
65    pub fn save(&self, path: &str) -> bool {
66        toml::to_string_pretty(self)
67            .ok()
68            .and_then(|s| std::fs::write(path, s).ok())
69            .is_some()
70    }
71
72    // ── Profiles ──────────────────────────────────────────────────────────────
73
74    /// Low-end PC profile: reduced resolution, no post-fx, minimal effects.
75    pub fn profile_low_end() -> Self {
76        Self {
77            window_width:  960,
78            window_height: 540,
79            target_fps:    30,
80            vsync:         true,
81            render:        RenderConfig {
82                bloom_enabled:        false,
83                motion_blur_enabled:  false,
84                chromatic_aberration: 0.0,
85                film_grain:           0.0,
86                scanlines_enabled:    false,
87                particle_multiplier:  0.5,
88                shadow_quality:       ShadowQuality::Off,
89                ..RenderConfig::default()
90            },
91            physics: PhysicsConfig {
92                fluid_grid_size:  16,
93                soft_body_iters:  2,
94                ..PhysicsConfig::default()
95            },
96            ..Self::default()
97        }
98    }
99
100    /// Steam Deck profile: 1280×800 locked 60, moderate effects.
101    pub fn profile_steam_deck() -> Self {
102        Self {
103            window_width:  1280,
104            window_height: 800,
105            target_fps:    60,
106            vsync:         true,
107            render:        RenderConfig {
108                bloom_intensity:      0.6,
109                chromatic_aberration: 0.001,
110                particle_multiplier:  0.75,
111                shadow_quality:       ShadowQuality::Low,
112                ..RenderConfig::default()
113            },
114            ..Self::default()
115        }
116    }
117
118    /// Ultra profile: max everything.
119    pub fn profile_ultra() -> Self {
120        Self {
121            window_width:  2560,
122            window_height: 1440,
123            target_fps:    144,
124            vsync:         false,
125            render:        RenderConfig {
126                bloom_intensity:      1.5,
127                chromatic_aberration: 0.003,
128                film_grain:           0.03,
129                particle_multiplier:  2.0,
130                shadow_quality:       ShadowQuality::Ultra,
131                ..RenderConfig::default()
132            },
133            physics: PhysicsConfig {
134                fluid_grid_size:  128,
135                soft_body_iters:  8,
136                ..PhysicsConfig::default()
137            },
138            ..Self::default()
139        }
140    }
141
142    /// Debug profile: all overlays on, no vsync, fast timestep.
143    pub fn profile_debug() -> Self {
144        Self {
145            vsync:      false,
146            target_fps: 0, // uncapped
147            debug: DebugConfig {
148                show_fps:          true,
149                show_frame_graph:  true,
150                show_physics:      true,
151                show_spawn_zones:  true,
152                show_entity_ids:   true,
153                log_level:         LogLevel::Debug,
154                ..DebugConfig::default()
155            },
156            ..Self::default()
157        }
158    }
159
160    // ── Command-line override ──────────────────────────────────────────────────
161
162    /// Parse and apply command-line arguments as overrides.
163    /// Supported flags: `--width N`, `--height N`, `--fps N`, `--no-audio`,
164    /// `--no-vsync`, `--no-bloom`, `--fullscreen`, `--windowed`.
165    pub fn apply_args(&mut self, args: &[String]) {
166        let mut i = 0;
167        while i < args.len() {
168            match args[i].as_str() {
169                "--width" => {
170                    if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
171                        self.window_width = v;
172                        i += 1;
173                    }
174                }
175                "--height" => {
176                    if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
177                        self.window_height = v;
178                        i += 1;
179                    }
180                }
181                "--fps" => {
182                    if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
183                        self.target_fps = v;
184                        i += 1;
185                    }
186                }
187                "--no-audio"    => self.audio.enabled = false,
188                "--no-vsync"    => self.vsync = false,
189                "--no-bloom"    => self.render.bloom_enabled = false,
190                "--no-postfx"   => {
191                    self.render.bloom_enabled = false;
192                    self.render.distortion_enabled = false;
193                    self.render.motion_blur_enabled = false;
194                    self.render.chromatic_aberration = 0.0;
195                    self.render.film_grain = 0.0;
196                }
197                "--fullscreen"  => self.render.fullscreen = true,
198                "--windowed"    => self.render.fullscreen = false,
199                "--low-end"     => *self = Self::profile_low_end(),
200                "--ultra"       => *self = Self::profile_ultra(),
201                "--debug"       => *self = Self::profile_debug(),
202                _ => {}
203            }
204            i += 1;
205        }
206        self.validate();
207    }
208
209    // ── Validation ─────────────────────────────────────────────────────────────
210
211    /// Clamp and sanitize all values to valid ranges.
212    pub fn validate(&mut self) {
213        self.window_width  = self.window_width.clamp(320, 7680);
214        self.window_height = self.window_height.clamp(240, 4320);
215        self.target_fps    = if self.target_fps == 0 { 0 } else { self.target_fps.clamp(15, 360) };
216        self.audio.validate();
217        self.render.validate();
218        self.physics.validate();
219    }
220
221    // ── Aspect ratio ──────────────────────────────────────────────────────────
222
223    pub fn aspect_ratio(&self) -> f32 {
224        self.window_width as f32 / self.window_height as f32
225    }
226
227    /// Check if resolution is standard 16:9.
228    pub fn is_widescreen(&self) -> bool {
229        let ar = self.aspect_ratio();
230        (ar - 16.0 / 9.0).abs() < 0.05
231    }
232
233    /// Check if the config differs from another (for hot-reload diffs).
234    pub fn diff(&self, other: &Self) -> ConfigDiff {
235        ConfigDiff {
236            window_changed:  self.window_width  != other.window_width
237                          || self.window_height != other.window_height,
238            audio_changed:   self.audio.enabled != other.audio.enabled
239                          || (self.audio.master_volume - other.audio.master_volume).abs() > 0.01,
240            render_changed:  self.render.bloom_enabled    != other.render.bloom_enabled
241                          || self.render.bloom_intensity  != other.render.bloom_intensity,
242            physics_changed: self.physics.fluid_grid_size != other.physics.fluid_grid_size,
243        }
244    }
245}
246
247/// Bitmask of which subsystems changed in a hot-reload diff.
248#[derive(Debug, Default)]
249pub struct ConfigDiff {
250    pub window_changed:  bool,
251    pub audio_changed:   bool,
252    pub render_changed:  bool,
253    pub physics_changed: bool,
254}
255
256impl ConfigDiff {
257    pub fn any_changed(&self) -> bool {
258        self.window_changed || self.audio_changed || self.render_changed || self.physics_changed
259    }
260}
261
262// ── AudioConfig ───────────────────────────────────────────────────────────────
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct AudioConfig {
266    pub enabled:       bool,
267    pub master_volume: f32,
268    pub music_volume:  f32,
269    pub sfx_volume:    f32,
270    /// Sample rate override (0 = use device default).
271    pub sample_rate:   u32,
272    /// Buffer size in frames (0 = auto).
273    pub buffer_size:   u32,
274    /// Enable spatialized 3D audio.
275    pub spatial_audio: bool,
276    /// Reverb room size (0.0 = dry, 1.0 = large hall).
277    pub reverb_room:   f32,
278    pub audio_backend: AudioBackend,
279}
280
281impl Default for AudioConfig {
282    fn default() -> Self {
283        Self {
284            enabled:       true,
285            master_volume: 1.0,
286            music_volume:  0.6,
287            sfx_volume:    0.8,
288            sample_rate:   0,
289            buffer_size:   0,
290            spatial_audio: true,
291            reverb_room:   0.2,
292            audio_backend: AudioBackend::Default,
293        }
294    }
295}
296
297impl AudioConfig {
298    pub fn validate(&mut self) {
299        self.master_volume = self.master_volume.clamp(0.0, 1.0);
300        self.music_volume  = self.music_volume.clamp(0.0, 1.0);
301        self.sfx_volume    = self.sfx_volume.clamp(0.0, 1.0);
302        self.reverb_room   = self.reverb_room.clamp(0.0, 1.0);
303    }
304}
305
306#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
307pub enum AudioBackend { Default, Wasapi, Asio, PulseAudio, Alsa, CoreAudio }
308
309// ── RenderConfig ──────────────────────────────────────────────────────────────
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct RenderConfig {
313    pub bloom_enabled:        bool,
314    pub bloom_intensity:      f32,
315    /// Bloom radius in pixels (higher = wider glow).
316    pub bloom_radius:         f32,
317    pub distortion_enabled:   bool,
318    pub motion_blur_enabled:  bool,
319    /// Motion blur sample count (2-16).
320    pub motion_blur_samples:  u32,
321    pub chromatic_aberration: f32,
322    pub film_grain:           f32,
323    pub scanlines_enabled:    bool,
324    pub scanline_intensity:   f32,
325    pub font_size:            u32,
326    pub fullscreen:           bool,
327    /// Render scale (1.0 = native, 0.5 = half res).
328    pub render_scale:         f32,
329    /// Particle system multiplier (1.0 = full, 0.5 = half particles).
330    pub particle_multiplier:  f32,
331    pub shadow_quality:       ShadowQuality,
332    /// Enable anti-aliasing (FXAA approximation for terminal renderer).
333    pub antialiasing:         bool,
334    /// Color depth per channel for dithering: 8, 16, or 32.
335    pub color_depth:          u8,
336}
337
338impl Default for RenderConfig {
339    fn default() -> Self {
340        Self {
341            bloom_enabled:        true,
342            bloom_intensity:      1.0,
343            bloom_radius:         4.0,
344            distortion_enabled:   true,
345            motion_blur_enabled:  true,
346            motion_blur_samples:  4,
347            chromatic_aberration: 0.002,
348            film_grain:           0.02,
349            scanlines_enabled:    false,
350            scanline_intensity:   0.15,
351            font_size:            16,
352            fullscreen:           false,
353            render_scale:         1.0,
354            particle_multiplier:  1.0,
355            shadow_quality:       ShadowQuality::Medium,
356            antialiasing:         true,
357            color_depth:          8,
358        }
359    }
360}
361
362impl RenderConfig {
363    pub fn validate(&mut self) {
364        self.bloom_intensity      = self.bloom_intensity.clamp(0.0, 5.0);
365        self.bloom_radius         = self.bloom_radius.clamp(1.0, 32.0);
366        self.chromatic_aberration = self.chromatic_aberration.clamp(0.0, 0.05);
367        self.film_grain           = self.film_grain.clamp(0.0, 0.5);
368        self.scanline_intensity   = self.scanline_intensity.clamp(0.0, 1.0);
369        self.font_size            = self.font_size.clamp(8, 64);
370        self.render_scale         = self.render_scale.clamp(0.25, 2.0);
371        self.particle_multiplier  = self.particle_multiplier.clamp(0.0, 4.0);
372        self.motion_blur_samples  = self.motion_blur_samples.clamp(1, 16);
373    }
374}
375
376#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
377pub enum ShadowQuality { Off, Low, Medium, High, Ultra }
378
379// ── PhysicsConfig ─────────────────────────────────────────────────────────────
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct PhysicsConfig {
383    /// Fixed timestep for physics simulation (seconds).
384    pub fixed_dt:         f32,
385    /// Maximum physics sub-steps per frame.
386    pub max_sub_steps:    u32,
387    /// Fluid simulation grid size (cells per axis).
388    pub fluid_grid_size:  usize,
389    /// Soft body position correction iterations.
390    pub soft_body_iters:  usize,
391    /// Gravity vector (Y-down = negative).
392    pub gravity_y:        f32,
393    /// Collision detection broadphase strategy.
394    pub broadphase:       BroadphaseStrategy,
395    /// Enable sleeping for idle bodies.
396    pub sleep_enabled:    bool,
397    /// Velocity threshold below which bodies can sleep.
398    pub sleep_threshold:  f32,
399}
400
401impl Default for PhysicsConfig {
402    fn default() -> Self {
403        Self {
404            fixed_dt:        1.0 / 60.0,
405            max_sub_steps:   4,
406            fluid_grid_size: 64,
407            soft_body_iters: 4,
408            gravity_y:       -9.8,
409            broadphase:      BroadphaseStrategy::Grid,
410            sleep_enabled:   true,
411            sleep_threshold: 0.01,
412        }
413    }
414}
415
416impl PhysicsConfig {
417    pub fn validate(&mut self) {
418        self.fixed_dt        = self.fixed_dt.clamp(1.0 / 240.0, 1.0 / 15.0);
419        self.max_sub_steps   = self.max_sub_steps.clamp(1, 16);
420        self.fluid_grid_size = self.fluid_grid_size.clamp(8, 256);
421        self.soft_body_iters = self.soft_body_iters.clamp(1, 32);
422        self.sleep_threshold = self.sleep_threshold.clamp(0.0, 1.0);
423    }
424}
425
426#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
427pub enum BroadphaseStrategy { BruteForce, Grid, BvhTree }
428
429// ── InputConfig ───────────────────────────────────────────────────────────────
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct InputConfig {
433    /// Mouse sensitivity multiplier.
434    pub mouse_sensitivity: f32,
435    /// Mouse Y-axis invert.
436    pub mouse_invert_y: bool,
437    /// Deadzone for analog gamepad axes.
438    pub gamepad_deadzone: f32,
439    /// Rumble intensity (0.0-1.0).
440    pub gamepad_rumble: f32,
441    /// Enable key repeat for held keys.
442    pub key_repeat: bool,
443    /// Key repeat initial delay (seconds).
444    pub key_repeat_delay: f32,
445    /// Key repeat rate (repeats/second).
446    pub key_repeat_rate: f32,
447}
448
449impl Default for InputConfig {
450    fn default() -> Self {
451        Self {
452            mouse_sensitivity: 1.0,
453            mouse_invert_y:    false,
454            gamepad_deadzone:  0.15,
455            gamepad_rumble:    0.7,
456            key_repeat:        true,
457            key_repeat_delay:  0.4,
458            key_repeat_rate:   30.0,
459        }
460    }
461}
462
463// ── DebugConfig ───────────────────────────────────────────────────────────────
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct DebugConfig {
467    pub show_fps:         bool,
468    pub show_frame_graph: bool,
469    pub show_physics:     bool,
470    pub show_spawn_zones: bool,
471    pub show_entity_ids:  bool,
472    pub show_force_fields: bool,
473    pub show_particle_count: bool,
474    pub log_level:        LogLevel,
475    /// Cap the log output to this many bytes per second to avoid spam.
476    pub log_rate_limit:   usize,
477    /// Enable Tracy/puffin profiler integration.
478    pub profiler_enabled: bool,
479}
480
481impl Default for DebugConfig {
482    fn default() -> Self {
483        Self {
484            show_fps:            false,
485            show_frame_graph:    false,
486            show_physics:        false,
487            show_spawn_zones:    false,
488            show_entity_ids:     false,
489            show_force_fields:   false,
490            show_particle_count: false,
491            log_level:           LogLevel::Info,
492            log_rate_limit:      1_000_000,
493            profiler_enabled:    false,
494        }
495    }
496}
497
498#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
499pub enum LogLevel { Off, Error, Warn, Info, Debug, Trace }
500
501// ── GameplayConfig ────────────────────────────────────────────────────────────
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct GameplayConfig {
505    /// Seed for the world generator (0 = random).
506    pub world_seed: u64,
507    /// Starting dungeon depth.
508    pub start_depth: u32,
509    /// Global difficulty multiplier (0.5 = easy, 1.0 = normal, 2.0 = brutal).
510    pub difficulty: f32,
511    /// Enable permadeath.
512    pub permadeath: bool,
513    /// Auto-save every N seconds (0 = disabled).
514    pub autosave_interval: u32,
515    /// Enable developer cheats.
516    pub cheats_enabled: bool,
517    /// Entity tick budget per frame (max entities updated per frame).
518    pub entity_tick_budget: usize,
519    /// Maximum number of active enemies.
520    pub max_enemies: usize,
521}
522
523impl Default for GameplayConfig {
524    fn default() -> Self {
525        Self {
526            world_seed:         0,
527            start_depth:        1,
528            difficulty:         1.0,
529            permadeath:         false,
530            autosave_interval:  60,
531            cheats_enabled:     false,
532            entity_tick_budget: 256,
533            max_enemies:        64,
534        }
535    }
536}
537
538// ── AccessibilityConfig ───────────────────────────────────────────────────────
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct AccessibilityConfig {
542    /// Enable colorblind compensation mode.
543    pub colorblind_mode:   ColorblindMode,
544    /// Global text size multiplier.
545    pub text_size:         f32,
546    /// Reduce motion (disables screen shake, reduces particles).
547    pub reduce_motion:     bool,
548    /// High contrast mode (brighter UI, bolder outlines).
549    pub high_contrast:     bool,
550    /// Screen flash warning suppression (warn when flash > this intensity).
551    pub flash_warning:     f32,
552    /// Enable subtitles / audio cues for deaf accessibility.
553    pub audio_cues_visual: bool,
554}
555
556impl Default for AccessibilityConfig {
557    fn default() -> Self {
558        Self {
559            colorblind_mode:   ColorblindMode::None,
560            text_size:         1.0,
561            reduce_motion:     false,
562            high_contrast:     false,
563            flash_warning:     0.5,
564            audio_cues_visual: false,
565        }
566    }
567}
568
569#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
570pub enum ColorblindMode { None, Deuteranopia, Protanopia, Tritanopia, Achromatopsia }
571
572// ── Unit tests ─────────────────────────────────────────────────────────────────
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn test_default_config() {
580        let c = EngineConfig::default();
581        assert_eq!(c.window_width,  1280);
582        assert_eq!(c.window_height, 800);
583        assert_eq!(c.target_fps,    60);
584        assert!(c.audio.enabled);
585        assert!(c.render.bloom_enabled);
586    }
587
588    #[test]
589    fn test_validate_clamps() {
590        let mut c = EngineConfig::default();
591        c.window_width  = 99999;
592        c.window_height = 0;
593        c.render.bloom_intensity = 100.0;
594        c.validate();
595        assert_eq!(c.window_width, 7680);
596        assert_eq!(c.window_height, 240);
597        assert!(c.render.bloom_intensity <= 5.0);
598    }
599
600    #[test]
601    fn test_apply_args_width_height() {
602        let mut c = EngineConfig::default();
603        c.apply_args(&["--width".to_string(), "1920".to_string(),
604                       "--height".to_string(), "1080".to_string()]);
605        assert_eq!(c.window_width,  1920);
606        assert_eq!(c.window_height, 1080);
607    }
608
609    #[test]
610    fn test_apply_args_no_audio() {
611        let mut c = EngineConfig::default();
612        c.apply_args(&["--no-audio".to_string()]);
613        assert!(!c.audio.enabled);
614    }
615
616    #[test]
617    fn test_apply_args_no_postfx() {
618        let mut c = EngineConfig::default();
619        c.apply_args(&["--no-postfx".to_string()]);
620        assert!(!c.render.bloom_enabled);
621        assert_eq!(c.render.chromatic_aberration, 0.0);
622    }
623
624    #[test]
625    fn test_profile_low_end() {
626        let c = EngineConfig::profile_low_end();
627        assert!(!c.render.bloom_enabled);
628        assert_eq!(c.target_fps, 30);
629    }
630
631    #[test]
632    fn test_profile_steam_deck() {
633        let c = EngineConfig::profile_steam_deck();
634        assert_eq!(c.window_width, 1280);
635        assert_eq!(c.window_height, 800);
636    }
637
638    #[test]
639    fn test_aspect_ratio() {
640        let c = EngineConfig::default(); // 1280x800
641        let ar = c.aspect_ratio();
642        assert!((ar - 1.6).abs() < 0.01);
643    }
644
645    #[test]
646    fn test_diff_detects_changes() {
647        let a = EngineConfig::default();
648        let mut b = a.clone();
649        b.window_width = 1920;
650        let diff = a.diff(&b);
651        assert!(diff.window_changed);
652        assert!(!diff.audio_changed);
653    }
654
655    #[test]
656    fn test_round_trip_toml() {
657        let c = EngineConfig::default();
658        let toml_str = toml::to_string_pretty(&c).expect("serialize");
659        let c2: EngineConfig = toml::from_str(&toml_str).expect("deserialize");
660        assert_eq!(c.window_width, c2.window_width);
661        assert_eq!(c.target_fps,   c2.target_fps);
662    }
663}