diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 6d931b4..f30aa5b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -5,8 +5,8 @@ body: - type: input id: version attributes: - label: SleepStrap Version - description: "What version of SleepStrap are you using?" + label: SleepBlox Version + description: "What version of SleepBlox are you using?" placeholder: "v2.9.0.0" validations: required: true @@ -30,7 +30,7 @@ body: - type: textarea id: log attributes: - label: SleepStrap Log - description: If you're getting a SleepStrap Exception error, upload your log file here. Otherwise, just leave it empty. + label: SleepBlox Log + description: If you're getting a SleepBlox Exception error, upload your log file here. Otherwise, just leave it empty. value: "N/A" render: text diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 13cbd7e..a30dd78 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -6,7 +6,7 @@ body: attributes: value: | ### **Preliminary instructions** - - Please first check whether your idea has already been suggested in the [SleepStrap issues](https://github.com/SleepyDebug/SleepStrap/issues?q=is%3Aissue). + - Please first check whether your idea has already been suggested in the [SleepBlox issues](https://github.com/SleepyDebug/SleepStrap/issues?q=is%3Aissue). - If your feature suggestion is to do with Roblox itself, please consider that what's possible is heavily constrained by what [FastFlags](https://github.com/pizzaboxer/bloxstrap/wiki/A-guide-to-FastFlags) are available. - type: textarea id: what-happened diff --git a/.github/workflows/ci-debug.yml b/.github/workflows/ci-debug.yml index fc2aacb..2ae4231 100644 --- a/.github/workflows/ci-debug.yml +++ b/.github/workflows/ci-debug.yml @@ -26,5 +26,5 @@ jobs: - name: Upload Artifact uses: actions/upload-artifact@v4 with: - name: SleepStrap (Debug) (${{ github.sha }}) + name: SleepBlox (Debug) (${{ github.sha }}) path: .\SleepStrap\bin\x64\Debug\net6.0-windows10.0.19041.0\win-x64\publish\* diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index d5bcc22..4b1a19b 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -26,13 +26,13 @@ jobs: - name: Upload Artifact uses: actions/upload-artifact@v4 with: - name: SleepStrap (Release) (${{ github.sha }}) + name: SleepBlox (Release) (${{ github.sha }}) path: .\SleepStrap\bin\x64\Release\net6.0-windows10.0.19041.0\win-x64\publish\* release: needs: build runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/sleepstrap-') + if: startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/sleepblox-') permissions: contents: write @@ -40,18 +40,18 @@ jobs: - name: Download artifact uses: actions/download-artifact@v4 with: - name: SleepStrap (Release) (${{ github.sha }}) + name: SleepBlox (Release) (${{ github.sha }}) path: release - name: Rename binaries - run: mv release/SleepStrap.exe SleepStrap-${{ github.ref_name }}.exe + run: mv release/SleepBlox.exe SleepBlox-${{ github.ref_name }}.exe - name: Create release uses: softprops/action-gh-release@v2 with: draft: true - files: SleepStrap-${{ github.ref_name }}.exe - name: SleepStrap ${{ github.ref_name }} + files: SleepBlox-${{ github.ref_name }}.exe + name: SleepBlox ${{ github.ref_name }} fail_on_unmatched_files: true release-test: @@ -65,16 +65,16 @@ jobs: - name: Download artifact uses: actions/download-artifact@v4 with: - name: SleepStrap (Release) (${{ github.sha }}) + name: SleepBlox (Release) (${{ github.sha }}) path: release - name: Rename binaries - run: mv release/SleepStrap.exe SleepStrap-${{ github.ref_name }}.exe + run: mv release/SleepBlox.exe SleepBlox-${{ github.ref_name }}.exe - name: Create release uses: softprops/action-gh-release@v2 with: draft: true - files: SleepStrap-${{ github.ref_name }}.exe - name: SleepStrap ${{ github.ref_name }} + files: SleepBlox-${{ github.ref_name }}.exe + name: SleepBlox ${{ github.ref_name }} fail_on_unmatched_files: true diff --git a/README.md b/README.md index ca99701..5c86282 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# SleepStrap 6.9.5 +# SleepBlox -![SleepStrap icon](SleepStrap/SleepStrap.png) +![SleepBlox icon](SleepStrap/SleepStrap.png) -SleepStrap is a purple Windows launcher for Roblox with skyboxes, texture presets, visual modes, fonts, and Rivals display tools. +SleepBlox is a Windows launcher for Roblox with skyboxes, texture presets, visual modes, fonts, and PC display tools. -[Download SleepStrap 6.9.5](https://github.com/SleepyDebug/SleepStrap/releases/tag/sleepstrap-6.9.5) · [Discord](https://discord.gg/W3CMjx8C7s) · [Report an issue](https://github.com/SleepyDebug/SleepStrap/issues) +[Download SleepBlox](https://github.com/SleepyDebug/SleepStrap/releases) · [Discord](https://discord.gg/W3CMjx8C7s) · [Report an issue](https://github.com/SleepyDebug/SleepStrap/issues) ## Repository layout | Path | Purpose | | --- | --- | -| `SleepStrap/` | SleepStrap application source, resources, and publish profile | +| `SleepStrap/` | SleepBlox application source, resources, and publish profile | | `vendor/Wpf.Ui/` | Vendored WPF UI dependency | | `scripts/` | Repository maintenance utilities | | `docs/` | Terms, risk notice, and upstream license notices | @@ -18,7 +18,7 @@ SleepStrap is a purple Windows launcher for Roblox with skyboxes, texture preset ## Features -- Purple SleepStrap interface, animated page transitions, and Sleep icon branding +- SleepBlox interface, animated page transitions, and Sleep icon branding - Scrollable skybox gallery with 25 included presets - Reversible basic and dark texture packs - Clear and polished-metal RTX-style visual modes @@ -41,8 +41,8 @@ The Windows x64 publish profile produces a self-contained single-file build, so ## Safety notice -SleepStrap is unofficial software. Its visual options replace local Roblox assets and settings; they do not inject code into the Roblox executable. No third-party launcher can guarantee protection from account enforcement. Read the full [Terms of Use and Risk Notice](docs/TERMS.md) before use. +SleepBlox is unofficial software. Its visual options replace local Roblox assets and settings; they do not inject code into the Roblox executable. No third-party launcher can guarantee protection from account enforcement. Read the full [Terms of Use and Risk Notice](docs/TERMS.md) before use. ## Credits and licenses -SleepStrap is derived from the open-source Fishstrap and Bloxstrap projects. Their names and copyrights belong to their respective owners. Required MIT notices are retained in [LICENSE](LICENSE) and [docs/licenses](docs/licenses). +SleepBlox is derived from the open-source Fishstrap and Bloxstrap projects. Their names and copyrights belong to their respective owners. Required MIT notices are retained in [LICENSE](LICENSE) and [docs/licenses](docs/licenses). diff --git a/SleepStrap.sln b/SleepStrap.sln index f09c88b..5b4e38e 100644 --- a/SleepStrap.sln +++ b/SleepStrap.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32819.101 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SleepStrap", "SleepStrap\SleepStrap.csproj", "{0D75146E-DA24-4B05-B6C9-250C8F81B0C7}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SleepBlox", "SleepStrap\SleepStrap.csproj", "{0D75146E-DA24-4B05-B6C9-250C8F81B0C7}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wpf.Ui", "vendor\Wpf.Ui\src\Wpf.Ui\Wpf.Ui.csproj", "{1ADC87D1-8963-4100-845A-18477824718E}" EndProject diff --git a/SleepStrap/App.xaml.cs b/SleepStrap/App.xaml.cs index b7496c3..4ad5207 100644 --- a/SleepStrap/App.xaml.cs +++ b/SleepStrap/App.xaml.cs @@ -14,10 +14,13 @@ namespace SleepStrap public partial class App : Application { #if QA_BUILD - public const string ProjectName = "SleepStrap-QA"; + public const string ProjectName = "SleepBlox-QA"; #else - public const string ProjectName = "SleepStrap"; + public const string ProjectName = "SleepBlox"; #endif + // Keep the old identity only for migration. Existing installations continue + // to use their original folder and settings after the public rebrand. + public const string LegacyProjectName = "SleepStrap"; public const string ProjectOwner = "SleepyDebug"; public const string ProjectRepository = "SleepyDebug/SleepStrap"; public const string ProjectDownloadLink = "https://github.com/SleepyDebug/SleepStrap/releases"; @@ -26,6 +29,16 @@ public partial class App : Application public const string ProjectRemoteDataLink = "https://config.fishstrap.app/v1/Data.json"; public const bool SupportsSelfUpdates = true; + public static string BootstrapperMutexName => $"{ProjectName}-Bootstrapper"; + public static string BackgroundUpdaterMutexName => $"{ProjectName}-BackgroundUpdater"; + public static string BackgroundUpdaterKillEventName => $"{ProjectName}-BackgroundUpdaterKillEvent"; + public static string MultiInstanceWatcherEventName => $"{ProjectName}-MultiInstanceWatcherInitialisationFinished"; + + public const string LegacyBootstrapperMutexName = "SleepStrap-Bootstrapper"; + public const string LegacyBackgroundUpdaterMutexName = "SleepStrap-BackgroundUpdater"; + public const string LegacyBackgroundUpdaterKillEventName = "SleepStrap-BackgroundUpdaterKillEvent"; + public const string LegacyMultiInstanceWatcherEventName = "SleepStrap-MultiInstanceWatcherInitialisationFinished"; + public const string RobloxPlayerAppName = "RobloxPlayerBeta.exe"; public const string RobloxStudioAppName = "RobloxStudioBeta.exe"; // one day ill add studio support, haha i never did! @@ -33,6 +46,7 @@ public partial class App : Application // simple shorthand for extremely frequently used and long string - this goes under HKCU public const string UninstallKey = $@"Software\Microsoft\Windows\CurrentVersion\Uninstall\{ProjectName}"; + public const string LegacyUninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\SleepStrap"; public const string ApisKey = $"Software\\{ProjectName}"; @@ -185,6 +199,12 @@ protected override async void OnStartup(StartupEventArgs e) { const string LOG_IDENT = "App::OnStartup"; + if (Services.NvidiaBlurElevationBridge.TryHandleElevatedHelper(e.Args)) + { + Shutdown(); + return; + } + Locale.Initialize(); base.OnStartup(e); @@ -228,7 +248,19 @@ protected override async void OnStartup(StartupEventArgs e) LaunchSettings = new LaunchSettings(e.Args); // installation check begins here - using var uninstallKey = Registry.CurrentUser.OpenSubKey(UninstallKey); + // A rebranded build must still find the existing SleepStrap install so + // it keeps the user's configuration, Roblox registration, and local mods. + RegistryKey? currentUninstallKey = Registry.CurrentUser.OpenSubKey(UninstallKey); +#if !QA_BUILD + RegistryKey? legacyUninstallKey = currentUninstallKey is null + ? Registry.CurrentUser.OpenSubKey(LegacyUninstallKey) + : null; + bool rebrandingLegacyInstall = legacyUninstallKey is not null; + using RegistryKey? uninstallKey = currentUninstallKey ?? legacyUninstallKey; +#else + const bool rebrandingLegacyInstall = false; + using RegistryKey? uninstallKey = currentUninstallKey; +#endif string? installLocation = null; bool fixInstallLocation = false; @@ -319,8 +351,8 @@ protected override async void OnStartup(StartupEventArgs e) FastFlags.Load(); GlobalSettings.Load(); - // Older SleepStrap builds hard-disabled this setting. Migrate those - // installations now that releases are published in the SleepStrap repo. + // Older builds hard-disabled this setting. Migrate those + // installations now that releases are published in the current repo. if (!Settings.Prop.CheckForUpdates) { Settings.Prop.CheckForUpdates = true; @@ -335,10 +367,18 @@ protected override async void OnStartup(StartupEventArgs e) Locale.Set(Settings.Prop.Locale); + // Finish the public rebrand on existing installations. Without this, + // browser protocols and shortcuts can keep launching an old-named + // executable even after a user has installed SleepBlox. + if (rebrandingLegacyInstall) + Installer.MigrateLegacyInstallation(); + if (!LaunchSettings.BypassUpdateCheck) Installer.HandleUpgrade(); - if (!LaunchSettings.ClippingFlag.Active && await Services.AppUpdateService.CheckAndPromptAsync()) + bool isBackgroundHost = LaunchSettings.ClippingFlag.Active || LaunchSettings.ExperimentalFlag.Active || LaunchSettings.ChatShortcutsFlag.Active; + + if (!isBackgroundHost && await Services.AppUpdateService.CheckAndPromptAsync()) { SoftTerminate(); return; @@ -349,12 +389,18 @@ protected override async void OnStartup(StartupEventArgs e) WindowsRegistry.RegisterApis(); // we want to register those early on // so we wont have any issues with bloxshade - if (!LaunchSettings.ClippingFlag.Active) + if (!isBackgroundHost) Services.ClippingHostService.StopRemovedSleePlayer(); - if (!LaunchSettings.ClippingFlag.Active && Settings.Prop.ClippingEnabled) + if (!isBackgroundHost && Settings.Prop.ClippingEnabled) Services.ClippingHostService.EnsureStarted(); + if (!isBackgroundHost) + Services.ExperimentalClickerHostService.EnsureStarted(); + + if (!isBackgroundHost) + Services.ChatShortcutsHostService.EnsureStarted(); + LaunchHandler.ProcessLaunchArgs(); } diff --git a/SleepStrap/Bootstrapper.cs b/SleepStrap/Bootstrapper.cs index 065379b..01798cd 100644 --- a/SleepStrap/Bootstrapper.cs +++ b/SleepStrap/Bootstrapper.cs @@ -75,7 +75,7 @@ public class Bootstrapper public bool IsStudioLaunch => _launchMode != LaunchMode.Player; - public string MutexName { get; set; } = "SleepStrap-Bootstrapper"; + public string MutexName { get; set; } = App.BootstrapperMutexName; public bool QuitIfMutexExists { get; set; } = false; #endregion @@ -270,7 +270,9 @@ public async Task Run() if (AppData.State.VersionGuid != _latestVersionGuid || _mustUpgrade) { - bool backgroundUpdaterMutexOpen = Utilities.DoesMutexExist("SleepStrap-BackgroundUpdater"); + bool backgroundUpdaterMutexOpen = Utilities.DoesAnyMutexExist( + App.BackgroundUpdaterMutexName, + App.LegacyBackgroundUpdaterMutexName); if (App.LaunchSettings.BackgroundUpdaterFlag.Active) backgroundUpdaterMutexOpen = false; // we want to actually update lol @@ -606,7 +608,7 @@ private static void LaunchMultiInstanceWatcher() return; } - using EventWaitHandle initEventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, "SleepStrap-MultiInstanceWatcherInitialisationFinished"); + using EventWaitHandle initEventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, App.MultiInstanceWatcherEventName); Process.Start(Paths.Process, "-multiinstancewatcher"); bool initSuccess = initEventHandle.WaitOne(TimeSpan.FromSeconds(2)); @@ -1253,7 +1255,7 @@ private static void StartBackgroundUpdater() { const string LOG_IDENT = "Bootstrapper::StartBackgroundUpdater"; - if (Utilities.DoesMutexExist("SleepStrap-BackgroundUpdater")) + if (Utilities.DoesAnyMutexExist(App.BackgroundUpdaterMutexName, App.LegacyBackgroundUpdaterMutexName)) { App.Logger.WriteLine(LOG_IDENT, "Background updater already running"); return; @@ -1432,14 +1434,14 @@ private async Task ApplyModifications() var currentModFiles = new HashSet(modFolderFiles, StringComparer.OrdinalIgnoreCase); var previouslyAppliedMods = new HashSet(App.RobloxState.Prop.ModManifest, StringComparer.OrdinalIgnoreCase); string managedTextureRepairMarker = Path.Combine( - Paths.SleepStrapData, + Paths.SleepBloxData, "State", $"managed-texture-repair-v2-{Path.GetFileName(_latestVersionDirectory)}.complete"); bool repairManagedTextures = !File.Exists(managedTextureRepairMarker); bool managedTextureRepairFailed = false; // Builds before this repair could forget removed texture entries after an - // unsuccessful package restore. Reconcile every SleepStrap-owned texture once + // unsuccessful package restore. Reconcile every SleepBlox-owned texture once // for the active Roblox version, while leaving currently enabled layers alone. if (repairManagedTextures) previouslyAppliedMods.UnionWith(VisualModService.GetManagedTextureModificationPaths()); diff --git a/SleepStrap/Enums/BootstrapperIcon.cs b/SleepStrap/Enums/BootstrapperIcon.cs index 4f1437c..31fdb5a 100644 --- a/SleepStrap/Enums/BootstrapperIcon.cs +++ b/SleepStrap/Enums/BootstrapperIcon.cs @@ -2,9 +2,9 @@ namespace SleepStrap.Enums { public enum BootstrapperIcon { - [EnumName(StaticName = "SleepStrap")] + [EnumName(StaticName = "SleepBlox")] IconSleepStrap, - [EnumName(StaticName = "SleepStrap (Legacy)")] + [EnumName(StaticName = "SleepBlox (Legacy)")] LegacySleepStrap, [EnumName(StaticName = "2008")] Icon2008, @@ -22,7 +22,7 @@ public enum BootstrapperIcon Icon2022, [EnumName(FromTranslation = "Common.Custom")] IconCustom, - [EnumName(StaticName = "SleepStrap (Legacy Classic)")] + [EnumName(StaticName = "SleepBlox (Legacy Classic)")] LegacySleepStrapClassic } } diff --git a/SleepStrap/Enums/BootstrapperStyle.cs b/SleepStrap/Enums/BootstrapperStyle.cs index 5073baa..ccc148d 100644 --- a/SleepStrap/Enums/BootstrapperStyle.cs +++ b/SleepStrap/Enums/BootstrapperStyle.cs @@ -8,7 +8,7 @@ public enum BootstrapperStyle ProgressDialog, ClassicFluentDialog, ByfronDialog, - [EnumName(StaticName = "SleepStrap")] + [EnumName(StaticName = "SleepBlox")] FluentDialog, FluentAeroDialog, CustomDialog diff --git a/SleepStrap/Installer.cs b/SleepStrap/Installer.cs index 6abd9fd..debff25 100644 --- a/SleepStrap/Installer.cs +++ b/SleepStrap/Installer.cs @@ -59,26 +59,7 @@ public void DoInstall() } } - using (var uninstallKey = Registry.CurrentUser.CreateSubKey(App.UninstallKey)) - { - uninstallKey.SetValueSafe("DisplayIcon", $"{Paths.Application},0"); - uninstallKey.SetValueSafe("DisplayName", App.ProjectName); - - uninstallKey.SetValueSafe("DisplayVersion", App.Version); - - if (uninstallKey.GetValue("InstallDate") is null) - uninstallKey.SetValueSafe("InstallDate", DateTime.Now.ToString("yyyyMMdd")); - - uninstallKey.SetValueSafe("InstallLocation", Paths.Base); - uninstallKey.SetValueSafe("NoRepair", 1); - uninstallKey.SetValueSafe("Publisher", App.ProjectOwner); - uninstallKey.SetValueSafe("ModifyPath", $"\"{Paths.Application}\" -settings"); - uninstallKey.SetValueSafe("QuietUninstallString", $"\"{Paths.Application}\" -uninstall -quiet"); - uninstallKey.SetValueSafe("UninstallString", $"\"{Paths.Application}\" -uninstall"); - uninstallKey.SetValueSafe("HelpLink", App.ProjectHelpLink); - uninstallKey.SetValueSafe("URLInfoAbout", App.ProjectSupportLink); - uninstallKey.SetValueSafe("URLUpdateInfo", App.ProjectDownloadLink); - } + WriteUninstallRegistration(); WindowsRegistry.RegisterApis(); @@ -92,7 +73,8 @@ public void DoInstall() if (CreateStartMenuShortcuts) Shortcut.Create(Paths.Application, "", StartMenuShortcut); - // Keep configuration from an earlier SleepStrap installation. + // Keep configuration from the existing installation, including one + // discovered through the legacy SleepStrap uninstall registration. App.Settings.Load(false); App.State.Load(false); App.FastFlags.Load(false); @@ -106,6 +88,73 @@ public void DoInstall() } + /// + /// Promotes an existing SleepStrap installation to the public SleepBlox + /// identity without moving its directory, settings, Roblox modifications, + /// or version cache. This runs once: the new uninstall registration causes + /// later startups to use the SleepBlox identity directly. + /// + public static void MigrateLegacyInstallation() + { + const string LOG_IDENT = "Installer::MigrateLegacyInstallation"; + + try + { + if (!Paths.Initialized || !File.Exists(Paths.Application)) + return; + + WriteUninstallRegistration(); + WindowsRegistry.RegisterApis(); + WindowsRegistry.RegisterPlayer(); + + if (App.IsStudioVisible) + WindowsRegistry.RegisterStudio(); + + Shortcut.Create(Paths.Application, "", DesktopShortcut); + Shortcut.Create(Paths.Application, "", StartMenuShortcut); + + // Preserve existing desktop/start-menu/browser shortcuts while making + // the old executable name run the rebranded binary too. Do not try to + // overwrite it when this very process is still running under that name. + string legacyExecutable = Path.Combine(Paths.Base, $"{App.LegacyProjectName}.exe"); + if (!String.Equals(Paths.Process, legacyExecutable, StringComparison.OrdinalIgnoreCase) && + File.Exists(legacyExecutable)) + { + File.Copy(Paths.Application, legacyExecutable, true); + } + + App.Logger.WriteLine(LOG_IDENT, "Migrated the legacy installation to the SleepBlox identity"); + } + catch (Exception ex) + { + // The active app remains usable even if an old shortcut or registry + // key cannot be rewritten. A later SleepBlox launch will retry. + App.Logger.WriteLine(LOG_IDENT, "Could not complete the legacy-install migration"); + App.Logger.WriteException(LOG_IDENT, ex); + } + } + + private static void WriteUninstallRegistration() + { + using RegistryKey uninstallKey = Registry.CurrentUser.CreateSubKey(App.UninstallKey); + uninstallKey.SetValueSafe("DisplayIcon", $"{Paths.Application},0"); + uninstallKey.SetValueSafe("DisplayName", App.ProjectName); + uninstallKey.SetValueSafe("DisplayVersion", App.Version); + + if (uninstallKey.GetValue("InstallDate") is null) + uninstallKey.SetValueSafe("InstallDate", DateTime.Now.ToString("yyyyMMdd")); + + uninstallKey.SetValueSafe("InstallLocation", Paths.Base); + uninstallKey.SetValueSafe("NoRepair", 1); + uninstallKey.SetValueSafe("Publisher", App.ProjectOwner); + uninstallKey.SetValueSafe("ModifyPath", $"\"{Paths.Application}\" -settings"); + uninstallKey.SetValueSafe("QuietUninstallString", $"\"{Paths.Application}\" -uninstall -quiet"); + uninstallKey.SetValueSafe("UninstallString", $"\"{Paths.Application}\" -uninstall"); + uninstallKey.SetValueSafe("HelpLink", App.ProjectHelpLink); + uninstallKey.SetValueSafe("URLInfoAbout", App.ProjectSupportLink); + uninstallKey.SetValueSafe("URLUpdateInfo", App.ProjectDownloadLink); + } + private bool ValidateLocation() { // prevent from installing to the root of a drive @@ -413,14 +462,14 @@ public static void HandleUpgrade() { int closedProcessCount = Services.ProcessShutdownService.CloseOtherSleepStrapProcesses(); if (closedProcessCount > 0) - App.Logger.WriteLine(LOG_IDENT, $"Closed {closedProcessCount} other SleepStrap process(es)"); + App.Logger.WriteLine(LOG_IDENT, $"Closed {closedProcessCount} other {App.ProjectName} process(es)"); } catch (Exception ex) { - App.Logger.WriteLine(LOG_IDENT, "Could not close other SleepStrap processes"); + App.Logger.WriteLine(LOG_IDENT, $"Could not close other {App.ProjectName} processes"); App.Logger.WriteException(LOG_IDENT, ex); Frontend.ShowMessageBox( - $"SleepStrap could not close its other running processes.\n\n{ex.Message}", + $"{App.ProjectName} could not close its other running processes.\n\n{ex.Message}", MessageBoxImage.Error); return; } @@ -618,7 +667,7 @@ public static void HandleUpgrade() { #pragma warning disable CS0162 // Unreachable code detected if (OpenReleaseNotes) - Utilities.ShellExecute($"https://github.com/{App.ProjectRepository}/wiki/Release-notes-for-SleepStrap-v{currentVer}"); + Utilities.ShellExecute(App.ProjectDownloadLink); #pragma warning restore CS0162 // Unreachable code detected } else diff --git a/SleepStrap/JsonManager.cs b/SleepStrap/JsonManager.cs index cb516de..24c719f 100644 --- a/SleepStrap/JsonManager.cs +++ b/SleepStrap/JsonManager.cs @@ -66,8 +66,9 @@ public virtual void Load(bool alertFailure = true) try { - // Create a backup of loaded file - File.Copy(FileLocation, FileLocation + ".bak", true); + // Preserve the damaged file so settings can be recovered or inspected. + if (File.Exists(FileLocation)) + File.Copy(FileLocation, FileLocation + $".corrupt-{DateTime.Now:yyyyMMdd-HHmmss}.bak", true); } catch (Exception copyEx) { @@ -92,7 +93,25 @@ public virtual void Save() { string contents = JsonSerializer.Serialize(Prop, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(FileLocation, contents); + string temporaryPath = FileLocation + $".{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"; + try + { + // Complete the new file before replacing the old one. A crash or + // power loss can no longer leave a partially written JSON file. + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + using (var writer = new StreamWriter(stream, new System.Text.UTF8Encoding(false))) + { + writer.Write(contents); + writer.Flush(); + stream.Flush(true); + } + + File.Move(temporaryPath, FileLocation, true); + } + finally + { + try { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } catch { } + } LastFileHash = MD5Hash.FromString(contents); } diff --git a/SleepStrap/LaunchHandler.cs b/SleepStrap/LaunchHandler.cs index d0c48b5..9455f91 100644 --- a/SleepStrap/LaunchHandler.cs +++ b/SleepStrap/LaunchHandler.cs @@ -55,6 +55,16 @@ public static void ProcessLaunchArgs() App.Logger.WriteLine(LOG_IDENT, "Opening persistent clipping service"); Services.ClippingHostService.RunHostMode(); } + else if (App.LaunchSettings.ExperimentalFlag.Active) + { + App.Logger.WriteLine(LOG_IDENT, "Opening persistent experimental input service"); + Services.ExperimentalClickerHostService.RunHostMode(); + } + else if (App.LaunchSettings.ChatShortcutsFlag.Active) + { + App.Logger.WriteLine(LOG_IDENT, "Opening persistent chat shortcuts service"); + Services.ChatShortcutsHostService.RunHostMode(); + } else if (App.LaunchSettings.MenuFlag.Active) { App.Logger.WriteLine(LOG_IDENT, "Opening settings"); @@ -131,7 +141,7 @@ public static void LaunchInstaller() else { #if QA_BUILD - Frontend.ShowMessageBox("You are about to install a QA build of SleepStrap. The red window border indicates that this is a QA build.\n\nQA builds are handled completely separately of your standard installation, like a virtual environment.", MessageBoxImage.Information); + Frontend.ShowMessageBox("You are about to install a QA build of SleepBlox. The red window border indicates that this is a QA build.\n\nQA builds are handled completely separately of your standard installation, like a virtual environment.", MessageBoxImage.Information); #endif new LanguageSelectorDialog().ShowDialog(); @@ -268,7 +278,7 @@ public static void LaunchRoblox(LaunchMode launchMode, bool keepSettingsOpen = f App.Logger.WriteLine(LOG_IDENT, "Could not close Roblox before launch"); App.Logger.WriteException(LOG_IDENT, ex); Frontend.ShowMessageBox( - $"SleepStrap could not close Roblox before launching it.\n\n{ex.Message}", + $"{App.ProjectName} could not close Roblox before launching it.\n\n{ex.Message}", MessageBoxImage.Error); if (!keepSettingsOpen) App.Terminate(ErrorCode.ERROR_INSTALL_FAILURE); @@ -387,7 +397,7 @@ public static void LaunchBackgroundUpdater() App.Logger.WriteLine(LOG_IDENT, "Initializing bootstrapper"); App.Bootstrapper = new Bootstrapper(LaunchMode.Player) { - MutexName = "SleepStrap-BackgroundUpdater", + MutexName = App.BackgroundUpdaterMutexName, QuitIfMutexExists = true }; @@ -396,7 +406,7 @@ public static void LaunchBackgroundUpdater() Task.Run(() => { App.Logger.WriteLine(LOG_IDENT, "Started event waiter"); - using (EventWaitHandle handle = new EventWaitHandle(false, EventResetMode.AutoReset, "SleepStrap-BackgroundUpdaterKillEvent")) + using (EventWaitHandle handle = new EventWaitHandle(false, EventResetMode.AutoReset, App.BackgroundUpdaterKillEventName)) handle.WaitOne(); App.Logger.WriteLine(LOG_IDENT, "Received close event, killing it all!"); diff --git a/SleepStrap/LaunchSettings.cs b/SleepStrap/LaunchSettings.cs index cc5d946..624c960 100644 --- a/SleepStrap/LaunchSettings.cs +++ b/SleepStrap/LaunchSettings.cs @@ -46,10 +46,13 @@ public class LaunchSettings public LaunchFlag ClippingFlag { get; } = new("clipping"); + public LaunchFlag ExperimentalFlag { get; } = new("experimental"); + public LaunchFlag ChatShortcutsFlag { get; } = new("chatshortcuts"); + #if DEBUG public bool BypassUpdateCheck => true; #else - public bool BypassUpdateCheck => UninstallFlag.Active || WatcherFlag.Active || ClippingFlag.Active; + public bool BypassUpdateCheck => UninstallFlag.Active || WatcherFlag.Active || ClippingFlag.Active || ExperimentalFlag.Active || ChatShortcutsFlag.Active; #endif public LaunchMode RobloxLaunchMode { get; set; } = LaunchMode.None; diff --git a/SleepStrap/Logger.cs b/SleepStrap/Logger.cs index c6bb3d5..7e00d2d 100644 --- a/SleepStrap/Logger.cs +++ b/SleepStrap/Logger.cs @@ -53,7 +53,7 @@ public void Initialize(bool useTempDir = false) if (NoWriteMode) return; - WriteLine(LOG_IDENT, $"Failed to initialize because SleepStrap cannot write to {directory}"); + WriteLine(LOG_IDENT, $"Failed to initialize because {App.ProjectName} cannot write to {directory}"); Frontend.ShowMessageBox( String.Format(Strings.Logger_NoWriteMode, directory), diff --git a/SleepStrap/Models/Persistable/Settings.cs b/SleepStrap/Models/Persistable/Settings.cs index ae71bc7..30a1842 100644 --- a/SleepStrap/Models/Persistable/Settings.cs +++ b/SleepStrap/Models/Persistable/Settings.cs @@ -14,6 +14,7 @@ public class Settings public bool DeveloperMode { get; set; } = false; public bool ForceLocalData { get; set; } = false; public bool CheckForUpdates { get; set; } = true; + public string LastShownChangelogVersion { get; set; } = ""; public bool MultiInstanceLaunching { get; set; } = false; public bool ConfirmLaunches { get; set; } = true; public string Locale { get; set; } = "nil"; @@ -30,6 +31,8 @@ public class Settings public bool BackgroundUpdatesEnabled { get; set; } = false; public bool CloseSleepStrapOnLaunch { get; set; } = false; public bool OverrideLegacyBloxstrapSettings { get; set; } = false; + // Local display-only Roblox chat labels. Disabled by default. + public bool ChatShortcutsEnabled { get; set; } = false; public Dictionary LegacyBloxstrapFlagBackup { get; set; } = new(); public bool DebugDisableVersionPackageCleanup { get; set; } = false; public WebEnvironment WebEnvironment { get; set; } = WebEnvironment.Production; @@ -56,8 +59,15 @@ public class Settings public Dictionary RtxShineFlagBackup { get; set; } = new(); public string SelectedFontName { get; set; } = "Roblox Default"; public string SelectedFontSource { get; set; } = ""; + public string SelectedFontColor { get; set; } = "#FFFFFF"; public bool CustomSkyboxEnabled { get; set; } = false; public string CustomSkyboxSourceName { get; set; } = ""; + public List FavoriteSkyboxes { get; set; } = new(); + // This is intentionally separate from CustomSkyboxEnabled: the latter + // describes the selected sky (including bundled presets), while this is + // the master switch for locally imported user panoramas. + public bool CustomImportedSkyboxesEnabled { get; set; } = false; + public List UserSkyboxes { get; set; } = new(); // SleepStrap Rivals display stretch public bool RivalsStretchEnabled { get; set; } = false; @@ -69,6 +79,8 @@ public class Settings public Dictionary RivalsFpsFlagBackup { get; set; } = new(); public bool RivalsFpsCounterEnabled { get; set; } = false; public Dictionary RivalsFpsCounterFlagBackup { get; set; } = new(); + public bool NvidiaBlurredTexturesEnabled { get; set; } = false; + public Dictionary NvidiaBlurredTexturesProfileBackup { get; set; } = new(); // SleepStrap replay buffer public bool ClippingEnabled { get; set; } = false; @@ -83,16 +95,23 @@ public class Settings public string ClippingMicrophoneDevice { get; set; } = ""; public int ClippingMicrophoneVolume { get; set; } = 70; - // SleepStrap RIVALS loadout macro + // Experimental input tools + public bool ExperimentalAutoClickerEnabled { get; set; } = false; + public int ExperimentalAutoClickerHotkeyModifiers { get; set; } = 0; + public int ExperimentalAutoClickerHotkeyVirtualKey { get; set; } = 0x75; // F6 + public int ExperimentalAutoClickerClicksPerSecond { get; set; } = 12; + public bool ExperimentalRobloxHoldToSpamEnabled { get; set; } = false; + public int ExperimentalRobloxHoldToSpamClicksPerSecond { get; set; } = 12; + // When enabled, hold-to-spam stays inactive until the local Roblox HUD + // scanner sees the Hand Gun/Handgun weapon label in the lower-right area. + public bool ExperimentalRobloxHoldToSpamHandgunOnly { get; set; } = false; + + // SleepStrap RIVALS grid loadout and hourly rejoin public List MacroMissingWeapons { get; set; } = new(); public string MacroPrimaryWeapon { get; set; } = "Distortion"; public string MacroSecondaryWeapon { get; set; } = "Warper"; public string MacroMeleeWeapon { get; set; } = "Maul"; public string MacroUtilityWeapon { get; set; } = "Grappler"; - public bool MacroUseListLayout { get; set; } = false; - public bool MacroQuickRespawn { get; set; } = false; - public bool MacroAutoUtility { get; set; } = false; - public bool MacroAutoInspect { get; set; } = false; public bool MacroAutoRejoinHourly { get; set; } = false; } } diff --git a/SleepStrap/Models/SkyboxChoice.cs b/SleepStrap/Models/SkyboxChoice.cs index e618042..352836d 100644 --- a/SleepStrap/Models/SkyboxChoice.cs +++ b/SleepStrap/Models/SkyboxChoice.cs @@ -1,6 +1,96 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; using System.Windows.Media; namespace SleepStrap.Models { - public sealed record SkyboxChoice(string Name, string ResourceFolder, ImageSource? Preview, bool IsNone = false); + public sealed class SkyboxChoice : INotifyPropertyChanged + { + public SkyboxChoice( + string name, + string resourceFolder, + ImageSource? preview, + bool isNone = false, + string? selectionKey = null, + bool isUserImported = false, + bool isAvailable = true) + { + Name = name; + ResourceFolder = resourceFolder; + Preview = preview; + IsNone = isNone; + SelectionKey = selectionKey ?? resourceFolder; + IsUserImported = isUserImported; + _isAvailable = isAvailable; + } + + public string Name { get; } + public string ResourceFolder { get; } + public ImageSource? Preview { get; } + public bool IsNone { get; } + /// + /// Stable persisted value used to identify this sky. It is distinct from + /// so user-created skies may use any display name. + /// + public string SelectionKey { get; } + public bool IsUserImported { get; } + public int OriginalIndex { get; set; } + + private bool _isAvailable; + /// + /// Whether this choice can be selected right now. Imported skies mirror + /// the CustomImportedSkyboxesEnabled master switch. + /// + public bool IsAvailable + { + get => _isAvailable; + set + { + if (_isAvailable == value) return; + _isAvailable = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsAvailable))); + } + } + + private bool _isFavorite; + public bool IsFavorite + { + get => _isFavorite; + set + { + if (_isFavorite == value) return; + _isFavorite = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsFavorite))); + } + } + + private bool _isRenaming; + public bool IsRenaming + { + get => _isRenaming; + set + { + if (_isRenaming == value) return; + _isRenaming = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRenaming))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsNotRenaming))); + } + } + + public bool IsNotRenaming => !IsRenaming; + + private string _editName = String.Empty; + public string EditName + { + get => _editName; + set + { + if (String.Equals(_editName, value, StringComparison.Ordinal)) return; + _editName = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(EditName))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + } } diff --git a/SleepStrap/Models/UserSkyboxDefinition.cs b/SleepStrap/Models/UserSkyboxDefinition.cs new file mode 100644 index 0000000..b9856c5 --- /dev/null +++ b/SleepStrap/Models/UserSkyboxDefinition.cs @@ -0,0 +1,15 @@ +namespace SleepStrap.Models +{ + /// + /// Metadata for a panorama imported by the user. The display name is never used + /// as a file-system path; owns the on-disk folder instead. + /// + public sealed class UserSkyboxDefinition + { + public string Id { get; set; } = ""; + + public string Name { get; set; } = ""; + + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + } +} diff --git a/SleepStrap/MultiInstanceWatcher.cs b/SleepStrap/MultiInstanceWatcher.cs index 1cf04f8..7bcec7f 100644 --- a/SleepStrap/MultiInstanceWatcher.cs +++ b/SleepStrap/MultiInstanceWatcher.cs @@ -9,7 +9,10 @@ private static int GetOpenProcessesCount() try { // Prevent race conditions by checking for other launcher processes too. - int count = Process.GetProcesses().Count(x => x.ProcessName is "RobloxPlayerBeta" or "SleepStrap"); + int count = Process.GetProcesses().Count(x => + x.ProcessName.Equals("RobloxPlayerBeta", StringComparison.OrdinalIgnoreCase) + || x.ProcessName.Equals(App.ProjectName, StringComparison.OrdinalIgnoreCase) + || x.ProcessName.Equals(App.LegacyProjectName, StringComparison.OrdinalIgnoreCase)); count -= 1; // ignore the current process return count; } @@ -23,7 +26,7 @@ private static int GetOpenProcessesCount() private static void FireInitialisedEvent() { - using EventWaitHandle initEventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, "SleepStrap-MultiInstanceWatcherInitialisationFinished"); + using EventWaitHandle initEventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, App.MultiInstanceWatcherEventName); initEventHandle.Set(); } diff --git a/SleepStrap/Paths.cs b/SleepStrap/Paths.cs index 7d03ef5..c98b554 100644 --- a/SleepStrap/Paths.cs +++ b/SleepStrap/Paths.cs @@ -26,7 +26,7 @@ static class Paths public static string Modifications { get; private set; } = ""; public static string Roblox { get; private set; } = ""; public static string CustomThemes { get; private set; } = ""; - public static string SleepStrapData { get; private set; } = ""; + public static string SleepBloxData { get; private set; } = ""; public static string Playbacks { get; private set; } = ""; // cleaner paths @@ -48,7 +48,27 @@ public static void Initialize(string baseDirectory) Versions = Path.Combine(Base, "Versions"); Modifications = Path.Combine(Base, "Modifications"); CustomThemes = Path.Combine(Base, "CustomThemes"); - SleepStrapData = Path.Combine(Base, "SleepStrapData"); + string rebrandedDataDirectory = Path.Combine(Base, "SleepBloxData"); + string legacyDataDirectory = Path.Combine(Base, "SleepStrapData"); + + // Carry forward custom skyboxes, font backups, and material backups + // from builds that used the old folder name. This happens before any + // settings or visual-mod services access the directory. + if (!Directory.Exists(rebrandedDataDirectory) && Directory.Exists(legacyDataDirectory)) + { + try + { + Directory.Move(legacyDataDirectory, rebrandedDataDirectory); + } + catch + { + // Keep using the old directory for this launch if another + // process has it open. A later launch will retry the move. + rebrandedDataDirectory = legacyDataDirectory; + } + } + + SleepBloxData = rebrandedDataDirectory; Playbacks = Path.Combine(Base, "Playbacks"); Roblox = Path.Combine(LocalAppData, "Roblox"); // that was base before? diff --git a/SleepStrap/Properties/PublishProfiles/Publish-x64.pubxml b/SleepStrap/Properties/PublishProfiles/Publish-x64.pubxml index 6f0ac2c..8e4994e 100644 --- a/SleepStrap/Properties/PublishProfiles/Publish-x64.pubxml +++ b/SleepStrap/Properties/PublishProfiles/Publish-x64.pubxml @@ -5,11 +5,11 @@ https://go.microsoft.com/fwlink/?LinkID=208121. Release - Any CPU + x64 bin\Release\net6.0-windows\publish\win-x64\ FileSystem <_TargetId>Folder - net6.0-windows + net6.0-windows10.0.19041.0 win-x64 true true diff --git a/SleepStrap/Properties/launchSettings.json b/SleepStrap/Properties/launchSettings.json index c0f8e78..913c47a 100644 --- a/SleepStrap/Properties/launchSettings.json +++ b/SleepStrap/Properties/launchSettings.json @@ -1,39 +1,39 @@ { "profiles": { - "SleepStrap (Launch)": { + "SleepBlox (Launch)": { "commandName": "Project" }, - "SleepStrap (Quiet Launch)": { + "SleepBlox (Quiet Launch)": { "commandName": "Project", "commandLineArgs": "-quiet" }, - "SleepStrap (Uninstall)": { + "SleepBlox (Uninstall)": { "commandName": "Project", "commandLineArgs": "-uninstall" }, - "SleepStrap (Quiet Uninstall)": { + "SleepBlox (Quiet Uninstall)": { "commandName": "Project", "commandLineArgs": "-uninstall -quiet" }, - "SleepStrap (Menu)": { + "SleepBlox (Menu)": { "commandName": "Project", "commandLineArgs": "-menu" }, - "SleepStrap (Deeplink)": { + "SleepBlox (Deeplink)": { "commandName": "Project", "commandLineArgs": "-player \"roblox://experiences/start?placeId=13700835620\"" }, - "SleepStrap (Studio Launch)": { + "SleepBlox (Studio Launch)": { "commandName": "Project", "commandLineArgs": "-studio" }, - "SleepStrap (Watcher)": { + "SleepBlox (Watcher)": { "commandName": "Project", "commandLineArgs": "-watcher" }, - "SleepStrap (Bloxshade)": { + "SleepBlox (Bloxshade)": { "commandName": "Project", "commandLineArgs": "-bloxshade" } } -} \ No newline at end of file +} diff --git a/SleepStrap/Resources/CustomBootstrapperSchema.json b/SleepStrap/Resources/CustomBootstrapperSchema.json index 4b313fc..fdd8bf5 100644 --- a/SleepStrap/Resources/CustomBootstrapperSchema.json +++ b/SleepStrap/Resources/CustomBootstrapperSchema.json @@ -38,7 +38,7 @@ "FontFamily": "FontFamily" } }, - "SleepStrapCustomBootstrapper": { + "SleepBloxCustomBootstrapper": { "SuperClass": "Control", "IsCreatable": true, "Attributes": { diff --git a/SleepStrap/Resources/CustomBootstrapperTemplate.xml b/SleepStrap/Resources/CustomBootstrapperTemplate.xml index 80649ae..2cdbdb0 100644 --- a/SleepStrap/Resources/CustomBootstrapperTemplate.xml +++ b/SleepStrap/Resources/CustomBootstrapperTemplate.xml @@ -1,4 +1,4 @@ - + - \ No newline at end of file + \ No newline at end of file diff --git a/SleepStrap/Resources/CustomBootstrapperTemplate_Blank.xml b/SleepStrap/Resources/CustomBootstrapperTemplate_Blank.xml index 1485934..9aca355 100644 --- a/SleepStrap/Resources/CustomBootstrapperTemplate_Blank.xml +++ b/SleepStrap/Resources/CustomBootstrapperTemplate_Blank.xml @@ -1,4 +1,4 @@ - + - \ No newline at end of file + \ No newline at end of file diff --git a/SleepStrap/Resources/CustomBootstrapperTemplate_Simple.xml b/SleepStrap/Resources/CustomBootstrapperTemplate_Simple.xml index fc7ba3f..5185b36 100644 --- a/SleepStrap/Resources/CustomBootstrapperTemplate_Simple.xml +++ b/SleepStrap/Resources/CustomBootstrapperTemplate_Simple.xml @@ -1,4 +1,4 @@ - + @@ -6,4 +6,4 @@ + + + @@ -102,6 +155,21 @@ + + + + + + + + + + + + + + + diff --git a/SleepStrap/UI/Elements/Settings/Pages/SkyboxPage.xaml.cs b/SleepStrap/UI/Elements/Settings/Pages/SkyboxPage.xaml.cs index 2259ef9..0b89c70 100644 --- a/SleepStrap/UI/Elements/Settings/Pages/SkyboxPage.xaml.cs +++ b/SleepStrap/UI/Elements/Settings/Pages/SkyboxPage.xaml.cs @@ -1,13 +1,177 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; +using SleepStrap.Models; using SleepStrap.UI.ViewModels.Settings; namespace SleepStrap.UI.Elements.Settings.Pages { public partial class SkyboxPage { + private readonly DispatcherTimer _favoriteTimer = new() { Interval = TimeSpan.FromMilliseconds(500) }; + private SkyboxChoice? _pressedChoice; + private bool _favoriteTriggered; + public SkyboxPage() { DataContext = new SkyboxViewModel(); InitializeComponent(); + if (DataContext is SkyboxViewModel viewModel) + viewModel.SkyboxChoices.CollectionChanged += SkyboxChoices_CollectionChanged; + _favoriteTimer.Tick += (_, _) => + { + _favoriteTimer.Stop(); + _favoriteTriggered = true; + (DataContext as SkyboxViewModel)?.ToggleFavorite(_pressedChoice); + }; + } + + private void SkyboxChoices_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(AnimateCards)); + } + + private void AnimateCards() + { + foreach (object item in SkyboxList.Items) + { + if (SkyboxList.ItemContainerGenerator.ContainerFromItem(item) is not ListBoxItem container) + continue; + + var offset = new TranslateTransform(0, -10); + container.RenderTransform = offset; + container.Opacity = 0.35; + var ease = new SineEase { EasingMode = EasingMode.EaseOut }; + container.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0.35, 1, TimeSpan.FromMilliseconds(280)) { EasingFunction = ease }); + offset.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(-10, 0, TimeSpan.FromMilliseconds(360)) { EasingFunction = ease }); + } + } + + private void SkyCard_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (IsInsideInteractiveControl(e.OriginalSource as DependencyObject)) + return; + _pressedChoice = (sender as FrameworkElement)?.DataContext as SkyboxChoice; + _favoriteTriggered = false; + if (sender is IInputElement input) + Mouse.Capture(input); + _favoriteTimer.Start(); + // Selection is committed on mouse-up. This keeps a long press from + // selecting a card before it is pinned. + e.Handled = true; + } + + private void SkyCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (IsInsideInteractiveControl(e.OriginalSource as DependencyObject)) + return; + + _favoriteTimer.Stop(); + Mouse.Capture(null); + if (!_favoriteTriggered && _pressedChoice is not null) + SkyboxList.SelectedItem = _pressedChoice; + e.Handled = true; + _pressedChoice = null; + } + + private void FavoriteStar_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is SkyboxChoice choice) + { + (DataContext as SkyboxViewModel)?.ToggleFavorite(choice); + e.Handled = true; + } + } + + private void RenameUserSkybox_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is not SkyboxChoice choice) + return; + + (DataContext as SkyboxViewModel)?.BeginRenameUserSkybox(choice); + if (FindParent((DependencyObject)sender) is Grid card && FindChild(card, "RenameSkyboxBox") is TextBox box) + Dispatcher.BeginInvoke(() => { box.Focus(); box.SelectAll(); }); + e.Handled = true; + } + + private void DeleteUserSkybox_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is not SkyboxChoice choice) + return; + + if (Frontend.ShowMessageBox($"Delete custom skybox '{choice.Name}'?", MessageBoxImage.Warning, MessageBoxButton.YesNo) == MessageBoxResult.Yes) + (DataContext as SkyboxViewModel)?.DeleteUserSkybox(choice); + e.Handled = true; + } + + private void RenameSkyboxBox_KeyDown(object sender, KeyEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is not SkyboxChoice choice) + return; + + if (e.Key == Key.Enter) + { + (DataContext as SkyboxViewModel)?.CommitRenameUserSkybox(choice); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + (DataContext as SkyboxViewModel)?.CancelRenameUserSkybox(choice); + e.Handled = true; + } + } + + private void RenameSkyboxBox_LostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) + { + if ((sender as FrameworkElement)?.DataContext is SkyboxChoice choice && choice.IsRenaming) + (DataContext as SkyboxViewModel)?.CommitRenameUserSkybox(choice); + } + + private static T? FindParent(DependencyObject? child) where T : DependencyObject + { + while (child is not null) + { + if (child is T match) return match; + child = VisualTreeHelper.GetParent(child); + } + return null; + } + + // The card uses preview mouse events for long-press favourites. A WPF UI + // SymbolIcon is not always a direct visual child of its Button, so a + // visual-only parent walk can accidentally swallow the rename/delete click. + private static bool IsInsideInteractiveControl(DependencyObject? child) + { + while (child is not null) + { + if (child is Button || child is TextBox) + return true; + + DependencyObject? visualParent = null; + try { visualParent = VisualTreeHelper.GetParent(child); } + catch (InvalidOperationException) { } + child = visualParent ?? LogicalTreeHelper.GetParent(child); + } + + return false; + } + + private static T? FindChild(DependencyObject parent, string? name = null) where T : FrameworkElement + { + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + DependencyObject child = VisualTreeHelper.GetChild(parent, i); + if (child is T typed && (name is null || typed.Name == name)) + return typed; + T? nested = FindChild(child, name); + if (nested is not null) + return nested; + } + + return null; } } } diff --git a/SleepStrap/UI/Elements/Settings/Pages/TexturesPage.xaml b/SleepStrap/UI/Elements/Settings/Pages/TexturesPage.xaml index addc8b9..69af5a0 100644 --- a/SleepStrap/UI/Elements/Settings/Pages/TexturesPage.xaml +++ b/SleepStrap/UI/Elements/Settings/Pages/TexturesPage.xaml @@ -60,8 +60,8 @@ - + @@ -116,8 +116,8 @@ - + @@ -182,6 +182,21 @@ Visibility="{Binding RestoreFontVisibility}" Margin="0,8,0,0" /> + + + + + + + + + + diff --git a/SleepStrap/UI/Elements/Settings/Pages/TexturesPage.xaml.cs b/SleepStrap/UI/Elements/Settings/Pages/TexturesPage.xaml.cs index 60c3c05..405c602 100644 --- a/SleepStrap/UI/Elements/Settings/Pages/TexturesPage.xaml.cs +++ b/SleepStrap/UI/Elements/Settings/Pages/TexturesPage.xaml.cs @@ -1,3 +1,7 @@ +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; using SleepStrap.UI.ViewModels.Settings; namespace SleepStrap.UI.Elements.Settings.Pages @@ -8,6 +12,65 @@ public TexturesPage() { DataContext = new VisualModsViewModel(); InitializeComponent(); + Loaded += (_, _) => { BuildFontColorWheel(); UpdateFontColorUi(); }; + } + + private void FontColorButton_Click(object sender, RoutedEventArgs e) + { + BuildFontColorWheel(); + UpdateFontColorUi(); + FontColorPopup.IsOpen = true; + } + + private void FontColorWheel_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + Point point = e.GetPosition(FontColorWheel); + double center = FontColorWheel.Width / 2; + double dx = point.X - center, dy = point.Y - center; + double radius = Math.Sqrt(dx * dx + dy * dy); + if (radius > center || DataContext is not VisualModsViewModel viewModel) + return; + double hue = (Math.Atan2(dy, dx) * 180 / Math.PI + 450) % 360; + Color color = HsvToColor(hue, Math.Clamp(radius / center, 0, 1), 1); + viewModel.FontColor = $"#{color.R:X2}{color.G:X2}{color.B:X2}"; + UpdateFontColorUi(); + } + + private void UpdateFontColorUi() + { + if (DataContext is not VisualModsViewModel viewModel || FontColorValue is null) + return; + FontColorValue.Text = viewModel.FontColor; + if (ColorConverter.ConvertFromString(viewModel.FontColor) is Color color) + FontColorSwatch.Background = new SolidColorBrush(color); + } + + private void BuildFontColorWheel() + { + const int size = 220; + var pixels = new byte[size * size * 4]; + double center = size / 2d; + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + double dx = x - center, dy = y - center; + double radius = Math.Sqrt(dx * dx + dy * dy) / center; + int index = (y * size + x) * 4; + if (radius > 1) { pixels[index + 3] = 0; continue; } + Color color = HsvToColor((Math.Atan2(dy, dx) * 180 / Math.PI + 450) % 360, radius, 1); + pixels[index] = color.B; pixels[index + 1] = color.G; pixels[index + 2] = color.R; pixels[index + 3] = 255; + } + var bitmap = new WriteableBitmap(size, size, 96, 96, PixelFormats.Bgra32, null); + bitmap.WritePixels(new Int32Rect(0, 0, size, size), pixels, size * 4, 0); + FontColorWheel.Source = bitmap; + } + + private static Color HsvToColor(double hue, double saturation, double value) + { + double c = value * saturation, x = c * (1 - Math.Abs(hue / 60 % 2 - 1)), m = value - c; + (double r, double g, double b) = hue switch + { < 60 => (c, x, 0d), < 120 => (x, c, 0d), < 180 => (0d, c, x), < 240 => (0d, x, c), < 300 => (x, 0d, c), _ => (c, 0d, x) }; + return Color.FromRgb((byte)((r + m) * 255), (byte)((g + m) * 255), (byte)((b + m) * 255)); } } } diff --git a/SleepStrap/UI/Elements/Settings/Pages/VisualModsPage.xaml b/SleepStrap/UI/Elements/Settings/Pages/VisualModsPage.xaml index 2153c37..49fd00e 100644 --- a/SleepStrap/UI/Elements/Settings/Pages/VisualModsPage.xaml +++ b/SleepStrap/UI/Elements/Settings/Pages/VisualModsPage.xaml @@ -27,7 +27,7 @@ - + @@ -35,7 +35,7 @@ - + diff --git a/SleepStrap/UI/Frontend.cs b/SleepStrap/UI/Frontend.cs index 89a9c3b..4a52f4f 100644 --- a/SleepStrap/UI/Frontend.cs +++ b/SleepStrap/UI/Frontend.cs @@ -30,7 +30,7 @@ public static void ShowPlayerErrorDialog(bool crash = false) string info = String.Format( Strings.Dialog_PlayerError_HelpInformation, $"https://github.com/{App.ProjectRepository}/wiki/Roblox-crashes-or-does-not-launch", - $"https://github.com/{App.ProjectRepository}/wiki/Switching-between-Roblox-and-SleepStrap" + App.ProjectHelpLink ); ShowMessageBox($"{topLine}\n\n{info}", MessageBoxImage.Error); diff --git a/SleepStrap/UI/NotifyIconWrapper.cs b/SleepStrap/UI/NotifyIconWrapper.cs index 8f1bb12..0c509d8 100644 --- a/SleepStrap/UI/NotifyIconWrapper.cs +++ b/SleepStrap/UI/NotifyIconWrapper.cs @@ -29,7 +29,7 @@ public NotifyIconWrapper(Watcher watcher) _notifyIcon = new(new System.ComponentModel.Container()) { Icon = Properties.Resources.IconSleepStrap, - Text = "SleepStrap", + Text = App.ProjectName, Visible = true }; diff --git a/SleepStrap/UI/Style/Dark.xaml b/SleepStrap/UI/Style/Dark.xaml index b56d90a..b8f17d7 100644 --- a/SleepStrap/UI/Style/Dark.xaml +++ b/SleepStrap/UI/Style/Dark.xaml @@ -1,4 +1,5 @@ - + diff --git a/SleepStrap/UI/Style/LiquidDX11.xaml b/SleepStrap/UI/Style/LiquidDX11.xaml new file mode 100644 index 0000000..f0a4fe2 --- /dev/null +++ b/SleepStrap/UI/Style/LiquidDX11.xaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SleepStrap/UI/ViewModels/Installer/WelcomeViewModel.cs b/SleepStrap/UI/ViewModels/Installer/WelcomeViewModel.cs index 622452c..a5fde66 100644 --- a/SleepStrap/UI/ViewModels/Installer/WelcomeViewModel.cs +++ b/SleepStrap/UI/ViewModels/Installer/WelcomeViewModel.cs @@ -5,7 +5,7 @@ public class WelcomeViewModel : NotifyPropertyChangedViewModel // formatting is done here instead of in xaml, it's just a bit easier public string MainText => String.Format( Strings.Installer_Welcome_MainText, - "[github.com/SleepyDebug/SleepStrap](https://github.com/SleepyDebug/SleepStrap)" + "[SleepBlox on GitHub](https://github.com/SleepyDebug/SleepStrap)" ); public bool CanContinue { get; set; } = false; diff --git a/SleepStrap/UI/ViewModels/Settings/ExperimentalViewModel.cs b/SleepStrap/UI/ViewModels/Settings/ExperimentalViewModel.cs new file mode 100644 index 0000000..b671fdf --- /dev/null +++ b/SleepStrap/UI/ViewModels/Settings/ExperimentalViewModel.cs @@ -0,0 +1,365 @@ +using System.IO; +using System.Windows; +using System.Windows.Input; + +using CommunityToolkit.Mvvm.Input; +using Microsoft.Win32; + +using SleepStrap.Services; + +namespace SleepStrap.UI.ViewModels.Settings +{ + public sealed class ExperimentalViewModel : NotifyPropertyChangedViewModel + { + private bool _isImportingCustomSkybox; + private string _customSkyboxName = ""; + private string _customSkyboxSourcePath = ""; + private IReadOnlyList _customSkyboxTexturePaths = Array.Empty(); + private string _customSkyboxStatus = "Choose a 2:1 panorama or 4x3 cube-cross PNG and give it a name."; + + public ExperimentalViewModel() + { + ChooseCustomSkyboxPngCommand = new RelayCommand(ChooseCustomSkyboxPng, () => !IsImportingCustomSkybox); + ImportCustomSkyboxCommand = new AsyncRelayCommand( + ImportCustomSkyboxAsync, + () => !IsImportingCustomSkybox && + !String.IsNullOrWhiteSpace(CustomSkyboxName) && + File.Exists(CustomSkyboxSourcePath)); + ChooseCustomSkyboxTexturesCommand = new RelayCommand(ChooseCustomSkyboxTextures, () => !IsImportingCustomSkybox); + ImportCustomSkyboxTexturesCommand = new AsyncRelayCommand( + ImportCustomSkyboxTexturesAsync, + () => !IsImportingCustomSkybox && + !String.IsNullOrWhiteSpace(CustomSkyboxName) && + _customSkyboxTexturePaths.Count == 6); + } + + public IRelayCommand ChooseCustomSkyboxPngCommand { get; } + public IAsyncRelayCommand ImportCustomSkyboxCommand { get; } + public IRelayCommand ChooseCustomSkyboxTexturesCommand { get; } + public IAsyncRelayCommand ImportCustomSkyboxTexturesCommand { get; } + + public bool AutoClickerEnabled + { + get => App.Settings.Prop.ExperimentalAutoClickerEnabled; + set + { + if (value == App.Settings.Prop.ExperimentalAutoClickerEnabled) + return; + + App.Settings.Prop.ExperimentalAutoClickerEnabled = value; + SaveAndRefresh(nameof(AutoClickerEnabled)); + OnPropertyChanged(nameof(AutoClickerStatus)); + } + } + + public int ClicksPerSecond + { + get => Math.Clamp(App.Settings.Prop.ExperimentalAutoClickerClicksPerSecond, 1, 50); + set + { + int clicksPerSecond = Math.Clamp(value, 1, 50); + if (clicksPerSecond == App.Settings.Prop.ExperimentalAutoClickerClicksPerSecond) + return; + + App.Settings.Prop.ExperimentalAutoClickerClicksPerSecond = clicksPerSecond; + SaveAndRefresh(nameof(ClicksPerSecond)); + } + } + + public bool RobloxHoldToSpamEnabled + { + get => App.Settings.Prop.ExperimentalRobloxHoldToSpamEnabled; + set + { + if (value == App.Settings.Prop.ExperimentalRobloxHoldToSpamEnabled) + return; + + App.Settings.Prop.ExperimentalRobloxHoldToSpamEnabled = value; + SaveAndRefresh(nameof(RobloxHoldToSpamEnabled)); + } + } + + public int RobloxHoldToSpamClicksPerSecond + { + get => Math.Clamp(App.Settings.Prop.ExperimentalRobloxHoldToSpamClicksPerSecond, 1, 50); + set + { + int clicksPerSecond = Math.Clamp(value, 1, 50); + if (clicksPerSecond == App.Settings.Prop.ExperimentalRobloxHoldToSpamClicksPerSecond) + return; + + App.Settings.Prop.ExperimentalRobloxHoldToSpamClicksPerSecond = clicksPerSecond; + SaveAndRefresh(nameof(RobloxHoldToSpamClicksPerSecond)); + } + } + + public bool RobloxHoldToSpamHandgunOnly + { + get => App.Settings.Prop.ExperimentalRobloxHoldToSpamHandgunOnly; + set + { + if (value == App.Settings.Prop.ExperimentalRobloxHoldToSpamHandgunOnly) + return; + + App.Settings.Prop.ExperimentalRobloxHoldToSpamHandgunOnly = value; + SaveAndRefresh(nameof(RobloxHoldToSpamHandgunOnly)); + } + } + + /// + /// Controls only user-imported skies. Preset skies remain available regardless of this setting. + /// Importing a PNG never changes this value, so imported skies stay disabled by default. + /// + public bool CustomImportedSkyboxesEnabled + { + get => App.Settings.Prop.CustomImportedSkyboxesEnabled; + set + { + if (value == App.Settings.Prop.CustomImportedSkyboxesEnabled) + return; + + App.Settings.Prop.CustomImportedSkyboxesEnabled = value; + + // If an imported sky is selected, disabling this master setting also + // disables that selection for the next launch. Preset skies are left alone. + if (!value && + App.Settings.Prop.CustomSkyboxEnabled && + UserSkyboxService.IsUserSkyboxKey(App.Settings.Prop.CustomSkyboxSourceName)) + { + App.Settings.Prop.CustomSkyboxEnabled = false; + App.Settings.Prop.CustomSkyboxSourceName = ""; + } + + App.Settings.Save(); + SkyboxGalleryService.NotifyGalleryChanged(); + OnPropertyChanged(nameof(CustomImportedSkyboxesEnabled)); + CustomSkyboxStatus = value + ? "Imported skyboxes are enabled. Pick one from Skybox when you are ready." + : "Imported skyboxes are disabled. Your saved skies stay here safely."; + } + } + + public string CustomSkyboxName + { + get => _customSkyboxName; + set + { + string name = value ?? ""; + if (String.Equals(_customSkyboxName, name, StringComparison.Ordinal)) + return; + + _customSkyboxName = name; + OnPropertyChanged(nameof(CustomSkyboxName)); + ImportCustomSkyboxCommand.NotifyCanExecuteChanged(); + ImportCustomSkyboxTexturesCommand.NotifyCanExecuteChanged(); + } + } + + public string CustomSkyboxSourcePath + { + get => _customSkyboxSourcePath; + private set + { + if (String.Equals(_customSkyboxSourcePath, value, StringComparison.Ordinal)) + return; + + _customSkyboxSourcePath = value; + OnPropertyChanged(nameof(CustomSkyboxSourcePath)); + OnPropertyChanged(nameof(CustomSkyboxSourceDisplay)); + ImportCustomSkyboxCommand.NotifyCanExecuteChanged(); + } + } + + public string CustomSkyboxSourceDisplay => String.IsNullOrWhiteSpace(CustomSkyboxSourcePath) + ? "No PNG selected" + : Path.GetFileName(CustomSkyboxSourcePath); + + public string CustomSkyboxTextureSetDisplay => _customSkyboxTexturePaths.Count == 0 + ? "Drop or choose all six sky512_*.tex files" + : $"{_customSkyboxTexturePaths.Count}/6 texture files selected"; + + public string CustomSkyboxStatus + { + get => _customSkyboxStatus; + private set + { + if (String.Equals(_customSkyboxStatus, value, StringComparison.Ordinal)) + return; + + _customSkyboxStatus = value; + OnPropertyChanged(nameof(CustomSkyboxStatus)); + } + } + + public bool IsImportingCustomSkybox + { + get => _isImportingCustomSkybox; + private set + { + if (_isImportingCustomSkybox == value) + return; + + _isImportingCustomSkybox = value; + OnPropertyChanged(nameof(IsImportingCustomSkybox)); + ChooseCustomSkyboxPngCommand.NotifyCanExecuteChanged(); + ImportCustomSkyboxCommand.NotifyCanExecuteChanged(); + ChooseCustomSkyboxTexturesCommand.NotifyCanExecuteChanged(); + ImportCustomSkyboxTexturesCommand.NotifyCanExecuteChanged(); + } + } + + public string AutoClickerHotkeyDisplay => FormatHotkey( + App.Settings.Prop.ExperimentalAutoClickerHotkeyModifiers, + App.Settings.Prop.ExperimentalAutoClickerHotkeyVirtualKey); + + public string AutoClickerStatus => AutoClickerEnabled + ? $"Ready — press {AutoClickerHotkeyDisplay} to toggle" + : "Off"; + + public void SetAutoClickerHotkey(int modifiers, int virtualKey) + { + App.Settings.Prop.ExperimentalAutoClickerHotkeyModifiers = modifiers; + App.Settings.Prop.ExperimentalAutoClickerHotkeyVirtualKey = virtualKey; + App.Settings.Save(); + ExperimentalClickerHostService.Refresh(); + OnPropertyChanged(nameof(AutoClickerHotkeyDisplay)); + OnPropertyChanged(nameof(AutoClickerStatus)); + } + + private void ChooseCustomSkyboxPng() + { + var dialog = new OpenFileDialog + { + Title = "Choose a 2:1 panorama or 4x3 cube-cross sky PNG", + Filter = "Sky PNG (*.png)|*.png" + }; + + if (dialog.ShowDialog() != true) + return; + + CustomSkyboxSourcePath = dialog.FileName; + if (String.IsNullOrWhiteSpace(CustomSkyboxName)) + CustomSkyboxName = Path.GetFileNameWithoutExtension(dialog.FileName); + + CustomSkyboxStatus = "Ready to save as a custom skybox. It will stay disabled until you enable imported skyboxes."; + } + + private async Task ImportCustomSkyboxAsync() + { + string sourcePath = CustomSkyboxSourcePath; + string displayName = CustomSkyboxName.Trim(); + if (String.IsNullOrWhiteSpace(displayName) || !File.Exists(sourcePath)) + return; + + try + { + IsImportingCustomSkybox = true; + CustomSkyboxStatus = "Converting the PNG into six Roblox skybox faces…"; + + // UserSkyboxService only stores the imported data under SleepStrap's data + // directory. It deliberately does not touch Roblox or its modifications folder. + await UserSkyboxService.ImportAsync(sourcePath, displayName); + + CustomSkyboxSourcePath = ""; + CustomSkyboxName = ""; + CustomSkyboxStatus = CustomImportedSkyboxesEnabled + ? $"{displayName} was added. You can select it in Skybox." + : $"{displayName} was added and remains disabled until you enable imported skyboxes."; + } + catch (Exception ex) + { + App.Logger.WriteException("ExperimentalViewModel::ImportCustomSkybox", ex); + Frontend.ShowMessageBox($"{App.ProjectName} could not import that skybox.\n\n{ex.Message}", MessageBoxImage.Error); + CustomSkyboxStatus = "Skybox import failed."; + } + finally + { + IsImportingCustomSkybox = false; + } + } + + public void SetCustomSkyboxTextureFiles(IEnumerable paths) + { + _customSkyboxTexturePaths = paths + .Where(path => !String.IsNullOrWhiteSpace(path) && + String.Equals(Path.GetExtension(path), ".tex", StringComparison.OrdinalIgnoreCase) && + File.Exists(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (String.IsNullOrWhiteSpace(CustomSkyboxName) && _customSkyboxTexturePaths.Count > 0) + CustomSkyboxName = new DirectoryInfo(Path.GetDirectoryName(_customSkyboxTexturePaths[0])!).Name; + + OnPropertyChanged(nameof(CustomSkyboxTextureSetDisplay)); + ImportCustomSkyboxTexturesCommand.NotifyCanExecuteChanged(); + CustomSkyboxStatus = _customSkyboxTexturePaths.Count == 6 + ? "Ready to add the six Roblox texture faces. It will stay disabled until you enable imported skyboxes." + : $"Select all six Roblox sky512 texture files ({_customSkyboxTexturePaths.Count}/6 selected)."; + } + + private void ChooseCustomSkyboxTextures() + { + var dialog = new OpenFileDialog + { + Title = "Choose all six Roblox sky512 texture files", + Filter = "Roblox sky textures (*.tex)|*.tex", + Multiselect = true + }; + + if (dialog.ShowDialog() == true) + SetCustomSkyboxTextureFiles(dialog.FileNames); + } + + private async Task ImportCustomSkyboxTexturesAsync() + { + string displayName = CustomSkyboxName.Trim(); + string[] texturePaths = _customSkyboxTexturePaths.ToArray(); + if (String.IsNullOrWhiteSpace(displayName) || texturePaths.Length != 6) + return; + + try + { + IsImportingCustomSkybox = true; + CustomSkyboxStatus = "Adding the six Roblox sky textures and generating their preview…"; + await UserSkyboxService.ImportTextureSetAsync(texturePaths, displayName); + _customSkyboxTexturePaths = Array.Empty(); + CustomSkyboxName = ""; + OnPropertyChanged(nameof(CustomSkyboxTextureSetDisplay)); + ImportCustomSkyboxTexturesCommand.NotifyCanExecuteChanged(); + CustomSkyboxStatus = CustomImportedSkyboxesEnabled + ? $"{displayName} was added. You can select it in Skybox." + : $"{displayName} was added and remains disabled until you enable imported skyboxes."; + } + catch (Exception ex) + { + App.Logger.WriteException("ExperimentalViewModel::ImportCustomSkyboxTextures", ex); + Frontend.ShowMessageBox($"{App.ProjectName} could not import that texture set.\n\n{ex.Message}", MessageBoxImage.Error); + CustomSkyboxStatus = "Texture-set import failed."; + } + finally + { + IsImportingCustomSkybox = false; + } + } + + 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 SaveAndRefresh(string propertyName) + { + App.Settings.Save(); + ExperimentalClickerHostService.Refresh(); + OnPropertyChanged(propertyName); + } + } +} diff --git a/SleepStrap/UI/ViewModels/Settings/MacroViewModel.cs b/SleepStrap/UI/ViewModels/Settings/MacroViewModel.cs index 7f280a7..c4cd11b 100644 --- a/SleepStrap/UI/ViewModels/Settings/MacroViewModel.cs +++ b/SleepStrap/UI/ViewModels/Settings/MacroViewModel.cs @@ -1,19 +1,18 @@ using System.Collections.ObjectModel; using System.Windows; using System.Windows.Input; -using System.Windows.Threading; using CommunityToolkit.Mvvm.Input; using SleepStrap.Services; namespace SleepStrap.UI.ViewModels.Settings { - public sealed class MacroViewModel : NotifyPropertyChangedViewModel, IDisposable + public sealed class MacroViewModel : NotifyPropertyChangedViewModel { public sealed class WeaponOption : NotifyPropertyChangedViewModel { private readonly Action _missingChanged; private bool _isMissing; - private int _effectiveSlotNumber; + private int _effectiveSlot; public string Name { get; } public MacroWeaponCategory Category { get; } @@ -27,6 +26,7 @@ public bool IsMissing { if (_isMissing == value) return; + _isMissing = value; OnPropertyChanged(nameof(IsMissing)); OnPropertyChanged(nameof(SlotText)); @@ -34,27 +34,35 @@ public bool IsMissing } } - public int EffectiveSlotNumber + public int EffectiveSlot { - get => _effectiveSlotNumber; + get => _effectiveSlot; set { - if (_effectiveSlotNumber == value) + if (_effectiveSlot == value) return; - _effectiveSlotNumber = value; - OnPropertyChanged(nameof(EffectiveSlotNumber)); + + _effectiveSlot = value; + OnPropertyChanged(nameof(EffectiveSlot)); OnPropertyChanged(nameof(SlotText)); } } - public string SlotText => IsMissing ? "Not owned" : $"Slot {EffectiveSlotNumber}"; + public string SlotText => IsMissing ? "Not owned" : $"Slot {EffectiveSlot}"; - public WeaponOption(string name, MacroWeaponCategory category, int originalIndex, string imageFile, bool isMissing, Action missingChanged) + public WeaponOption( + string name, + MacroWeaponCategory category, + int originalIndex, + string imageFile, + bool isMissing, + Action missingChanged) { Name = name; Category = category; OriginalIndex = originalIndex; - ImageSource = $"pack://application:,,,/Resources/SleepStrap/WeaponImages/{category}/{Uri.EscapeDataString(imageFile)}"; + ImageSource = + $"pack://application:,,,/Resources/SleepStrap/WeaponImages/{category}/{Uri.EscapeDataString(imageFile)}"; _isMissing = isMissing; _missingChanged = missingChanged; } @@ -62,118 +70,79 @@ public WeaponOption(string name, MacroWeaponCategory category, int originalIndex public override string ToString() => Name; } - private readonly DispatcherTimer _automationTimer; - private readonly AsyncRelayCommand _runMacroCommand; - private long _lastRespawn; - private long _lastUtility; - private long _lastInspect; - private bool _isRunning; - private bool _automationMasterEnabled = true; - private bool _masterKeyWasDown; - private CancellationTokenSource? _macroCancellation; - private DateTime? _nextAutoRejoinUtc; - private bool _autoRejoinInProgress; + private readonly AsyncRelayCommand _runGridLoadoutCommand; private WeaponOption? _selectedPrimary; private WeaponOption? _selectedSecondary; private WeaponOption? _selectedMelee; private WeaponOption? _selectedUtility; + private bool _isRunning; public ObservableCollection PrimaryWeapons { get; } = new(); public ObservableCollection SecondaryWeapons { get; } = new(); public ObservableCollection MeleeWeapons { get; } = new(); public ObservableCollection UtilityWeapons { get; } = new(); - public ObservableCollection AvailablePrimary { get; } = new(); public ObservableCollection AvailableSecondary { get; } = new(); public ObservableCollection AvailableMelee { get; } = new(); public ObservableCollection AvailableUtility { get; } = new(); - public ICommand RunMacroCommand => _runMacroCommand; + public ICommand RunGridLoadoutCommand => _runGridLoadoutCommand; public bool IsRunning { get => _isRunning; private set { + if (_isRunning == value) + return; + _isRunning = value; OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(RunButtonText)); - _runMacroCommand.NotifyCanExecuteChanged(); - } - } - - public string RunButtonText => IsRunning ? "Repeating — press ] to stop" : "Start repeating loadout"; - - public bool AutomationMasterEnabled - { - get => _automationMasterEnabled; - private set - { - if (_automationMasterEnabled == value) - return; - _automationMasterEnabled = value; - OnPropertyChanged(nameof(AutomationMasterEnabled)); - OnPropertyChanged(nameof(AutomationMasterText)); + _runGridLoadoutCommand.NotifyCanExecuteChanged(); } } - public string AutomationMasterText => AutomationMasterEnabled - ? "Automatic actions on — press ] to pause" - : "Automatic actions paused — press ] to resume"; - - public bool UseListLayout - { - get => App.Settings.Prop.MacroUseListLayout; - set - { - App.Settings.Prop.MacroUseListLayout = value; - SaveAndNotify(nameof(UseListLayout)); - OnPropertyChanged(nameof(LayoutName)); - } - } - - public string LayoutName => UseListLayout ? "List" : "Grid"; + public string RunButtonText => IsRunning ? "Applying..." : "Apply grid loadout"; public WeaponOption? SelectedPrimary { get => _selectedPrimary; - set => SetSelection(ref _selectedPrimary, value, nameof(SelectedPrimary), selected => App.Settings.Prop.MacroPrimaryWeapon = selected.Name); + set => SetSelection( + ref _selectedPrimary, + value, + nameof(SelectedPrimary), + name => App.Settings.Prop.MacroPrimaryWeapon = name); } public WeaponOption? SelectedSecondary { get => _selectedSecondary; - set => SetSelection(ref _selectedSecondary, value, nameof(SelectedSecondary), selected => App.Settings.Prop.MacroSecondaryWeapon = selected.Name); + set => SetSelection( + ref _selectedSecondary, + value, + nameof(SelectedSecondary), + name => App.Settings.Prop.MacroSecondaryWeapon = name); } public WeaponOption? SelectedMelee { get => _selectedMelee; - set => SetSelection(ref _selectedMelee, value, nameof(SelectedMelee), selected => App.Settings.Prop.MacroMeleeWeapon = selected.Name); + set => SetSelection( + ref _selectedMelee, + value, + nameof(SelectedMelee), + name => App.Settings.Prop.MacroMeleeWeapon = name); } public WeaponOption? SelectedUtility { get => _selectedUtility; - set => SetSelection(ref _selectedUtility, value, nameof(SelectedUtility), selected => App.Settings.Prop.MacroUtilityWeapon = selected.Name); - } - - public bool QuickRespawn - { - get => App.Settings.Prop.MacroQuickRespawn; - set { App.Settings.Prop.MacroQuickRespawn = value; SaveAndNotify(nameof(QuickRespawn)); } - } - - public bool AutoUtility - { - get => App.Settings.Prop.MacroAutoUtility; - set { App.Settings.Prop.MacroAutoUtility = value; SaveAndNotify(nameof(AutoUtility)); } - } - - public bool AutoInspect - { - get => App.Settings.Prop.MacroAutoInspect; - set { App.Settings.Prop.MacroAutoInspect = value; SaveAndNotify(nameof(AutoInspect)); } + set => SetSelection( + ref _selectedUtility, + value, + nameof(SelectedUtility), + name => App.Settings.Prop.MacroUtilityWeapon = name); } public bool AutoRejoinEnabled @@ -183,15 +152,17 @@ public bool AutoRejoinEnabled { if (value == App.Settings.Prop.MacroAutoRejoinHourly) return; - App.Settings.Prop.MacroAutoRejoinHourly = value; - _nextAutoRejoinUtc = value ? DateTime.UtcNow.AddHours(1) : null; - SaveAndNotify(nameof(AutoRejoinEnabled)); + + AutoRejoinSchedulerService.SetEnabled(value); + OnPropertyChanged(nameof(AutoRejoinEnabled)); } } public MacroViewModel() { - HashSet missing = new(App.Settings.Prop.MacroMissingWeapons, StringComparer.OrdinalIgnoreCase); + HashSet missing = new( + App.Settings.Prop.MacroMissingWeapons, + StringComparer.OrdinalIgnoreCase); AddCategory(PrimaryWeapons, MacroWeaponCategory.Primary, missing, new[] { @@ -214,8 +185,8 @@ public MacroViewModel() AddCategory(MeleeWeapons, MacroWeaponCategory.Melee, missing, new[] { ("Maul", "Maul Icon.png"), ("Spear", "Spear Icon.png"), ("Trowel", "Trowel Icon.png"), - ("Battle Axe", "BattleAxe Icon.png"), ("Chainsaw", "Chainsaw Icon.png"), ("Fists", "Fists Icon.png"), - ("Katana", "Katana Icon.png"), ("Knife", "Knife Icon.png"), + ("Battle Axe", "BattleAxe Icon.png"), ("Chainsaw", "Chainsaw Icon.png"), + ("Fists", "Fists Icon.png"), ("Katana", "Katana Icon.png"), ("Knife", "Knife Icon.png"), ("Riot Shield", "RiotShield Icon.png"), ("Scythe", "Scythe Icon.png") }); AddCategory(UtilityWeapons, MacroWeaponCategory.Utility, missing, new[] @@ -228,18 +199,14 @@ public MacroViewModel() ("Smoke Grenade", "SmokeGrenade Icon.png"), ("War Horn", "WarHorn Icon.png") }); - RefreshAvailableWeapons(); - RefreshEffectiveSlots(); + RefreshAll(); _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); - _runMacroCommand = new AsyncRelayCommand(RunMacroAsync, () => !IsRunning && AllSelectionsPresent()); - _automationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) }; - _automationTimer.Tick += AutomationTimer_Tick; - _automationTimer.Start(); - if (AutoRejoinEnabled) - _nextAutoRejoinUtc = DateTime.UtcNow.AddHours(1); + _runGridLoadoutCommand = new AsyncRelayCommand( + RunGridLoadoutAsync, + () => !IsRunning && AllSelectionsPresent()); } private void AddCategory( @@ -250,7 +217,15 @@ private void AddCategory( { int index = 0; foreach ((string name, string image) in weapons) - destination.Add(new WeaponOption(name, category, index++, image, missing.Contains(name), MissingChanged)); + { + destination.Add(new WeaponOption( + name, + category, + index++, + image, + missing.Contains(name), + MissingChanged)); + } } private void MissingChanged(WeaponOption changed) @@ -259,267 +234,148 @@ private void MissingChanged(WeaponOption changed) .Where(weapon => weapon.IsMissing) .Select(weapon => weapon.Name) .ToList(); - RefreshEffectiveSlots(); - SyncAvailableWeapons(AvailableWeapons(changed.Category), CategoryWeapons(changed.Category)); - - if (changed.IsMissing) - RepairSelectionIfRemoved(changed); - _runMacroCommand?.NotifyCanExecuteChanged(); + RefreshCategory(changed.Category); + RepairSelection(changed.Category); App.Settings.Save(); + _runGridLoadoutCommand.NotifyCanExecuteChanged(); } - private void RefreshAvailableWeapons() + private void RefreshAll() { - SyncAvailableWeapons(AvailablePrimary, PrimaryWeapons); - SyncAvailableWeapons(AvailableSecondary, SecondaryWeapons); - SyncAvailableWeapons(AvailableMelee, MeleeWeapons); - SyncAvailableWeapons(AvailableUtility, UtilityWeapons); + foreach (MacroWeaponCategory category in Enum.GetValues()) + RefreshCategory(category); } - private static void SyncAvailableWeapons(ObservableCollection destination, IEnumerable source) + private void RefreshCategory(MacroWeaponCategory category) { + ObservableCollection source = CategoryWeapons(category); + ObservableCollection available = AvailableWeapons(category); WeaponOption[] wanted = source.Where(weapon => !weapon.IsMissing).ToArray(); - for (int index = destination.Count - 1; index >= 0; index--) - { - if (!wanted.Contains(destination[index])) - destination.RemoveAt(index); - } + available.Clear(); + foreach (WeaponOption weapon in wanted) + available.Add(weapon); - for (int index = 0; index < wanted.Length; index++) + int missingBefore = 0; + foreach (WeaponOption weapon in source) { - WeaponOption weapon = wanted[index]; - int currentIndex = destination.IndexOf(weapon); - if (currentIndex < 0) - destination.Insert(index, weapon); - else if (currentIndex != index) - destination.Move(currentIndex, index); + if (weapon.IsMissing) + { + weapon.EffectiveSlot = 0; + missingBefore++; + } + else + { + weapon.EffectiveSlot = weapon.OriginalIndex - missingBefore + 1; + } } } - private void RepairSelectionIfRemoved(WeaponOption removed) + private void RepairSelection(MacroWeaponCategory category) { - WeaponOption? replacement = AvailableWeapons(removed.Category) - .FirstOrDefault(weapon => weapon.OriginalIndex > removed.OriginalIndex) - ?? AvailableWeapons(removed.Category).LastOrDefault(); - - switch (removed.Category) + switch (category) { - case MacroWeaponCategory.Primary when ReferenceEquals(_selectedPrimary, removed): - SetSelectionOrClear(ref _selectedPrimary, replacement, nameof(SelectedPrimary), value => App.Settings.Prop.MacroPrimaryWeapon = value); + case MacroWeaponCategory.Primary when _selectedPrimary?.IsMissing == true: + SelectedPrimary = AvailablePrimary.FirstOrDefault(); break; - case MacroWeaponCategory.Secondary when ReferenceEquals(_selectedSecondary, removed): - SetSelectionOrClear(ref _selectedSecondary, replacement, nameof(SelectedSecondary), value => App.Settings.Prop.MacroSecondaryWeapon = value); + case MacroWeaponCategory.Secondary when _selectedSecondary?.IsMissing == true: + SelectedSecondary = AvailableSecondary.FirstOrDefault(); break; - case MacroWeaponCategory.Melee when ReferenceEquals(_selectedMelee, removed): - SetSelectionOrClear(ref _selectedMelee, replacement, nameof(SelectedMelee), value => App.Settings.Prop.MacroMeleeWeapon = value); + case MacroWeaponCategory.Melee when _selectedMelee?.IsMissing == true: + SelectedMelee = AvailableMelee.FirstOrDefault(); break; - case MacroWeaponCategory.Utility when ReferenceEquals(_selectedUtility, removed): - SetSelectionOrClear(ref _selectedUtility, replacement, nameof(SelectedUtility), value => App.Settings.Prop.MacroUtilityWeapon = value); + case MacroWeaponCategory.Utility when _selectedUtility?.IsMissing == true: + SelectedUtility = AvailableUtility.FirstOrDefault(); break; } - - } - - private void RefreshEffectiveSlots() - { - foreach (MacroWeaponCategory category in Enum.GetValues()) - { - int missingBefore = 0; - foreach (WeaponOption weapon in CategoryWeapons(category)) - { - if (weapon.IsMissing) - { - weapon.EffectiveSlotNumber = 0; - missingBefore++; - } - else - { - weapon.EffectiveSlotNumber = weapon.OriginalIndex - missingBefore + 1; - } - } - } } - private async Task RunMacroAsync() + private async Task RunGridLoadoutAsync() { if (!AllSelectionsPresent()) return; IsRunning = true; - AutomationMasterEnabled = true; - _macroCancellation = new CancellationTokenSource(); try { var selections = new[] { - CreateSelection(SelectedPrimary!), CreateSelection(SelectedSecondary!), - CreateSelection(SelectedMelee!), CreateSelection(SelectedUtility!) + CreateSelection(SelectedPrimary!), + CreateSelection(SelectedSecondary!), + CreateSelection(SelectedMelee!), + CreateSelection(SelectedUtility!) }; - - while (AutomationMasterEnabled && !_macroCancellation.IsCancellationRequested) - { - MacroWeaponLayout layout = UseListLayout ? MacroWeaponLayout.List : MacroWeaponLayout.Grid; - await MacroAutomationService.RunLoadoutAsync(selections, layout, _macroCancellation.Token); - if (AutomationMasterEnabled && !_macroCancellation.IsCancellationRequested) - await Task.Delay(250, _macroCancellation.Token); - } - } - catch (OperationCanceledException) - { - // Pressing ] intentionally stops the repeating macro immediately. + await MacroAutomationService.RunGridLoadoutAsync(selections, CancellationToken.None); } catch (Exception ex) { - App.Logger.WriteException("MacroViewModel::RunMacro", ex); - Frontend.ShowMessageBox($"SleepStrap could not apply that loadout.\n\n{ex.Message}", MessageBoxImage.Error); + App.Logger.WriteException("MacroViewModel::RunGridLoadout", ex); + Frontend.ShowMessageBox( + $"{App.ProjectName} could not apply that grid loadout.\n\n{ex.Message}", + MessageBoxImage.Error); } finally { - _macroCancellation.Dispose(); - _macroCancellation = null; IsRunning = false; } } - private (MacroWeaponCategory Category, int OriginalIndex, IReadOnlyList MissingIndices) CreateSelection(WeaponOption weapon) + private (MacroWeaponCategory Category, int OriginalIndex, IReadOnlyList MissingIndices) + CreateSelection(WeaponOption weapon) { - IReadOnlyList missing = CategoryWeapons(weapon.Category) + int[] missing = CategoryWeapons(weapon.Category) .Where(item => item.IsMissing) .Select(item => item.OriginalIndex) .ToArray(); return (weapon.Category, weapon.OriginalIndex, missing); } - private void AutomationTimer_Tick(object? sender, EventArgs e) - { - bool masterKeyDown = MacroAutomationService.IsKeyDown(0xDD); // ] / OEM close bracket - if (masterKeyDown && !_masterKeyWasDown) - { - if (IsRunning) - { - AutomationMasterEnabled = false; - _macroCancellation?.Cancel(); - } - else - { - AutomationMasterEnabled = !AutomationMasterEnabled; - } - } - _masterKeyWasDown = masterKeyDown; - - if (AutoRejoinEnabled && !_autoRejoinInProgress && - _nextAutoRejoinUtc is DateTime nextRejoin && DateTime.UtcNow >= nextRejoin) - { - _nextAutoRejoinUtc = DateTime.UtcNow.AddHours(1); - _ = RunHourlyAutoRejoinAsync(); - } - - if (!AutomationMasterEnabled || _autoRejoinInProgress || !MacroAutomationService.IsRobloxForeground()) - return; - - long now = Environment.TickCount64; - if (QuickRespawn && now - _lastRespawn >= 75) - { - MacroAutomationService.TapKey(0x20); - _lastRespawn = now; - } - if (AutoUtility && now - _lastUtility >= 120) - { - MacroAutomationService.TapKey(0x47); // G - _lastUtility = now; - } - if (AutoInspect && now - _lastInspect >= 180) - { - MacroAutomationService.TapKey(0x56); // V - _lastInspect = now; - } - } - - private void SetSelection(ref WeaponOption? field, WeaponOption? value, string propertyName, Action save) + private void SetSelection( + ref WeaponOption? field, + WeaponOption? value, + string propertyName, + Action save) { if (value is null || ReferenceEquals(field, value)) return; - field = value; - save(value); - App.Settings.Save(); - OnPropertyChanged(propertyName); - _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(); - - private IEnumerable AllWeapons() => PrimaryWeapons.Concat(SecondaryWeapons).Concat(MeleeWeapons).Concat(UtilityWeapons); - - private IEnumerable CategoryWeapons(MacroWeaponCategory category) => category switch - { - MacroWeaponCategory.Primary => PrimaryWeapons, - MacroWeaponCategory.Secondary => SecondaryWeapons, - MacroWeaponCategory.Melee => MeleeWeapons, - _ => 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 void SaveAndNotify(string propertyName) - { + save(value.Name); App.Settings.Save(); OnPropertyChanged(propertyName); + _runGridLoadoutCommand?.NotifyCanExecuteChanged(); } - private async Task RunHourlyAutoRejoinAsync() - { - bool resumeRepeatingLoadout = IsRunning; - _autoRejoinInProgress = true; - - try - { - if (resumeRepeatingLoadout) - { - _macroCancellation?.Cancel(); - for (int attempt = 0; attempt < 100 && IsRunning; attempt++) - await Task.Delay(50); - } + private IEnumerable AllWeapons() => + PrimaryWeapons.Concat(SecondaryWeapons).Concat(MeleeWeapons).Concat(UtilityWeapons); - await MacroAutomationService.RunAutoRejoinAsync(CancellationToken.None); - } - catch (Exception ex) + private ObservableCollection CategoryWeapons(MacroWeaponCategory category) => + category switch { - App.Logger.WriteException("MacroViewModel::RunHourlyAutoRejoin", ex); - Frontend.ShowMessageBox($"SleepStrap could not complete Auto Rejoin.\n\n{ex.Message}", MessageBoxImage.Error); - } - finally + MacroWeaponCategory.Primary => PrimaryWeapons, + MacroWeaponCategory.Secondary => SecondaryWeapons, + MacroWeaponCategory.Melee => MeleeWeapons, + _ => UtilityWeapons + }; + + private ObservableCollection AvailableWeapons(MacroWeaponCategory category) => + category switch { - _autoRejoinInProgress = false; - if (resumeRepeatingLoadout && AutomationMasterEnabled && AllSelectionsPresent()) - _ = RunMacroAsync(); - } - } + MacroWeaponCategory.Primary => AvailablePrimary, + MacroWeaponCategory.Secondary => AvailableSecondary, + MacroWeaponCategory.Melee => AvailableMelee, + _ => AvailableUtility + }; - public void Dispose() - { - _automationTimer.Stop(); - _automationTimer.Tick -= AutomationTimer_Tick; - } + private static WeaponOption? FindSaved(IEnumerable options, string saved) => + options.FirstOrDefault(option => option.Name.Equals(saved, StringComparison.OrdinalIgnoreCase)) + ?? options.FirstOrDefault(); + + private bool AllSelectionsPresent() => + SelectedPrimary is not null && + SelectedSecondary is not null && + SelectedMelee is not null && + SelectedUtility is not null; } } diff --git a/SleepStrap/UI/ViewModels/Settings/MainWindowViewModel.cs b/SleepStrap/UI/ViewModels/Settings/MainWindowViewModel.cs index 618a712..db6853d 100644 --- a/SleepStrap/UI/ViewModels/Settings/MainWindowViewModel.cs +++ b/SleepStrap/UI/ViewModels/Settings/MainWindowViewModel.cs @@ -22,7 +22,7 @@ public class MainWindowViewModel : NotifyPropertyChangedViewModel public bool GBSEnabled = App.GlobalSettings.Loaded; - public string WindowTitle => $"SleepStrap {new Version(App.Version).ToString(3)}"; + public string WindowTitle => $"{App.ProjectName} {new Version(App.Version).ToString(3)}"; public string VersionText => $"v{new Version(App.Version).ToString(3)}"; public bool TestModeEnabled diff --git a/SleepStrap/UI/ViewModels/Settings/OtherViewModel.cs b/SleepStrap/UI/ViewModels/Settings/OtherViewModel.cs index 40e7ada..1fe5ab0 100644 --- a/SleepStrap/UI/ViewModels/Settings/OtherViewModel.cs +++ b/SleepStrap/UI/ViewModels/Settings/OtherViewModel.cs @@ -1,48 +1,86 @@ +using System.Windows; +using System.Windows.Input; +using CommunityToolkit.Mvvm.Input; using SleepStrap.Services; namespace SleepStrap.UI.ViewModels.Settings { public class OtherViewModel : NotifyPropertyChangedViewModel { - public string VersionText => $"SleepStrap {new Version(App.Version).ToString(3)}"; + private string _shareCode = String.Empty; + private string _importCode = String.Empty; - public bool CloseSleepStrapOnLaunch + public OtherViewModel() { - get => App.Settings.Prop.CloseSleepStrapOnLaunch; - set + ResetSettingsCommand = new RelayCommand(ResetSettings); + RestoreNvidiaChangesCommand = new AsyncRelayCommand(RestoreNvidiaChangesAsync); + CopySettingsCommand = new RelayCommand(CopySettings); + ImportSettingsCommand = new RelayCommand(ImportSettings, () => !String.IsNullOrWhiteSpace(ImportCode)); + } + + public string VersionText => $"{App.ProjectName} {new Version(App.Version).ToString(3)}"; + public ICommand ResetSettingsCommand { get; } + public IAsyncRelayCommand RestoreNvidiaChangesCommand { get; } + public IRelayCommand CopySettingsCommand { get; } + public IRelayCommand ImportSettingsCommand { get; } + public string ShareCode { get => _shareCode; private set { if (String.Equals(_shareCode, value, StringComparison.Ordinal)) return; _shareCode = value; OnPropertyChanged(nameof(ShareCode)); } } + public string ImportCode { get => _importCode; set { string code = value ?? String.Empty; if (String.Equals(_importCode, code, StringComparison.Ordinal)) return; _importCode = code; OnPropertyChanged(nameof(ImportCode)); ImportSettingsCommand.NotifyCanExecuteChanged(); } } + + private async Task RestoreNvidiaChangesAsync() + { + if (App.Settings.Prop.NvidiaBlurredTexturesProfileBackup.Count == 0) { Frontend.ShowMessageBox($"{App.ProjectName} does not have a saved NVIDIA profile backup to restore. Use NVIDIA Control Panel → Manage 3D settings → Program Settings → Roblox VR → Restore.", MessageBoxImage.Information); return; } + if (Process.GetProcessesByName("RobloxPlayerBeta").Length > 0) { Frontend.ShowMessageBox("Close Roblox before restoring the NVIDIA profile.", MessageBoxImage.Warning); return; } + if (Frontend.ShowMessageBox($"Restore the saved NVIDIA Roblox VR profile now? This only restores values {App.ProjectName} saved before Blur was enabled.", MessageBoxImage.Warning, MessageBoxButton.YesNo, MessageBoxResult.No) != MessageBoxResult.Yes) return; + try { - if (value == App.Settings.Prop.CloseSleepStrapOnLaunch) - return; + NvidiaBlurElevationBridge.HelperResult result = await NvidiaBlurElevationBridge.RunElevatedAsync(false, App.Settings.Prop.NvidiaBlurredTexturesProfileBackup); + if (!result.Success) throw new InvalidOperationException(String.IsNullOrWhiteSpace(result.Error) ? "The NVIDIA profile could not be restored." : result.Error); + App.Settings.Prop.NvidiaBlurredTexturesEnabled = false; App.Settings.Prop.NvidiaBlurredTexturesProfileBackup.Clear(); App.Settings.Save(); Frontend.ShowMessageBox("NVIDIA changes restored.", MessageBoxImage.Information); + } + catch (Exception ex) { App.Logger.WriteException("OtherViewModel::RestoreNvidiaChanges", ex); Frontend.ShowMessageBox($"{App.ProjectName} could not restore the NVIDIA changes.\n\n{ex.Message}", MessageBoxImage.Error); } + } - App.Settings.Prop.CloseSleepStrapOnLaunch = value; - App.Settings.Save(); - OnPropertyChanged(nameof(CloseSleepStrapOnLaunch)); + private void ResetSettings() + { + if (Frontend.ShowMessageBox($"Reset all {App.ProjectName} settings to their defaults? This permanently deletes all custom skyboxes and removes your selected textures, skybox, font, clipping and PC preferences.", MessageBoxImage.Warning, MessageBoxButton.YesNo, MessageBoxResult.No) != MessageBoxResult.Yes) return; + try + { + try { VisualModService.RemoveCustomSkybox(); } catch (Exception ex) { App.Logger.WriteException("OtherViewModel::ResetSettingsRestoreSky", ex); } + UserSkyboxService.DeleteAll(); App.Settings.Prop = new Models.Persistable.Settings(); App.Settings.Save(); SkyboxGalleryService.NotifyGalleryChanged(); ChatShortcutsHostService.Stop(); + Frontend.ShowMessageBox($"{App.ProjectName} settings were reset. Reopen {App.ProjectName} to reload the defaults.", MessageBoxImage.Information); + OnPropertyChanged(nameof(CloseSleepStrapOnLaunch)); OnPropertyChanged(nameof(OverrideLegacyBloxstrapSettings)); OnPropertyChanged(nameof(ChatShortcutsEnabled)); } + catch (Exception ex) { App.Logger.WriteException("OtherViewModel::ResetSettings", ex); Frontend.ShowMessageBox($"{App.ProjectName} could not reset its settings.\n\n{ex.Message}", MessageBoxImage.Error); } } + private void CopySettings() + { + try { ShareCode = SettingsShareService.CreateCode(App.Settings.Prop); try { Clipboard.SetText(ShareCode); Frontend.ShowMessageBox("Your compact settings code was copied.", MessageBoxImage.Information); } catch (Exception ex) { App.Logger.WriteException("OtherViewModel::CopySettingsClipboard", ex); Frontend.ShowMessageBox("Your settings code is ready below. Windows could not open the clipboard, so copy the text manually.", MessageBoxImage.Information); } } + catch (Exception ex) { App.Logger.WriteException("OtherViewModel::CopySettings", ex); Frontend.ShowMessageBox($"{App.ProjectName} could not create a settings code.\n\n{ex.Message}", MessageBoxImage.Error); } + } + private void ImportSettings() + { + if (String.IsNullOrWhiteSpace(ImportCode)) return; + if (Frontend.ShowMessageBox("Import these portable SleepBlox settings? Your custom imported skyboxes, device choices, local backups, and file paths will stay on this PC.", MessageBoxImage.Question, MessageBoxButton.YesNo, MessageBoxResult.No) != MessageBoxResult.Yes) return; + try + { + SettingsShareService.ApplyCode(ImportCode, App.Settings.Prop); SettingsShareService.TryApplyPortableFont(App.Settings.Prop); App.Settings.Save(); ExperimentalClickerHostService.Refresh(); AutoRejoinSchedulerService.Refresh(); ChatShortcutsHostService.Refresh(); SkyboxGalleryService.NotifyGalleryChanged(); + OnPropertyChanged(nameof(CloseSleepStrapOnLaunch)); OnPropertyChanged(nameof(OverrideLegacyBloxstrapSettings)); OnPropertyChanged(nameof(ChatShortcutsEnabled)); ImportCode = String.Empty; + Frontend.ShowMessageBox("Settings imported. Texture, sky, and font changes apply when you next launch Roblox.", MessageBoxImage.Information); + } + catch (Exception ex) { App.Logger.WriteException("OtherViewModel::ImportSettings", ex); Frontend.ShowMessageBox($"{App.ProjectName} could not import that settings code.\n\n{ex.Message}", MessageBoxImage.Error); } + } + public bool CloseSleepStrapOnLaunch { get => App.Settings.Prop.CloseSleepStrapOnLaunch; set { if (value == App.Settings.Prop.CloseSleepStrapOnLaunch) return; App.Settings.Prop.CloseSleepStrapOnLaunch = value; App.Settings.Save(); OnPropertyChanged(nameof(CloseSleepStrapOnLaunch)); } } + public bool ChatShortcutsEnabled { get => App.Settings.Prop.ChatShortcutsEnabled; set { if (value == App.Settings.Prop.ChatShortcutsEnabled) return; App.Settings.Prop.ChatShortcutsEnabled = value; App.Settings.Save(); ChatShortcutsHostService.Refresh(); OnPropertyChanged(nameof(ChatShortcutsEnabled)); } } public bool OverrideLegacyBloxstrapSettings { get => App.Settings.Prop.OverrideLegacyBloxstrapSettings; set { - if (value == App.Settings.Prop.OverrideLegacyBloxstrapSettings) - return; - - try - { - LegacyBloxstrapOverrideService.SetEnabled(value); - App.Settings.Prop.OverrideLegacyBloxstrapSettings = value; - App.Settings.Save(); - OnPropertyChanged(nameof(OverrideLegacyBloxstrapSettings)); - } - catch (Exception ex) - { - App.Logger.WriteException("OtherViewModel::OverrideLegacyBloxstrapSettings", ex); - Frontend.ShowMessageBox($"SleepStrap could not update the FastFlag override.\n\n{ex.Message}", System.Windows.MessageBoxImage.Error); - OnPropertyChanged(nameof(OverrideLegacyBloxstrapSettings)); - } + if (value == App.Settings.Prop.OverrideLegacyBloxstrapSettings) return; + try { LegacyBloxstrapOverrideService.SetEnabled(value); App.Settings.Prop.OverrideLegacyBloxstrapSettings = value; App.Settings.Save(); OnPropertyChanged(nameof(OverrideLegacyBloxstrapSettings)); } + catch (Exception ex) { App.Logger.WriteException("OtherViewModel::OverrideLegacyBloxstrapSettings", ex); Frontend.ShowMessageBox($"{App.ProjectName} could not update the FastFlag override.\n\n{ex.Message}", MessageBoxImage.Error); OnPropertyChanged(nameof(OverrideLegacyBloxstrapSettings)); } } } - } } diff --git a/SleepStrap/UI/ViewModels/Settings/RivalsViewModel.cs b/SleepStrap/UI/ViewModels/Settings/RivalsViewModel.cs index de2f51c..c06190c 100644 --- a/SleepStrap/UI/ViewModels/Settings/RivalsViewModel.cs +++ b/SleepStrap/UI/ViewModels/Settings/RivalsViewModel.cs @@ -10,6 +10,8 @@ public class RivalsViewModel : NotifyPropertyChangedViewModel private const string TargetFpsFlag = "DFIntTaskSchedulerTargetFps"; private const string UnlockFpsFlag = "FFlagTaskSchedulerLimitTargetFpsTo2402"; private const int StretchPercent = 75; + private bool _isChangingNvidiaBlur; + private string _nvidiaBlurStatus = String.Empty; public sealed record StretchPreset(string Name, int Percent) { @@ -68,7 +70,7 @@ public bool StretchEnabled catch (Exception ex) { App.Logger.WriteException("RivalsViewModel::SetStretch", ex); - Frontend.ShowMessageBox($"SleepStrap could not change the display stretch.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not change the display stretch.\n\n{ex.Message}", MessageBoxImage.Error); OnPropertyChanged(nameof(StretchEnabled)); RefreshStatus(); } @@ -99,7 +101,7 @@ public int SelectedStretchPercent { App.Settings.Prop.RivalsStretchPercent = previous; App.Logger.WriteException("RivalsViewModel::ChangeStrength", ex); - Frontend.ShowMessageBox($"SleepStrap could not apply that stretch strength.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not apply that stretch strength.\n\n{ex.Message}", MessageBoxImage.Error); OnPropertyChanged(nameof(SelectedStretchPercent)); RefreshStatus(); } @@ -131,12 +133,47 @@ public int SelectedFpsLimit catch (Exception ex) { App.Logger.WriteException("RivalsViewModel::ChangeFps", ex); - Frontend.ShowMessageBox($"SleepStrap could not change the FPS limit.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not change the FPS limit.\n\n{ex.Message}", MessageBoxImage.Error); OnPropertyChanged(nameof(SelectedFpsLimit)); } } } + public bool NvidiaBlurredTexturesEnabled + { + get => App.Settings.Prop.NvidiaBlurredTexturesEnabled; + set + { + if (_isChangingNvidiaBlur || value == App.Settings.Prop.NvidiaBlurredTexturesEnabled) + return; + + if (value && Frontend.ShowMessageBox( + "Blurred textures changes NVIDIA's Roblox VR driver profile and requires administrator permission. " + + "It only works with an NVIDIA GPU and can affect the driver until you turn it back off. Rejoin Roblox after changing it. Continue?", + MessageBoxImage.Warning, + MessageBoxButton.YesNo, + MessageBoxResult.No) != MessageBoxResult.Yes) + { + OnPropertyChanged(nameof(NvidiaBlurredTexturesEnabled)); + return; + } + + _ = SetNvidiaBlurredTexturesAsync(value); + } + } + + public bool CanChangeNvidiaBlur => !_isChangingNvidiaBlur; + + public string NvidiaBlurStatus + { + get => _nvidiaBlurStatus; + private set + { + _nvidiaBlurStatus = value; + OnPropertyChanged(nameof(NvidiaBlurStatus)); + } + } + private string _statusText = "Checking display…"; public string StatusText { @@ -151,9 +188,68 @@ private set public RivalsViewModel() { RemoveLegacyFpsCounter(); + NvidiaBlurStatus = App.Settings.Prop.NvidiaBlurredTexturesEnabled + ? "Enabled - rejoin Roblox to apply" + : "Off"; RefreshStatus(); } + private async Task SetNvidiaBlurredTexturesAsync(bool enable) + { + _isChangingNvidiaBlur = true; + OnPropertyChanged(nameof(CanChangeNvidiaBlur)); + OnPropertyChanged(nameof(NvidiaBlurredTexturesEnabled)); + NvidiaBlurStatus = enable ? "Applying NVIDIA profile..." : "Restoring NVIDIA profile..."; + + try + { + NvidiaBlurElevationBridge.HelperResult result = + await NvidiaBlurElevationBridge.RunElevatedAsync( + enable, + App.Settings.Prop.NvidiaBlurredTexturesProfileBackup); + + if (!result.Success) + { + throw new InvalidOperationException( + String.IsNullOrWhiteSpace(result.Error) + ? "The NVIDIA profile helper failed." + : result.Error); + } + + if (enable) + { + App.Settings.Prop.NvidiaBlurredTexturesProfileBackup = + new Dictionary(result.Backup, StringComparer.OrdinalIgnoreCase); + } + else + { + App.Settings.Prop.NvidiaBlurredTexturesProfileBackup.Clear(); + } + + App.Settings.Prop.NvidiaBlurredTexturesEnabled = enable; + App.Settings.Save(); + NvidiaBlurStatus = enable + ? "Enabled - rejoin Roblox to apply" + : "NVIDIA driver settings restored"; + } + catch (Exception ex) + { + App.Logger.WriteException("RivalsViewModel::SetNvidiaBlurredTextures", ex); + NvidiaBlurStatus = NvidiaBlurredTexturesEnabled + ? "Enabled for the NVIDIA Roblox VR profile" + : "NVIDIA profile was not changed"; + Frontend.ShowMessageBox( + $"{App.ProjectName} could not change the NVIDIA texture settings.\n\n{ex.Message}", + MessageBoxImage.Error); + } + finally + { + _isChangingNvidiaBlur = false; + OnPropertyChanged(nameof(CanChangeNvidiaBlur)); + OnPropertyChanged(nameof(NvidiaBlurredTexturesEnabled)); + } + } + private void EnableStretch() { DisplayStretchService.DisplayMode native = DisplayStretchService.GetCurrentMode(); diff --git a/SleepStrap/UI/ViewModels/Settings/SkyboxViewModel.cs b/SleepStrap/UI/ViewModels/Settings/SkyboxViewModel.cs index f2e41f1..b436331 100644 --- a/SleepStrap/UI/ViewModels/Settings/SkyboxViewModel.cs +++ b/SleepStrap/UI/ViewModels/Settings/SkyboxViewModel.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Windows; using System.Windows.Input; @@ -11,32 +12,187 @@ public class SkyboxViewModel : NotifyPropertyChangedViewModel { private bool _isBusy; private SkyboxChoice? _selectedSkybox; - private string _statusText; + private string _statusText = ""; public SkyboxViewModel() { - SkyboxChoices = SkyboxGalleryService.GetChoices(); + SkyboxChoices = new ObservableCollection(); OpenModsFolderCommand = new RelayCommand(OpenModsFolder); + ReloadChoices(); + SkyboxGalleryService.GalleryChanged += SkyboxGalleryService_GalleryChanged; + } + + public ObservableCollection SkyboxChoices { get; } + public ICommand OpenModsFolderCommand { get; } + + public void BeginRenameUserSkybox(SkyboxChoice? choice) + { + if (choice is null || !choice.IsUserImported || IsBusy) + return; + + choice.EditName = choice.Name; + choice.IsRenaming = true; + } + + public void CancelRenameUserSkybox(SkyboxChoice? choice) + { + if (choice is not null) + choice.IsRenaming = false; + } + + public void CommitRenameUserSkybox(SkyboxChoice? choice) + { + if (choice is null || !choice.IsUserImported || !choice.IsRenaming || IsBusy) + return; + + string name = choice.EditName.Trim(); + if (String.IsNullOrWhiteSpace(name)) + { + choice.IsRenaming = false; + return; + } + + try + { + IsBusy = true; + UserSkyboxService.Rename(choice.SelectionKey, name); + // Update immediately. Do not wait for the gallery event because the + // page can remain open while a user renames several imported skies. + ReloadChoices(); + StatusText = $"Renamed to {name}."; + } + catch (Exception ex) + { + App.Logger.WriteException("SkyboxViewModel::RenameUserSkybox", ex); + Frontend.ShowMessageBox($"{App.ProjectName} could not rename that custom skybox.\n\n{ex.Message}", MessageBoxImage.Error); + choice.IsRenaming = false; + } + finally + { + IsBusy = false; + } + } + + public void DeleteUserSkybox(SkyboxChoice? choice) + { + if (choice is null || !choice.IsUserImported || IsBusy) + return; + + try + { + IsBusy = true; + string deletedName = choice.Name; + UserSkyboxService.Delete(choice.SelectionKey); + // The selected item and its action buttons must disappear in the + // same interaction, even when the gallery event is delayed. + ReloadChoices(); + StatusText = $"Deleted {deletedName}."; + } + catch (Exception ex) + { + App.Logger.WriteException("SkyboxViewModel::DeleteUserSkybox", ex); + Frontend.ShowMessageBox($"{App.ProjectName} could not delete that custom skybox.\n\n{ex.Message}", MessageBoxImage.Error); + } + finally + { + IsBusy = false; + } + } + + public void ToggleFavorite(SkyboxChoice? choice) + { + if (choice is null || choice.IsNone || IsBusy) + return; + + choice.IsFavorite = !choice.IsFavorite; + App.Settings.Prop.FavoriteSkyboxes = SkyboxChoices + .Where(x => x.IsFavorite) + .Select(x => x.SelectionKey) + .ToList(); + App.Settings.Save(); + ReorderFavoritesAndOriginals(); + } + + private void SkyboxGalleryService_GalleryChanged(object? sender, EventArgs e) + { + if (Application.Current?.Dispatcher is { } dispatcher && !dispatcher.CheckAccess()) + { + _ = dispatcher.BeginInvoke(new Action(ReloadChoices)); + return; + } + + ReloadChoices(); + } + + private void ReloadChoices() + { + string selectedKey = App.Settings.Prop.CustomSkyboxSourceName ?? ""; + List choices = SkyboxGalleryService.GetChoices().ToList(); + + for (int index = 0; index < choices.Count; index++) + { + SkyboxChoice choice = choices[index]; + choice.OriginalIndex = index; + choice.IsFavorite = !choice.IsNone && App.Settings.Prop.FavoriteSkyboxes.Any(favorite => + String.Equals(favorite, choice.SelectionKey, StringComparison.OrdinalIgnoreCase) || + String.Equals(favorite, choice.Name, StringComparison.OrdinalIgnoreCase) || + String.Equals(favorite, choice.ResourceFolder, StringComparison.OrdinalIgnoreCase)); + } + + SkyboxChoices.Clear(); + foreach (SkyboxChoice choice in choices) + SkyboxChoices.Add(choice); + ReorderFavoritesAndOriginals(); + + SkyboxChoice none = SkyboxChoices.First(x => x.IsNone); if (!App.Settings.Prop.CustomSkyboxEnabled) { - _selectedSkybox = SkyboxChoices.First(x => x.IsNone); + _selectedSkybox = none; _statusText = "Roblox's original sky is active."; } else { - _selectedSkybox = SkyboxChoices.FirstOrDefault(x => - !x.IsNone && - (String.Equals(x.Name, App.Settings.Prop.CustomSkyboxSourceName, StringComparison.OrdinalIgnoreCase) || - String.Equals(x.ResourceFolder, App.Settings.Prop.CustomSkyboxSourceName, StringComparison.OrdinalIgnoreCase))); - _statusText = _selectedSkybox is null - ? "A legacy imported sky is active. Pick a preset or None to replace it." - : $"Selected: {_selectedSkybox.Name}."; + _selectedSkybox = SkyboxChoices.FirstOrDefault(choice => + !choice.IsNone && MatchesSelection(choice, selectedKey)); + + if (_selectedSkybox?.IsUserImported == true && !_selectedSkybox.IsAvailable) + { + _selectedSkybox = none; + _statusText = UserSkyboxService.IsEnabled + ? "This imported sky needs to be re-imported with the updated Roblox texture format." + : "Imported skyboxes are disabled in Experimental."; + } + else + { + _statusText = _selectedSkybox is null + ? "A legacy imported sky is active. Pick a preset or None to replace it." + : $"Selected: {_selectedSkybox.Name}."; + } } + + OnPropertyChanged(nameof(SelectedSkybox)); + OnPropertyChanged(nameof(StatusText)); } - public IReadOnlyList SkyboxChoices { get; } - public ICommand OpenModsFolderCommand { get; } + private static bool MatchesSelection(SkyboxChoice choice, string selection) => + String.Equals(choice.SelectionKey, selection, StringComparison.OrdinalIgnoreCase) || + String.Equals(choice.Name, selection, StringComparison.OrdinalIgnoreCase) || + String.Equals(choice.ResourceFolder, selection, StringComparison.OrdinalIgnoreCase); + + private void ReorderFavoritesAndOriginals() + { + List ordered = SkyboxChoices + .OrderByDescending(x => x.IsFavorite) + .ThenBy(x => x.OriginalIndex) + .ToList(); + for (int target = 0; target < ordered.Count; target++) + { + int index = SkyboxChoices.IndexOf(ordered[target]); + if (index >= 0 && index != target) + SkyboxChoices.Move(index, target); + } + } public SkyboxChoice? SelectedSkybox { @@ -46,6 +202,15 @@ public SkyboxChoice? SelectedSkybox if (value is null || value == _selectedSkybox || IsBusy) return; + if (value.IsUserImported && !value.IsAvailable) + { + StatusText = UserSkyboxService.IsEnabled + ? "Re-import this sky to update it to Roblox's current texture format." + : "Enable imported skyboxes in Experimental before selecting one."; + OnPropertyChanged(nameof(SelectedSkybox)); + return; + } + SkyboxChoice? previous = _selectedSkybox; try { @@ -66,7 +231,7 @@ public SkyboxChoice? SelectedSkybox } App.Settings.Prop.CustomSkyboxEnabled = true; - App.Settings.Prop.CustomSkyboxSourceName = value.Name; + App.Settings.Prop.CustomSkyboxSourceName = value.SelectionKey; StatusText = $"Selected: {value.Name}. It will be applied when you click Launch Roblox."; } @@ -78,7 +243,7 @@ public SkyboxChoice? SelectedSkybox { _selectedSkybox = previous; App.Logger.WriteException("SkyboxViewModel::SelectSkybox", ex); - Frontend.ShowMessageBox($"SleepStrap could not apply that sky.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not apply that sky.\n\n{ex.Message}", MessageBoxImage.Error); StatusText = "Sky selection failed."; OnPropertyChanged(nameof(SelectedSkybox)); } @@ -120,7 +285,7 @@ private static bool EnsureRiskAcknowledged() if (App.State.Prop.VisualModsWarningAcknowledged) return true; - const string message = "Skybox presets replace local Roblox asset files. They do not inject code or modify the Roblox executable, but they are unofficial and SleepStrap cannot guarantee that Roblox will never take enforcement action.\n\nContinue with visual mods?"; + const string message = "Skybox presets replace local Roblox asset files. They do not inject code or modify the Roblox executable, but they are unofficial and SleepBlox cannot guarantee that Roblox will never take enforcement action.\n\nContinue with visual mods?"; if (Frontend.ShowMessageBox(message, MessageBoxImage.Warning, MessageBoxButton.YesNo) != MessageBoxResult.Yes) return false; diff --git a/SleepStrap/UI/ViewModels/Settings/VisualModsViewModel.cs b/SleepStrap/UI/ViewModels/Settings/VisualModsViewModel.cs index 311d37f..67c4003 100644 --- a/SleepStrap/UI/ViewModels/Settings/VisualModsViewModel.cs +++ b/SleepStrap/UI/ViewModels/Settings/VisualModsViewModel.cs @@ -70,7 +70,7 @@ public FontChoice? SelectedFont catch (Exception ex) { App.Logger.WriteException("VisualModsViewModel::SelectFont", ex); - Frontend.ShowMessageBox($"SleepStrap could not apply that font.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not apply that font.\n\n{ex.Message}", MessageBoxImage.Error); OnPropertyChanged(nameof(SelectedFont)); } finally @@ -91,6 +91,23 @@ private set } } + public string FontColor + { + get => App.Settings.Prop.SelectedFontColor; + set + { + string color = (value ?? "").Trim(); + if (!System.Text.RegularExpressions.Regex.IsMatch(color, "^#[0-9A-Fa-f]{6}$")) + return; + if (String.Equals(color, App.Settings.Prop.SelectedFontColor, StringComparison.OrdinalIgnoreCase)) + return; + App.Settings.Prop.SelectedFontColor = color.ToUpperInvariant(); + App.Settings.Save(); + OnPropertyChanged(nameof(FontColor)); + FontStatus = $"Font color {App.Settings.Prop.SelectedFontColor} saved."; + } + } + public Visibility RestoreFontVisibility => _selectedFont?.IsDefault == false ? Visibility.Visible : Visibility.Collapsed; public bool IsBusy @@ -163,7 +180,7 @@ public bool DarkTexturesEnabled catch (Exception ex) { App.Logger.WriteException("VisualModsViewModel::SetDarkTextures", ex); - Frontend.ShowMessageBox($"SleepStrap could not switch the texture pack.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not switch the texture pack.\n\n{ex.Message}", MessageBoxImage.Error); NotifyTexturePackSelectionChanged(); StatusText = "Texture switch failed."; } @@ -207,7 +224,7 @@ public bool RtxShineEnabled catch (Exception ex) { App.Logger.WriteException("VisualModsViewModel::SetRtxShine", ex); - Frontend.ShowMessageBox($"SleepStrap could not change RTX shine.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not change RTX shine.\n\n{ex.Message}", MessageBoxImage.Error); OnPropertyChanged(nameof(RtxShineEnabled)); StatusText = "RTX shine switch failed."; } @@ -276,7 +293,7 @@ public string SelectedTextureEffect App.Logger.WriteException("VisualModsViewModel::RollbackTextureEffect", rollbackException); } - Frontend.ShowMessageBox($"SleepStrap could not change the texture effect.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not change the texture effect.\n\n{ex.Message}", MessageBoxImage.Error); OnPropertyChanged(nameof(SelectedTextureEffect)); StatusText = "Texture effect failed."; } @@ -331,7 +348,7 @@ private async Task ImportSkyboxAsync() catch (Exception ex) { App.Logger.WriteException("VisualModsViewModel::ImportSkybox", ex); - Frontend.ShowMessageBox($"SleepStrap could not convert that panorama.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not convert that panorama.\n\n{ex.Message}", MessageBoxImage.Error); StatusText = "Skybox import failed."; } finally { IsBusy = false; } @@ -353,7 +370,7 @@ private void RemoveSkybox() catch (Exception ex) { App.Logger.WriteException("VisualModsViewModel::RemoveSkybox", ex); - Frontend.ShowMessageBox($"SleepStrap could not restore the previous skybox.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not restore the previous skybox.\n\n{ex.Message}", MessageBoxImage.Error); StatusText = "Skybox restore failed."; } finally { IsBusy = false; } @@ -388,7 +405,7 @@ private void RestoreDefaultFont() catch (Exception ex) { App.Logger.WriteException("VisualModsViewModel::RestoreFont", ex); - Frontend.ShowMessageBox($"SleepStrap could not restore the Roblox font.\n\n{ex.Message}", MessageBoxImage.Error); + Frontend.ShowMessageBox($"{App.ProjectName} could not restore the Roblox font.\n\n{ex.Message}", MessageBoxImage.Error); } finally { @@ -401,7 +418,7 @@ private static bool EnsureRiskAcknowledged() { if (App.State.Prop.VisualModsWarningAcknowledged) return true; - const string message = "Custom skyboxes and textures replace local Roblox asset files. They do not inject code or modify the Roblox executable, but they are unofficial and SleepStrap cannot guarantee that Roblox will never take enforcement action.\n\nContinue with visual mods?"; + const string message = "Custom skyboxes and textures replace local Roblox asset files. They do not inject code or modify the Roblox executable, but they are unofficial and SleepBlox cannot guarantee that Roblox will never take enforcement action.\n\nContinue with visual mods?"; if (Frontend.ShowMessageBox(message, MessageBoxImage.Warning, MessageBoxButton.YesNo) != MessageBoxResult.Yes) return false; App.State.Prop.VisualModsWarningAcknowledged = true; diff --git a/SleepStrap/Utilities.cs b/SleepStrap/Utilities.cs index 4e17a50..dd4d032 100644 --- a/SleepStrap/Utilities.cs +++ b/SleepStrap/Utilities.cs @@ -158,10 +158,28 @@ public static bool DoesMutexExist(string name) } } + public static bool DoesAnyMutexExist(params string[] names) + { + return names.Any(DoesMutexExist); + } + public static void KillBackgroundUpdater() { - using EventWaitHandle handle = new EventWaitHandle(false, EventResetMode.AutoReset, "SleepStrap-BackgroundUpdaterKillEvent"); - handle.Set(); + SignalBackgroundUpdater(App.BackgroundUpdaterKillEventName); + SignalBackgroundUpdater(App.LegacyBackgroundUpdaterKillEventName); + } + + private static void SignalBackgroundUpdater(string eventName) + { + try + { + using EventWaitHandle handle = EventWaitHandle.OpenExisting(eventName); + handle.Set(); + } + catch (WaitHandleCannotBeOpenedException) + { + // No background updater with this identity is running. + } } } } diff --git a/docs/README.md b/docs/README.md index 172a8f4..2d5ac79 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,10 @@ -# SleepStrap documentation +# SleepBlox documentation - [Terms of Use and Risk Notice](TERMS.md) - [Upstream MIT license notice](licenses/Bloxstrap-LICENSE.txt) +- [NVIDIA Profile Inspector MIT license](licenses/NvidiaProfileInspector-LICENSE.txt) +- [liquidDX11 MIT license](licenses/liquidDX11-LICENSE.txt) Upstream project names are retained only where required for license attribution. +The settings-window glass palette is visually adapted from liquidDX11 for WPF; +SleepBlox does not bundle or execute liquidDX11's native renderer. diff --git a/docs/TERMS.md b/docs/TERMS.md index d995837..af5363d 100644 --- a/docs/TERMS.md +++ b/docs/TERMS.md @@ -1,25 +1,25 @@ -# SleepStrap Terms of Use and Risk Notice +# SleepBlox Terms of Use and Risk Notice -SleepStrap is unofficial, community-made software. It is not made, sponsored, endorsed, approved, or supported by Roblox Corporation. Roblox and related names, logos, and assets belong to their respective owners. +SleepBlox is unofficial, community-made software. It is not made, sponsored, endorsed, approved, or supported by Roblox Corporation. Roblox and related names, logos, and assets belong to their respective owners. -SleepStrap is a fork derived from Fishstrap and Bloxstrap and also contains code, libraries, concepts, and assets from the wider open-source community. Fishstrap, Bloxstrap, their contributors, and all third-party projects remain separate projects owned by their respective authors. The license notices included with SleepStrap continue to apply to the portions they cover. +SleepBlox is a fork derived from Fishstrap and Bloxstrap and also contains code, libraries, concepts, and assets from the wider open-source community. Fishstrap, Bloxstrap, their contributors, and all third-party projects remain separate projects owned by their respective authors. The license notices included with SleepBlox continue to apply to the portions they cover. ## Account enforcement and bans -Use SleepStrap entirely at your own risk. SleepStrap can change local settings and Roblox assets, apply FastFlags, alter graphics behavior, change display settings, and interact with files used by Roblox. Roblox can change its rules, client, detection systems, and enforcement practices at any time. No SleepStrap feature is guaranteed to be permitted by Roblox or safe from detection. +Use SleepBlox entirely at your own risk. SleepBlox can change local settings and Roblox assets, apply FastFlags, alter graphics behavior, change display settings, and interact with files used by Roblox. Roblox can change its rules, client, detection systems, and enforcement practices at any time. No SleepBlox feature is guaranteed to be permitted by Roblox or safe from detection. -The SleepStrap maintainers and distributors are not responsible for warnings, suspensions, account bans, hardware bans, deleted progress, lost purchases, moderation actions, or any other account penalty connected directly or indirectly to installing, launching, configuring, or using SleepStrap. +The SleepBlox maintainers and distributors are not responsible for warnings, suspensions, account bans, hardware bans, deleted progress, lost purchases, moderation actions, or any other account penalty connected directly or indirectly to installing, launching, configuring, or using SleepBlox. -You are responsible for reviewing and following the Roblox Terms of Use, Community Standards, individual game rules, platform rules, and applicable law. You are also responsible for deciding whether each modification, visual option, FastFlag, texture, font, skybox, display stretch, or other feature is appropriate for your account and computer. If you are unwilling to accept the possibility of account enforcement or technical problems, do not use SleepStrap. +You are responsible for reviewing and following the Roblox Terms of Use, Community Standards, individual game rules, platform rules, and applicable law. You are also responsible for deciding whether each modification, visual option, FastFlag, texture, font, skybox, display stretch, or other feature is appropriate for your account and computer. If you are unwilling to accept the possibility of account enforcement or technical problems, do not use SleepBlox. ## No warranty -SleepStrap is provided **as-is** and **as-available**, without warranties or guarantees of any kind. There is no promise that it will always work, remain compatible with Roblox or Windows, improve performance, preserve files, avoid crashes, prevent data loss, or be free of bugs, security issues, interruptions, or false detections. Features can stop working after Roblox, Windows, graphics-driver, browser, or third-party service updates. +SleepBlox is provided **as-is** and **as-available**, without warranties or guarantees of any kind. There is no promise that it will always work, remain compatible with Roblox or Windows, improve performance, preserve files, avoid crashes, prevent data loss, or be free of bugs, security issues, interruptions, or false detections. Features can stop working after Roblox, Windows, graphics-driver, browser, or third-party service updates. Some options modify files, configuration values, graphics settings, local caches, fonts, textures, display resolution, or other device state. Backup and restoration features are provided for convenience but are not guaranteed. You are responsible for maintaining your own backups and checking settings before applying them. The maintainers and distributors are not responsible for corrupted files, lost configurations, display problems, reduced performance, crashes, incompatibility, network usage, storage usage, or damage to software or hardware. -SleepStrap may open or depend on Roblox, Discord, Windows components, websites, open-source packages, or other third-party software and services. Those services have their own terms and privacy practices. SleepStrap does not control them and is not responsible for their availability, content, security, behavior, fees, enforcement decisions, or changes. +SleepBlox may open or depend on Roblox, Discord, Windows components, websites, open-source packages, or other third-party software and services. Those services have their own terms and privacy practices. SleepBlox does not control them and is not responsible for their availability, content, security, behavior, fees, enforcement decisions, or changes. -To the fullest extent allowed by applicable law, the SleepStrap maintainers, distributors, contributors, Fishstrap contributors, Bloxstrap contributors, and third-party component authors are not liable for direct, indirect, incidental, special, consequential, exemplary, or punitive losses arising from SleepStrap. This includes account penalties, lost data, lost revenue, lost purchases, lost opportunities, interruption, device problems, and claims by others, even if someone was told that such harm could occur. +To the fullest extent allowed by applicable law, the SleepBlox maintainers, distributors, contributors, Fishstrap contributors, Bloxstrap contributors, and third-party component authors are not liable for direct, indirect, incidental, special, consequential, exemplary, or punitive losses arising from SleepBlox. This includes account penalties, lost data, lost revenue, lost purchases, lost opportunities, interruption, device problems, and claims by others, even if someone was told that such harm could occur. -By installing, launching, or continuing to use SleepStrap, you confirm that you understand it is unofficial, accept these risks, and take responsibility for your choices. If you do not agree, stop using SleepStrap and remove it. This notice may change in later versions as features, platform rules, and risks change. +By installing, launching, or continuing to use SleepBlox, you confirm that you understand it is unofficial, accept these risks, and take responsibility for your choices. If you do not agree, stop using SleepBlox and remove it. This notice may change in later versions as features, platform rules, and risks change. diff --git a/docs/licenses/NvidiaProfileInspector-LICENSE.txt b/docs/licenses/NvidiaProfileInspector-LICENSE.txt new file mode 100644 index 0000000..f8ebc81 --- /dev/null +++ b/docs/licenses/NvidiaProfileInspector-LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Orbmu2k + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/licenses/liquidDX11-LICENSE.txt b/docs/licenses/liquidDX11-LICENSE.txt new file mode 100644 index 0000000..d5ba315 --- /dev/null +++ b/docs/licenses/liquidDX11-LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2026 Omar Cornut + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/MacroRecording/GridWeaponPositionRecorder.ahk b/tools/MacroRecording/GridWeaponPositionRecorder.ahk new file mode 100644 index 0000000..db0e1f5 --- /dev/null +++ b/tools/MacroRecording/GridWeaponPositionRecorder.ahk @@ -0,0 +1,187 @@ +#Requires AutoHotkey v2.0 +#SingleInstance Force + +CoordMode "Mouse", "Screen" + +weapons := [ + {category: "Primary", name: "Distortion"}, + {category: "Primary", name: "Permafrost"}, + {category: "Primary", name: "Energy Rifle"}, + {category: "Primary", name: "Flamethrower"}, + {category: "Primary", name: "Grenade Launcher"}, + {category: "Primary", name: "Minigun"}, + {category: "Primary", name: "Paintball Gun"}, + {category: "Primary", name: "Assault Rifle"}, + {category: "Primary", name: "Bow"}, + {category: "Primary", name: "Burst Rifle"}, + {category: "Primary", name: "Crossbow"}, + {category: "Primary", name: "Gunblade"}, + {category: "Primary", name: "RPG"}, + {category: "Primary", name: "Shotgun"}, + {category: "Primary", name: "Sniper"}, + {category: "Secondary", name: "Warper"}, + {category: "Secondary", name: "Energy Pistols"}, + {category: "Secondary", name: "Exogun"}, + {category: "Secondary", name: "Slingshot"}, + {category: "Secondary", name: "Daggers"}, + {category: "Secondary", name: "Flare Gun"}, + {category: "Secondary", name: "Handgun"}, + {category: "Secondary", name: "Revolver"}, + {category: "Secondary", name: "Shorty"}, + {category: "Secondary", name: "Spray"}, + {category: "Secondary", name: "Uzi"}, + {category: "Melee", name: "Maul"}, + {category: "Melee", name: "Spear"}, + {category: "Melee", name: "Trowel"}, + {category: "Melee", name: "Battle Axe"}, + {category: "Melee", name: "Chainsaw"}, + {category: "Melee", name: "Fists"}, + {category: "Melee", name: "Katana"}, + {category: "Melee", name: "Knife"}, + {category: "Melee", name: "Riot Shield"}, + {category: "Melee", name: "Scythe"}, + {category: "Utility", name: "Grappler"}, + {category: "Utility", name: "Medkit"}, + {category: "Utility", name: "Subspace Tripmine"}, + {category: "Utility", name: "Warpstone"}, + {category: "Utility", name: "Flashbang"}, + {category: "Utility", name: "Freeze Ray"}, + {category: "Utility", name: "Grenade"}, + {category: "Utility", name: "Jump Pad"}, + {category: "Utility", name: "Molotov"}, + {category: "Utility", name: "Satchel"}, + {category: "Utility", name: "Smoke Grenade"}, + {category: "Utility", name: "War Horn"} +] + +outputFile := A_ScriptDir "\grid_weapon_positions.csv" +positions := [] + +recorder := Gui("+AlwaysOnTop", "SleepBlox Grid Weapon Recorder") +recorder.BackColor := "101014" +recorder.SetFont("s10 cFFFFFF", "Segoe UI") +recorder.MarginX := 18 +recorder.MarginY := 16 +recorder.AddText("w440", "Open Primary in Grid view and keep the full weapon grid visible.") +nextText := recorder.AddText("xm y+14 w440 cFFFFFF", "Next: Primary - Distortion") +progressText := recorder.AddText("xm y+8 w440 cA0A0A0", "0 / " weapons.Length " weapons") +recorder.AddText( + "xm y+12 w440 c808080", + "Hover the named weapon and press 1. Record each grid left-to-right, top-to-bottom.") +recorder.AddText( + "xm y+8 w440 c808080", + "Ctrl+Z: undo F8: progress F10: reset Esc: exit") +recorder.OnEvent("Close", (*) => ExitApp()) +recorder.Show() + +1::RecordWeapon() +Numpad1::RecordWeapon() +^z::UndoWeapon() +F8::ShowProgress() +F10::ResetRecording() +Esc::ExitApp() + +RecordWeapon() { + global positions, weapons, nextText, progressText, outputFile + + if positions.Length >= weapons.Length + return + + MouseGetPos &mouseX, &mouseY + weapon := weapons[positions.Length + 1] + categoryIndex := GetCategoryIndex(positions.Length + 1) + positions.Push({ + category: weapon.category, + categoryIndex: categoryIndex, + name: weapon.name, + x: mouseX, + y: mouseY + }) + + SavePositions() + SoundBeep 880, 45 + progressText.Text := positions.Length " / " weapons.Length " weapons" + + if positions.Length = weapons.Length { + nextText.Text := "Complete - grid_weapon_positions.csv is ready" + MsgBox( + "All " weapons.Length " Grid weapon positions were recorded.`n`nSaved to:`n" outputFile, + "Grid recording complete", + "Iconi") + return + } + + nextWeapon := weapons[positions.Length + 1] + nextText.Text := "Next: " nextWeapon.category " - " nextWeapon.name + + if nextWeapon.category != weapon.category { + ToolTip( + "Open " nextWeapon.category " in Grid view.`n" + "Hover " nextWeapon.name " and press 1.") + SetTimer () => ToolTip(), -3000 + } +} + +GetCategoryIndex(globalIndex) { + global weapons + + category := weapons[globalIndex].category + index := 0 + Loop globalIndex { + if weapons[A_Index].category = category + index += 1 + } + return index +} + +UndoWeapon() { + global positions, weapons, nextText, progressText + + if positions.Length = 0 + return + + removed := positions.Pop() + SavePositions() + next := weapons[positions.Length + 1] + nextText.Text := "Next: " next.category " - " next.name + progressText.Text := positions.Length " / " weapons.Length " weapons" + ToolTip "Removed " removed.name "." + SetTimer () => ToolTip(), -1600 +} + +ResetRecording() { + global positions, weapons, nextText, progressText + + if MsgBox( + "Erase this Grid recording and restart at Distortion?", + "Restart Grid recording", + "YesNo Icon! Default2") != "Yes" + { + return + } + + positions := [] + SavePositions() + nextText.Text := "Next: Primary - Distortion" + progressText.Text := "0 / " weapons.Length " weapons" +} + +ShowProgress() { + global positions, weapons, outputFile + MsgBox( + positions.Length " / " weapons.Length " weapons recorded.`n`n" outputFile, + "Grid recorder progress", + "Iconi") +} + +SavePositions() { + global positions, outputFile + + csv := "category,index,weapon,x,y`n" + for position in positions + csv .= position.category "," position.categoryIndex "," position.name "," position.x "," position.y "`n" + + if FileExist(outputFile) + FileDelete outputFile + FileAppend csv, outputFile, "UTF-8" +} diff --git a/tools/MacroRecording/ListWeaponPositionRecorder.ahk b/tools/MacroRecording/ListWeaponPositionRecorder.ahk new file mode 100644 index 0000000..3a09fad --- /dev/null +++ b/tools/MacroRecording/ListWeaponPositionRecorder.ahk @@ -0,0 +1,168 @@ +#Requires AutoHotkey v2.0 +#SingleInstance Force + +CoordMode "Mouse", "Screen" + +weapons := [ + {category: "Primary", name: "Distortion"}, + {category: "Primary", name: "Permafrost"}, + {category: "Primary", name: "Energy Rifle"}, + {category: "Primary", name: "Flamethrower"}, + {category: "Primary", name: "Grenade Launcher"}, + {category: "Primary", name: "Minigun"}, + {category: "Primary", name: "Paintball Gun"}, + {category: "Primary", name: "Assault Rifle"}, + {category: "Primary", name: "Bow"}, + {category: "Primary", name: "Burst Rifle"}, + {category: "Primary", name: "Crossbow"}, + {category: "Primary", name: "Gunblade"}, + {category: "Primary", name: "RPG"}, + {category: "Primary", name: "Shotgun"}, + {category: "Primary", name: "Sniper"}, + {category: "Secondary", name: "Warper"}, + {category: "Secondary", name: "Energy Pistols"}, + {category: "Secondary", name: "Exogun"}, + {category: "Secondary", name: "Slingshot"}, + {category: "Secondary", name: "Daggers"}, + {category: "Secondary", name: "Flare Gun"}, + {category: "Secondary", name: "Handgun"}, + {category: "Secondary", name: "Revolver"}, + {category: "Secondary", name: "Shorty"}, + {category: "Secondary", name: "Spray"}, + {category: "Secondary", name: "Uzi"}, + {category: "Melee", name: "Maul"}, + {category: "Melee", name: "Spear"}, + {category: "Melee", name: "Trowel"}, + {category: "Melee", name: "Battle Axe"}, + {category: "Melee", name: "Chainsaw"}, + {category: "Melee", name: "Fists"}, + {category: "Melee", name: "Katana"}, + {category: "Melee", name: "Knife"}, + {category: "Melee", name: "Riot Shield"}, + {category: "Melee", name: "Scythe"}, + {category: "Utility", name: "Grappler"}, + {category: "Utility", name: "Medkit"}, + {category: "Utility", name: "Subspace Tripmine"}, + {category: "Utility", name: "Warpstone"}, + {category: "Utility", name: "Flashbang"}, + {category: "Utility", name: "Freeze Ray"}, + {category: "Utility", name: "Grenade"}, + {category: "Utility", name: "Jump Pad"}, + {category: "Utility", name: "Molotov"}, + {category: "Utility", name: "Satchel"}, + {category: "Utility", name: "Smoke Grenade"}, + {category: "Utility", name: "War Horn"} +] + +outputDirectory := A_ScriptDir "\recorded_positions" +outputFile := outputDirectory "\list_weapon_positions.csv" +positions := [] +DirCreate outputDirectory + +recorder := Gui("+AlwaysOnTop", "SleepBlox List Weapon Recorder") +recorder.SetFont("s10", "Segoe UI") +recorder.MarginX := 18 +recorder.MarginY := 16 +recorder.AddText("w420", "Record every List weapon in order. Keep the active category open and begin at the top.") +nextText := recorder.AddText("xm y+14 w420 cFFFFFF", "Next: Primary — Distortion") +progressText := recorder.AddText("xm y+8 w420 cA0A0A0", "0 / 48 weapons") +recorder.AddText("xm y+12 w420 c808080", "Hover the weapon and press 1. The recorder scrolls after every seven weapons. Ctrl+Z undoes.") +recorder.OnEvent("Close", (*) => ExitApp()) +recorder.Show() + +1::RecordWeapon() +Numpad1::RecordWeapon() +^z::UndoWeapon() +F8::ShowProgress() +F10::ResetRecording() +Esc::ExitApp() + +RecordWeapon() { + global positions, weapons, nextText, progressText, outputFile + if positions.Length >= weapons.Length + return + + MouseGetPos &mouseX, &mouseY + weapon := weapons[positions.Length + 1] + categoryIndex := GetCategoryIndex(positions.Length + 1) + positions.Push({category: weapon.category, categoryIndex: categoryIndex, name: weapon.name, x: mouseX, y: mouseY}) + SavePositions() + SoundBeep 880, 45 + progressText.Text := positions.Length " / " weapons.Length " weapons" + + if positions.Length = weapons.Length { + nextText.Text := "Complete — send list_weapon_positions.csv back to Codex" + MsgBox "All 48 List weapon positions were recorded.`n`nSaved to:`n" outputFile, + "List recording complete", "Iconi" + return + } + + nextWeapon := weapons[positions.Length + 1] + nextText.Text := "Next: " nextWeapon.category " — " nextWeapon.name + + if nextWeapon.category != weapon.category { + ToolTip "Open " nextWeapon.category " and scroll fully to the top.`nThen hover " nextWeapon.name " and press 1." + SetTimer () => ToolTip(), -3000 + return + } + + if Mod(categoryIndex, 7) = 0 { + Loop 7 { + Send "{WheelDown}" + Sleep 18 + } + ToolTip "Scrolled to the next seven " weapon.category " weapons.`nNext: " nextWeapon.name + SetTimer () => ToolTip(), -2200 + } +} + +GetCategoryIndex(globalIndex) { + global weapons + category := weapons[globalIndex].category + index := 0 + Loop globalIndex { + if weapons[A_Index].category = category + index += 1 + } + return index +} + +UndoWeapon() { + global positions, weapons, nextText, progressText + if positions.Length = 0 + return + + removed := positions.Pop() + SavePositions() + next := weapons[positions.Length + 1] + nextText.Text := "Next: " next.category " — " next.name + progressText.Text := positions.Length " / " weapons.Length " weapons" + ToolTip "Removed " removed.name ". Reposition the List manually if that crossed a scroll boundary." + SetTimer () => ToolTip(), -2200 +} + +ResetRecording() { + global positions, weapons, nextText, progressText + if MsgBox("Erase this List recording and restart at Distortion?", "Restart List recording", "YesNo Icon! Default2") != "Yes" + return + positions := [] + SavePositions() + nextText.Text := "Next: Primary — Distortion" + progressText.Text := "0 / " weapons.Length " weapons" +} + +ShowProgress() { + global positions, weapons, outputFile + MsgBox positions.Length " / " weapons.Length " weapons recorded.`n`n" outputFile, + "List recorder progress", "Iconi" +} + +SavePositions() { + global positions, outputFile + csv := "category,index,weapon,x,y`n" + for position in positions + csv .= position.category "," position.categoryIndex "," position.name "," position.x "," position.y "`n" + if FileExist(outputFile) + FileDelete outputFile + FileAppend csv, outputFile, "UTF-8" +} diff --git a/tools/MacroRecording/README.md b/tools/MacroRecording/README.md new file mode 100644 index 0000000..cb0a65a --- /dev/null +++ b/tools/MacroRecording/README.md @@ -0,0 +1,14 @@ +# Macro recording tools + +Development-only tools and reference data used to record the RIVALS list-layout +weapon positions. + +- `ListWeaponPositionRecorder.ahk` records each weapon position in order. +- `GridWeaponPositionRecorder.ahk` records the same weapons in Grid view. +- `list_weapon_positions.csv` contains the latest recorded reference positions. + +Both recorders use `1` (or Numpad `1`) to capture the mouse position, +`Ctrl+Z` to undo, `F8` to show progress, and `F10` to reset. + +These files are not required at runtime; SleepBlox embeds the finalized position +data in the application. diff --git a/tools/MacroRecording/grid_weapon_positions.csv b/tools/MacroRecording/grid_weapon_positions.csv new file mode 100644 index 0000000..8a8aee0 --- /dev/null +++ b/tools/MacroRecording/grid_weapon_positions.csv @@ -0,0 +1,49 @@ +category,index,weapon,x,y +Primary,1,Distortion,-1120,577 +Primary,2,Permafrost,-958,573 +Primary,3,Energy Rifle,-826,573 +Primary,4,Flamethrower,-701,571 +Primary,5,Grenade Launcher,-1268,730 +Primary,6,Minigun,-1126,724 +Primary,7,Paintball Gun,-963,722 +Primary,8,Assault Rifle,-839,719 +Primary,9,Bow,-661,711 +Primary,10,Burst Rifle,-1232,868 +Primary,11,Crossbow,-1101,866 +Primary,12,Gunblade,-980,862 +Primary,13,RPG,-849,859 +Primary,14,Shotgun,-706,866 +Primary,15,Sniper,-1279,1047 +Secondary,1,Warper,-1120,604 +Secondary,2,Energy Pistols,-952,584 +Secondary,3,Exogun,-834,582 +Secondary,4,Slingshot,-674,585 +Secondary,5,Daggers,-1271,723 +Secondary,6,Flare Gun,-1117,716 +Secondary,7,Handgun,-942,709 +Secondary,8,Revolver,-832,711 +Secondary,9,Shorty,-704,716 +Secondary,10,Spray,-1273,898 +Secondary,11,Uzi,-1131,883 +Melee,1,Maul,-1117,568 +Melee,2,Spear,-959,574 +Melee,3,Trowel,-819,575 +Melee,4,Battle Axe,-692,584 +Melee,5,Chainsaw,-1268,725 +Melee,6,Fists,-1118,725 +Melee,7,Katana,-993,725 +Melee,8,Knife,-832,725 +Melee,9,Riot Shield,-660,722 +Melee,10,Scythe,-1259,891 +Utility,1,Grappler,-1147,591 +Utility,2,Medkit,-986,589 +Utility,3,Subspace Tripmine,-809,592 +Utility,4,Warpstone,-697,587 +Utility,5,Flashbang,-1272,761 +Utility,6,Freeze Ray,-1141,748 +Utility,7,Grenade,-937,736 +Utility,8,Jump Pad,-823,736 +Utility,9,Molotov,-651,742 +Utility,10,Satchel,-1284,905 +Utility,11,Smoke Grenade,-1136,877 +Utility,12,War Horn,-979,873 diff --git a/tools/MacroRecording/list_weapon_positions.csv b/tools/MacroRecording/list_weapon_positions.csv new file mode 100644 index 0000000..adf7f6f --- /dev/null +++ b/tools/MacroRecording/list_weapon_positions.csv @@ -0,0 +1,49 @@ +category,index,weapon,x,y +Primary,1,Distortion,-968,492 +Primary,2,Permafrost,-958,577 +Primary,3,Energy Rifle,-969,671 +Primary,4,Flamethrower,-965,740 +Primary,5,Grenade Launcher,-984,830 +Primary,6,Minigun,-980,914 +Primary,7,Paintball Gun,-974,997 +Primary,8,Assault Rifle,-969,437 +Primary,9,Bow,-963,531 +Primary,10,Burst Rifle,-960,602 +Primary,11,Crossbow,-953,691 +Primary,12,Gunblade,-950,788 +Primary,13,RPG,-952,877 +Primary,14,Shotgun,-949,950 +Primary,15,Sniper,-957,1030 +Secondary,1,Warper,-962,477 +Secondary,2,Energy Pistols,-957,571 +Secondary,3,Exogun,-965,657 +Secondary,4,Slingshot,-956,741 +Secondary,5,Daggers,-972,827 +Secondary,6,Flare Gun,-989,902 +Secondary,7,Handgun,-955,992 +Secondary,8,Revolver,-969,774 +Secondary,9,Shorty,-969,858 +Secondary,10,Spray,-972,949 +Secondary,11,Uzi,-953,1022 +Melee,1,Maul,-962,489 +Melee,2,Spear,-955,570 +Melee,3,Trowel,-954,655 +Melee,4,Battle Axe,-954,740 +Melee,5,Chainsaw,-968,826 +Melee,6,Fists,-960,901 +Melee,7,Katana,-962,1005 +Melee,8,Knife,-970,850 +Melee,9,Riot Shield,-958,932 +Melee,10,Scythe,-959,1024 +Utility,1,Grappler,-953,480 +Utility,2,Medkit,-968,570 +Utility,3,Subspace Tripmine,-960,643 +Utility,4,Warpstone,-974,738 +Utility,5,Flashbang,-968,828 +Utility,6,Freeze Ray,-952,914 +Utility,7,Grenade,-990,1009 +Utility,8,Jump Pad,-973,680 +Utility,9,Molotov,-975,774 +Utility,10,Satchel,-966,855 +Utility,11,Smoke Grenade,-971,942 +Utility,12,War Horn,-990,1043 diff --git a/tools/SleepSkybox/App.xaml b/tools/SleepSkybox/App.xaml new file mode 100644 index 0000000..0b12628 --- /dev/null +++ b/tools/SleepSkybox/App.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + diff --git a/tools/SleepSkybox/App.xaml.cs b/tools/SleepSkybox/App.xaml.cs new file mode 100644 index 0000000..20a47e2 --- /dev/null +++ b/tools/SleepSkybox/App.xaml.cs @@ -0,0 +1,33 @@ +using System.Windows; + +namespace SleepSkybox; + +public partial class App : System.Windows.Application +{ + protected override void OnStartup(StartupEventArgs e) + { + // Optional script mode: SleepSkybox.exe "panorama.png" "output folder" + // keeps this useful from a shortcut or batch file without ever touching Roblox. + if (e.Args.Length == 2) + { + try + { + SkyboxConverter.ConvertPngPanorama(e.Args[0], e.Args[1]); + Shutdown(0); + } + catch (Exception ex) + { + System.Windows.MessageBox.Show( + ex.Message, + "SleepSky", + MessageBoxButton.OK, + MessageBoxImage.Error); + Shutdown(1); + } + + return; + } + + base.OnStartup(e); + } +} diff --git a/tools/SleepSkybox/MainWindow.xaml b/tools/SleepSkybox/MainWindow.xaml new file mode 100644 index 0000000..b91ddd2 --- /dev/null +++ b/tools/SleepSkybox/MainWindow.xaml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +