From 55566fd8ef1d124818b5002e8b53c76bfe2cc0ee Mon Sep 17 00:00:00 2001 From: SleepyDebug <171101568+SleepyDebug@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:17:08 -0400 Subject: [PATCH] Release SleepStrap 6.9.1 --- README.md | 6 +- SleepStrap/Models/Persistable/Settings.cs | 6 + SleepStrap/Services/ReplayBufferService.cs | 17 +- SleepStrap/SleepStrap.csproj | 8 +- .../Settings/Pages/ClippingPage.xaml.cs | 3 +- .../UI/Elements/Settings/Pages/MacroPage.xaml | 41 +++- .../Elements/Settings/Pages/MacroPage.xaml.cs | 92 ++++++- .../UI/Elements/Settings/Pages/OtherPage.xaml | 4 +- .../ViewModels/Settings/ClippingViewModel.cs | 49 +++- .../UI/ViewModels/Settings/MacroViewModel.cs | 225 ++++++++++++++++-- 10 files changed, 409 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 7477059..ea9d305 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# SleepStrap 6.9 +# SleepStrap 6.9.1 ![SleepStrap icon](SleepStrap/SleepStrap.png) SleepStrap is a purple Windows launcher for Roblox with skyboxes, texture presets, visual modes, fonts, and Rivals display tools. -[Download SleepStrap 6.9](https://github.com/SleepyDebug/SleepStrap/releases/tag/sleepstrap-6.9) · [Discord](https://discord.gg/W3CMjx8C7s) · [Report an issue](https://github.com/SleepyDebug/SleepStrap/issues) +[Download SleepStrap 6.9.1](https://github.com/SleepyDebug/SleepStrap/releases/tag/sleepstrap-6.9.1) · [Discord](https://discord.gg/W3CMjx8C7s) · [Report an issue](https://github.com/SleepyDebug/SleepStrap/issues) ## Repository layout @@ -25,7 +25,7 @@ SleepStrap is a purple Windows launcher for Roblox with skyboxes, texture preset - Montserrat-first selector for Roblox GUI and direct in-game text fonts - PC display stretching and Roblox FPS presets - Replay-buffer clipping with configurable capture devices, audio, duration, and hotkey -- RIVALS loadout macro with missing-weapon calibration, automatic actions, and hourly Auto Rejoin +- RIVALS loadout macro with missing-weapon calibration, a hidden hotkey-driven Quick Loadout, automatic actions, and hourly Auto Rejoin - Automatic GitHub release checks with a confirmation prompt before updating - Other page with Discord, version, attribution, and terms diff --git a/SleepStrap/Models/Persistable/Settings.cs b/SleepStrap/Models/Persistable/Settings.cs index d9216c7..4cce525 100644 --- a/SleepStrap/Models/Persistable/Settings.cs +++ b/SleepStrap/Models/Persistable/Settings.cs @@ -89,6 +89,12 @@ public class Settings public string MacroSecondaryWeapon { get; set; } = "Warper"; public string MacroMeleeWeapon { get; set; } = "Maul"; public string MacroUtilityWeapon { get; set; } = "Grappler"; + public string MacroQuickLoadoutPrimaryWeapon { get; set; } = "Distortion"; + public string MacroQuickLoadoutSecondaryWeapon { get; set; } = "Warper"; + public string MacroQuickLoadoutMeleeWeapon { get; set; } = "Maul"; + public string MacroQuickLoadoutUtilityWeapon { get; set; } = "Grappler"; + public int MacroQuickLoadoutHotkeyModifiers { get; set; } = 3; // Ctrl + Alt + public int MacroQuickLoadoutHotkeyVirtualKey { get; set; } = 0x51; // Q public bool MacroQuickRespawn { get; set; } = false; public bool MacroAutoUtility { get; set; } = false; public bool MacroAutoInspect { get; set; } = false; diff --git a/SleepStrap/Services/ReplayBufferService.cs b/SleepStrap/Services/ReplayBufferService.cs index 82e3d9a..f625126 100644 --- a/SleepStrap/Services/ReplayBufferService.cs +++ b/SleepStrap/Services/ReplayBufferService.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using System.ComponentModel; using System.Windows.Interop; using ScreenRecorderLib; @@ -34,6 +35,7 @@ public void Start() Directory.CreateDirectory(Paths.Playbacks); Directory.CreateDirectory(GetBufferDirectory()); RegisterHotkey(); + App.Logger.WriteLine("ReplayBufferService::Start", "Starting the replay capture loop"); _captureTask = Task.Run(() => CaptureLoopAsync(_stopToken.Token)); } @@ -44,7 +46,7 @@ public void SaveReplay() Interlocked.Exchange(ref _saveRequested, 1); try { _rotateSignal.Release(); } catch (SemaphoreFullException) { } - Frontend.ShowBalloonTip("SleepStrap Clipping", "Saving replay…"); + App.Logger.WriteLine("ReplayBufferService::SaveReplay", "Replay save requested"); } private async Task CaptureLoopAsync(CancellationToken cancellationToken) @@ -204,7 +206,7 @@ private async Task RenderReplayAsync(IReadOnlyList availableSegme if (selected.Count == 0) { - Frontend.ShowBalloonTip("SleepStrap Clipping", "The replay buffer is still warming up."); + App.Logger.WriteLine("ReplayBufferService::SaveReplay", "Replay buffer is still warming up"); return; } @@ -242,12 +244,11 @@ private async Task RenderReplayAsync(IReadOnlyList availableSegme if (result != TranscodeFailureReason.None) throw new InvalidOperationException($"Windows could not render the replay ({result})."); - Frontend.ShowBalloonTip("SleepStrap Clipping", $"Saved {Path.GetFileName(output.Path)}"); + App.Logger.WriteLine("ReplayBufferService::SaveReplay", $"Saved {output.Path}"); } catch (Exception ex) { App.Logger.WriteException("ReplayBufferService::SaveReplay", ex); - Frontend.ShowBalloonTip("SleepStrap Clipping", "The replay could not be saved.", System.Windows.Forms.ToolTipIcon.Error); } } @@ -278,7 +279,12 @@ private void RegisterHotkey() uint modifiers = (uint)App.Settings.Prop.ClippingHotkeyModifiers | 0x4000; // MOD_NOREPEAT uint virtualKey = (uint)App.Settings.Prop.ClippingHotkeyVirtualKey; if (!RegisterHotKey(_hotkeyWindow.Handle, HotkeyId, modifiers, virtualKey)) - App.Logger.WriteLine("ReplayBufferService::Hotkey", "The configured replay hotkey is already in use"); + { + int error = Marshal.GetLastWin32Error(); + throw new Win32Exception(error, "The configured replay hotkey could not be registered"); + } + + App.Logger.WriteLine("ReplayBufferService::Hotkey", $"Registered global replay hotkey modifiers=0x{modifiers:X} key=0x{virtualKey:X}"); } private IntPtr HotkeyWindowHook(IntPtr hwnd, int message, IntPtr wParam, IntPtr lParam, ref bool handled) @@ -286,6 +292,7 @@ private IntPtr HotkeyWindowHook(IntPtr hwnd, int message, IntPtr wParam, IntPtr if (message == 0x0312 && wParam.ToInt32() == HotkeyId) { handled = true; + App.Logger.WriteLine("ReplayBufferService::Hotkey", "Global replay hotkey pressed"); SaveReplay(); } diff --git a/SleepStrap/SleepStrap.csproj b/SleepStrap/SleepStrap.csproj index c650686..232c746 100644 --- a/SleepStrap/SleepStrap.csproj +++ b/SleepStrap/SleepStrap.csproj @@ -7,12 +7,16 @@ true True SleepStrap.ico - 6.9.0.0 - 6.9.0.0 + 6.9.1.0 + 6.9.1.0 app.manifest true x64 x64 + + true false SleepStrap $(AssemblyName) diff --git a/SleepStrap/UI/Elements/Settings/Pages/ClippingPage.xaml.cs b/SleepStrap/UI/Elements/Settings/Pages/ClippingPage.xaml.cs index fa07922..28b1e9f 100644 --- a/SleepStrap/UI/Elements/Settings/Pages/ClippingPage.xaml.cs +++ b/SleepStrap/UI/Elements/Settings/Pages/ClippingPage.xaml.cs @@ -19,7 +19,8 @@ public ClippingPage() _viewModel = new ClippingViewModel(); DataContext = _viewModel; InitializeComponent(); - Unloaded += (_, _) => _viewModel.Dispose(); + Loaded += (_, _) => _viewModel.StartLiveUpdates(); + Unloaded += (_, _) => _viewModel.StopLiveUpdates(); } private void HotkeyBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) diff --git a/SleepStrap/UI/Elements/Settings/Pages/MacroPage.xaml b/SleepStrap/UI/Elements/Settings/Pages/MacroPage.xaml index 2bd52b3..4cda8c9 100644 --- a/SleepStrap/UI/Elements/Settings/Pages/MacroPage.xaml +++ b/SleepStrap/UI/Elements/Settings/Pages/MacroPage.xaml @@ -6,7 +6,8 @@ xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:dmodels="clr-namespace:SleepStrap.UI.ViewModels.Settings" mc:Ignorable="d" d:DataContext="{d:DesignInstance dmodels:MacroViewModel, IsDesignTimeCreatable=False}" - d:DesignHeight="700" d:DesignWidth="800" Title="Macro" Scrollable="True"> + d:DesignHeight="700" d:DesignWidth="800" Title="Macro" Scrollable="True" + PreviewKeyDown="Page_PreviewKeyDown"> @@ -145,5 +146,43 @@ Appearance="Primary" HorizontalAlignment="Stretch" Padding="16,10" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SleepStrap/UI/Elements/Settings/Pages/MacroPage.xaml.cs b/SleepStrap/UI/Elements/Settings/Pages/MacroPage.xaml.cs index 4cecca4..ae5504c 100644 --- a/SleepStrap/UI/Elements/Settings/Pages/MacroPage.xaml.cs +++ b/SleepStrap/UI/Elements/Settings/Pages/MacroPage.xaml.cs @@ -1,13 +1,103 @@ +using System.Windows.Controls; +using System.Windows.Input; + using SleepStrap.UI.ViewModels.Settings; namespace SleepStrap.UI.Elements.Settings.Pages { public partial class MacroPage { + private readonly MacroViewModel _viewModel; + private bool _capturingQuickHotkey; + private int _pendingModifiers; + private int _pendingVirtualKey; + public MacroPage() { - DataContext = new MacroViewModel(); + _viewModel = new MacroViewModel(); + DataContext = _viewModel; InitializeComponent(); } + + private void Page_PreviewKeyDown(object sender, KeyEventArgs e) + { + Key key = e.Key == Key.System ? e.SystemKey : e.Key; + bool sevenDown = Keyboard.IsKeyDown(Key.D7) || Keyboard.IsKeyDown(Key.NumPad7); + bool revealChord = ((key == Key.D7 || key == Key.NumPad7) && Keyboard.IsKeyDown(Key.U)) || + (key == Key.U && sevenDown); + if (!revealChord) + return; + + _viewModel.RevealQuickLoadout(); + e.Handled = true; + } + + private void QuickHotkeyBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + _capturingQuickHotkey = true; + _viewModel.SetQuickHotkeyCaptureActive(true); + _pendingModifiers = 0; + _pendingVirtualKey = 0; + QuickHotkeyBox.Text = "Press keys, then Enter"; + QuickHotkeyBox.Focus(); + Keyboard.Focus(QuickHotkeyBox); + e.Handled = true; + } + + private void QuickHotkeyBox_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (!_capturingQuickHotkey) + return; + + e.Handled = true; + Key key = e.Key == Key.System ? e.SystemKey : e.Key; + if (key == Key.Enter) + { + if (_pendingVirtualKey != 0) + _viewModel.SetQuickLoadoutHotkey(_pendingModifiers, _pendingVirtualKey); + FinishQuickHotkeyCapture(); + return; + } + if (key == Key.Escape) + { + FinishQuickHotkeyCapture(); + return; + } + if (key is Key.LeftAlt or Key.RightAlt or Key.LeftCtrl or Key.RightCtrl or Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin) + return; + + ModifierKeys modifiers = Keyboard.Modifiers; + _pendingModifiers = 0; + if (modifiers.HasFlag(ModifierKeys.Alt)) _pendingModifiers |= 1; + if (modifiers.HasFlag(ModifierKeys.Control)) _pendingModifiers |= 2; + if (modifiers.HasFlag(ModifierKeys.Shift)) _pendingModifiers |= 4; + if (modifiers.HasFlag(ModifierKeys.Windows)) _pendingModifiers |= 8; + _pendingVirtualKey = KeyInterop.VirtualKeyFromKey(key); + QuickHotkeyBox.Text = FormatHotkey(key, modifiers) + " • press Enter"; + } + + private void QuickHotkeyBox_LostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) + { + if (_capturingQuickHotkey) + FinishQuickHotkeyCapture(); + } + + private void FinishQuickHotkeyCapture() + { + _capturingQuickHotkey = false; + _viewModel.SetQuickHotkeyCaptureActive(false); + QuickHotkeyBox.GetBindingExpression(TextBox.TextProperty)?.UpdateTarget(); + } + + private static string FormatHotkey(Key key, ModifierKeys modifiers) + { + var parts = new List(); + if (modifiers.HasFlag(ModifierKeys.Control)) parts.Add("Ctrl"); + if (modifiers.HasFlag(ModifierKeys.Alt)) parts.Add("Alt"); + if (modifiers.HasFlag(ModifierKeys.Shift)) parts.Add("Shift"); + if (modifiers.HasFlag(ModifierKeys.Windows)) parts.Add("Win"); + parts.Add(key.ToString()); + return String.Join(" + ", parts); + } } } diff --git a/SleepStrap/UI/Elements/Settings/Pages/OtherPage.xaml b/SleepStrap/UI/Elements/Settings/Pages/OtherPage.xaml index 0c5a4a3..0b1824b 100644 --- a/SleepStrap/UI/Elements/Settings/Pages/OtherPage.xaml +++ b/SleepStrap/UI/Elements/Settings/Pages/OtherPage.xaml @@ -95,7 +95,7 @@ - + @@ -125,7 +125,7 @@ - + diff --git a/SleepStrap/UI/ViewModels/Settings/ClippingViewModel.cs b/SleepStrap/UI/ViewModels/Settings/ClippingViewModel.cs index 7133a71..44e4bac 100644 --- a/SleepStrap/UI/ViewModels/Settings/ClippingViewModel.cs +++ b/SleepStrap/UI/ViewModels/Settings/ClippingViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Windows; using System.Windows.Input; +using System.Windows.Threading; using Forms = System.Windows.Forms; @@ -52,8 +53,10 @@ private static string FormatBytes(long bytes) => bytes >= 1_073_741_824 } private readonly FileSystemWatcher _clipWatcher; + private readonly DispatcherTimer _liveRefreshTimer; private bool _disposed; private int _refreshPending; + private string _clipSnapshot = String.Empty; private string _statusText = "Disabled"; private static readonly HashSet VideoExtensions = new(StringComparer.OrdinalIgnoreCase) @@ -164,12 +167,34 @@ public ClippingViewModel() { NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size, IncludeSubdirectories = true, - EnableRaisingEvents = true + EnableRaisingEvents = false }; _clipWatcher.Created += ClipsChanged; _clipWatcher.Changed += ClipsChanged; _clipWatcher.Deleted += ClipsChanged; _clipWatcher.Renamed += ClipsChanged; + + _liveRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _liveRefreshTimer.Tick += (_, _) => RefreshClips(); + } + + public void StartLiveUpdates() + { + if (_disposed) + return; + + _clipWatcher.EnableRaisingEvents = true; + _liveRefreshTimer.Start(); + RefreshClips(true); + } + + public void StopLiveUpdates() + { + if (_disposed) + return; + + _liveRefreshTimer.Stop(); + _clipWatcher.EnableRaisingEvents = false; } public void SetHotkey(int modifiers, int virtualKey) @@ -232,23 +257,26 @@ public static void OpenPlaybacksFolder() Process.Start(new ProcessStartInfo(Paths.Playbacks) { UseShellExecute = true }); } - public void RefreshClips() + public void RefreshClips(bool force = false) { try { - string[] files = Directory.EnumerateFiles(Paths.Playbacks, "*.*", SearchOption.AllDirectories) + FileInfo[] files = Directory.EnumerateFiles(Paths.Playbacks, "*.*", SearchOption.AllDirectories) .Where(path => VideoExtensions.Contains(Path.GetExtension(path))) - .OrderByDescending(path => - { - try { return File.GetLastWriteTime(path); } - catch { return DateTime.MinValue; } - }) + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTimeUtc) .ToArray(); + string snapshot = String.Join("\n", files.Select(file => $"{file.FullName}|{file.Length}|{file.LastWriteTimeUtc.Ticks}")); + if (!force && snapshot == _clipSnapshot) + return; + + _clipSnapshot = snapshot; + Clips.Clear(); - foreach (string file in files) + foreach (FileInfo file in files) { - try { Clips.Add(new ClipItem(file)); } + try { Clips.Add(new ClipItem(file.FullName)); } catch (IOException ex) { App.Logger.WriteException("ClippingViewModel::RefreshClips", ex); } catch (UnauthorizedAccessException ex) { App.Logger.WriteException("ClippingViewModel::RefreshClips", ex); } } @@ -329,6 +357,7 @@ public void Dispose() if (_disposed) return; _disposed = true; + _liveRefreshTimer.Stop(); _clipWatcher.EnableRaisingEvents = false; _clipWatcher.Dispose(); } diff --git a/SleepStrap/UI/ViewModels/Settings/MacroViewModel.cs b/SleepStrap/UI/ViewModels/Settings/MacroViewModel.cs index 7125dc9..c851c02 100644 --- a/SleepStrap/UI/ViewModels/Settings/MacroViewModel.cs +++ b/SleepStrap/UI/ViewModels/Settings/MacroViewModel.cs @@ -71,6 +71,10 @@ public WeaponOption(string name, MacroWeaponCategory category, int originalIndex private bool _automationMasterEnabled = true; private bool _masterKeyWasDown; private bool _usingAutoHotkeyActions; + private bool _quickLoadoutVisible; + private bool _quickHotkeyWasDown; + private bool _quickLoadoutRunning; + private bool _quickHotkeyCaptureActive; private CancellationTokenSource? _macroCancellation; private DateTime? _nextAutoRejoinUtc; private bool _autoRejoinInProgress; @@ -78,6 +82,10 @@ public WeaponOption(string name, MacroWeaponCategory category, int originalIndex private WeaponOption? _selectedSecondary; private WeaponOption? _selectedMelee; private WeaponOption? _selectedUtility; + private WeaponOption? _quickSelectedPrimary; + private WeaponOption? _quickSelectedSecondary; + private WeaponOption? _quickSelectedMelee; + private WeaponOption? _quickSelectedUtility; public ObservableCollection PrimaryWeapons { get; } = new(); public ObservableCollection SecondaryWeapons { get; } = new(); @@ -90,6 +98,10 @@ public WeaponOption(string name, MacroWeaponCategory category, int originalIndex public ObservableCollection AvailableUtility { get; } = new(); public ICommand RunMacroCommand => _runMacroCommand; + public Visibility QuickLoadoutVisibility => _quickLoadoutVisible ? Visibility.Visible : Visibility.Collapsed; + public string QuickLoadoutHotkeyDisplay => FormatHotkey( + App.Settings.Prop.MacroQuickLoadoutHotkeyModifiers, + App.Settings.Prop.MacroQuickLoadoutHotkeyVirtualKey); public bool IsRunning { @@ -148,6 +160,30 @@ public WeaponOption? SelectedUtility set => SetSelection(ref _selectedUtility, value, nameof(SelectedUtility), selected => App.Settings.Prop.MacroUtilityWeapon = selected.Name); } + public WeaponOption? QuickSelectedPrimary + { + get => _quickSelectedPrimary; + set => SetSelection(ref _quickSelectedPrimary, value, nameof(QuickSelectedPrimary), selected => App.Settings.Prop.MacroQuickLoadoutPrimaryWeapon = selected.Name); + } + + public WeaponOption? QuickSelectedSecondary + { + get => _quickSelectedSecondary; + set => SetSelection(ref _quickSelectedSecondary, value, nameof(QuickSelectedSecondary), selected => App.Settings.Prop.MacroQuickLoadoutSecondaryWeapon = selected.Name); + } + + public WeaponOption? QuickSelectedMelee + { + get => _quickSelectedMelee; + set => SetSelection(ref _quickSelectedMelee, value, nameof(QuickSelectedMelee), selected => App.Settings.Prop.MacroQuickLoadoutMeleeWeapon = selected.Name); + } + + public WeaponOption? QuickSelectedUtility + { + get => _quickSelectedUtility; + set => SetSelection(ref _quickSelectedUtility, value, nameof(QuickSelectedUtility), selected => App.Settings.Prop.MacroQuickLoadoutUtilityWeapon = selected.Name); + } + public bool QuickRespawn { get => App.Settings.Prop.MacroQuickRespawn; @@ -218,12 +254,16 @@ public MacroViewModel() ("Smoke Grenade", "SmokeGrenade Icon.png"), ("War Horn", "WarHorn Icon.png") }); - RefreshAvailableWeapons(false); + RefreshAvailableWeapons(); RefreshEffectiveSlots(); _selectedPrimary = FindSaved(AvailablePrimary, App.Settings.Prop.MacroPrimaryWeapon); _selectedSecondary = FindSaved(AvailableSecondary, App.Settings.Prop.MacroSecondaryWeapon); _selectedMelee = FindSaved(AvailableMelee, App.Settings.Prop.MacroMeleeWeapon); _selectedUtility = FindSaved(AvailableUtility, App.Settings.Prop.MacroUtilityWeapon); + _quickSelectedPrimary = FindSaved(AvailablePrimary, App.Settings.Prop.MacroQuickLoadoutPrimaryWeapon); + _quickSelectedSecondary = FindSaved(AvailableSecondary, App.Settings.Prop.MacroQuickLoadoutSecondaryWeapon); + _quickSelectedMelee = FindSaved(AvailableMelee, App.Settings.Prop.MacroQuickLoadoutMeleeWeapon); + _quickSelectedUtility = FindSaved(AvailableUtility, App.Settings.Prop.MacroQuickLoadoutUtilityWeapon); _runMacroCommand = new AsyncRelayCommand(RunMacroAsync, () => !IsRunning && AllSelectionsPresent()); _automationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) }; @@ -245,6 +285,31 @@ private void AddCategory( destination.Add(new WeaponOption(name, category, index++, image, missing.Contains(name), MissingChanged)); } + public void RevealQuickLoadout() + { + if (_quickLoadoutVisible) + return; + + _quickLoadoutVisible = true; + OnPropertyChanged(nameof(QuickLoadoutVisibility)); + } + + public void SetQuickLoadoutHotkey(int modifiers, int virtualKey) + { + App.Settings.Prop.MacroQuickLoadoutHotkeyModifiers = modifiers; + App.Settings.Prop.MacroQuickLoadoutHotkeyVirtualKey = virtualKey; + App.Settings.Save(); + _quickHotkeyWasDown = false; + OnPropertyChanged(nameof(QuickLoadoutHotkeyDisplay)); + } + + public void SetQuickHotkeyCaptureActive(bool active) + { + _quickHotkeyCaptureActive = active; + if (active) + _quickHotkeyWasDown = false; + } + private void MissingChanged(WeaponOption changed) { App.Settings.Prop.MacroMissingWeapons = AllWeapons() @@ -252,32 +317,81 @@ private void MissingChanged(WeaponOption changed) .Select(weapon => weapon.Name) .ToList(); RefreshEffectiveSlots(); - RefreshAvailableWeapons(true); + SyncAvailableWeapons(AvailableWeapons(changed.Category), CategoryWeapons(changed.Category)); + + if (changed.IsMissing) + RepairSelectionIfRemoved(changed); + + _runMacroCommand?.NotifyCanExecuteChanged(); App.Settings.Save(); } - private void RefreshAvailableWeapons(bool repairSelections) + private void RefreshAvailableWeapons() { - FillAvailable(AvailablePrimary, PrimaryWeapons); - FillAvailable(AvailableSecondary, SecondaryWeapons); - FillAvailable(AvailableMelee, MeleeWeapons); - FillAvailable(AvailableUtility, UtilityWeapons); + SyncAvailableWeapons(AvailablePrimary, PrimaryWeapons); + SyncAvailableWeapons(AvailableSecondary, SecondaryWeapons); + SyncAvailableWeapons(AvailableMelee, MeleeWeapons); + SyncAvailableWeapons(AvailableUtility, UtilityWeapons); + } + + private static void SyncAvailableWeapons(ObservableCollection destination, IEnumerable source) + { + WeaponOption[] wanted = source.Where(weapon => !weapon.IsMissing).ToArray(); - if (repairSelections) + for (int index = destination.Count - 1; index >= 0; index--) { - if (SelectedPrimary?.IsMissing != false) SelectedPrimary = AvailablePrimary.FirstOrDefault(); - if (SelectedSecondary?.IsMissing != false) SelectedSecondary = AvailableSecondary.FirstOrDefault(); - if (SelectedMelee?.IsMissing != false) SelectedMelee = AvailableMelee.FirstOrDefault(); - if (SelectedUtility?.IsMissing != false) SelectedUtility = AvailableUtility.FirstOrDefault(); - _runMacroCommand?.NotifyCanExecuteChanged(); + if (!wanted.Contains(destination[index])) + destination.RemoveAt(index); + } + + for (int index = 0; index < wanted.Length; index++) + { + WeaponOption weapon = wanted[index]; + int currentIndex = destination.IndexOf(weapon); + if (currentIndex < 0) + destination.Insert(index, weapon); + else if (currentIndex != index) + destination.Move(currentIndex, index); } } - private static void FillAvailable(ObservableCollection destination, IEnumerable source) + private void RepairSelectionIfRemoved(WeaponOption removed) { - destination.Clear(); - foreach (WeaponOption weapon in source.Where(weapon => !weapon.IsMissing)) - destination.Add(weapon); + WeaponOption? replacement = AvailableWeapons(removed.Category) + .FirstOrDefault(weapon => weapon.OriginalIndex > removed.OriginalIndex) + ?? AvailableWeapons(removed.Category).LastOrDefault(); + + switch (removed.Category) + { + case MacroWeaponCategory.Primary when ReferenceEquals(_selectedPrimary, removed): + SetSelectionOrClear(ref _selectedPrimary, replacement, nameof(SelectedPrimary), value => App.Settings.Prop.MacroPrimaryWeapon = value); + break; + case MacroWeaponCategory.Secondary when ReferenceEquals(_selectedSecondary, removed): + SetSelectionOrClear(ref _selectedSecondary, replacement, nameof(SelectedSecondary), value => App.Settings.Prop.MacroSecondaryWeapon = value); + break; + case MacroWeaponCategory.Melee when ReferenceEquals(_selectedMelee, removed): + SetSelectionOrClear(ref _selectedMelee, replacement, nameof(SelectedMelee), value => App.Settings.Prop.MacroMeleeWeapon = value); + break; + case MacroWeaponCategory.Utility when ReferenceEquals(_selectedUtility, removed): + SetSelectionOrClear(ref _selectedUtility, replacement, nameof(SelectedUtility), value => App.Settings.Prop.MacroUtilityWeapon = value); + break; + } + + switch (removed.Category) + { + case MacroWeaponCategory.Primary when ReferenceEquals(_quickSelectedPrimary, removed): + SetSelectionOrClear(ref _quickSelectedPrimary, replacement, nameof(QuickSelectedPrimary), value => App.Settings.Prop.MacroQuickLoadoutPrimaryWeapon = value); + break; + case MacroWeaponCategory.Secondary when ReferenceEquals(_quickSelectedSecondary, removed): + SetSelectionOrClear(ref _quickSelectedSecondary, replacement, nameof(QuickSelectedSecondary), value => App.Settings.Prop.MacroQuickLoadoutSecondaryWeapon = value); + break; + case MacroWeaponCategory.Melee when ReferenceEquals(_quickSelectedMelee, removed): + SetSelectionOrClear(ref _quickSelectedMelee, replacement, nameof(QuickSelectedMelee), value => App.Settings.Prop.MacroQuickLoadoutMeleeWeapon = value); + break; + case MacroWeaponCategory.Utility when ReferenceEquals(_quickSelectedUtility, removed): + SetSelectionOrClear(ref _quickSelectedUtility, replacement, nameof(QuickSelectedUtility), value => App.Settings.Prop.MacroQuickLoadoutUtilityWeapon = value); + break; + } } private void RefreshEffectiveSlots() @@ -351,6 +465,11 @@ private async Task RunMacroAsync() private void AutomationTimer_Tick(object? sender, EventArgs e) { + bool quickHotkeyDown = IsQuickLoadoutHotkeyDown(); + if (quickHotkeyDown && !_quickHotkeyWasDown && !_quickLoadoutRunning && !IsRunning && AllQuickSelectionsPresent()) + _ = RunQuickLoadoutAsync(); + _quickHotkeyWasDown = quickHotkeyDown; + bool masterKeyDown = MacroAutomationService.IsKeyDown(0xDD); // ] / OEM close bracket if (masterKeyDown && !_masterKeyWasDown) { @@ -394,6 +513,46 @@ private void AutomationTimer_Tick(object? sender, EventArgs e) } } + private bool IsQuickLoadoutHotkeyDown() + { + if (_quickHotkeyCaptureActive) + return false; + + int key = App.Settings.Prop.MacroQuickLoadoutHotkeyVirtualKey; + if (key == 0 || !MacroAutomationService.IsKeyDown(key)) + return false; + + int modifiers = App.Settings.Prop.MacroQuickLoadoutHotkeyModifiers; + if ((modifiers & 1) != 0 && !MacroAutomationService.IsKeyDown(0x12)) return false; + if ((modifiers & 2) != 0 && !MacroAutomationService.IsKeyDown(0x11)) return false; + if ((modifiers & 4) != 0 && !MacroAutomationService.IsKeyDown(0x10)) return false; + if ((modifiers & 8) != 0 && !MacroAutomationService.IsKeyDown(0x5B) && !MacroAutomationService.IsKeyDown(0x5C)) return false; + return true; + } + + private async Task RunQuickLoadoutAsync() + { + _quickLoadoutRunning = true; + try + { + var selections = new[] + { + CreateSelection(QuickSelectedPrimary!), CreateSelection(QuickSelectedSecondary!), + CreateSelection(QuickSelectedMelee!), CreateSelection(QuickSelectedUtility!) + }; + await MacroAutomationService.RunLoadoutAsync(selections, CancellationToken.None); + } + catch (Exception ex) + { + App.Logger.WriteException("MacroViewModel::RunQuickLoadout", ex); + Frontend.ShowMessageBox($"SleepStrap could not apply the quick loadout.\n\n{ex.Message}", MessageBoxImage.Error); + } + finally + { + _quickLoadoutRunning = false; + } + } + private void SetSelection(ref WeaponOption? field, WeaponOption? value, string propertyName, Action save) { if (value is null || ReferenceEquals(field, value)) @@ -405,6 +564,16 @@ private void SetSelection(ref WeaponOption? field, WeaponOption? value, string p _runMacroCommand?.NotifyCanExecuteChanged(); } + private void SetSelectionOrClear(ref WeaponOption? field, WeaponOption? value, string propertyName, Action save) + { + if (ReferenceEquals(field, value)) + return; + + field = value; + save(value?.Name ?? String.Empty); + OnPropertyChanged(propertyName); + } + private static WeaponOption? FindSaved(IEnumerable options, string saved) => options.FirstOrDefault(option => option.Name.Equals(saved, StringComparison.OrdinalIgnoreCase)) ?? options.FirstOrDefault(); @@ -418,8 +587,30 @@ private void SetSelection(ref WeaponOption? field, WeaponOption? value, string p _ => UtilityWeapons }; + private ObservableCollection AvailableWeapons(MacroWeaponCategory category) => category switch + { + MacroWeaponCategory.Primary => AvailablePrimary, + MacroWeaponCategory.Secondary => AvailableSecondary, + MacroWeaponCategory.Melee => AvailableMelee, + _ => AvailableUtility + }; + private bool AllSelectionsPresent() => SelectedPrimary is not null && SelectedSecondary is not null && SelectedMelee is not null && SelectedUtility is not null; + private bool AllQuickSelectionsPresent() => QuickSelectedPrimary is not null && QuickSelectedSecondary is not null && QuickSelectedMelee is not null && QuickSelectedUtility is not null; + + private static string FormatHotkey(int modifiers, int virtualKey) + { + var parts = new List(); + if ((modifiers & 2) != 0) parts.Add("Ctrl"); + if ((modifiers & 1) != 0) parts.Add("Alt"); + if ((modifiers & 4) != 0) parts.Add("Shift"); + if ((modifiers & 8) != 0) parts.Add("Win"); + Key key = KeyInterop.KeyFromVirtualKey(virtualKey); + parts.Add(key == Key.None ? $"0x{virtualKey:X2}" : key.ToString()); + return String.Join(" + ", parts); + } + private void SaveAndNotify(string propertyName) { App.Settings.Save();