From 12119942cf39b2cd5135e5ac0b0b04a92027b8da Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 21:52:53 -0700 Subject: [PATCH 1/4] feat(config): raise the default render target from 30 to 60 fps The render loop treats daemon.target_fps as a ceiling: the FPS controller never upshifts above it and downshifts on its own after two consecutive budget misses. A shipped default of 30 pinned every fresh install to the Medium tier however much headroom the machine had; a fresh 0.7.1 install on a Mac held 30 fps with frames finishing in about half a millisecond. The default is now 60, the top tier, and slower machines stay protected by the same downshift that already guards a hand-set 60. The default lives in one constant, DEFAULT_TARGET_FPS, read by the serde default. The settings page, the configuration guide, the guide overview, and the low FPS troubleshooting page now name 60 as the default and Full as the stock steady state. Tests that asserted 30, or wrote a value equal to the new default, now pin 60 or pick 45 so each still proves a change. Config files are not touched here: a file saved by an earlier build carries target_fps = 30 explicitly and keeps it until a schema migration moves it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../hypercolor-core/tests/config_persistence_tests.rs | 3 ++- crates/hypercolor-core/tests/config_sources_tests.rs | 2 +- crates/hypercolor-core/tests/config_tests.rs | 10 +++++----- .../tests/daemon_integration_tests.rs | 2 +- crates/hypercolor-daemon/tests/startup_tests.rs | 10 +++++----- crates/hypercolor-types/src/config/daemon.rs | 10 ++++++++++ crates/hypercolor-types/src/config/mod.rs | 2 +- crates/hypercolor-types/tests/config_tests.rs | 2 +- .../hypercolor-ui/src/components/settings_sections.rs | 2 +- docs/content/guide/_index.md | 2 +- docs/content/guide/configuration.md | 8 ++++---- docs/content/troubleshooting/performance.md | 6 +++--- 12 files changed, 35 insertions(+), 24 deletions(-) diff --git a/crates/hypercolor-core/tests/config_persistence_tests.rs b/crates/hypercolor-core/tests/config_persistence_tests.rs index 5bf11e7b8..aaf95d8f0 100644 --- a/crates/hypercolor-core/tests/config_persistence_tests.rs +++ b/crates/hypercolor-core/tests/config_persistence_tests.rs @@ -9,6 +9,7 @@ use std::time::Duration; use hypercolor_core::config::{ConfigManager, ConfigPersistence}; use hypercolor_core::persistence::AtomicFileWriter; +use hypercolor_types::config::DEFAULT_TARGET_FPS; fn set_mode(path: &Path, mode: u32) { fs::set_permissions(path, fs::Permissions::from_mode(mode)) @@ -63,7 +64,7 @@ fn save_into_a_read_only_directory_is_live_and_lagging_until_the_retry_lands() { .expect("the previous file stays readable") .daemon .target_fps, - 30 + DEFAULT_TARGET_FPS ); AtomicFileWriter::new(&path) diff --git a/crates/hypercolor-core/tests/config_sources_tests.rs b/crates/hypercolor-core/tests/config_sources_tests.rs index b96477d24..d3fa92e40 100644 --- a/crates/hypercolor-core/tests/config_sources_tests.rs +++ b/crates/hypercolor-core/tests/config_sources_tests.rs @@ -163,7 +163,7 @@ fn pending_restart_flags_only_restart_classified_changes() { // A live-classified exact key inside the restart-classified // daemon section must NOT flag a restart. - manager.modify(|config| config.daemon.target_fps = 60); + manager.modify(|config| config.daemon.target_fps = 45); assert!( manager.pending_restart().is_empty(), "live render knobs never flag a restart" diff --git a/crates/hypercolor-core/tests/config_tests.rs b/crates/hypercolor-core/tests/config_tests.rs index d4af1ab8c..29dfce7b8 100644 --- a/crates/hypercolor-core/tests/config_tests.rs +++ b/crates/hypercolor-core/tests/config_tests.rs @@ -25,7 +25,7 @@ fn load_minimal_toml() { assert_eq!(config.schema_version, 6); // Sections should fall back to their serde defaults assert_eq!(config.daemon.port, 9420); - assert_eq!(config.daemon.target_fps, 30); + assert_eq!(config.daemon.target_fps, 60); assert!(config.web.enabled); assert_eq!(config.input.daemon_route, InteractionRoutePolicy::Host); assert_eq!(config.input.preview_route, InteractionRoutePolicy::Browser); @@ -341,7 +341,7 @@ fn new_with_missing_file_uses_defaults() { assert_eq!(config.schema_version, 6); assert_eq!(config.daemon.port, 9420); - assert_eq!(config.daemon.target_fps, 30); + assert_eq!(config.daemon.target_fps, 60); assert!(config.web.enabled); } @@ -760,7 +760,7 @@ mod change_stream { assert_eq!( drain_config_changes(&mut events), - vec![("daemon.target_fps".to_owned(), Some(json!(30)), json!(45))] + vec![("daemon.target_fps".to_owned(), Some(json!(60)), json!(45))] ); } @@ -870,7 +870,7 @@ mod change_stream { assert_eq!(manager.get().daemon.target_fps, 50); assert_eq!( drain_config_changes(&mut events), - vec![("daemon.target_fps".to_owned(), Some(json!(30)), json!(50))] + vec![("daemon.target_fps".to_owned(), Some(json!(60)), json!(50))] ); } @@ -940,7 +940,7 @@ mod change_stream { assert_eq!( drain_config_changes(&mut events), - vec![("daemon.target_fps".to_owned(), Some(json!(30)), json!(50))] + vec![("daemon.target_fps".to_owned(), Some(json!(60)), json!(50))] ); } diff --git a/crates/hypercolor-daemon/tests/daemon_integration_tests.rs b/crates/hypercolor-daemon/tests/daemon_integration_tests.rs index 650803847..776e9e467 100644 --- a/crates/hypercolor-daemon/tests/daemon_integration_tests.rs +++ b/crates/hypercolor-daemon/tests/daemon_integration_tests.rs @@ -421,7 +421,7 @@ async fn config_loading_defaults_have_correct_schema() { .boot; assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); - assert_eq!(config.daemon.target_fps, 30); + assert_eq!(config.daemon.target_fps, 60); assert_eq!(config.daemon.port, 9420); assert_eq!(config.daemon.listen_address, "127.0.0.1"); assert_eq!(config.daemon.canvas_width, DEFAULT_CANVAS_WIDTH); diff --git a/crates/hypercolor-daemon/tests/startup_tests.rs b/crates/hypercolor-daemon/tests/startup_tests.rs index acfdef9e2..6fce4945c 100644 --- a/crates/hypercolor-daemon/tests/startup_tests.rs +++ b/crates/hypercolor-daemon/tests/startup_tests.rs @@ -629,7 +629,7 @@ async fn load_config_falls_back_to_defaults_when_no_file() { .expect("default config should load") .boot; assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); - assert_eq!(config.daemon.target_fps, 30); + assert_eq!(config.daemon.target_fps, 60); assert_eq!(config.daemon.port, 9420); } @@ -639,7 +639,7 @@ async fn load_config_reads_toml_file() { schema_version = 5 [daemon] -target_fps = 30 +target_fps = 45 port = 8080 listen_address = "0.0.0.0" "#; @@ -655,7 +655,7 @@ listen_address = "0.0.0.0" )) .expect("config should load from file"); - assert_eq!(loaded.boot.daemon.target_fps, 30); + assert_eq!(loaded.boot.daemon.target_fps, 45); assert_eq!(loaded.boot.daemon.port, 8080); assert_eq!(loaded.boot.daemon.listen_address, "0.0.0.0"); assert_eq!(loaded.manager.path(), temp.path()); @@ -785,7 +785,7 @@ fn parse_config_toml_minimal() { // runs, so an old schema comes back migrated forward. assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); // All sections should have serde defaults. - assert_eq!(config.daemon.target_fps, 30); + assert_eq!(config.daemon.target_fps, 60); assert!(config.audio.enabled); } @@ -905,7 +905,7 @@ fn parse_config_toml_rejects_invalid_toml() { fn default_config_has_sane_values() { let config = default_config(); assert_eq!(config.schema_version, 6); - assert_eq!(config.daemon.target_fps, 30); + assert_eq!(config.daemon.target_fps, 60); assert_eq!(config.daemon.port, 9420); assert_eq!(config.daemon.listen_address, "127.0.0.1"); assert_eq!(config.daemon.canvas_width, DEFAULT_CANVAS_WIDTH); diff --git a/crates/hypercolor-types/src/config/daemon.rs b/crates/hypercolor-types/src/config/daemon.rs index 81f3a871f..db4d13271 100644 --- a/crates/hypercolor-types/src/config/daemon.rs +++ b/crates/hypercolor-types/src/config/daemon.rs @@ -4,6 +4,15 @@ use super::defaults; // ─── Daemon ────────────────────────────────────────────────────────────────── +/// Default `[daemon] target_fps`, the top render tier. +/// +/// The value is a ceiling, not a fixed rate: the render loop's FPS +/// controller never upshifts past it, and it downshifts on its own after +/// consecutive budget misses. A lower default would only strand headroom +/// on machines that can hold the full tier, while slower machines are +/// already protected by the downshift. +pub const DEFAULT_TARGET_FPS: u32 = 60; + /// Core daemon settings: networking, render loop, lifecycle. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DaemonConfig { @@ -16,6 +25,7 @@ pub struct DaemonConfig { #[serde(default = "defaults::bool_true")] pub unix_socket: bool, + /// Highest render tier the FPS controller may run at. #[serde(default = "defaults::target_fps")] pub target_fps: u32, diff --git a/crates/hypercolor-types/src/config/mod.rs b/crates/hypercolor-types/src/config/mod.rs index a06177a51..5a737ace0 100644 --- a/crates/hypercolor-types/src/config/mod.rs +++ b/crates/hypercolor-types/src/config/mod.rs @@ -58,7 +58,7 @@ mod defaults { 9420 } pub fn target_fps() -> u32 { - 30 + super::DEFAULT_TARGET_FPS } pub fn canvas_width() -> u32 { 640 diff --git a/crates/hypercolor-types/tests/config_tests.rs b/crates/hypercolor-types/tests/config_tests.rs index 2a7dd6ca8..8d7416302 100644 --- a/crates/hypercolor-types/tests/config_tests.rs +++ b/crates/hypercolor-types/tests/config_tests.rs @@ -24,7 +24,7 @@ fn daemon_defaults_match_spec() { assert_eq!(d.listen_address, "127.0.0.1"); assert_eq!(d.port, 9420); assert!(d.unix_socket); - assert_eq!(d.target_fps, 30); + assert_eq!(d.target_fps, 60); assert_eq!(d.canvas_width, 640); assert_eq!(d.canvas_height, 480); assert_eq!(d.max_devices, 32); diff --git a/crates/hypercolor-ui/src/components/settings_sections.rs b/crates/hypercolor-ui/src/components/settings_sections.rs index 6d23613b8..234286687 100644 --- a/crates/hypercolor-ui/src/components/settings_sections.rs +++ b/crates/hypercolor-ui/src/components/settings_sections.rs @@ -1049,7 +1049,7 @@ pub fn RenderingSection( Result<()> { + let root = document + .as_table_mut() + .ok_or_else(|| anyhow::anyhow!("configuration root must be a TOML table"))?; + if let Some(daemon) = root.get_mut("daemon").and_then(toml::Value::as_table_mut) + && daemon.get("target_fps").and_then(toml::Value::as_integer) == Some(V6_DEFAULT_TARGET_FPS) + { + daemon.remove("target_fps"); + } root.insert( "schema_version".to_owned(), toml::Value::Integer(i64::from(CURRENT_SCHEMA_VERSION)), diff --git a/crates/hypercolor-core/tests/config_tests.rs b/crates/hypercolor-core/tests/config_tests.rs index 29dfce7b8..2142a8af1 100644 --- a/crates/hypercolor-core/tests/config_tests.rs +++ b/crates/hypercolor-core/tests/config_tests.rs @@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex, MutexGuard}; use hypercolor_core::config::ConfigManager; use hypercolor_types::config::{ - CURRENT_SCHEMA_VERSION, DEFAULT_BEAT_SENSITIVITY, EffectErrorFallbackPolicy, HypercolorConfig, - InteractionRoutePolicy, + CURRENT_SCHEMA_VERSION, DEFAULT_BEAT_SENSITIVITY, DEFAULT_TARGET_FPS, + EffectErrorFallbackPolicy, HypercolorConfig, InteractionRoutePolicy, }; // ─── TOML Parsing ─────────────────────────────────────────────────────────── @@ -14,7 +14,7 @@ use hypercolor_types::config::{ #[test] fn load_minimal_toml() { let toml = r" - schema_version = 6 + schema_version = 7 "; let tmp = tempfile::NamedTempFile::new().expect("failed to create temp file"); @@ -22,7 +22,7 @@ fn load_minimal_toml() { let config = ConfigManager::load(tmp.path()).expect("minimal TOML should parse without error"); - assert_eq!(config.schema_version, 6); + assert_eq!(config.schema_version, 7); // Sections should fall back to their serde defaults assert_eq!(config.daemon.port, 9420); assert_eq!(config.daemon.target_fps, 60); @@ -37,7 +37,7 @@ fn schema_v4_renames_start_profile_before_deserialization() { ConfigManager::parse_toml("schema_version = 4\n[daemon]\nstart_profile = \"Evening\"\n") .expect("schema v4 should migrate"); - assert_eq!(config.schema_version, 6); + assert_eq!(config.schema_version, 7); assert_eq!(config.daemon.start_scene, "Evening"); } @@ -109,11 +109,126 @@ fn schema_v5_keeps_a_beat_threshold_someone_chose() { } #[test] -fn current_schema_keeps_a_beat_threshold_of_point_six() { - let config = ConfigManager::parse_toml("schema_version = 6\n[audio]\nbeat_sensitivity = 0.6\n") - .expect("current schema should parse"); +fn schema_v6_and_later_keep_a_beat_threshold_of_point_six() { + for version in [6, CURRENT_SCHEMA_VERSION] { + let config = ConfigManager::parse_toml(&format!( + "schema_version = {version}\n[audio]\nbeat_sensitivity = 0.6\n" + )) + .expect("schema v6 and later should parse"); + + assert!( + (config.audio.beat_sensitivity - 0.6).abs() < f32::EPSILON, + "schema {version} moved {}", + config.audio.beat_sensitivity + ); + } +} + +#[test] +fn schema_v6_moves_the_retired_render_target_to_the_current_one() { + for version in [4, 5, 6] { + let config = ConfigManager::parse_toml(&format!( + "schema_version = {version}\n[daemon]\ntarget_fps = 30\n" + )) + .expect("older schemas should migrate"); + + assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); + assert_eq!( + config.daemon.target_fps, DEFAULT_TARGET_FPS, + "schema {version} kept the retired render target" + ); + } +} + +/// A v6 save wrote the whole document, so the retired default sits on disk +/// as an explicit `target_fps = 30` that nobody chose. +#[test] +fn schema_v6_moves_the_render_target_exactly_as_a_save_wrote_it() { + let mut saved = HypercolorConfig { + schema_version: 6, + ..HypercolorConfig::default() + }; + saved.daemon.target_fps = 30; + let written = toml::to_string_pretty(&saved).expect("serialize a v6 save"); + assert!(written.contains("target_fps = 30"), "{written}"); + + let config = ConfigManager::parse_toml(&written).expect("a v6 save should migrate"); + + assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); + assert_eq!(config.daemon.target_fps, DEFAULT_TARGET_FPS); +} - assert!((config.audio.beat_sensitivity - 0.6).abs() < f32::EPSILON); +#[test] +fn schema_v6_keeps_a_render_target_someone_chose() { + for chosen in [10, 20, 45, 60, 120] { + let config = ConfigManager::parse_toml(&format!( + "schema_version = 6\n[daemon]\ntarget_fps = {chosen}\n" + )) + .expect("schema v6 should migrate"); + + assert_eq!(config.daemon.target_fps, chosen); + } +} + +#[test] +fn current_schema_keeps_a_render_target_of_thirty() { + let config = ConfigManager::parse_toml(&format!( + "schema_version = {CURRENT_SCHEMA_VERSION}\n[daemon]\ntarget_fps = 30\n" + )) + .expect("current schema should parse"); + + assert_eq!(config.daemon.target_fps, 30); +} + +/// Loading never rewrites the file; the migrated value reaches disk with +/// the next save, stamped with the current schema so it is never migrated +/// again. +#[test] +fn a_migrated_render_target_reaches_disk_on_the_next_save() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let path = dir.path().join("hypercolor.toml"); + fs::write(&path, "schema_version = 6\n\n[daemon]\ntarget_fps = 30\n") + .expect("failed to write v6 config"); + + let manager = ConfigManager::new(path.clone()).expect("a v6 file should load"); + assert_eq!(manager.get().daemon.target_fps, DEFAULT_TARGET_FPS); + assert!(manager.save().expect("save should succeed").is_durable()); + + let written = fs::read_to_string(&path).expect("saved config should read"); + let on_disk: toml::Value = toml::from_str(&written).expect("saved config should parse"); + assert_eq!( + on_disk + .get("schema_version") + .and_then(toml::Value::as_integer), + Some(i64::from(CURRENT_SCHEMA_VERSION)) + ); + assert_eq!( + on_disk + .get("daemon") + .and_then(|daemon| daemon.get("target_fps")) + .and_then(toml::Value::as_integer), + Some(i64::from(DEFAULT_TARGET_FPS)) + ); +} + +/// A v4 file runs every migration in order: the start-key rename, the +/// beat threshold, and the render target. +#[test] +fn schema_v4_chains_through_every_later_migration() { + let config = ConfigManager::parse_toml(concat!( + "schema_version = 4\n\n", + "[daemon]\n", + "start_profile = \"Evening\"\n", + "target_fps = 30\n\n", + "[audio]\n", + "beat_sensitivity = 0.6\n", + )) + .expect("schema v4 should migrate"); + + assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); + assert_eq!(config.daemon.start_scene, "Evening"); + assert_eq!(config.daemon.target_fps, DEFAULT_TARGET_FPS); + assert!((config.audio.beat_sensitivity - DEFAULT_BEAT_SENSITIVITY).abs() < f32::EPSILON); } #[test] @@ -177,7 +292,7 @@ fn following_the_hand_migration_runs_every_later_migration() { #[test] fn newer_schema_is_refused_as_written_by_a_newer_hypercolor() { let tmp = tempfile::NamedTempFile::new().expect("failed to create temp file"); - fs::write(tmp.path(), "schema_version = 7\n").expect("failed to write future config"); + fs::write(tmp.path(), "schema_version = 8\n").expect("failed to write future config"); let error = ConfigManager::load(tmp.path()) .expect_err("a future schema must be refused, never guessed at"); @@ -187,7 +302,7 @@ fn newer_schema_is_refused_as_written_by_a_newer_hypercolor() { rendered.contains(&tmp.path().display().to_string()), "{rendered}" ); - assert!(rendered.contains("schema_version 7"), "{rendered}"); + assert!(rendered.contains("schema_version 8"), "{rendered}"); assert!(rendered.contains("newer hypercolor"), "{rendered}"); // A future file is not an old file: no hand-migration is offered. assert!( @@ -202,7 +317,7 @@ fn current_schema_keeps_explicit_route_fields() { fs::write( tmp.path(), concat!( - "schema_version = 6\n\n", + "schema_version = 7\n\n", "[input]\n", "daemon_route = \"host\"\n", "preview_route = \"merge\"\n", @@ -212,7 +327,7 @@ fn current_schema_keeps_explicit_route_fields() { let config = ConfigManager::load(tmp.path()).expect("current-schema config should load"); - assert_eq!(config.schema_version, 6); + assert_eq!(config.schema_version, 7); assert_eq!(config.input.daemon_route, InteractionRoutePolicy::Host); assert_eq!(config.input.preview_route, InteractionRoutePolicy::Merge); } @@ -339,7 +454,7 @@ fn new_with_missing_file_uses_defaults() { ConfigManager::new(path).expect("ConfigManager should fall back to defaults gracefully"); let config = manager.get(); - assert_eq!(config.schema_version, 6); + assert_eq!(config.schema_version, 7); assert_eq!(config.daemon.port, 9420); assert_eq!(config.daemon.target_fps, 60); assert!(config.web.enabled); diff --git a/crates/hypercolor-daemon/src/durable_stores.rs b/crates/hypercolor-daemon/src/durable_stores.rs index 6856ac0cc..f4622a74d 100644 --- a/crates/hypercolor-daemon/src/durable_stores.rs +++ b/crates/hypercolor-daemon/src/durable_stores.rs @@ -114,7 +114,7 @@ const ATTACHMENT: u32 = hypercolor_types::attachment::CURRENT_ATTACHMENT_SCHEMA_ /// Every durable store this release reads or writes. pub const DURABLE_STORES: &[DurableStore] = &[ - // Reads schemas 4 through 6; 4 and 5 are upgraded in memory + // Reads schemas 4 through 7; 4, 5, and 6 are upgraded in memory // (hypercolor_core::config::ConfigManager::parse_toml). DurableStore { name: "config", diff --git a/crates/hypercolor-daemon/tests/daemon_integration_tests.rs b/crates/hypercolor-daemon/tests/daemon_integration_tests.rs index 776e9e467..63295ccca 100644 --- a/crates/hypercolor-daemon/tests/daemon_integration_tests.rs +++ b/crates/hypercolor-daemon/tests/daemon_integration_tests.rs @@ -645,7 +645,10 @@ async fn daemon_render_loop_uses_configured_fps() { let mut config = default_config(); config.daemon.target_fps = 30; - let toml_str = "schema_version = 5\n[daemon]\ntarget_fps = 30\n"; + // Only a current-schema file keeps 30; an older one carries the + // retired default and loads at the new one. + let toml_str = + format!("schema_version = {CURRENT_SCHEMA_VERSION}\n[daemon]\ntarget_fps = 30\n"); let mut temp = NamedTempFile::new().expect("create temp"); temp.write_all(toml_str.as_bytes()).expect("write"); temp.flush().expect("flush"); diff --git a/crates/hypercolor-daemon/tests/durable_store_tests.rs b/crates/hypercolor-daemon/tests/durable_store_tests.rs index fc05396a6..811b235c7 100644 --- a/crates/hypercolor-daemon/tests/durable_store_tests.rs +++ b/crates/hypercolor-daemon/tests/durable_store_tests.rs @@ -83,7 +83,7 @@ fn enforced_versions_come_from_the_stores_own_constants() { hypercolor_daemon::device_settings::DEVICE_SETTINGS_SCHEMA_VERSION ); for (name, min, max) in [ - ("config", 4, 6), + ("config", 4, 7), ("scenes", 2, 2), ("device-settings", 1, 3), ("device-aliases", 2, 2), diff --git a/crates/hypercolor-daemon/tests/startup_tests.rs b/crates/hypercolor-daemon/tests/startup_tests.rs index 6fce4945c..e37a068d5 100644 --- a/crates/hypercolor-daemon/tests/startup_tests.rs +++ b/crates/hypercolor-daemon/tests/startup_tests.rs @@ -861,11 +861,11 @@ fn parse_config_toml_refuses_an_outdated_schema() { #[test] fn parse_config_toml_refuses_a_newer_schema() { - let error = parse_config_toml("schema_version = 7\n") + let error = parse_config_toml("schema_version = 8\n") .expect_err("a future schema must be refused, not read"); let rendered = format!("{error:#}"); - assert!(rendered.contains("schema_version 7"), "{rendered}"); + assert!(rendered.contains("schema_version 8"), "{rendered}"); assert!(rendered.contains("newer hypercolor"), "{rendered}"); } @@ -904,7 +904,7 @@ fn parse_config_toml_rejects_invalid_toml() { #[test] fn default_config_has_sane_values() { let config = default_config(); - assert_eq!(config.schema_version, 6); + assert_eq!(config.schema_version, 7); assert_eq!(config.daemon.target_fps, 60); assert_eq!(config.daemon.port, 9420); assert_eq!(config.daemon.listen_address, "127.0.0.1"); diff --git a/crates/hypercolor-types/src/config/root.rs b/crates/hypercolor-types/src/config/root.rs index de05173b4..be0c71dca 100644 --- a/crates/hypercolor-types/src/config/root.rs +++ b/crates/hypercolor-types/src/config/root.rs @@ -71,7 +71,7 @@ pub struct HypercolorConfig { } /// Current schema version for newly created configurations. -pub const CURRENT_SCHEMA_VERSION: u32 = 6; +pub const CURRENT_SCHEMA_VERSION: u32 = 7; impl HypercolorConfig { /// Decode a config document from an authoritative source, refusing any diff --git a/docs/content/guide/configuration.md b/docs/content/guide/configuration.md index d92b3d7e6..374c7529e 100644 --- a/docs/content/guide/configuration.md +++ b/docs/content/guide/configuration.md @@ -76,7 +76,7 @@ state_history_generations = 10 # Previous versions kept per state file; 0 state_history_min_interval_secs = 30 # Content changed more recently than this is transient ``` -**`target_fps`** controls the maximum render cadence. The FPS controller auto-shifts between tiers (10, 20, 30, 45, 60) based on frame budget; this key sets the ceiling. The default of `60` lets the loop climb to the top tier when the machine has headroom, and a machine that can't keep up downshifts on its own, so there is no need to lower it for a slower machine. Changes to this key take effect live without a daemon restart. +**`target_fps`** controls the maximum render cadence. The FPS controller auto-shifts between tiers (10, 20, 30, 45, 60) based on frame budget; this key sets the ceiling. The default of `60` lets the loop climb to the top tier when the machine has headroom, and a machine that can't keep up downshifts on its own, so there is no need to lower it for a slower machine. Changes to this key take effect live without a daemon restart. A config file whose `schema_version` is 4 through 6 and that still carries the old `30` default moves to `60` when it loads. Any other value stays, and setting `30` again afterwards sticks. **`canvas_width` / `canvas_height`** are the render canvas dimensions. LED spatial positions are normalized to `[0.0, 1.0]`, so layouts remain valid across different canvas sizes. Canvas resizes take effect at the next frame boundary. Both keys support live reload. diff --git a/packaging/managed/durable-stores.json b/packaging/managed/durable-stores.json index 4d7012de6..bd6c457a2 100644 --- a/packaging/managed/durable-stores.json +++ b/packaging/managed/durable-stores.json @@ -4,8 +4,8 @@ "name": "config", "storage_format": "toml", "readable_schema_min": 4, - "readable_schema_max": 6, - "written_schema": 6, + "readable_schema_max": 7, + "written_schema": 7, "migration_mode": "backward_compatible" }, { From 551d37bd225b7e60714fc051f24982241ad5b1fa Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 22:23:22 -0700 Subject: [PATCH 3/4] test(config): carry the retired render target through the hand-migration The hand-migration test proves a schema 3 file edited onto schema 4 runs every later in-memory migration. It only carried the retired beat threshold, so nothing pinned that such a file also reaches the v6 to v7 step. The fixture now carries target_fps = 30 as well and expects the current default after the load. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/hypercolor-core/tests/config_tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/hypercolor-core/tests/config_tests.rs b/crates/hypercolor-core/tests/config_tests.rs index 2142a8af1..3aa186343 100644 --- a/crates/hypercolor-core/tests/config_tests.rs +++ b/crates/hypercolor-core/tests/config_tests.rs @@ -271,10 +271,12 @@ fn outdated_schema_is_refused_and_names_the_file_and_the_fix() { /// The hand-migration lands an old file on the oldest schema the chain /// still upgrades, so later migrations run on it. Jumping straight to the -/// current schema would skip them and keep a retired beat threshold. +/// current schema would skip them and keep a retired beat threshold and +/// render target. #[test] fn following_the_hand_migration_runs_every_later_migration() { - let old = "schema_version = 3\n\n[audio]\nbeat_sensitivity = 0.6\n"; + let old = + "schema_version = 3\n\n[daemon]\ntarget_fps = 30\n\n[audio]\nbeat_sensitivity = 0.6\n"; let error = ConfigManager::parse_toml(old).expect_err("schema 3 must be refused"); let rendered = format!("{error:#}"); assert!(rendered.contains("schema_version = 4"), "{rendered}"); @@ -285,6 +287,7 @@ fn following_the_hand_migration_runs_every_later_migration() { assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); assert!((config.audio.beat_sensitivity - DEFAULT_BEAT_SENSITIVITY).abs() < f32::EPSILON); + assert_eq!(config.daemon.target_fps, DEFAULT_TARGET_FPS); assert_eq!(config.input.daemon_route, InteractionRoutePolicy::Merge); assert_eq!(config.input.preview_route, InteractionRoutePolicy::Browser); } From 24d621d2011f0f264af24246458b12c45c9c4e27 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 22:23:22 -0700 Subject: [PATCH 4/4] docs(troubleshooting): show the diagnose tier as the daemon prints it The render_loop check formats the tier through FpsTier's Display impl, which prints the rate and the name, so a stock config reports `tier=60fps (full)` rather than a bare tier name. The example now matches, and the prose says what the field carries. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/content/troubleshooting/performance.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/troubleshooting/performance.md b/docs/content/troubleshooting/performance.md index 5692f4a1b..f13002af2 100644 --- a/docs/content/troubleshooting/performance.md +++ b/docs/content/troubleshooting/performance.md @@ -46,10 +46,10 @@ The `render` category has five checks for FPS and LED output problems. ### `render_loop` ``` -render_loop state=running, tier=full +render_loop state=running, tier=60fps (full) ``` -The `tier` field shows where the FPS controller has landed. Compare it against your configured `daemon.target_fps`: on the default config, `full` (60 FPS) is the healthy steady state. A tier sitting below your configured target means the loop has downshifted under budget pressure. If `state` is anything other than `running`, the render loop has stopped; see [common issues](@/troubleshooting/common-issues.md). +The `tier` field shows where the FPS controller has landed, as its rate and name. Compare it against your configured `daemon.target_fps`: on the default config, `full` (60 FPS) is the healthy steady state. A tier sitting below your configured target means the loop has downshifted under budget pressure. If `state` is anything other than `running`, the render loop has stopped; see [common issues](@/troubleshooting/common-issues.md). **Tier reference:**