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
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}