From 8da7b7c3901efaeee1438271081999b0fd7adc7c Mon Sep 17 00:00:00 2001 From: Fernando Chorney Date: Thu, 9 Jul 2026 13:23:58 -0500 Subject: [PATCH 1/7] fix(smx): show the saved gif pack selection on the options screen The SmxBgPack and SmxJudgePack rows hold a single static placeholder choice; the real list (Default plus discovered packs) is provided dynamically by the options layout. The screen-init sync went through set_choice_by_id, which clamps the index to the static choice list length, forcing the displayed selection to 0. The rows therefore always showed Default when re-entering the options screen, even though the saved config and the actual pad lighting kept the chosen pack. Write the index directly via get_choice_by_id_mut instead, matching the noteskin and software-renderer-threads rows that use the same dynamic-choices pattern. --- src/screens/options/state.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/screens/options/state.rs b/src/screens/options/state.rs index 24a2c2e9d..07cecf6df 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,12 +565,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::SmxJudgePack, - judge_pack_idx, - ); + ) { + *slot = judge_pack_idx; + } // 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 // both the saved serial assignment and the hardware jumper, so reading it covers From 417846be115e1e30988fa1b86b458e9502e3e3f9 Mon Sep 17 00:00:00 2001 From: Fernando Chorney Date: Thu, 9 Jul 2026 13:44:40 -0500 Subject: [PATCH 2/7] fix(smx): stop CanBeEmpty roles from falling back to the default role CanBeEmpty exists so a pack can say a role should never have an animation. The registry honored that for the pack-level chain (declared Fallback pack, automatic common fallback), but the app's screen resolution then tried the default role, so a pack that declared CanBeEmpty=gameplay while supplying its own default gif still showed that default gif during gameplay. Walk the role candidates (grade-specific results roles, the screen role, then default) in order and stop dead at any candidate the selected pack declares under CanBeEmpty without supplying: no animation resolves at all. A supplied gif still wins over its own declaration, and per-song / per-pack scoped gifs sit above pack policy and are unaffected. Also aligns the docs with the actual empty behavior: during gameplay the game keeps LED ownership so an empty background is solid black; on other screens an empty pad is handed back to the pad firmware's built-in lighting. --- crates/deadsync-smx/src/gifs.rs | 58 ++++++++++++++++++++++++++++++ docs/stepmaniax.md | 28 +++++++++++---- src/app/mod.rs | 63 +++++++++++++++++++++------------ 3 files changed, 120 insertions(+), 29 deletions(-) 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/docs/stepmaniax.md b/docs/stepmaniax.md index ed05b41bb..ba972c837 100644 --- a/docs/stepmaniax.md +++ b/docs/stepmaniax.md @@ -760,11 +760,16 @@ 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 "no animation" looks like depends on the screen. During gameplay the +game still owns the pad LEDs (judgement effects may fire), so the background +is solid black. On every other screen a pad with nothing to show is handed +back to the firmware, which resumes the pad's own built-in lighting (its +stored idle and step animations) until the game next takes the LEDs. 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 +829,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 +935,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 +994,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..d98c102d8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1854,35 +1854,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| { From fcc7a589ecacdf6f9bb08475fd4b10cc4b2d8491 Mon Sep 17 00:00:00 2001 From: Fernando Chorney Date: Thu, 9 Jul 2026 14:16:03 -0500 Subject: [PATCH 3/7] perf(smx): suppress duplicate light frames with a firmware keepalive The lights worker sent the full 1350-byte frame to the SDK every 33ms tick even when nothing changed, so a static background (or an all-black one) streamed at 30Hz for no visual benefit. Compare the final wire bytes (after the user brightness scale, so a brightness change alone still sends) and skip identical frames. Identical frames are still re-sent every 400ms: 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 that fallback cannot be disabled, so holding a static frame requires periodic refreshes. The suppression state resets on every active transition, since an identical-looking frame must still be re-sent to take the LEDs back from firmware content. --- crates/deadsync-smx/src/panels.rs | 132 ++++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 23 deletions(-) diff --git a/crates/deadsync-smx/src/panels.rs b/crates/deadsync-smx/src/panels.rs index 1d5260ac8..bedd41330 100644 --- a/crates/deadsync-smx/src/panels.rs +++ b/crates/deadsync-smx/src/panels.rs @@ -557,6 +557,77 @@ 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 a built wire frame 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; FRAME_BYTES]>, + buf: &[u8; FRAME_BYTES], + since_last_send: Duration, +) -> bool { + match last { + Some(last) => last != buf || since_last_send >= RESEND_INTERVAL, + None => true, + } +} + +/// Frame transmitter with 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. +struct LightsTx { + last: [u8; FRAME_BYTES], + have_last: bool, + sent_at: Instant, +} + +impl LightsTx { + fn new() -> Self { + Self { + last: [0u8; FRAME_BYTES], + have_last: false, + sent_at: Instant::now(), + } + } + + /// Forget the last-sent frame so the next send goes out unconditionally. + /// Called on every active transition: while we were inactive the pad was + /// showing firmware content, so an identical-looking frame must still be + /// re-sent to take the LEDs back. + fn invalidate(&mut self) { + self.have_last = false; + } + + fn send(&mut self, frame: &[u8; FRAME_BYTES]) { + 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(); + let last = self.have_last.then_some(&self.last); + if !should_send(last, &buf, now.duration_since(self.sent_at)) { + return; + } + self.last = buf; + self.have_last = true; + self.sent_at = now; + m.set_lights(&self.last); + } +} + /// 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. @@ -742,6 +813,7 @@ impl Drop for SmxPanelLights { fn run_worker(rx: Receiver) { let mut fx = PanelFx::new(); + let mut lights = LightsTx::new(); let mut active = false; let mut last_tick = Instant::now(); @@ -764,11 +836,11 @@ 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; } } @@ -784,19 +856,20 @@ fn run_worker(rx: Receiver) { 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())); } } } // On exit, leave the panels dark and restore the pad firmware idle lighting. fx.clear_all(); - send_lights(fx.tick(0.0)); + lights.invalidate(); + lights.send(fx.tick(0.0)); 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, ev: Ev) -> bool { match ev { Ev::Background { pad, background } => fx.set_background_for_pad(pad.into(), background), Ev::Beat(beat) => fx.set_beat(beat), @@ -818,6 +891,10 @@ fn handle(fx: &mut PanelFx, active: &mut bool, ev: Ev) -> bool { Ev::ClearPanels => fx.clear_panels(), Ev::Active(a) => { *active = a; + // Either transition means the pad's ownership is changing hands: + // forget the duplicate-suppression state so the next frame goes + // out unconditionally. + lights.invalidate(); if a { // Entering a screen: drop stale per-panel effects but keep any // background the app set up for it. @@ -826,7 +903,7 @@ fn handle(fx: &mut PanelFx, active: &mut bool, ev: Ev) -> bool { // Going idle: drop everything, push one black frame, and hand // the pad back to firmware. fx.clear_all(); - send_lights(fx.tick(0.0)); + lights.send(fx.tick(0.0)); reenable_auto(); } } @@ -835,23 +912,6 @@ 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; - } - 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() { if let Some(m) = crate::manager() { m.reenable_auto_lights(); @@ -1370,6 +1430,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(); From e4c924f8327ec9a5859debff6efc97ff6dd9b6bf Mon Sep 17 00:00:00 2001 From: Fernando Chorney Date: Thu, 9 Jul 2026 18:36:41 -0500 Subject: [PATCH 4/7] feat(smx): idle pad lights option, firmware or black When pad gif lighting has nothing to display for the current screen (the none pack, or a role a pack leaves empty), the pads reverted to the firmware's built-in lighting. Some setups want them dark instead. Add a machine option to the StepManiaX options page, Idle Pad Lights: Firmware (default, current behavior) releases idle pads to the pad's stored idle and step animations; Black keeps the lights worker active so the pads hold solid black, with panel press animations still playing on every screen. Screens that drive the pad LEDs themselves (Init, Test Lights, pad assignment, the SMX options assignment preview) always release the pads regardless of the option. Holding black costs almost nothing on the wire: the worker's duplicate suppression drops the static frame to the 400ms firmware keepalive. Persisted as SmxIdleLightsBlack in deadsync.ini with load/save round-trip coverage; docs describe the empty-pad behavior per mode and correct the old claim that an empty role always showed solid black. --- assets/languages/en.ini | 6 +++- assets/languages/pseudo.ini | 4 +++ crates/deadsync-config/src/app_config.rs | 2 ++ crates/deadsync-config/src/app_update.rs | 4 +++ crates/deadsync-config/src/defaults.rs | 1 + crates/deadsync-config/src/load.rs | 1 + crates/deadsync-config/src/options.rs | 22 +++++++++++++- crates/deadsync-config/src/runtime_state.rs | 4 +++ crates/deadsync-config/src/runtime_update.rs | 1 + crates/deadsync-config/src/save.rs | 1 + crates/deadsync-shell/src/lighting.rs | 4 +++ crates/deadsync-smx/src/gameplay_driver.rs | 31 ++++++++++++++++---- docs/stepmaniax.md | 26 +++++++++------- src/app/mod.rs | 11 +++++++ src/screens/options/input.rs | 5 ++++ src/screens/options/item.rs | 1 + src/screens/options/row.rs | 1 + src/screens/options/state.rs | 6 ++++ src/screens/options/submenus/input_dev.rs | 21 +++++++++++++ src/screens/options/tests.rs | 1 + src/screens/options/visibility.rs | 6 +++- 21 files changed, 140 insertions(+), 19 deletions(-) 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/src/gameplay_driver.rs b/crates/deadsync-smx/src/gameplay_driver.rs index e9fcd7cb4..6f5b65374 100644 --- a/crates/deadsync-smx/src/gameplay_driver.rs +++ b/crates/deadsync-smx/src/gameplay_driver.rs @@ -40,6 +40,10 @@ pub struct SmxPanelDriver { gameplay_active: bool, /// The worker owns the pad lights (gameplay effects or a background). worker_active: bool, + /// 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. @@ -64,6 +68,7 @@ impl Default for SmxPanelDriver { lights: SmxPanelLights::new(), gameplay_active: false, worker_active: false, + idle_black: false, backgrounds: std::array::from_fn(|_| None), judgement_gifs: std::array::from_fn(|_| JudgementGifs::default()), notes_ptr: 0, @@ -291,6 +296,18 @@ 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. @@ -346,12 +363,16 @@ impl SmxPanelDriver { self.lights.set_active(true); } - /// 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 the worker from the gameplay, background, and idle-black + /// 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. With `idle_black` set the worker stays + /// active regardless, holding the pads black (the keepalive in the worker makes a + /// static black frame nearly free on the wire). fn sync_worker(&mut self) { - let want = self.gameplay_active || self.backgrounds.iter().any(|b| b.is_some()); + let want = self.gameplay_active + || self.idle_black + || self.backgrounds.iter().any(|b| b.is_some()); if want != self.worker_active { self.worker_active = want; self.lights.set_active(want); diff --git a/docs/stepmaniax.md b/docs/stepmaniax.md index ba972c837..5b10a30c3 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. @@ -765,11 +766,14 @@ via `gifpack.ini` (see §11e). A role the selected pack lists under `Fallback` pack, no `common`, and no `default`-role fallback (steps 6-8 are skipped) — that role shows no animation at all. -What "no animation" looks like depends on the screen. During gameplay the -game still owns the pad LEDs (judgement effects may fire), so the background -is solid black. On every other screen a pad with nothing to show is handed -back to the firmware, which resumes the pad's own built-in lighting (its -stored idle and step animations) until the game next takes the LEDs. +What a pad shows when nothing resolves at all is controlled by the **Idle Pad +Lights** option on the StepManiaX options page. During gameplay the game +always owns the LEDs (judgement effects can fire at any moment), so an empty +background is **solid black** there regardless of the option. On every other +screen, **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 pads dark; panel press animations +still play, since the game is still driving the panels. The table below lists **role names** (the internal key used for lookup). The corresponding filename is `{role}_{size}.gif` — for grade-tagged roles like diff --git a/src/app/mod.rs b/src/app/mod.rs index d98c102d8..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). 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 07cecf6df..053ae29a2 100644 --- a/src/screens/options/state.rs +++ b/src/screens/options/state.rs @@ -572,6 +572,12 @@ pub fn init() -> State { ) { *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 // both the saved serial assignment and the hardware jumper, so reading it covers 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() From 9cab5915a3834a908a5b9fd2b4dbcae1ceed0abc Mon Sep 17 00:00:00 2001 From: Fernando Chorney Date: Fri, 10 Jul 2026 08:57:07 -0500 Subject: [PATCH 5/7] fix(smx): own pad lights per pad, not globally The panel worker held one global `active` flag and sent one frame covering both pads. Since each player picks their own gif packs, one pad routinely resolves a background while the other resolves nothing: the worker went active for the first and then streamed the second a black frame plus the 400ms keepalive, so a pad set to the `none` pack never returned to its firmware lighting and still played press gifs. Idle Pad Lights: Firmware could not take effect on either pad unless both were empty. Decide ownership per pad: - `worker_active` and the worker's `active` become `[bool; PADS]`, with `want[pad] = gameplay_active || idle_black || backgrounds[pad]`. - `Ev::Active` carries a pad. Releasing one clears only that pad (`clear_pad`) and calls `reenable_auto_lights_for_pad`; the other pad keeps animating. - `LightsTx` dedups and keepalives per pad, and masks unowned pads out of the send, so nothing reaches a released pad and its firmware resumes. - `on_raw_panel` gates on that pad's own ownership, so a released pad shows the firmware's step lighting rather than our press gif. Gameplay still claims both pads unconditionally: a judgement effect can fire on either at any moment. Both pads' commands still ride one queued frame, so driven pads stay frame-synchronized and releasing one neither stalls nor skips the other's animation. Releasing a pad deliberately sends no black frame. A frame sent after the `S 1` command would re-disable the auto-lighting it just asked for. Needs rustmaniax-sdk 2.1.0 for `set_lights_for_pads` and `reenable_auto_lights_for_pad`. --- Cargo.lock | 4 +- crates/deadsync-smx/Cargo.toml | 2 +- crates/deadsync-smx/src/gameplay_driver.rs | 50 +++-- crates/deadsync-smx/src/lib.rs | 9 + crates/deadsync-smx/src/panels.rs | 221 +++++++++++++++------ docs/stepmaniax.md | 8 + 6 files changed, 212 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d99ea20b..7ae31c135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4543,9 +4543,9 @@ dependencies = [ [[package]] name = "rustmaniax-sdk" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d1c5f0deea652893532c0d054f88691b4da86200973921f94a5dfbc70d27915" +checksum = "2ee5287f39062f8b415f3d72750712e4e3641d8216fd9b1ac8186a850d113c5c" dependencies = [ "bitflags 2.13.0", "bytemuck", diff --git a/crates/deadsync-smx/Cargo.toml b/crates/deadsync-smx/Cargo.toml index a2d1d97a3..62aa010e1 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.0" [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 6f5b65374..a322732a3 100644 --- a/crates/deadsync-smx/src/gameplay_driver.rs +++ b/crates/deadsync-smx/src/gameplay_driver.rs @@ -38,8 +38,10 @@ 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. @@ -67,7 +69,7 @@ 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()), @@ -312,7 +314,10 @@ impl SmxPanelDriver { /// 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 { @@ -358,24 +363,31 @@ impl SmxPanelDriver { 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); + // when the worker was already running for a background. Gameplay owns both pads + // unconditionally, since a judgement effect can fire on either at any moment. + for pad in 0..PADS { + self.worker_active[pad] = true; + self.lights.set_active_for_pad(pad, true); + } } - /// Activate or release the worker from the gameplay, background, and idle-black - /// 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. With `idle_black` set the worker stays - /// active regardless, holding the pads black (the keepalive in the worker makes a - /// static black frame nearly free on the wire). + /// 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. Gameplay is the exception and claims both. fn sync_worker(&mut self) { - let want = self.gameplay_active - || self.idle_black - || 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.gameplay_active || self.idle_black || self.backgrounds[pad].is_some(); + if want != self.worker_active[pad] { + self.worker_active[pad] = want; + self.lights.set_active_for_pad(pad, want); + } } } } 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/panels.rs b/crates/deadsync-smx/src/panels.rs index bedd41330..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. @@ -566,48 +583,52 @@ const FRAME_INTERVAL: Duration = Duration::from_micros(33_333); /// cutting a static frame's USB traffic from 30 sends/s to 2.5. const RESEND_INTERVAL: Duration = Duration::from_millis(400); -/// Whether a built wire frame must go out now: always for the first frame +/// 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; FRAME_BYTES]>, - buf: &[u8; FRAME_BYTES], - since_last_send: Duration, -) -> bool { +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 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 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, - sent_at: Instant, + have_last: [bool; PADS], + sent_at: [Instant; PADS], } impl LightsTx { fn new() -> Self { Self { last: [0u8; FRAME_BYTES], - have_last: false, - sent_at: Instant::now(), + have_last: [false; PADS], + sent_at: [Instant::now(); PADS], } } - /// Forget the last-sent frame so the next send goes out unconditionally. - /// Called on every active transition: while we were inactive the pad was - /// showing firmware content, so an identical-looking frame must still be - /// re-sent to take the LEDs back. - fn invalidate(&mut self) { - self.have_last = false; + /// 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; + } } - fn send(&mut self, frame: &[u8; FRAME_BYTES]) { + /// 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 @@ -617,14 +638,29 @@ impl LightsTx { crate::apply_brightness(&mut buf, brightness); } let now = Instant::now(); - let last = self.have_last.then_some(&self.last); - if !should_send(last, &buf, now.duration_since(self.sent_at)) { + // 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; } - self.last = buf; - self.have_last = true; - self.sent_at = now; - m.set_lights(&self.last); + 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); } } @@ -663,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, @@ -772,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. @@ -802,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() { @@ -814,13 +860,15 @@ impl Drop for SmxPanelLights { fn run_worker(rx: Receiver) { let mut fx = PanelFx::new(); let mut lights = LightsTx::new(); - let mut active = false; + 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, @@ -844,32 +892,36 @@ fn run_worker(rx: Receiver) { 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; - lights.send(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(); - lights.invalidate(); - lights.send(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, lights: &mut LightsTx, 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), @@ -889,22 +941,28 @@ fn handle(fx: &mut PanelFx, lights: &mut LightsTx, active: &mut bool, ev: Ev) -> 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; - // Either transition means the pad's ownership is changing hands: - // forget the duplicate-suppression state so the next frame goes + 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(); + 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(); - lights.send(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, @@ -918,6 +976,12 @@ fn reenable_auto() { } } +fn reenable_auto_for_pad(pad: usize) { + if let Some(m) = crate::manager() { + m.reenable_auto_lights_for_pad(pad); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1011,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( @@ -1021,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 } @@ -1404,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(); diff --git a/docs/stepmaniax.md b/docs/stepmaniax.md index 5b10a30c3..0c5f611c2 100644 --- a/docs/stepmaniax.md +++ b/docs/stepmaniax.md @@ -775,6 +775,14 @@ lighting — the idle and step animations stored on the pad — while **Black** keeps ownership of the LEDs and holds the pads dark; panel press animations still play, since the game is still driving the panels. +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 `results@S+` the `@` suffix goes after the `_size` in the filename: From 930b54175450667120db3bda142c37655dc52362 Mon Sep 17 00:00:00 2001 From: Fernando Chorney Date: Fri, 10 Jul 2026 09:22:19 -0500 Subject: [PATCH 6/7] fix(smx): don't claim a pad gameplay can never draw on Gameplay claimed both pads unconditionally, on the reasoning that a judgement effect can fire on either at any moment. That only holds if the pad has judgement gifs. With the judge pack set to `none` nothing can ever fire, so the pad was owned, composited to solid black, and streamed with the keepalive for the whole song: a dead black pad, and no firmware step lighting, even with Idle Pad Lights on Firmware. Menus released the pad correctly, so the behaviour changed on entering gameplay. Own a pad only when it has something to draw: want[pad] = idle_black || backgrounds[pad].is_some() || (gameplay_active && judgement_gifs[pad].has_any()) Factored into `wants_pad` so `activate` and `sync_worker` cannot disagree, and `set_judgement_gifs_for_pad` now re-syncs, since a pack change can claim or release a pad. `has_any` destructures `JudgementGifs`, so a new animation slot is a compile error rather than a silently missed one. Black still overrides and holds an empty pad dark. A pad with a real judgement pack is still claimed for the whole song and shows black between events. --- crates/deadsync-smx/src/gameplay_driver.rs | 83 +++++++++++++++++++--- crates/deadsync-smx/src/panel_fx.rs | 38 ++++++++++ docs/stepmaniax.md | 20 ++++-- 3 files changed, 126 insertions(+), 15 deletions(-) diff --git a/crates/deadsync-smx/src/gameplay_driver.rs b/crates/deadsync-smx/src/gameplay_driver.rs index a322732a3..fd43fb9a9 100644 --- a/crates/deadsync-smx/src/gameplay_driver.rs +++ b/crates/deadsync-smx/src/gameplay_driver.rs @@ -289,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(); } } @@ -362,13 +365,28 @@ 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. Gameplay owns both pads - // unconditionally, since a judgement effect can fire on either at any moment. + // 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 { - self.worker_active[pad] = true; - self.lights.set_active_for_pad(pad, true); + 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 each pad from the gameplay, background, and idle-black states. @@ -379,11 +397,10 @@ impl SmxPanelDriver { /// 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. Gameplay is the exception and claims both. + /// other pad animates a background. fn sync_worker(&mut self) { for pad in 0..PADS { - let want = - self.gameplay_active || self.idle_black || self.backgrounds[pad].is_some(); + 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); @@ -391,3 +408,53 @@ impl SmxPanelDriver { } } } + +#[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/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/docs/stepmaniax.md b/docs/stepmaniax.md index 0c5f611c2..fbcd0eca5 100644 --- a/docs/stepmaniax.md +++ b/docs/stepmaniax.md @@ -767,13 +767,19 @@ via `gifpack.ini` (see §11e). A role the selected pack lists under 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. During gameplay the game -always owns the LEDs (judgement effects can fire at any moment), so an empty -background is **solid black** there regardless of the option. On every other -screen, **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 pads dark; panel press animations -still play, since the game is still driving the panels. +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 From d0aff6796f0fa27f742edb7bf8c35e57e80dd4d7 Mon Sep 17 00:00:00 2001 From: Fernando Chorney Date: Fri, 10 Jul 2026 15:54:17 -0500 Subject: [PATCH 7/7] chore(smx): bump rustmaniax-sdk to 2.1.1 Picks up the fix for a race in ReenableAutoLightsForPad: an already-queued lights frame could land after re-enabling auto lights for a pad and immediately re-disable it. See fchorney/rustmaniax-sdk#13. --- Cargo.lock | 4 ++-- crates/deadsync-smx/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ae31c135..e00c0124c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4543,9 +4543,9 @@ dependencies = [ [[package]] name = "rustmaniax-sdk" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee5287f39062f8b415f3d72750712e4e3641d8216fd9b1ac8186a850d113c5c" +checksum = "bd9a39764dfdaf9155648cf9d4edbc401a6f2be24bfb3c63159a7cbe9e9d4be7" dependencies = [ "bitflags 2.13.0", "bytemuck", diff --git a/crates/deadsync-smx/Cargo.toml b/crates/deadsync-smx/Cargo.toml index 62aa010e1..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.1.0" +rustmaniax-sdk = "2.1.1" [lints.clippy] perf = { level = "warn", priority = -1 }