diff --git a/Cargo.lock b/Cargo.lock index 0d99ea20b..e00c0124c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4543,9 +4543,9 @@ dependencies = [ [[package]] name = "rustmaniax-sdk" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d1c5f0deea652893532c0d054f88691b4da86200973921f94a5dfbc70d27915" +checksum = "bd9a39764dfdaf9155648cf9d4edbc401a6f2be24bfb3c63159a7cbe9e9d4be7" dependencies = [ "bitflags 2.13.0", "bytemuck", diff --git a/assets/languages/en.ini b/assets/languages/en.ini index fa65005f7..14af2dd9e 100644 --- a/assets/languages/en.ini +++ b/assets/languages/en.ini @@ -325,6 +325,9 @@ DefaultPadConfig=Default Pad Config SmxDefaultLightBrightness=Default Light Brightness SmxBgPack=Pad Lights Pack SmxJudgePack=Judgement Pack +SmxIdleLights=Idle Pad Lights +SmxIdleLightsFirmware=Firmware +SmxIdleLightsBlack=Black SmxAssignPads=Assign Pads to Players SmxSwapPads=Swap P1/P2 Pads SmxSwapPadsAction=Swap @@ -1703,7 +1706,7 @@ SmxInputHelp=Enable native StepManiaX stage input via the RustManiaX SDK, connec UseFSRsHelp=Enable direct FSR pad diagnostics and threshold editing on the Configure Pads screen. This reads supported pads directly instead of relying on the gameplay input backend.\nAlso reveals the StepManiaX settings page.\nDefault is No. SmxConfigHelp=StepManiaX pad settings: enable StepManiaX input and pad-config management.\nShown when Use FSRs is enabled. SmxManagesPadConfigHelp=Controls who owns your StepManiaX pad thresholds.\nOn: DeadSync writes thresholds to each pad (the pad's saved default profile, or the Default Pad Config preset if it has none) and re-applies them on launch and whenever the active profile or pad changes. Edits made in Configure Pads are temporary; save tunings as profiles from Song Select to keep them.\nOff: DeadSync never changes your pads. Edit them directly in Configure Pads and the values stay on the pad.\nDefault is No. -SmxPanelLightsHelp=Drive StepManiaX pad panel LEDs with GIF animations. Plays configurable background animations on all screens, per-panel judgement effects during gameplay (tap grades, freezes, rolls, mine hits), and press feedback while navigating menus. While on, the game owns the LEDs and the pad's own firmware lighting is suppressed.\nDefault is No. +SmxPanelLightsHelp=Drive StepManiaX pad panel LEDs with GIF animations. Plays configurable background animations on all screens, per-panel judgement effects during gameplay (tap grades, freezes, rolls, mine hits), and press feedback while navigating menus. While the game is showing something, the pad's own firmware lighting is suppressed; what an idle pad shows is controlled by Idle Pad Lights.\nDefault is No. SmxUnderglowThemeHelp=Set the StepManiaX pad edge underglow LEDs to your theme colour. P1 pad uses the selected colour; P2 pad uses the paired offset colour. Applied on pad connect and whenever the theme colour changes. While this options page is open, the strips show a red test colour instead (blue when this is off). Requires a newer pad with master board firmware v4 or later; older pads do not support underglow and will not light up.\nDefault is No. SmxUnderglowGrbHelp=Wire byte order for the underglow LED strips. Some strips read colours in GRB order, which swaps red and green: purple shows as cyan and green as magenta. While this options page is open the strips hold a red test colour; if they show green instead of red, switch this to GRB.\nDefault is RGB. DefaultPadConfigHelp=The built-in threshold preset (Low/Medium/High, matching the official StepManiaX tool) DeadSync uses as its default.\nDefault is Low. @@ -1711,6 +1714,7 @@ SmxSinglePadPlayerHelp=With a single StepManiaX pad connected, choose whether it SmxDefaultLightBrightnessHelp=Machine default for StepManiaX pad-light brightness, 0% (off) to 100%. Used to seed new player profiles; each player can then set their own value in Player Options.\nDefault is 100%. SmxBgPackHelp=The GIF pack to use for full-pad background animations. Default uses the built-in common pack. User packs go in assets/smx-pad-lights/dance//. Only shown when Panel Lights is on. SmxJudgePackHelp=The GIF pack to use for per-panel judgement animations. Default uses the built-in common pack. User packs go in assets/smx-judge-lights/dance//. Only shown when Panel Lights is on. +SmxIdleLightsHelp=What the pads show when GIF lighting has nothing to display, such as the none pack or a role a pack leaves empty.\nFirmware: the pads fall back to their built-in lighting (the stored idle and step animations).\nBlack: the game keeps the LEDs and holds the pads dark; panel press animations still play.\nOnly shown when Panel Lights is on. Default is Firmware. SmxAssignPadsHelp=Choose which physical pad is Player 1 and which is Player 2 by stepping on each. Use this when both pads share the same P1/P2 jumper, or when the pads are installed on the wrong sides. The pads light blue (P1) and red (P2) so you can see the assignment. SmxSwapPadsHelp=Instantly swap which pad is Player 1 and which is Player 2. Handy when the pads are jumpered correctly but installed on the wrong sides. Both pads must be connected. DebugFsrDumpHelp=If you have FSRs, use this to create fsrdump.txt in your deadsync folder. Please send it to PerfectTaste so he can debug and implement support for more FSR I/O boards and firmware versions. diff --git a/assets/languages/pseudo.ini b/assets/languages/pseudo.ini index 41b15393d..376e96bd2 100644 --- a/assets/languages/pseudo.ini +++ b/assets/languages/pseudo.ini @@ -492,6 +492,9 @@ SmxAssignStatusNone=[(ńóńé)__] SmxConfig=[ŠťépMáńíáX___] SmxBgPack=[Páđ Ĺíghťš Páçk___] SmxDefaultLightBrightness=[Đéfáúĺť Ĺíghť Bŕíghťńéšš_______] +SmxIdleLights=[Íđĺé Páđ Ĺíghťš___] +SmxIdleLightsBlack=[Bĺáçk_] +SmxIdleLightsFirmware=[Fíŕmwáŕé__] SmxInput=[Úšé ŠťépMáńíáX____] SmxJudgePack=[Júđgéméńť Páçk___] SmxManagesPadConfig=[ĐéáđŠýńç Máńágéš Páđ Çóńfíg________] @@ -521,6 +524,7 @@ SmxAssignPadsHelp=[Çhóóšé whíçh phýšíçáĺ páđ íš Pĺáýéŕ 1 SmxConfigHelp=[ŠťépMáńíáX páđ šéťťíńgš: éńábĺé ŠťépMáńíáX íńpúť áńđ páđ-çóńfíg máńágéméńť.\nŠhówń whéń Úšé FŠŔš íš éńábĺéđ.____________________________] SmxBgPackHelp=[Ťhé GÍF páçk ťó úšé fóŕ fúĺĺ-páđ báçkgŕóúńđ áńímáťíóńš. Đéfáúĺť úšéš ťhé búíĺť-íń çómmóń páçk. Úšéŕ páçkš gó íń ášéťš/šmx-páđ-ĺíghťš/đáńçé//. Óńĺý šhówń whéń Páńéĺ Ĺíghťš íš óń.____________] SmxDefaultLightBrightnessHelp=[Máçhíńé đéfáúĺť fóŕ ŠťépMáńíáX páđ-ĺíghť bŕíghťńéšš, 0% (óff) ťó 100%. Úšéđ ťó šééđ ńéw pĺáýéŕ pŕófíĺéš; éáçh pĺáýéŕ çáń ťhéń šéť ťhéíŕ ówń váĺúé íń Pĺáýéŕ Ópťíóńš.\nĐéfáúĺť íš 100%._________________________________________] +SmxIdleLightsHelp=[Wháť ťhé páđš šhów whéń GÍF ĺíghťíńg háš ńóťhíńg ťó đíšpĺáý, šúçh áš ťhé ńóńé páçk óŕ á ŕóĺé á páçk ĺéávéš émpťý.\nFíŕmwáŕé: ťhé páđš fáĺĺ báçk ťó ťhéíŕ búíĺť-íń ĺíghťíńg (ťhé šťóŕéđ íđĺé áńđ šťép áńímáťíóńš).\nBĺáçk: ťhé gámé kéépš ťhé ĹÉĐš áńđ hóĺđš ťhé páđš đáŕk; páńéĺ pŕéšš áńímáťíóńš šťíĺĺ pĺáý.\nÓńĺý šhówń whéń Páńéĺ Ĺíghťš íš óń. Đéfáúĺť íš Fíŕmwáŕé.____________________] SmxJudgePackHelp=[Ťhé GÍF páçk ťó úšé fóŕ péŕ-páńéĺ júđgéméńť áńímáťíóńš. Đéfáúĺť úšéš ťhé búíĺť-íń çómmóń páçk. Úšéŕ páçkš gó íń ášéťš/šmx-júđgé-ĺíghťš/đáńçé//. Óńĺý šhówń whéń Páńéĺ Ĺíghťš íš óń.___________] SmxInputHelp=[Éńábĺé ńáťívé ŠťépMáńíáX šťágé íńpúť víá ťhé ŔúšťMáńíáX ŠĐK, çóńńéçťíńg đíŕéçťĺý ťó ťhé páđš.\nÇháńgíńg ťhíš ŕéqúíŕéš á ŕéšťáŕť.\nĐéfáúĺť íš Ńó.____________________________________] SmxManagesPadConfigHelp=[Çóńťŕóĺš whó ówńš ýóúŕ ŠťépMáńíáX páđ ťhŕéšhóĺđš.\nÓń: ĐéáđŠýńç wŕíťéš ťhŕéšhóĺđš ťó éáçh páđ (ťhé páđ'š šávéđ đéfáúĺť pŕófíĺé, óŕ ťhé Đéfáúĺť Páđ Çóńfíg pŕéšéť íf íť háš ńóńé) áńđ ŕé-áppĺíéš ťhém óń ĺáúńçh áńđ whéńévéŕ ťhé áçťívé pŕófíĺé óŕ páđ çháńgéš. Éđíťš máđé íń Çóńfígúŕé Páđš áŕé ťémpóŕáŕý; šávé ťúńíńgš áš pŕófíĺéš fŕóm Šóńg Šéĺéçť ťó kéép ťhém.\nÓff: ĐéáđŠýńç ńévéŕ çháńgéš ýóúŕ páđš. Éđíť ťhém đíŕéçťĺý íń Çóńfígúŕé Páđš áńđ ťhé váĺúéš šťáý óń ťhé páđ.\nĐéfáúĺť íš Ńó.____________________________________________________________________________________________________________________] diff --git a/crates/deadsync-config/src/app_config.rs b/crates/deadsync-config/src/app_config.rs index 79acdc091..8f84c4622 100644 --- a/crates/deadsync-config/src/app_config.rs +++ b/crates/deadsync-config/src/app_config.rs @@ -105,6 +105,7 @@ pub struct Config { /// effects, press feedback). While on, the game owns the LEDs and the /// pad's own firmware lighting is suppressed. pub smx_panel_lights: bool, + pub smx_idle_lights_black: bool, /// User animation pack supplying the pad backgrounds (a directory under /// `assets/smx-pad-lights/dance/`). Empty selects the built-in set. pub smx_pad_gifs_pack: SmxPackName, @@ -381,6 +382,7 @@ impl Default for Config { smx_input: system.smx_input, smx_manages_pad_config: system.smx_manages_pad_config, smx_panel_lights: system.smx_panel_lights, + smx_idle_lights_black: system.smx_idle_lights_black, smx_pad_gifs_pack: system.smx_pad_gifs_pack, smx_judge_gifs_pack: system.smx_judge_gifs_pack, smx_underglow_theme: system.smx_underglow_theme, diff --git a/crates/deadsync-config/src/app_update.rs b/crates/deadsync-config/src/app_update.rs index 57871e85e..b7e65485c 100644 --- a/crates/deadsync-config/src/app_update.rs +++ b/crates/deadsync-config/src/app_update.rs @@ -138,6 +138,10 @@ pub fn set_smx_panel_lights(cfg: &mut Config, enabled: bool) -> bool { set_if_changed(&mut cfg.smx_panel_lights, enabled) } +pub fn set_smx_idle_lights_black(cfg: &mut Config, black: bool) -> bool { + set_if_changed(&mut cfg.smx_idle_lights_black, black) +} + pub fn set_smx_pad_gifs_pack(cfg: &mut Config, pack: crate::options::SmxPackName) -> bool { set_if_changed(&mut cfg.smx_pad_gifs_pack, pack) } diff --git a/crates/deadsync-config/src/defaults.rs b/crates/deadsync-config/src/defaults.rs index 822eefeba..77b1bd62f 100644 --- a/crates/deadsync-config/src/defaults.rs +++ b/crates/deadsync-config/src/defaults.rs @@ -28,6 +28,7 @@ pub const DEFAULT_SMX_MANAGES_PAD_CONFIG: bool = false; pub const DEFAULT_SMX_PANEL_LIGHTS: bool = false; pub const DEFAULT_SMX_UNDERGLOW_THEME: bool = false; pub const DEFAULT_SMX_UNDERGLOW_GRB: bool = false; +pub const DEFAULT_SMX_IDLE_LIGHTS_BLACK: bool = false; pub const DEFAULT_SMX_DEFAULT_LIGHT_BRIGHTNESS: u8 = 100; pub const DEFAULT_SOFTWARE_RENDERER_THREADS: u8 = 1; pub const DEFAULT_SONG_PARSING_THREADS: u8 = 0; diff --git a/crates/deadsync-config/src/load.rs b/crates/deadsync-config/src/load.rs index 9d835efc9..ce36e316d 100644 --- a/crates/deadsync-config/src/load.rs +++ b/crates/deadsync-config/src/load.rs @@ -271,6 +271,7 @@ fn apply_system_opts(loaded: SystemOptions, cfg: &mut Config) { cfg.smx_input = loaded.smx_input; cfg.smx_manages_pad_config = loaded.smx_manages_pad_config; cfg.smx_panel_lights = loaded.smx_panel_lights; + cfg.smx_idle_lights_black = loaded.smx_idle_lights_black; cfg.smx_pad_gifs_pack = loaded.smx_pad_gifs_pack; cfg.smx_judge_gifs_pack = loaded.smx_judge_gifs_pack; cfg.smx_underglow_theme = loaded.smx_underglow_theme; diff --git a/crates/deadsync-config/src/options.rs b/crates/deadsync-config/src/options.rs index 7bc0d4f3f..fd25d5216 100644 --- a/crates/deadsync-config/src/options.rs +++ b/crates/deadsync-config/src/options.rs @@ -23,7 +23,8 @@ use crate::defaults::{ DEFAULT_SHOW_SELECT_MUSIC_SCOREBOX, DEFAULT_SHOW_SELECT_MUSIC_STAGE_DISPLAY, DEFAULT_SHOW_SELECT_MUSIC_VIDEO_BANNERS, DEFAULT_SHOW_STATS_MODE, DEFAULT_SHOW_VERSION_OVERLAY, DEFAULT_SMOOTH_HISTOGRAM, DEFAULT_SMX_INPUT, DEFAULT_SMX_MANAGES_PAD_CONFIG, - DEFAULT_SMX_PANEL_LIGHTS, DEFAULT_SMX_UNDERGLOW_GRB, DEFAULT_SMX_UNDERGLOW_THEME, + DEFAULT_SMX_IDLE_LIGHTS_BLACK, DEFAULT_SMX_PANEL_LIGHTS, DEFAULT_SMX_UNDERGLOW_GRB, + DEFAULT_SMX_UNDERGLOW_THEME, DEFAULT_SOFTWARE_RENDERER_THREADS, DEFAULT_SONG_PARSING_THREADS, DEFAULT_SUBMIT_ARROWCLOUD_FAILS, DEFAULT_THREE_KEY_NAVIGATION, DEFAULT_TRANSLATED_TITLES, DEFAULT_UPDATER_INSTALL_ENABLED, DEFAULT_USE_FSRS, @@ -179,6 +180,10 @@ pub struct SystemOptions { /// Send platform strip (underglow) colours in GRB wire order instead of /// RGB, for strip hardware that consumes WS2812 channel order. pub smx_underglow_grb: bool, + /// When pad gif lighting is on but nothing resolves for the current + /// screen, hold the pads solid black (true) instead of releasing them to + /// the pad firmware's built-in lighting (false, the default). + pub smx_idle_lights_black: bool, /// User animation pack supplying the pad backgrounds (a directory under /// `assets/smx-pad-lights/dance/`). Empty selects the built-in set. pub smx_pad_gifs_pack: SmxPackName, @@ -231,6 +236,7 @@ impl Default for SystemOptions { smx_input: DEFAULT_SMX_INPUT, smx_manages_pad_config: DEFAULT_SMX_MANAGES_PAD_CONFIG, smx_panel_lights: DEFAULT_SMX_PANEL_LIGHTS, + smx_idle_lights_black: DEFAULT_SMX_IDLE_LIGHTS_BLACK, smx_underglow_theme: DEFAULT_SMX_UNDERGLOW_THEME, smx_underglow_grb: DEFAULT_SMX_UNDERGLOW_GRB, smx_pad_gifs_pack: SmxPackName::default(), @@ -609,6 +615,10 @@ pub fn load_system_options(conf: &SimpleIni, default: SystemOptions) -> SystemOp .get("Options", "SmxPanelLights") .and_then(|value| parse_loose_bool_str(&value)) .unwrap_or(default.smx_panel_lights), + smx_idle_lights_black: conf + .get("Options", "SmxIdleLightsBlack") + .and_then(|value| parse_loose_bool_str(&value)) + .unwrap_or(default.smx_idle_lights_black), smx_underglow_theme: conf .get("Options", "SmxUnderglowTheme") .and_then(|value| parse_loose_bool_str(&value)) @@ -770,6 +780,11 @@ pub fn push_system_input_hardware_option_lines( options.system.smx_manages_pad_config, ); push_bool(content, "SmxPanelLights", options.system.smx_panel_lights); + push_bool( + content, + "SmxIdleLightsBlack", + options.system.smx_idle_lights_black, + ); if let Some(enabled) = options.smx_underglow_theme { push_bool(content, "SmxUnderglowTheme", enabled); } @@ -2128,6 +2143,7 @@ mod tests { smx_input: false, smx_manages_pad_config: false, smx_panel_lights: false, + smx_idle_lights_black: false, smx_underglow_theme: false, smx_underglow_grb: false, smx_pad_gifs_pack: SmxPackName::default(), @@ -2407,6 +2423,7 @@ mod tests { SmxInput=1 SmxManagesPadConfig=1 SmxPanelLights=1 + SmxIdleLightsBlack=1 SmxUnderglowTheme=1 SmxUnderglowGrb=1 SmxPadGifsPack=senpi-basic @@ -2463,6 +2480,7 @@ mod tests { assert!(loaded.smx_input); assert!(loaded.smx_manages_pad_config); assert!(loaded.smx_panel_lights); + assert!(loaded.smx_idle_lights_black); assert!(loaded.smx_underglow_theme); assert!(loaded.smx_underglow_grb); assert_eq!(loaded.smx_pad_gifs_pack.as_str(), "senpi-basic"); @@ -2591,6 +2609,7 @@ mod tests { options.smx_input = true; options.smx_manages_pad_config = true; options.smx_panel_lights = false; + options.smx_idle_lights_black = true; let hardware_options = SystemInputHardwareOptions { system: options, gamepad_backend: "RawInput", @@ -2611,6 +2630,7 @@ mod tests { "SmxInput=1\n", "SmxManagesPadConfig=1\n", "SmxPanelLights=0\n", + "SmxIdleLightsBlack=1\n", "SmxUnderglowTheme=1\n", "SmxUnderglowGrb=0\n", "SmxPadGifsPack=\n", diff --git a/crates/deadsync-config/src/runtime_state.rs b/crates/deadsync-config/src/runtime_state.rs index a797d8828..b91468e28 100644 --- a/crates/deadsync-config/src/runtime_state.rs +++ b/crates/deadsync-config/src/runtime_state.rs @@ -282,6 +282,10 @@ impl RuntimeConfigStore { self.update_config(|cfg| config_update::set_smx_underglow_grb(cfg, grb)) } + pub fn update_smx_idle_lights_black(&self, black: bool) -> bool { + self.update_config(|cfg| config_update::set_smx_idle_lights_black(cfg, black)) + } + pub fn update_smx_default_pad_config(&self, preset: deadsync_smx::SmxPadPreset) -> bool { self.update_config(|cfg| config_update::set_smx_default_pad_config(cfg, preset)) } diff --git a/crates/deadsync-config/src/runtime_update.rs b/crates/deadsync-config/src/runtime_update.rs index 0b2241bb1..53109db63 100644 --- a/crates/deadsync-config/src/runtime_update.rs +++ b/crates/deadsync-config/src/runtime_update.rs @@ -108,6 +108,7 @@ update_config_fn!(pub fn update_use_fsrs(enabled: bool) => set_use_fsrs); runtime_config_fn!(pub fn update_smx_input(enabled: bool) => update_smx_input); runtime_config_fn!(pub fn update_smx_manages_pad_config(enabled: bool) => update_smx_manages_pad_config); runtime_config_fn!(pub fn update_smx_panel_lights(enabled: bool) => update_smx_panel_lights); +runtime_config_fn!(pub fn update_smx_idle_lights_black(black: bool) => update_smx_idle_lights_black); runtime_config_fn!(pub fn update_smx_pad_gifs_pack(pack: crate::options::SmxPackName) => update_smx_pad_gifs_pack); runtime_config_fn!(pub fn update_smx_judge_gifs_pack(pack: crate::options::SmxPackName) => update_smx_judge_gifs_pack); runtime_config_fn!(pub fn update_smx_default_pad_config(preset: deadsync_smx::SmxPadPreset) => update_smx_default_pad_config); diff --git a/crates/deadsync-config/src/save.rs b/crates/deadsync-config/src/save.rs index b5ed3d942..581289749 100644 --- a/crates/deadsync-config/src/save.rs +++ b/crates/deadsync-config/src/save.rs @@ -472,6 +472,7 @@ fn system_options(cfg: &Config) -> SystemOptions { smx_input: cfg.smx_input, smx_manages_pad_config: cfg.smx_manages_pad_config, smx_panel_lights: cfg.smx_panel_lights, + smx_idle_lights_black: cfg.smx_idle_lights_black, smx_underglow_theme: cfg.smx_underglow_theme, smx_underglow_grb: cfg.smx_underglow_grb, smx_pad_gifs_pack: cfg.smx_pad_gifs_pack, diff --git a/crates/deadsync-shell/src/lighting.rs b/crates/deadsync-shell/src/lighting.rs index 679a3595e..541cae8ec 100644 --- a/crates/deadsync-shell/src/lighting.rs +++ b/crates/deadsync-shell/src/lighting.rs @@ -184,6 +184,10 @@ impl SmxPanelDriver { self.inner.set_pad_blackout(pad, on); } + pub fn set_idle_black(&mut self, on: bool) { + self.inner.set_idle_black(on); + } + pub fn on_raw_panel(&self, pad: usize, panel: usize, pressed: bool) { self.inner.on_raw_panel(pad, panel, pressed); } diff --git a/crates/deadsync-smx/Cargo.toml b/crates/deadsync-smx/Cargo.toml index a2d1d97a3..708222b3a 100644 --- a/crates/deadsync-smx/Cargo.toml +++ b/crates/deadsync-smx/Cargo.toml @@ -14,7 +14,7 @@ deadsync-score = { path = "../deadsync-score" } deadlib-platform = { path = "../deadlib-platform" } image = { version = "0.25.10", default-features = false, features = ["gif"] } log = "0.4.33" -rustmaniax-sdk = "2.0.0" +rustmaniax-sdk = "2.1.1" [lints.clippy] perf = { level = "warn", priority = -1 } diff --git a/crates/deadsync-smx/src/gameplay_driver.rs b/crates/deadsync-smx/src/gameplay_driver.rs index e9fcd7cb4..fd43fb9a9 100644 --- a/crates/deadsync-smx/src/gameplay_driver.rs +++ b/crates/deadsync-smx/src/gameplay_driver.rs @@ -38,8 +38,14 @@ pub struct SmxPanelDriver { lights: SmxPanelLights, /// Gameplay judgement effects are running (the original "active"). gameplay_active: bool, - /// The worker owns the pad lights (gameplay effects or a background). - worker_active: bool, + /// Per-slot: the worker owns this pad's lights (gameplay effects or a background). + /// Tracked per pad so a pad with nothing to show returns to its firmware lighting + /// even while the other pad animates. + worker_active: [bool; PADS], + /// Keep the worker active (pads held solid black) even with nothing to + /// show, instead of releasing the pads to the firmware's built-in + /// lighting. Mirrors the "Idle Pad Lights: Black" machine option. + idle_black: bool, /// Per-slot background currently applied to the worker, for change detection. backgrounds: [Option<(Arc, Clock)>; PADS], /// Per-slot judgement animations; empty (all `None`) means no panel effects at all. @@ -63,7 +69,8 @@ impl Default for SmxPanelDriver { Self { lights: SmxPanelLights::new(), gameplay_active: false, - worker_active: false, + worker_active: [false; PADS], + idle_black: false, backgrounds: std::array::from_fn(|_| None), judgement_gifs: std::array::from_fn(|_| JudgementGifs::default()), notes_ptr: 0, @@ -282,6 +289,9 @@ impl SmxPanelDriver { pub fn set_judgement_gifs_for_pad(&mut self, pad: usize, gifs: JudgementGifs) { if pad < PADS { self.judgement_gifs[pad] = gifs; + // Ownership depends on whether this pad has any judgement gif to draw, so a + // pack change during gameplay can claim or release the pad. + self.sync_worker(); } } @@ -291,11 +301,26 @@ impl SmxPanelDriver { self.lights.set_pad_blackout(pad, on); } + /// Set whether an otherwise-idle worker keeps ownership of the pads and + /// holds them solid black (the "Idle Pad Lights: Black" option) instead of + /// releasing them to the pad firmware's built-in lighting. With it on, the + /// press gif also works on every screen, since the worker stays active. + /// Deduplicated, so calling every frame with the option value is cheap. + pub fn set_idle_black(&mut self, on: bool) { + if self.idle_black != on { + self.idle_black = on; + self.sync_worker(); + } + } + /// Handle a raw SMX panel press/release outside gameplay. Plays the `press` gif on /// contact and releases it on lift. No-op when the worker is idle or no press gif is /// configured; always safe to call regardless of current screen. pub fn on_raw_panel(&self, pad: usize, panel: usize, pressed: bool) { - if !self.worker_active { + // Gated on this pad's own ownership: a pad handed back to its firmware shows + // the firmware's step lighting, not our press gif, even while the other pad + // is ours. + if !self.worker_active.get(pad).copied().unwrap_or(false) { return; } if pressed { @@ -340,21 +365,96 @@ impl SmxPanelDriver { self.prev_hold_judged = [NO_EVENT; MAX_COLS]; self.prev_mine = [NO_EVENT; MAX_COLS]; self.prev_pressed = [false; MAX_COLS]; - // Always (re)send: entering active clears stale panel effects worker-side even - // when the worker was already running for a background. - self.worker_active = true; - self.lights.set_active(true); + // Always (re)send for a pad we own: entering active clears stale panel effects + // worker-side even when the worker was already running for a background. A pad + // that can't draw anything is released instead of held black (see `wants_pad`). + for pad in 0..PADS { + if self.wants_pad(pad) { + self.worker_active[pad] = true; + self.lights.set_active_for_pad(pad, true); + } + } + self.sync_worker(); + } + + /// Whether the worker should own pad `pad`'s LEDs. + /// + /// A pad is owned when it has something to draw: a background, a gameplay judgement + /// set that can actually fire, or the idle-black option forcing it dark. A pad with + /// an empty judgement pack draws nothing all song, so gameplay does not claim it and + /// it keeps its firmware step lighting, same as on the menus. + fn wants_pad(&self, pad: usize) -> bool { + self.idle_black + || self.backgrounds[pad].is_some() + || (self.gameplay_active && self.judgement_gifs[pad].has_any()) } - /// Activate or release the worker from the gameplay and background states. Releasing - /// clears the worker (including its background copy) and restores firmware lighting; - /// `self.backgrounds` is already all `None` whenever that happens, so the driver and - /// worker stay in step. + /// Activate or release each pad from the gameplay, background, and idle-black states. + /// Releasing a pad clears it worker-side (including its background copy) and restores + /// that pad's firmware lighting; its `self.backgrounds` slot is already `None` whenever + /// that happens, so the driver and worker stay in step. With `idle_black` set a pad + /// stays owned regardless, held solid black (the keepalive in the worker makes a static + /// black frame nearly free on the wire). + /// + /// Decided per pad: a pad showing nothing goes back to its firmware even while the + /// other pad animates a background. fn sync_worker(&mut self) { - let want = self.gameplay_active || self.backgrounds.iter().any(|b| b.is_some()); - if want != self.worker_active { - self.worker_active = want; - self.lights.set_active(want); + for pad in 0..PADS { + let want = self.wants_pad(pad); + if want != self.worker_active[pad] { + self.worker_active[pad] = want; + self.lights.set_active_for_pad(pad, want); + } } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::gifs::{PANEL_RGB_BYTES, PanelAnim}; + + fn any_panel_anim() -> Arc { + Arc::new(PanelAnim { + frames: vec![[0u8; PANEL_RGB_BYTES]], + durations: vec![0.1], + loop_frame: 0, + loop_end: 0, + }) + } + + /// An empty judgement pack can never draw a gameplay event, so gameplay must not + /// claim that pad: it keeps its firmware step lighting for the song, as on menus. + #[test] + fn gameplay_does_not_claim_a_pad_with_no_judgement_gifs() { + let mut driver = SmxPanelDriver::default(); + driver.gameplay_active = true; + assert!(!driver.wants_pad(0)); + assert!(!driver.wants_pad(1)); + } + + #[test] + fn gameplay_claims_a_pad_that_has_a_judgement_gif() { + let mut driver = SmxPanelDriver::default(); + driver.gameplay_active = true; + driver.judgement_gifs[1].miss = Some(any_panel_anim()); + assert!(!driver.wants_pad(0), "pad 0 still has nothing to draw"); + assert!(driver.wants_pad(1)); + } + + /// Idle-black wins over everything: an empty pad is held dark rather than released. + #[test] + fn idle_black_claims_a_pad_with_nothing_to_draw() { + let mut driver = SmxPanelDriver::default(); + driver.idle_black = true; + assert!(driver.wants_pad(0)); + } + + /// Outside gameplay a judgement pack alone is not enough; nothing can fire. + #[test] + fn judgement_gifs_alone_do_not_claim_a_pad_outside_gameplay() { + let mut driver = SmxPanelDriver::default(); + driver.judgement_gifs[0].miss = Some(any_panel_anim()); + assert!(!driver.wants_pad(0)); + } +} diff --git a/crates/deadsync-smx/src/gifs.rs b/crates/deadsync-smx/src/gifs.rs index 9a00ebd24..69ac11420 100644 --- a/crates/deadsync-smx/src/gifs.rs +++ b/crates/deadsync-smx/src/gifs.rs @@ -751,6 +751,25 @@ impl GifRegistry { .is_some_and(|m| m.match_color_to_difficulty.contains(role)) } + /// Whether `pack` declares `name` under `CanBeEmpty` in its background + /// `gifpack.ini` and supplies no gif for it (either size): the pack has + /// explicitly resolved `name` to nothing. Callers walking a role-level + /// fallback chain (e.g. screen role, then the `default` role) must stop at + /// such a name rather than resurrect an animation from a later candidate; + /// `background()` itself already returns `None` for it. A supplied gif + /// always wins over the declaration, and `None` or the default pack has no + /// declarations. + pub fn background_declared_empty(&self, pack: Option<&str>, name: &str, size: PadSize) -> bool { + let Some(p) = pack.filter(|p| *p != DEFAULT_PACK) else { + return false; + }; + self.background_pack_meta + .get(p) + .is_some_and(|m| m.can_be_empty.contains(name)) + && !self.backgrounds.contains_key(&key(p, name, size)) + && !self.backgrounds.contains_key(&key(p, name, size.other())) + } + pub fn is_empty(&self) -> bool { self.backgrounds.is_empty() && self.judgements.is_empty() } @@ -1586,6 +1605,45 @@ mod tests { assert!(reg.judgement(Some("foo"), "ok", PadSize::Leds25).is_some()); } + #[test] + fn background_declared_empty_flags_only_declared_and_unsupplied_names() { + let mut reg = GifRegistry::default(); + reg.backgrounds.insert( + key(DEFAULT_PACK, "gameplay", PadSize::Leds25), + dummy_variants(), + ); + reg.backgrounds + .insert(key("foo", "default", PadSize::Leds25), dummy_variants()); + reg.background_pack_meta.insert( + "foo".to_owned(), + PackMeta { + fallback: PackFallback::Auto, + can_be_empty: ["gameplay".to_owned(), "default".to_owned()] + .into_iter() + .collect(), + match_color_to_difficulty: Default::default(), + merge_common_bpm_variants: Default::default(), + merge_fallback_bpm_variants: Default::default(), + }, + ); + + // Declared and unsupplied: explicitly empty, and background() agrees. + assert!(reg.background_declared_empty(Some("foo"), "gameplay", PadSize::Leds25)); + assert!( + reg.background(Some("foo"), "gameplay", PadSize::Leds25, None) + .is_none() + ); + // Declared but supplied (either size): the pack's own gif wins. + assert!(!reg.background_declared_empty(Some("foo"), "default", PadSize::Leds16)); + // Not declared: missing names still use the regular fallbacks. + assert!(!reg.background_declared_empty(Some("foo"), "song_select", PadSize::Leds25)); + // No pack (or the default pack) has no declarations. + assert!(!reg.background_declared_empty(None, "gameplay", PadSize::Leds25)); + assert!(!reg.background_declared_empty(Some(DEFAULT_PACK), "gameplay", PadSize::Leds25)); + // A pack without a gifpack.ini has none either. + assert!(!reg.background_declared_empty(Some("bar"), "gameplay", PadSize::Leds25)); + } + #[test] fn judgement_falls_back_to_default_pack_even_without_gifpack_ini() { let mut reg = GifRegistry::default(); diff --git a/crates/deadsync-smx/src/lib.rs b/crates/deadsync-smx/src/lib.rs index 55389ff43..d65a9a518 100644 --- a/crates/deadsync-smx/src/lib.rs +++ b/crates/deadsync-smx/src/lib.rs @@ -916,6 +916,15 @@ pub fn reenable_auto_lights() { } } +/// Re-enable one pad slot's built-in automatic lighting, leaving the other pad +/// under whatever lighting currently drives it. Used when a pad has nothing to +/// show but its neighbour is still animating. +pub fn reenable_auto_lights_for_pad(pad: usize) { + if let Some(s) = SHARED.get() { + s.manager.reenable_auto_lights_for_pad(pad); + } +} + /// Player-indicator colours: P1 = blue, P2 = red. Used by the pad-assignment /// screen so the user can see which physical pad is which without reading serials. pub const PLAYER1_LIGHT: [u8; 3] = [0, 80, 255]; diff --git a/crates/deadsync-smx/src/panel_fx.rs b/crates/deadsync-smx/src/panel_fx.rs index a8863207b..313f80c71 100644 --- a/crates/deadsync-smx/src/panel_fx.rs +++ b/crates/deadsync-smx/src/panel_fx.rs @@ -41,6 +41,44 @@ pub struct JudgementGifs { } impl JudgementGifs { + /// Whether any animation at all resolved. `false` means no gameplay event on this + /// pad can ever draw anything (an empty judgement pack), so the pad is left to its + /// firmware rather than being owned and held black for the whole song. + pub fn has_any(&self) -> bool { + let Self { + fantastic_blue, + fantastic_white, + excellent, + great, + decent, + way_off, + miss, + mine, + ok, + bad, + freeze, + roll, + press, + } = self; + [ + fantastic_blue, + fantastic_white, + excellent, + great, + decent, + way_off, + miss, + mine, + ok, + bad, + freeze, + roll, + press, + ] + .iter() + .any(|slot| slot.is_some()) + } + /// Resolve the standard judgement names from a registry through the usual /// pack-then-size fallback. `_25` is the baseline both pad layouts render. pub fn resolve(registry: &GifRegistry, pack: Option<&str>) -> Self { diff --git a/crates/deadsync-smx/src/panels.rs b/crates/deadsync-smx/src/panels.rs index 1d5260ac8..ef9dd6e85 100644 --- a/crates/deadsync-smx/src/panels.rs +++ b/crates/deadsync-smx/src/panels.rs @@ -275,6 +275,23 @@ impl PanelFx { self.backgrounds = std::array::from_fn(|_| None); } + /// Drop one pad slot's per-panel effects, keeping its background. + pub fn clear_panels_for_pad(&mut self, pad: usize) { + if pad < PADS { + self.panels[pad] = std::array::from_fn(|_| PanelState::default()); + } + } + + /// Full reset of one pad slot: its per-panel effects plus its background, + /// leaving the other pad untouched. Used when handing a single pad back to + /// its firmware lighting while its neighbour keeps animating. + pub fn clear_pad(&mut self, pad: usize) { + self.clear_panels_for_pad(pad); + if pad < PADS { + self.backgrounds[pad] = None; + } + } + /// Advance all playback by `dt_s`, rebuild the reused both-pads RGB frame, and return /// it. Every panel is filled, so all 1350 bytes are overwritten and the buffer needs /// no clear. @@ -557,6 +574,96 @@ fn composite_led( /// coalesces to the newest frame, so this only governs how often we rebuild a frame. const FRAME_INTERVAL: Duration = Duration::from_micros(33_333); +/// How often an unchanged frame is re-sent anyway. The pad firmware falls back +/// to its built-in auto lights after `autoLightsTimeout` without receiving a +/// lights command (a pad-config value in 128ms units, default ~896ms), and +/// there is no way to disable that fallback: holding any static frame (a black +/// background between judgement events, a paused animation) requires periodic +/// refreshes. 400ms keeps a wide margin under the default timeout while +/// cutting a static frame's USB traffic from 30 sends/s to 2.5. +const RESEND_INTERVAL: Duration = Duration::from_millis(400); + +/// Whether one pad's wire bytes must go out now: always for the first frame +/// after an (in)activation, on any byte change, or when the unchanged frame +/// has been held for `RESEND_INTERVAL` (the firmware-takeover keepalive). +fn should_send(last: Option<&[u8]>, buf: &[u8], since_last_send: Duration) -> bool { + match last { + Some(last) => last != buf || since_last_send >= RESEND_INTERVAL, + None => true, + } +} + +/// Frame transmitter with per-pad duplicate suppression and the firmware +/// keepalive. Compares the final wire bytes (after the user brightness scale), +/// so a brightness change alone still triggers a send even when the composited +/// frame is identical. +/// +/// Dedup and the keepalive are tracked per pad: a pad holding a static frame +/// keeps its own 400ms refresh cadence regardless of how often its neighbour +/// changes, and a pad the worker doesn't own is never sent to at all, so its +/// firmware auto-lighting resumes. +struct LightsTx { + last: [u8; FRAME_BYTES], + have_last: [bool; PADS], + sent_at: [Instant; PADS], +} + +impl LightsTx { + fn new() -> Self { + Self { + last: [0u8; FRAME_BYTES], + have_last: [false; PADS], + sent_at: [Instant::now(); PADS], + } + } + + /// Forget a pad's last-sent frame so its next send goes out unconditionally. + /// Called on that pad's every active transition: while we didn't own it the + /// pad was showing firmware content, so an identical-looking frame must still + /// be re-sent to take the LEDs back. + fn invalidate_pad(&mut self, pad: usize) { + if pad < PADS { + self.have_last[pad] = false; + } + } + + /// Send the pads named by `owned`; the rest are left to their firmware. + fn send(&mut self, frame: &[u8; FRAME_BYTES], owned: [bool; PADS]) { + let Some(m) = crate::manager() else { return }; + let mut buf = *frame; + // Apply the user brightness as a final per-slot scale. 100/100 is an + // exact identity, so skip the pass on the common full-brightness path. + let brightness = crate::light_brightness(); + if brightness != [100, 100] { + crate::apply_brightness(&mut buf, brightness); + } + let now = Instant::now(); + // Decide per pad, then send once: both pads' commands ride the same + // queued frame, so an owned pad never drifts out of phase with its + // neighbour. A pad we don't own is masked out entirely rather than sent + // black, which is what lets its firmware reclaim the LEDs. + let mut mask = [false; PADS]; + for pad in 0..PADS { + if !owned[pad] { + continue; + } + let range = pad * BYTES_PER_PAD..(pad + 1) * BYTES_PER_PAD; + let last = self.have_last[pad].then_some(&self.last[range.clone()]); + mask[pad] = should_send(last, &buf[range], now.duration_since(self.sent_at[pad])); + } + if !mask.iter().any(|&m| m) { + return; + } + for pad in (0..PADS).filter(|&pad| mask[pad]) { + let range = pad * BYTES_PER_PAD..(pad + 1) * BYTES_PER_PAD; + self.last[range.clone()].copy_from_slice(&buf[range]); + self.have_last[pad] = true; + self.sent_at[pad] = now; + } + m.set_lights_for_pads(&buf, mask); + } +} + /// Messages from the app to the worker. Pad/panel/animation are resolved /// app-side (including registry lookups) so the worker stays free of app policy /// and style knowledge; animations travel as cheap `Arc` handles. @@ -592,9 +699,10 @@ enum Ev { pad: u8, panel: u8, }, - /// Enter (true) or leave (false) active panel effect ownership. Leaving hands the pad - /// back to its firmware idle lighting. - Active(bool), + /// Enter (true) or leave (false) active panel effect ownership of one pad slot. + /// Leaving hands that pad back to its firmware idle lighting, independently of + /// the other pad. + Active { pad: u8, active: bool }, /// Force a pad slot to solid black (true) or restore normal compositing (false). Blackout { pad: u8, @@ -701,10 +809,14 @@ impl SmxPanelLights { }); } - /// Mark whether panel effects are active. On `false` the worker clears the panels and - /// hands the pad back to its firmware idle lighting. - pub fn set_active(&self, active: bool) { - self.send(Ev::Active(active)); + /// Mark whether panel effects are active for one pad slot. On `false` the worker + /// clears that pad and hands it back to its firmware idle lighting, leaving the + /// other pad as it was. + pub fn set_active_for_pad(&self, pad: usize, active: bool) { + self.send(Ev::Active { + pad: pad as u8, + active, + }); } /// Force pad slot `pad` to solid black (`on = true`) or restore normal compositing. @@ -731,7 +843,12 @@ impl SmxPanelLights { impl Drop for SmxPanelLights { fn drop(&mut self) { if let Some(tx) = self.tx.take() { - let _ = tx.send(Ev::Active(false)); + for pad in 0..PADS { + let _ = tx.send(Ev::Active { + pad: pad as u8, + active: false, + }); + } let _ = tx.send(Ev::Shutdown); } if let Some(join) = self.join.take() { @@ -742,13 +859,16 @@ impl Drop for SmxPanelLights { fn run_worker(rx: Receiver) { let mut fx = PanelFx::new(); - let mut active = false; + let mut lights = LightsTx::new(); + let mut active = [false; PADS]; let mut last_tick = Instant::now(); 'outer: loop { - // Active: wake at most one frame out to decay flashes and keep the pad ours. - // Idle: nothing to tick, so block until the next event instead of spinning at 30Hz. - let next = if active { + // Any pad active: wake at most one frame out to decay flashes and keep that pad + // ours. All idle: nothing to tick, so block until the next event instead of + // spinning at 30Hz. + let any_active = active.iter().any(|&a| a); + let next = if any_active { match rx.recv_timeout(FRAME_INTERVAL) { Ok(ev) => Some(ev), Err(RecvTimeoutError::Timeout) => None, @@ -764,39 +884,44 @@ fn run_worker(rx: Receiver) { if let Some(ev) = next { let was_active = active; // Apply this event, then drain any burst queued behind it. - if handle(&mut fx, &mut active, ev) { + if handle(&mut fx, &mut lights, &mut active, ev) { break 'outer; } while let Ok(ev) = rx.try_recv() { - if handle(&mut fx, &mut active, ev) { + if handle(&mut fx, &mut lights, &mut active, ev) { break 'outer; } } - if active && !was_active { + if active.iter().any(|&a| a) && !was_active.iter().any(|&a| a) { // Just woke from the idle block; start the frame clock fresh so the first // tick uses a normal dt instead of the whole idle gap. last_tick = Instant::now(); } } - if active { + if active.iter().any(|&a| a) { let now = Instant::now(); let dt = now.saturating_duration_since(last_tick); if dt >= FRAME_INTERVAL { last_tick = now; - send_lights(fx.tick(dt.as_secs_f32())); + lights.send(fx.tick(dt.as_secs_f32()), active); } } } - // On exit, leave the panels dark and restore the pad firmware idle lighting. + // On exit, leave any pad we still own dark, then restore firmware idle lighting on + // both. A pad already released above is masked out of the send and simply stays with + // its firmware. fx.clear_all(); - send_lights(fx.tick(0.0)); + for pad in 0..PADS { + lights.invalidate_pad(pad); + } + lights.send(fx.tick(0.0), active); reenable_auto(); } /// Apply one event to the effect state. Returns `true` when the worker should stop. -fn handle(fx: &mut PanelFx, active: &mut bool, ev: Ev) -> bool { +fn handle(fx: &mut PanelFx, lights: &mut LightsTx, active: &mut [bool; PADS], ev: Ev) -> bool { match ev { Ev::Background { pad, background } => fx.set_background_for_pad(pad.into(), background), Ev::Beat(beat) => fx.set_beat(beat), @@ -816,18 +941,28 @@ fn handle(fx: &mut PanelFx, active: &mut bool, ev: Ev) -> bool { Ev::PressRelease { pad, panel } => fx.release_press_overlay(pad.into(), panel.into()), Ev::Blackout { pad, on } => fx.set_pad_blackout(pad.into(), on), Ev::ClearPanels => fx.clear_panels(), - Ev::Active(a) => { - *active = a; + Ev::Active { pad, active: a } => { + let pad = usize::from(pad); + if pad >= PADS { + return false; + } + active[pad] = a; + // Either transition means this pad's ownership is changing hands: + // forget its duplicate-suppression state so its next frame goes + // out unconditionally. + lights.invalidate_pad(pad); if a { - // Entering a screen: drop stale per-panel effects but keep any - // background the app set up for it. - fx.clear_panels(); + // Entering a screen: drop this pad's stale per-panel effects but + // keep any background the app set up for it. + fx.clear_panels_for_pad(pad); } else { - // Going idle: drop everything, push one black frame, and hand - // the pad back to firmware. - fx.clear_all(); - send_lights(fx.tick(0.0)); - reenable_auto(); + // Going idle: drop this pad's effects and hand it straight back to + // firmware. Deliberately no black frame: sending one would re-disable + // the auto-lighting we are asking for. The other pad keeps animating, + // and `reenable_auto_lights_for_pad` drops only this pad's share of + // any frame already queued. + fx.clear_pad(pad); + reenable_auto_for_pad(pad); } } Ev::Shutdown => return true, @@ -835,26 +970,15 @@ fn handle(fx: &mut PanelFx, active: &mut bool, ev: Ev) -> bool { false } -fn send_lights(frame: &[u8]) { - let Some(m) = crate::manager() else { return }; - // Apply the user brightness as a final per-slot scale. 100/100 is an exact - // identity, so skip the copy on the common full-brightness path. Otherwise scale - // into a stack buffer (no heap on the 30Hz worker) and send that. - let brightness = crate::light_brightness(); - if brightness == [100, 100] || frame.len() > FRAME_BYTES { - m.set_lights(frame); - return; +fn reenable_auto() { + if let Some(m) = crate::manager() { + m.reenable_auto_lights(); } - let mut buf = [0u8; FRAME_BYTES]; - let n = frame.len(); - buf[..n].copy_from_slice(frame); - crate::apply_brightness(&mut buf[..n], brightness); - m.set_lights(&buf[..n]); } -fn reenable_auto() { +fn reenable_auto_for_pad(pad: usize) { if let Some(m) = crate::manager() { - m.reenable_auto_lights(); + m.reenable_auto_lights_for_pad(pad); } } @@ -951,7 +1075,7 @@ mod tests { // With no SMX manager initialized, set_lights and reenable are no-ops, so this // exercises the channel, thread, event handling, and a clean Drop/join. let lights = SmxPanelLights::new(); - lights.set_active(true); + lights.set_active_for_pad(0, true); lights.set_background_for_pad(0, Some((bg_anim(&[1, 2], 0), Clock::Realtime))); lights.set_beat(1.5); lights.play_overlay( @@ -961,7 +1085,7 @@ mod tests { OverlayDrive::OneShot { pressed: false }, ); lights.release_overlay(0, 3); - lights.set_active(false); + lights.set_active_for_pad(0, false); drop(lights); // joins the worker thread } @@ -1344,6 +1468,43 @@ mod tests { assert_eq!(led0(fx.tick(0.1), 0, 3), 4); } + #[test] + fn clear_pad_drops_only_that_pads_background_and_panels() { + let mut fx = PanelFx::new(); + fx.set_background_for_pad(0, Some((bg_anim(&[10], 0), Clock::Realtime))); + fx.set_background_for_pad(1, Some((bg_anim(&[20], 0), Clock::Realtime))); + fx.play_overlay( + 0, + 1, + panel_anim(&[91], 0), + OverlayDrive::Sustain { resume: false }, + ); + + // Releasing pad 0 to its firmware must not disturb pad 1's animation. + fx.clear_pad(0); + let frame = fx.tick(0.0); + assert_eq!(led0(frame, 0, 1), 0, "released pad keeps nothing"); + assert_eq!(led0(frame, 1, 1), 20, "the other pad keeps its background"); + } + + #[test] + fn clear_panels_for_pad_keeps_that_pads_background_and_the_other_pad() { + let mut fx = PanelFx::new(); + fx.set_background_for_pad(0, Some((bg_anim(&[10], 0), Clock::Realtime))); + fx.set_background_for_pad(1, Some((bg_anim(&[20], 0), Clock::Realtime))); + fx.play_overlay( + 0, + 1, + panel_anim(&[91], 0), + OverlayDrive::Sustain { resume: false }, + ); + + fx.clear_panels_for_pad(0); + let frame = fx.tick(0.0); + assert_eq!(led0(frame, 0, 1), 10, "overlay gone, background stays"); + assert_eq!(led0(frame, 1, 1), 20, "the other pad is untouched"); + } + #[test] fn clear_panels_keeps_the_background_and_clear_all_drops_it() { let mut fx = PanelFx::new(); @@ -1370,6 +1531,32 @@ mod tests { assert!(fx.tick(0.0).iter().all(|&b| b == 0)); } + // Wire-frame duplicate suppression + keepalive + + #[test] + fn should_send_first_frame_changes_and_keepalive() { + let black = [0u8; FRAME_BYTES]; + let mut lit = [0u8; FRAME_BYTES]; + lit[0] = 1; + + // No last frame (fresh activation): always send. + assert!(should_send(None, &black, Duration::ZERO)); + // Unchanged frame inside the keepalive window: suppressed. + assert!(!should_send(Some(&black), &black, Duration::from_millis(100))); + // Any byte change sends immediately. + assert!(should_send(Some(&black), &lit, Duration::ZERO)); + // Unchanged frame held past the keepalive: re-sent so the pad + // firmware's auto-lights timeout never elapses. + assert!(should_send(Some(&black), &black, RESEND_INTERVAL)); + } + + #[test] + fn keepalive_interval_stays_under_the_default_firmware_timeout() { + // SMXConfig.autoLightsTimeout default: 1000/128 units of 128ms. + let default_timeout = Duration::from_millis((1000 / 128) * 128); + assert!(RESEND_INTERVAL * 2 <= default_timeout); + } + #[test] fn overlay_on_out_of_range_panel_is_ignored() { let mut fx = PanelFx::new(); diff --git a/docs/stepmaniax.md b/docs/stepmaniax.md index ed05b41bb..fbcd0eca5 100644 --- a/docs/stepmaniax.md +++ b/docs/stepmaniax.md @@ -669,15 +669,16 @@ assets/ Both trees also ship a `dance/none` pack: an empty pack whose `gifpack.ini` declares `Fallback = "none"`, so every role resolves to nothing. Select it to -turn that group off entirely (backgrounds dark, or judgement effects dark) -without touching the master Panel Lights toggle, and independently per group -and per player. +turn that group off entirely without touching the master Panel Lights toggle, +and independently per group and per player. What "off" looks like on the pad +is controlled by **Idle Pad Lights** (see below): the pads either revert to +their firmware lighting or hold solid black. **Selecting a pack:** the machine defaults live on the StepManiaX options page (**Pad Lights Pack** for backgrounds, **Judgement Pack** for judgement -gifs; the rows appear once Panel Lights is on). Each player can override both -per profile in **Player Options**; a profile with no override follows the -machine default. A pack dropped into `dance/` while the game is running shows +gifs, and **Idle Pad Lights** for what an empty pad shows; the rows appear +once Panel Lights is on). Each player can override both packs per profile in +**Player Options**; a profile with no override follows the machine default. A pack dropped into `dance/` while the game is running shows up in the selectors right away (the list is re-scanned each time), but its GIFs are only decoded at first use after launch, so restart the game to actually see a brand-new pack's animations. @@ -760,11 +761,33 @@ collapse to `common` directly. Every pack automatically falls back to `common` for any role it doesn't supply (steps 5 and 8) — a pack only has to author what it wants to customize. A pack can opt individual roles, or itself entirely, out of this -via `gifpack.ini` (see §11e); an opted-out missing role shows **solid black** -on the pad. This is not the same as the pad's own auto-lights: when Panel -Lights is on the game holds ownership of the LEDs at all times and the -firmware's built-in animations are suppressed. Auto-lights only resume when -Panel Lights is turned off. +via `gifpack.ini` (see §11e). A role the selected pack lists under +`CanBeEmpty` (and doesn't supply) ends the chain at its step outright: no +`Fallback` pack, no `common`, and no `default`-role fallback (steps 6-8 are +skipped) — that role shows no animation at all. + +What a pad shows when nothing resolves at all is controlled by the **Idle Pad +Lights** option on the StepManiaX options page. **Firmware** (the default) hands +an empty pad back to its built-in lighting (the idle and step animations stored +on the pad), while **Black** keeps ownership of the LEDs and holds the pad dark; +panel press animations still play under **Black**, since the game is still +driving the panels. + +The game takes a pad's LEDs only when it has something to draw on them: a +background, or (during gameplay) a judgement pack that can actually fire. So +during a song a pad with an empty background but a real judgement pack is +**solid black** between events, because a judgement can land at any moment, while +a pad with the `none` judgement pack is left to its firmware for the whole song: +nothing would ever draw on it. **Black** overrides this and holds the pad dark +either way. + +Ownership is decided **per pad**. Because each player can pick their own packs, +one pad may resolve a background while the other resolves nothing: the first +keeps animating and the second goes back to its firmware (or holds black), +independently. A pad handed back to its firmware shows the firmware's own step +lighting rather than the game's press animation, since the game no longer drives +it. Frames for the two pads are still sent together, so pads that are both driven +stay in step with each other. The table below lists **role names** (the internal key used for lookup). The corresponding filename is `{role}_{size}.gif` — for grade-tagged roles like @@ -824,6 +847,12 @@ results@F --> results --> default So authoring `results_25@A.gif` covers A+, A, and A- automatically unless you also provide the `+`/`-` variants. +These grade chains are walked with the same `CanBeEmpty` rule as the main +resolution chain: a candidate the selected pack lists under `CanBeEmpty` (and +doesn't supply) stops the walk right there, so e.g. `CanBeEmpty = "results"` +shows nothing for any grade you didn't author a grade-specific gif for, +instead of falling through to `default`. + **Difficulty tagging:** any `results@` role above can also be qualified with the difficulty of the chart that earned the grade: `results@@`, e.g. `results_25@hard@S+.gif`. Difficulty is @@ -924,7 +953,7 @@ are ignored). | Key | Values | Effect | | --- | --- | --- | | `Fallback` | any pack name, or `"none"` | Try the named pack (both LED sizes) before falling back to `common`. `"none"` opts the *whole pack* out of the automatic `common` fallback -- every role/judgement this pack doesn't supply shows nothing rather than pulling from `common`. Omitting the key is the default: no extra pack to try, but the automatic `common` fallback still applies. | -| `CanBeEmpty` | comma-separated list of role or judgement names | These specific names never fall back to anything (not `Fallback`, not `common`) when this pack doesn't supply them -- they show nothing for that name specifically, while every other name still falls back normally. | +| `CanBeEmpty` | comma-separated list of role or judgement names | These specific names never fall back to anything when this pack doesn't supply them -- not `Fallback`, not `common`, and for background roles not the `default`-role fallback either (the resolution chain stops dead at the listed name). They show nothing for that name specifically, while every other name still falls back normally. | | `MatchColorToDifficulty` | comma-separated list of base role names (background packs only, e.g. `"results"`) | Whatever gif actually resolves for that role gets recolored to match the played chart's difficulty color. See "Difficulty color matching" below. | | `MergeCommonBPMVariants` | comma-separated list of base role names (background packs only, e.g. `"song_select"`) | Pool this pack's own BPM-tagged variants for that role with `common`'s, instead of using only this pack's own variants. See "Merging BPM variants across packs" below. | | `MergeFallbackBPMVariants` | comma-separated list of base role names (background packs only) | Same as `MergeCommonBPMVariants`, but pools with the declared `Fallback` pack's variants instead. No-op if this pack has no `Fallback` declared. | @@ -983,7 +1012,10 @@ for the gameplay role and every other role from `cool-pack` (or `common` if events to show no gif at all rather than borrowing `common`'s (or a `Fallback` pack's) version -- for example a minimalist judgement pack that only lights up misses and mines, and wants everything else to stay dark -instead of showing `common`'s style for the rest. +instead of showing `common`'s style for the rest. For background packs the +opt-out is total: a pack that supplies its own `default` but declares +`CanBeEmpty = "gameplay"` gets no background during gameplay at all -- the +`gameplay` role does not slide over to the pack's `default` gif. **Combining GIFs from more than two packs** is not directly supported beyond the automatic `common` step: `Fallback` is a single value pointing to one diff --git a/src/app/mod.rs b/src/app/mod.rs index 830621dd4..8948c15cb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1770,6 +1770,17 @@ impl App { None }; + // Idle-black mode: when the feature is on and the screen would normally + // show a background (role is Some) but nothing resolves for it, keep the + // worker active so the pads hold solid black instead of reverting to the + // pad firmware's built-in lighting. Screens with no role (Init, + // TestLights, pad assignment, the SMX options assignment preview) still + // release the pads: they drive the LEDs themselves. Runs before the + // dedup key check below because the option is not part of the key; the + // driver dedups the value itself. + self.smx_panels + .set_idle_black(role.is_some() && config::get().smx_idle_lights_black); + // A song rescan may have changed per-song/per-pack files; drop the // scoped cache and force a re-resolve (a recycled `Arc` pointer must not // read as the same song). @@ -1854,35 +1865,54 @@ impl App { // the global pack (selected -> basic), then the global `default` // role. `_25` is the baseline both pad layouts render; 16-LED pads // show its outer ring. Each tier picks the BPM-best variant. + // Scoped (song/pack folder) gifs are authored by the song and sit + // above pack policy, so the selected pack's `CanBeEmpty` does not + // affect them. let scoped = song_dir .as_deref() .and_then(|dir| self.resolve_scoped_smx_background(dir, role, song_bpm)); scoped.or_else(|| { - let registry = self.smx_gif_registry(); + let registry = self.smx_gif_registry().clone(); let size = deadsync_smx::gifs::PadSize::Leds25; - let try_role = - |role_str: &str| registry.background(pack_str, role_str, size, song_bpm); - // On results screens, try grade- and difficulty-specific roles - // before the plain role; `results_role_candidates` documents and - // tests the exact order. - let grade_anim = if role == "results" { - eval_grade.and_then(|grade| { - deadsync_smx::panel_fx::results_role_candidates(grade, eval_difficulty) - .iter() - .find_map(|r| try_role(r)) - }) + // Global-registry role candidates, most specific first: on + // results screens the grade- and difficulty-specific roles + // (`results_role_candidates` documents and tests the exact + // order), then the screen role, then the global `default` + // role. A candidate the selected pack declares under + // `CanBeEmpty` (and doesn't supply) ends the chain with no + // animation at all: the pack opted that name out, so a later + // candidate must not resurrect one. + let mut candidates: Vec = if role == "results" { + eval_grade + .map(|grade| { + deadsync_smx::panel_fx::results_role_candidates( + grade, + eval_difficulty, + ) + }) + .unwrap_or_default() } else { - None + Vec::new() }; - let resolved = grade_anim - .or_else(|| registry.background(pack_str, role, size, song_bpm)) - .or_else(|| registry.background(pack_str, "default", size, song_bpm)); - // Only pack-resolved gifs get tinted; a per-song/pack scoped gif - // (the `scoped` branch above, handled outside this closure) is - // fully authored by the song and left as-is. - resolved.map(|anim| { - self.maybe_tint_smx_background(pack_str, role, eval_difficulty, anim) - }) + candidates.push(role.to_owned()); + candidates.push("default".to_owned()); + for name in &candidates { + if registry.background_declared_empty(pack_str, name, size) { + return None; + } + if let Some(anim) = registry.background(pack_str, name, size, song_bpm) { + // Only pack-resolved gifs get tinted; a per-song/pack + // scoped gif (the `scoped` branch above) is fully + // authored by the song and left as-is. + return Some(self.maybe_tint_smx_background( + pack_str, + role, + eval_difficulty, + anim, + )); + } + } + None }) }); let background = anim.map(|anim| { diff --git a/src/screens/options/input.rs b/src/screens/options/input.rs index 21f7c0b93..786797708 100644 --- a/src/screens/options/input.rs +++ b/src/screens/options/input.rs @@ -421,6 +421,11 @@ pub(super) fn apply_submenu_choice_delta( }; config::update_smx_judge_gifs_pack(pack); } + if row.id == SubRowId::SmxIdleLights { + // Index 0 = Firmware (release idle pads to the pad's built-in + // lighting), 1 = Black (keep the LEDs and hold idle pads dark). + config::update_smx_idle_lights_black(new_index == 1); + } if row.id == SubRowId::SmxSinglePadPlayer { // Pin the lone connected pad's serial to the chosen side (index 0 = P1, // 1 = P2). The SDK then relocates it to that slot. Row is only shown diff --git a/src/screens/options/item.rs b/src/screens/options/item.rs index 9ea85e050..206ed4a72 100644 --- a/src/screens/options/item.rs +++ b/src/screens/options/item.rs @@ -75,6 +75,7 @@ pub enum ItemId { InpSmxDefaultLightBrightness, InpSmxBgPack, InpSmxJudgePack, + InpSmxIdleLights, InpSmxAssignPads, InpSmxSwapPads, InpMenuNavigation, diff --git a/src/screens/options/row.rs b/src/screens/options/row.rs index 7497d7656..1afc6cd18 100644 --- a/src/screens/options/row.rs +++ b/src/screens/options/row.rs @@ -64,6 +64,7 @@ pub enum SubRowId { SmxDefaultLightBrightness, SmxBgPack, SmxJudgePack, + SmxIdleLights, SmxAssignPads, SmxSwapPads, MenuNavigation, diff --git a/src/screens/options/state.rs b/src/screens/options/state.rs index 24a2c2e9d..053ae29a2 100644 --- a/src/screens/options/state.rs +++ b/src/screens/options/state.rs @@ -533,7 +533,11 @@ pub fn init() -> State { SubRowId::SmxDefaultPadConfig, cfg.smx_default_pad_config.index(), ); - // Bg/judge pack selections: index 0 = default, 1..N = user packs. + // Bg/judge pack selections: index 0 = default, 1..N = user packs. These rows' + // real choice lists are provided dynamically by the layout (the static row + // definition holds a single placeholder), so write the index directly like the + // noteskin and software-thread rows do; `set_choice_by_id` would clamp it to + // the placeholder length and always show "Default". let bg_pack_idx = if cfg.smx_pad_gifs_pack.is_empty() { 0 } else { @@ -544,12 +548,13 @@ pub fn init() -> State { .map(|i| i + 1) .unwrap_or(0) }; - set_choice_by_id( + if let Some(slot) = get_choice_by_id_mut( &mut state.sub[SubmenuKind::SmxConfig].choice_indices, SMX_CONFIG_OPTIONS_ROWS, SubRowId::SmxBgPack, - bg_pack_idx, - ); + ) { + *slot = bg_pack_idx; + } let judge_pack_idx = if cfg.smx_judge_gifs_pack.is_empty() { 0 } else { @@ -560,11 +565,18 @@ pub fn init() -> State { .map(|i| i + 1) .unwrap_or(0) }; - set_choice_by_id( + if let Some(slot) = get_choice_by_id_mut( &mut state.sub[SubmenuKind::SmxConfig].choice_indices, SMX_CONFIG_OPTIONS_ROWS, SubRowId::SmxJudgePack, - judge_pack_idx, + ) { + *slot = judge_pack_idx; + } + set_choice_by_id( + &mut state.sub[SubmenuKind::SmxConfig].choice_indices, + SMX_CONFIG_OPTIONS_ROWS, + SubRowId::SmxIdleLights, + usize::from(cfg.smx_idle_lights_black), ); // Single-pad P1/P2 picker: reflect the slot the SDK currently has the lone pad // in (slot 1 = P2, index 1; slot 0 = P1, index 0). The slot already accounts for diff --git a/src/screens/options/submenus/input_dev.rs b/src/screens/options/submenus/input_dev.rs index fb7b99c8e..f3b650a83 100644 --- a/src/screens/options/submenus/input_dev.rs +++ b/src/screens/options/submenus/input_dev.rs @@ -102,6 +102,7 @@ pub(in crate::screens::options) const INPUT_OPTIONS_ITEMS: &[Item] = &[ HelpEntry::Bullet(lookup_key("OptionsInput", "Debounce")), HelpEntry::Bullet(lookup_key("OptionsInput", "SmxBgPack")), HelpEntry::Bullet(lookup_key("OptionsInput", "SmxJudgePack")), + HelpEntry::Bullet(lookup_key("OptionsInput", "SmxIdleLights")), ], }, Item { @@ -341,6 +342,18 @@ pub(in crate::screens::options) const SMX_CONFIG_OPTIONS_ROWS: &[SubRow] = &[ choices: &[localized_choice("Common", "Default")], inline: true, }, + SubRow { + // What an idle pad shows when gif lighting has nothing to display for + // the current screen: release it to the pad firmware's built-in + // lighting, or keep the LEDs and hold it black. + id: SubRowId::SmxIdleLights, + label: lookup_key("OptionsInput", "SmxIdleLights"), + choices: &[ + localized_choice("OptionsInput", "SmxIdleLightsFirmware"), + localized_choice("OptionsInput", "SmxIdleLightsBlack"), + ], + inline: true, + }, SubRow { id: SubRowId::SmxAssignPads, label: lookup_key("OptionsInput", "SmxAssignPads"), @@ -436,6 +449,14 @@ pub(in crate::screens::options) const SMX_CONFIG_OPTIONS_ITEMS: &[Item] = &[ "SmxJudgePackHelp", ))], }, + Item { + id: ItemId::InpSmxIdleLights, + name: lookup_key("OptionsInput", "SmxIdleLights"), + help: &[HelpEntry::Paragraph(lookup_key( + "OptionsInputHelp", + "SmxIdleLightsHelp", + ))], + }, Item { id: ItemId::InpSmxAssignPads, name: lookup_key("OptionsInput", "SmxAssignPads"), diff --git a/src/screens/options/tests.rs b/src/screens/options/tests.rs index 89371f04e..7c2279ddb 100644 --- a/src/screens/options/tests.rs +++ b/src/screens/options/tests.rs @@ -139,6 +139,7 @@ fn smx_config_items_match_rows() { ), (SubRowId::SmxBgPack, ItemId::InpSmxBgPack), (SubRowId::SmxJudgePack, ItemId::InpSmxJudgePack), + (SubRowId::SmxIdleLights, ItemId::InpSmxIdleLights), (SubRowId::SmxAssignPads, ItemId::InpSmxAssignPads), (SubRowId::SmxSwapPads, ItemId::InpSmxSwapPads), ]; diff --git a/src/screens/options/visibility.rs b/src/screens/options/visibility.rs index 54fa56eae..1a7ee363e 100644 --- a/src/screens/options/visibility.rs +++ b/src/screens/options/visibility.rs @@ -278,7 +278,11 @@ pub(super) fn submenu_visible_row_indices( SubRowId::SmxUnderglowGrb if !config::get().smx_underglow_theme => None, SubRowId::SmxSinglePadPlayer if pad_count != 1 => None, SubRowId::SmxAssignPads | SubRowId::SmxSwapPads if pad_count != 2 => None, - SubRowId::SmxBgPack | SubRowId::SmxJudgePack if !panel_lights => None, + SubRowId::SmxBgPack | SubRowId::SmxJudgePack | SubRowId::SmxIdleLights + if !panel_lights => + { + None + } _ => Some(idx), }) .collect()