From 856f65b5b58aaa0e6b56bf2c2257f468a33b89af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Tue, 29 Sep 2026 22:00:54 +0200 Subject: [PATCH] Tighten hold buffer lifetime semantics --- src/input/buffer.rs | 240 +++++++++++++++++++++++++++++++++----------- 1 file changed, 179 insertions(+), 61 deletions(-) diff --git a/src/input/buffer.rs b/src/input/buffer.rs index 41ee2f2..431586f 100644 --- a/src/input/buffer.rs +++ b/src/input/buffer.rs @@ -101,8 +101,7 @@ impl FighterAttackCommandType { struct CommandSlot { lifetime: u8, age: u16, - hold_remaining: u16, - hold_latched: bool, + hold_remaining: HoldLimit, consumed: bool, } @@ -111,24 +110,45 @@ impl Default for CommandSlot { Self { lifetime: 0, age: 0, - hold_remaining: 0, - hold_latched: false, + hold_remaining: HoldLimit::Disabled, consumed: false, } } } -#[derive(Clone, Copy, Debug, Hash)] -struct HoldLimit { - enabled: bool, - max_frames: u16, +#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] +enum HoldLimit { + #[default] + Disabled, + Frames(u16), + Infinite, } -impl Default for HoldLimit { - fn default() -> Self { - Self { - enabled: false, - max_frames: 0, +#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] +enum HoldOverride { + #[default] + Inherit, + Disabled, + Frames(u16), + Infinite, +} + +impl HoldOverride { + fn from_max_frames(max_frames: u16) -> Self { + if max_frames == 0 { + Self::Infinite + } else { + Self::Frames(max_frames) + } + } +} + +impl HoldLimit { + fn from_max_frames(max_frames: u16) -> Self { + if max_frames == 0 { + Self::Infinite + } else { + Self::Frames(max_frames) } } } @@ -181,7 +201,7 @@ impl ButtonHistory { #[require(Rollback)] pub struct FighterInput { command_slots: [CommandSlot; FighterCommands::COUNT], - hold_limits: [HoldLimit; FighterCommands::COUNT], + hold_limits: [HoldOverride; FighterCommands::COUNT], hold_all: HoldLimit, button_history: [ButtonHistory; 3], prev_frame: FighterInputFrame, @@ -195,7 +215,7 @@ impl Default for FighterInput { fn default() -> Self { Self { command_slots: [CommandSlot::default(); FighterCommands::COUNT], - hold_limits: [HoldLimit::default(); FighterCommands::COUNT], + hold_limits: [HoldOverride::default(); FighterCommands::COUNT], hold_all: HoldLimit::default(), button_history: [ButtonHistory::default(); 3], prev_frame: FighterInputFrame::default(), @@ -224,8 +244,7 @@ impl FighterInput { pub fn clear_command(&mut self, command: FighterCommands) { let slot = &mut self.command_slots[command.index()]; slot.lifetime = 0; - slot.hold_remaining = 0; - slot.hold_latched = false; + slot.hold_remaining = HoldLimit::Disabled; slot.consumed = true; } @@ -242,8 +261,7 @@ impl FighterInput { let slot = &mut self.command_slots[command.index()]; slot.lifetime = lifetime; slot.age = 0; - slot.hold_remaining = 0; - slot.hold_latched = false; + slot.hold_remaining = HoldLimit::Disabled; slot.consumed = false; self.latch_if_hold_enabled(command); } @@ -259,39 +277,65 @@ impl FighterInput { } pub fn enable_hold_all(&mut self, max_frames: u16) { - self.hold_all = HoldLimit { - enabled: true, - max_frames: if max_frames == 0 { u16::MAX } else { max_frames }, - }; + self.hold_all = HoldLimit::from_max_frames(max_frames); self.latch_active_commands(); } + /// Stops enrolling commands in the global hold buffer. Commands already + /// latched retain their remaining lifetime so they can cross a state + /// boundary, such as jump squat into the first airborne frame. pub fn disable_hold_all(&mut self) { - self.hold_all = HoldLimit::default(); + self.hold_all = HoldLimit::Disabled; + } + + /// Stops global enrollment and immediately cancels every latched command. + pub fn cancel_hold_all(&mut self) { + self.disable_hold_all(); + for slot in &mut self.command_slots { + slot.hold_remaining = HoldLimit::Disabled; + } } pub fn enable_hold(&mut self, command: FighterCommands, max_frames: u16) { - self.hold_limits[command.index()] = HoldLimit { - enabled: true, - max_frames: if max_frames == 0 { u16::MAX } else { max_frames }, - }; + self.hold_limits[command.index()] = HoldOverride::from_max_frames(max_frames); if self.has_command(command) { self.latch_if_hold_enabled(command); } } + /// Explicitly prevents this command from using either command-specific or + /// global hold buffering, while preserving an existing latch. pub fn disable_hold(&mut self, command: FighterCommands) { - self.hold_limits[command.index()] = HoldLimit::default(); + self.hold_limits[command.index()] = HoldOverride::Disabled; + } + + /// Removes the command-specific policy so this command inherits the + /// global policy again. + pub fn reset_hold_override(&mut self, command: FighterCommands) { + self.hold_limits[command.index()] = HoldOverride::Inherit; + } + + /// Stops enrollment and immediately cancels this command's hold lifetime. + pub fn cancel_hold(&mut self, command: FighterCommands) { + self.disable_hold(command); + self.command_slots[command.index()].hold_remaining = HoldLimit::Disabled; } fn latch_if_hold_enabled(&mut self, command: FighterCommands) { let limit = self.hold_limit(command); - if !limit.enabled { - return; - } let slot = &mut self.command_slots[command.index()]; - slot.hold_latched = true; - slot.hold_remaining = limit.max_frames; + slot.hold_remaining = match limit { + HoldLimit::Disabled => return, + HoldLimit::Infinite => HoldLimit::Infinite, + // The limit includes tap-buffer frames already elapsed. + HoldLimit::Frames(max_frames) => { + let remaining = max_frames.saturating_sub(slot.age); + if remaining == 0 { + return; + } + HoldLimit::Frames(remaining) + } + }; } fn latch_active_commands(&mut self) { @@ -341,10 +385,11 @@ impl FighterInput { fn hold_limit(&self, command: FighterCommands) -> HoldLimit { let specific = self.hold_limits[command.index()]; - if specific.enabled { - specific - } else { - self.hold_all + match specific { + HoldOverride::Inherit => self.hold_all, + HoldOverride::Disabled => HoldLimit::Disabled, + HoldOverride::Frames(frames) => HoldLimit::Frames(frames), + HoldOverride::Infinite => HoldLimit::Infinite, } } @@ -352,15 +397,27 @@ impl FighterInput { let index = command.index(); let slot = &mut self.command_slots[index]; slot.age = slot.age.saturating_add(1); - if slot.hold_latched && slot.hold_remaining != 0 { - if slot.hold_remaining != u16::MAX { - slot.hold_remaining = slot.hold_remaining.saturating_sub(1); + match slot.hold_remaining { + HoldLimit::Disabled => {} + HoldLimit::Infinite => { + if slot.lifetime == 0 { + slot.lifetime = 1; + } } - if slot.lifetime == 0 { - slot.lifetime = 1; + HoldLimit::Frames(remaining) => { + // This tick consumes one of the total persistent frames, but + // the command remains active for the tick that consumes the + // final frame. + let remaining = remaining.saturating_sub(1); + slot.hold_remaining = if remaining == 0 { + HoldLimit::Disabled + } else { + HoldLimit::Frames(remaining) + }; + if slot.lifetime == 0 { + slot.lifetime = 1; + } } - } else if slot.hold_latched { - slot.hold_latched = false; } } } @@ -496,16 +553,11 @@ pub fn postprocess_input( input.set_attack_command_type(attack_type); } - // Extend only commands whose source is still held. Recognition above - // has already reset age and cleared the consumed marker for new edges. - input.update_hold(FighterCommands::Jump); - input.update_hold(FighterCommands::Shield); - input.update_hold(FighterCommands::GuardHold); - input.update_hold(FighterCommands::Attack); - input.update_hold(FighterCommands::SmashMoveUp); - input.update_hold(FighterCommands::SmashMoveDown); - input.update_hold(FighterCommands::SmashMoveLeft); - input.update_hold(FighterCommands::SmashMoveRight); + // Advance each command's independently latched persistence. Physical + // source state is intentionally irrelevant after recognition. + for command in FighterCommands::iter() { + input.update_hold(command); + } } } @@ -514,6 +566,11 @@ mod tests { use super::*; use crate::input::ActionBindingMask; + fn advance_command(input: &mut FighterInput, command: FighterCommands) { + input.reduce_command_lifetime(command); + input.update_hold(command); + } + #[test] fn second_binding_is_an_edge_not_a_level() { let first = ActionBindingMask(0b0001); @@ -556,19 +613,80 @@ mod tests { fn hold_buffer_keeps_released_attack_through_jump_squat() { let mut input = FighterInput::default(); input.set_lifetime(FighterCommands::Attack, 3); + // Recognition happened before jump squat enabled persistence. + input.update_hold(FighterCommands::Attack); input.enable_hold_all(10); - // The command remains latched even though its physical source is no - // longer held. Five simulation ticks cover the test fighter's squat. + // Five simulation ticks cover the test fighter's jump squat. The + // physical attack binding is released, but persistence belongs to the + // recognized command rather than the binding's held state. for _ in 0..5 { - for command in FighterCommands::iter() { - input.reduce_command_lifetime(command); - } - input.update_hold(FighterCommands::Attack); + advance_command(&mut input, FighterCommands::Attack); } assert!(input.has_command(FighterCommands::Attack)); + // Jump-squat exit stops enrollment. The latch crosses into Jump and + // remains visible on its first airborne interrupt frame. input.disable_hold_all(); + advance_command(&mut input, FighterCommands::Attack); + assert!(input.has_command(FighterCommands::Attack)); + assert!(input.consume_command(FighterCommands::Attack)); + assert!(!input.has_command(FighterCommands::Attack)); + } + + #[test] + fn finite_hold_limit_includes_elapsed_tap_frames() { + let mut input = FighterInput::default(); + input.set_lifetime(FighterCommands::Attack, 3); + // The recognition frame has already elapsed when jump squat starts. + input.update_hold(FighterCommands::Attack); + input.enable_hold_all(10); + + for _ in 0..9 { + advance_command(&mut input, FighterCommands::Attack); + assert!(input.has_command(FighterCommands::Attack)); + } + advance_command(&mut input, FighterCommands::Attack); + assert!(!input.has_command(FighterCommands::Attack)); + } + + #[test] + fn stopping_enrollment_and_cancelling_latches_are_distinct() { + let mut input = FighterInput::default(); + input.enable_hold_all(10); + input.set_lifetime(FighterCommands::Shield, 1); + input.disable_hold_all(); + advance_command(&mut input, FighterCommands::Shield); + assert!(input.has_command(FighterCommands::Shield)); + + input.cancel_hold_all(); + advance_command(&mut input, FighterCommands::Shield); + assert!(!input.has_command(FighterCommands::Shield)); + } + + #[test] + fn command_override_can_disable_global_hold() { + let mut input = FighterInput::default(); + input.enable_hold_all(10); + input.disable_hold(FighterCommands::Attack); + input.set_lifetime(FighterCommands::Attack, 1); + advance_command(&mut input, FighterCommands::Attack); + assert!(!input.has_command(FighterCommands::Attack)); + + input.reset_hold_override(FighterCommands::Attack); + input.set_lifetime(FighterCommands::Attack, 1); + advance_command(&mut input, FighterCommands::Attack); assert!(input.has_command(FighterCommands::Attack)); } + + #[test] + fn infinite_hold_uses_an_explicit_state() { + let mut input = FighterInput::default(); + input.enable_hold(FighterCommands::Shield, 0); + input.set_lifetime(FighterCommands::Shield, 1); + for _ in 0..u16::MAX as usize + 1 { + advance_command(&mut input, FighterCommands::Shield); + } + assert!(input.has_command(FighterCommands::Shield)); + } }