diff --git a/WheelWizard.Test/Features/DiscImages/DiscImageServiceTests.cs b/WheelWizard.Test/Features/DiscImages/DiscImageServiceTests.cs new file mode 100644 index 00000000..560b6c66 --- /dev/null +++ b/WheelWizard.Test/Features/DiscImages/DiscImageServiceTests.cs @@ -0,0 +1,87 @@ +using System.Text; +using Testably.Abstractions.Testing; +using WheelWizard.DiscImages; +using WheelWizard.DiscImages.Domain; + +namespace WheelWizard.Test.Features.DiscImages; + +public class DiscImageServiceTests +{ + [Fact] + public void ReadInfo_ReadsWiiDiscHeader() + { + var fs = Games(); + fs.File.WriteAllBytes("/games/game.iso", WiiDisc("RMCP01", "MarioKartWii")); + + var info = new DiscImageService(fs).ReadInfo("/games/game.iso"); + + Assert.True(info.IsSuccess, info.IsFailure ? info.Error.Message : null); + Assert.Equal(new DiscImageInfo("RMCP01", "MarioKartWii", 0, DiscImageFormat.Iso), info.Value); + } + + [Fact] + public void ReadInfo_RejectsFilesWithoutDiscHeader() + { + var fs = Games(); + fs.File.WriteAllBytes("/games/notes.iso", new byte[0x8000]); + + Assert.True(new DiscImageService(fs).ReadInfo("/games/notes.iso").IsFailure); + } + + [Fact] + public void ReadInfo_FailsForMissingFile() + { + Assert.True(new DiscImageService(new MockFileSystem()).ReadInfo("/missing.rvz").IsFailure); + } + + [Fact] + public void ReadInfo_RereadsChangedFile() + { + var fs = Games(); + var service = new DiscImageService(fs); + fs.File.WriteAllBytes("/games/game.iso", WiiDisc("RMCE01", "MarioKartWii")); + Assert.Equal("RMCE01", service.ReadInfo("/games/game.iso").Value.GameId); + + fs.File.WriteAllBytes("/games/game.iso", WiiDisc("RMCJ01", "MarioKartWii", extraBytes: 1)); + + Assert.Equal("RMCJ01", service.ReadInfo("/games/game.iso").Value.GameId); + } + + [Fact] + public void ReadInfo_RetriesAfterFileWasLocked() + { + var fs = Games(); + var service = new DiscImageService(fs); + fs.File.WriteAllBytes("/games/game.iso", WiiDisc("RMCP01", "MarioKartWii")); + + using (fs.File.Open("/games/game.iso", FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + Assert.True(service.ReadInfo("/games/game.iso").IsFailure); + + Assert.Equal("RMCP01", service.ReadInfo("/games/game.iso").Value.GameId); + } + + [Fact] + public void SupportedExtensions_CoverWiiContainers() + { + var extensions = new DiscImageService(new MockFileSystem()).SupportedExtensions; + + Assert.Equal(new[] { ".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz" }.Order(), extensions.Order()); + } + + // A file can't be written to a drive root the mock hasn't seen yet, which the Windows CI runner hits on D:. + private static MockFileSystem Games() + { + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/games"); + return fs; + } + + private static byte[] WiiDisc(string gameId, string name, int extraBytes = 0) + { + var data = new byte[0x50000 + extraBytes]; + Encoding.ASCII.GetBytes(gameId).CopyTo(data, 0); + new byte[] { 0x5D, 0x1C, 0x9E, 0xA3 }.CopyTo(data, 0x18); + Encoding.ASCII.GetBytes(name).CopyTo(data, 0x20); + return data; + } +} diff --git a/WheelWizard.Test/Features/SaveRegionServiceTests.cs b/WheelWizard.Test/Features/SaveRegionServiceTests.cs index abd9ff04..95be40a7 100644 --- a/WheelWizard.Test/Features/SaveRegionServiceTests.cs +++ b/WheelWizard.Test/Features/SaveRegionServiceTests.cs @@ -1,5 +1,10 @@ using Testably.Abstractions.Testing; +using WheelWizard.DiscImages; +using WheelWizard.DiscImages.Domain; using WheelWizard.Models.Enums; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Shared; using WheelWizard.WiiManagement.GameLicense; namespace WheelWizard.Test.Features; @@ -16,7 +21,7 @@ public void AvailableRegions_RequireSaveFileInSuppliedRoot() fs.Directory.CreateDirectory("/other/RMCJ"); fs.File.WriteAllText("/other/RMCJ/rksys.dat", "save"); - var regions = new SaveRegionService(fs).GetAvailableRegions("/selected"); + var regions = Create(fs).Service.GetAvailableRegions("/selected"); Assert.Equal([MarioKartWiiEnums.Regions.America], regions); } @@ -24,6 +29,69 @@ public void AvailableRegions_RequireSaveFileInSuppliedRoot() [Fact] public void NoSaves_ReturnsNoneRegion() { - Assert.Equal([MarioKartWiiEnums.Regions.None], new SaveRegionService(new MockFileSystem()).GetAvailableRegions("/missing")); + Assert.Equal([MarioKartWiiEnums.Regions.None], Create(new MockFileSystem()).Service.GetAvailableRegions("/missing")); + } + + [Theory] + [InlineData("RMCE01", MarioKartWiiEnums.Regions.America)] + [InlineData("RMCP01", MarioKartWiiEnums.Regions.Europe)] + [InlineData("RMCJ01", MarioKartWiiEnums.Regions.Japan)] + [InlineData("RMCK01", MarioKartWiiEnums.Regions.Korea)] + [InlineData("RWSJ01", MarioKartWiiEnums.Regions.None)] + [InlineData("RMC", MarioKartWiiEnums.Regions.None)] + [InlineData(null, MarioKartWiiEnums.Regions.None)] + public void GameRegion_FromGameId(string? gameId, MarioKartWiiEnums.Regions expected) + { + Assert.Equal(expected, GameRegion.FromGameId(gameId)); + } + + [Fact] + public void ActiveRegion_FollowsGameFile_OverLastUsedRegion() + { + var fixture = Create(new MockFileSystem(), MarioKartWiiEnums.Regions.America); + fixture + .Discs.ReadInfo("/game.rvz") + .Returns((OperationResult)new DiscImageInfo("RMCP01", "MarioKartWii", 0, DiscImageFormat.Rvz)); + + Assert.Equal(MarioKartWiiEnums.Regions.Europe, fixture.Service.ResolveActiveRegion("/saves")); + } + + [Fact] + public void ActiveRegion_IgnoresOtherGames() + { + var fixture = Create(new MockFileSystem(), MarioKartWiiEnums.Regions.Japan); + fixture + .Discs.ReadInfo("/game.rvz") + .Returns((OperationResult)new DiscImageInfo("RWSJ01", "MARIO & SONIC", 0, DiscImageFormat.Rvz)); + + Assert.Equal(MarioKartWiiEnums.Regions.Japan, fixture.Service.ResolveActiveRegion("/saves")); + } + + [Fact] + public void ActiveRegion_WithoutReadableGame_FallsBackToFirstSave() + { + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/saves/RMCK"); + fs.File.WriteAllText("/saves/RMCK/rksys.dat", "save"); + var fixture = Create(fs); + fixture.Discs.ReadInfo(Arg.Any()).Returns((OperationResult)Fail("Game file not found")); + + Assert.Equal(MarioKartWiiEnums.Regions.Korea, fixture.Service.ResolveActiveRegion("/saves")); + } + + private static (SaveRegionService Service, IDiscImageService Discs) Create( + MockFileSystem fs, + MarioKartWiiEnums.Regions lastRegion = MarioKartWiiEnums.Regions.None + ) + { + var settings = Substitute.For(); + var gamePath = new WhWzSetting("GameLocation", "/game.rvz"); + var region = new WhWzSetting("RR_Region", lastRegion); + settings.GAME_LOCATION.Returns(gamePath); + settings.RR_REGION.Returns(region); + settings.Get(gamePath).Returns(_ => (string)gamePath.Get()); + settings.Get(region).Returns(_ => (MarioKartWiiEnums.Regions)region.Get()); + var discs = Substitute.For(); + return (new SaveRegionService(fs, settings, discs), discs); } } diff --git a/WheelWizard/Features/DiscImages/DiscImageService.cs b/WheelWizard/Features/DiscImages/DiscImageService.cs new file mode 100644 index 00000000..00be285d --- /dev/null +++ b/WheelWizard/Features/DiscImages/DiscImageService.cs @@ -0,0 +1,120 @@ +using System.IO.Abstractions; +using RVZSharp.Blobs; +using RVZSharp.Interfaces; +using RVZSharp.Models; +using WheelWizard.DiscImages.Domain; + +namespace WheelWizard.DiscImages; + +public interface IDiscImageService +{ + /// File extensions (with dot) of the disc image formats a Wii game can be read from. + IReadOnlyList SupportedExtensions { get; } + + /// Reads the disc header of a game file in any supported format. + OperationResult ReadInfo(string path); +} + +public sealed class DiscImageService(IFileSystem fileSystem) : IDiscImageService +{ + private readonly object _cacheLock = new(); + private (string Path, long Length, DateTime LastWrite, DiscImageInfo Info)? _cache; + + public IReadOnlyList SupportedExtensions { get; } = Enum.GetValues().SelectMany(ExtensionsFor).ToArray(); + + private static string[] ExtensionsFor(BlobType type) => + type switch + { + BlobType.Plain => [".iso", ".gcm"], + BlobType.Gcz => [".gcz"], + BlobType.Ciso => [".ciso"], + BlobType.Wbfs => [".wbfs"], + BlobType.Wia => [".wia"], + BlobType.Rvz => [".rvz"], + // TGC only wraps GameCube discs, and NFS needs key files from a Wii U title next to it. + _ => [], + }; + + public OperationResult ReadInfo(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return Fail("No game file is set."); + + try + { + var file = fileSystem.FileInfo.New(path); + if (!file.Exists) + return Fail($"Game file not found: {path}"); + + // The license loader asks on every poll, so only open the image again once the file changes. + lock (_cacheLock) + { + if ( + _cache is { } cached + && cached.Path == path + && cached.Length == file.Length + && cached.LastWrite == file.LastWriteTimeUtc + ) + return cached.Info; + } + + var result = Read(path); + // Failures aren't cached: a file that was briefly locked should read fine on the next call. + if (result.IsSuccess) + { + lock (_cacheLock) + _cache = (path, file.Length, file.LastWriteTimeUtc, result.Value); + } + return result; + } + catch (Exception ex) + { + return new OperationError { Message = $"Failed to read game file: {ex.Message}", Exception = ex }; + } + } + + private OperationResult Read(string path) + { + try + { + var stream = fileSystem.File.OpenRead(path); + IBlobReader reader; + try + { + reader = Blob.Open(stream, path, leaveOpen: false); + } + catch + { + // Blob.Open only takes ownership of the stream once it succeeds. + stream.Dispose(); + throw; + } + + using (reader) + { + var info = DiscInfo.TryRead(reader); + if (info == null) + return Fail("This file is not a GameCube or Wii disc image."); + + return new DiscImageInfo(info.GameId, info.InternalName, info.Revision, ToFormat(reader.Type)); + } + } + catch (Exception ex) + { + return new OperationError { Message = $"Failed to read game file: {ex.Message}", Exception = ex }; + } + } + + private static DiscImageFormat ToFormat(BlobType type) => + type switch + { + BlobType.Gcz => DiscImageFormat.Gcz, + BlobType.Ciso => DiscImageFormat.Ciso, + BlobType.Wbfs => DiscImageFormat.Wbfs, + BlobType.Tgc => DiscImageFormat.Tgc, + BlobType.Wia => DiscImageFormat.Wia, + BlobType.Rvz => DiscImageFormat.Rvz, + BlobType.Nfs => DiscImageFormat.Nfs, + _ => DiscImageFormat.Iso, + }; +} diff --git a/WheelWizard/Features/DiscImages/DiscImagesExtensions.cs b/WheelWizard/Features/DiscImages/DiscImagesExtensions.cs new file mode 100644 index 00000000..f4a19fc6 --- /dev/null +++ b/WheelWizard/Features/DiscImages/DiscImagesExtensions.cs @@ -0,0 +1,10 @@ +namespace WheelWizard.DiscImages; + +public static class DiscImagesExtensions +{ + public static IServiceCollection AddDiscImages(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/WheelWizard/Features/DiscImages/Domain/DiscImageInfo.cs b/WheelWizard/Features/DiscImages/Domain/DiscImageInfo.cs new file mode 100644 index 00000000..f6e60d7f --- /dev/null +++ b/WheelWizard/Features/DiscImages/Domain/DiscImageInfo.cs @@ -0,0 +1,16 @@ +namespace WheelWizard.DiscImages.Domain; + +public enum DiscImageFormat +{ + Iso, + Gcz, + Ciso, + Wbfs, + Tgc, + Wia, + Rvz, + Nfs, +} + +/// What the header of a game disc image says about the game on it. +public sealed record DiscImageInfo(string GameId, string InternalName, byte Revision, DiscImageFormat Format); diff --git a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs index e788fe2b..7ed71ab4 100644 --- a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs +++ b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs @@ -4,8 +4,10 @@ using Avalonia.Platform.Storage; using WheelWizard.ApplicationData; using WheelWizard.ApplicationLifecycle.Logging; +using WheelWizard.DiscImages; using WheelWizard.Dolphin.Discovery; using WheelWizard.Dolphin.Paths; +using WheelWizard.Models.Enums; using WheelWizard.Settings; using WheelWizard.Settings.Types; using WheelWizard.Shared.Desktop.Storage; @@ -15,6 +17,7 @@ using WheelWizard.Views.Components; using WheelWizard.Views.Dialogs; using WheelWizard.Views.Shell; +using WheelWizard.WiiManagement.GameLicense; using SettingsButton = WheelWizard.Views.Components.ActionButton; namespace WheelWizard.Settings.Views; @@ -38,6 +41,8 @@ public partial class WhWzSettings : UserControl private IApplicationDataLocation ApplicationData { get; } + private IDiscImageService DiscImages { get; } + public WhWzSettings( IApplicationLogFiles logFiles, IFilePickerService filePicker, @@ -45,9 +50,11 @@ public WhWzSettings( ISettingsManager settingsService, IDolphinSettingManager dolphinSettingsService, IDolphinDiscoveryService dolphinDiscovery, - IApplicationDataLocation applicationData + IApplicationDataLocation applicationData, + IDiscImageService discImages ) { + DiscImages = discImages; FilePicker = filePicker; DolphinPaths = dolphinPaths; SettingsService = settingsService; @@ -75,7 +82,7 @@ private void ConfigureLocationFieldsForActiveFrontend() private void RefreshLocalizedCodeText() { - MarioKartHelperText.Text = t("helper_text.end_with_x") + " .iso/.gcm/.gcz/.ciso/.wbfs/.wia/.rvz"; + UpdateGameFileDetails(); if (string.IsNullOrWhiteSpace(DolphinPaths.ExecutablePath)) DolphinExecutableValueText.Text = GetDolphinExecutableHelperText(); } @@ -207,16 +214,46 @@ private async void GameLocationBrowse_OnClick(object sender, RoutedEventArgs e) { var fileType = new FilePickerFileType("Game files") { - Patterns = ["*.iso", "*.gcm", "*.gcz", "*.ciso", "*.wbfs", "*.wia", "*.rvz"], + Patterns = DiscImages.SupportedExtensions.Select(extension => "*" + extension).ToArray(), }; var filePath = await FilePicker.OpenSingleFileAsync("Select Mario Kart Wii Game File", [fileType]); - if (!string.IsNullOrEmpty(filePath)) + if (string.IsNullOrEmpty(filePath)) + return; + + var info = DiscImages.ReadInfo(filePath); + if (info.IsFailure || GameRegion.FromGameId(info.Value.GameId) == MarioKartWiiEnums.Regions.None) + { + var reason = info.IsFailure + ? t("message_error.invalid_game_file.unreadable") + : t("message_error.invalid_game_file.wrong_game", new { name = info.Value.InternalName, id = info.Value.GameId }); + await new MessageBoxWindow() + .SetMessageType(MessageBoxWindow.MessageType.Error) + .SetTitleText(t("message_error.invalid_game_file.title")) + .SetInfoText(reason) + .ShowDialog(); + return; + } + + if (await ApplyLocationSettingAsync(SettingsService.GAME_LOCATION, filePath)) { - await ApplyLocationSettingAsync(SettingsService.GAME_LOCATION, filePath); + // A game from another region switches to that region's save. + var layout = ViewUtils.GetLayout(); + layout.UpdateSidebarProfile(); + layout.UpdateFriendCount(); } } + private void UpdateGameFileDetails() + { + var info = DiscImages.ReadInfo(SettingsService.Get(SettingsService.GAME_LOCATION)); + var region = info.IsSuccess ? GameRegion.FromGameId(info.Value.GameId) : MarioKartWiiEnums.Regions.None; + MarioKartHelperText.Text = + region != MarioKartWiiEnums.Regions.None + ? $"Mario Kart Wii · {GameRegion.GetDisplayName(region)} · {info.Value.Format.ToString().ToUpperInvariant()}" + : t("helper_text.end_with_x") + " " + string.Join("/", DiscImages.SupportedExtensions); + } + private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs e) { var folderPath = DolphinDiscovery.FindUserDirectory( @@ -353,6 +390,7 @@ private void UpdateLocationRows() DolphinExecutableOpenButton.IsEnabled = DolphinExecutableOpenButton.IsVisible && CanOpenContainingFolder(DolphinPaths.ExecutablePath); GameLocationOpenButton.IsEnabled = CanOpenContainingFolder(SettingsService.Get(SettingsService.GAME_LOCATION)); + UpdateGameFileDetails(); DolphinUserFolderOpenButton.IsEnabled = Directory.Exists(DolphinPaths.UserFolderPath); } diff --git a/WheelWizard/Features/WheelWizardData/Views/UserProfilePage.axaml b/WheelWizard/Features/WheelWizardData/Views/UserProfilePage.axaml index c941d59e..63fabb05 100644 --- a/WheelWizard/Features/WheelWizardData/Views/UserProfilePage.axaml +++ b/WheelWizard/Features/WheelWizardData/Views/UserProfilePage.axaml @@ -59,16 +59,10 @@ VerticalAlignment="Bottom" Classes="PageTitleText" /> - - - - - - + diff --git a/WheelWizard/Features/WheelWizardData/Views/UserProfilePage.axaml.cs b/WheelWizard/Features/WheelWizardData/Views/UserProfilePage.axaml.cs index 31e46af8..758a1c52 100644 --- a/WheelWizard/Features/WheelWizardData/Views/UserProfilePage.axaml.cs +++ b/WheelWizard/Features/WheelWizardData/Views/UserProfilePage.axaml.cs @@ -59,10 +59,6 @@ public partial class UserProfilePage : UserControl, INotifyPropertyChanged private ISettingsManager SettingsService { get; } - private ISaveRegionService SaveRegions { get; } - - private ICustomDistributionPaths DistributionPaths { get; } - public Mii? CurrentMii { get => _currentMii; @@ -237,9 +233,7 @@ public UserProfilePage( IGameLicenseSingletonService gameLicenseService, IWhWzDataSingletonService badgeService, IMiiDbService miiDbService, - ISettingsManager settingsService, - ISaveRegionService saveRegions, - ICustomDistributionPaths distributionPaths + ISettingsManager settingsService ) { CustomCharacters = customCharacters; @@ -249,68 +243,23 @@ ICustomDistributionPaths distributionPaths BadgeService = badgeService; MiiDbService = miiDbService; SettingsService = settingsService; - SaveRegions = saveRegions; - DistributionPaths = distributionPaths; HistoryGraph = historyGraph; InitializeComponent(); HistoryHost.Content = historyGraph; historyGraph.Bind(VrHistoryGraph.FriendCodeProperty, new Binding("CurrentFriendCode") { Source = this }); _currentUserIndex = FocusedUser; - PopulateRegions(); GreetWithMii(); UpdatePage(); DataContext = this; ProfileContent.SizeChanged += (_, _) => FitChart(); ProfileBody.SizeChanged += (_, _) => FitChart(); CardFace.SizeChanged += (_, e) => BrStat.IsVisible = e.NewSize.Width >= BrStatMinWidth; - // Make sure this action gets subscribed AFTER the PopulateRegions method - RegionDropdown.SelectionChanged += RegionDropdown_SelectionChanged; } internal void RefreshSelectedProfile() { - RegionDropdown.SelectionChanged -= RegionDropdown_SelectionChanged; - try - { - RegionDropdown.Items.Clear(); - PopulateRegions(); - _currentUserIndex = FocusedUser; - UpdatePage(); - } - finally - { - RegionDropdown.SelectionChanged += RegionDropdown_SelectionChanged; - } - } - - private void PopulateRegions() - { - var validRegions = SaveRegions.GetAvailableRegions(DistributionPaths.SaveFolderPath); - var currentRegion = SettingsService.Get(SettingsService.RR_REGION); - foreach (var region in Enum.GetValues()) - { - if (region == MarioKartWiiEnums.Regions.None) - continue; - - var name = region switch - { - MarioKartWiiEnums.Regions.Europe => t("region.europe"), - MarioKartWiiEnums.Regions.America => t("region.america"), - MarioKartWiiEnums.Regions.Korea => t("region.south_korea"), - MarioKartWiiEnums.Regions.Japan => t("region.japan"), - _ => t("state.unknown"), - }; - var itemForRegionDropdown = new DropdownItem - { - Content = name, - Tag = region, - IsEnabled = validRegions.Contains(region), - }; - RegionDropdown.Items.Add(itemForRegionDropdown); - - if (currentRegion == region) - RegionDropdown.SelectedItem = itemForRegionDropdown; - } + _currentUserIndex = FocusedUser; + UpdatePage(); } #region Update page @@ -365,6 +314,11 @@ private void UpdatePage() HasCurrentUserRoom = IsUserInLiveRoom(currentPlayer.FriendCode); Badges = BadgeService.GetBadges(currentPlayer.FriendCode).ToArray(); + // The region follows the game file, so it is shown rather than picked. + var region = SettingsService.Get(SettingsService.RR_REGION); + RegionBadge.IsVisible = region != MarioKartWiiEnums.Regions.None; + RegionBadge.Text = GameRegion.GetDisplayName(region); + RefreshLicenseTabs(); } @@ -386,32 +340,6 @@ private void SetUserAsPrimary() ViewUtils.ShowSnackbar(t("snackbar_success.profile_set_primary")); } - private void RegionDropdown_SelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (RegionDropdown.SelectedItem is not DropdownItem { Tag: MarioKartWiiEnums.Regions region }) - return; - - SettingsService.Set(SettingsService.RR_REGION, region); - var loadResult = GameLicenseService.LoadLicense(); - if (loadResult.IsFailure) - { - new MessageBoxWindow() - .SetMessageType(MessageBoxWindow.MessageType.Error) - .SetTitleText("Failed to load game data") - .SetInfoText(loadResult.Error.Message) - .Show(); - return; - } - - _currentUserIndex = 0; // Just in case you have current user set as 4. and you change to a region where there are only 3 users. - SetUserAsPrimary(); - SwapMii(0); - UpdatePage(); - var layout = ViewUtils.GetLayout(); - layout.UpdateFriendCount(); - layout.UpdateSidebarProfile(); - } - private void LicensePeeker_OnClick(object? sender, RoutedEventArgs e) { if (sender is not Control { DataContext: LicenseTab tab } || tab.Index == _currentUserIndex) diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index 78b803dd..27dcf445 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -694,20 +694,18 @@ private OperationResult ReadRksys() if (!_fileSystem.Directory.Exists(_distributionPaths.SaveFolderPath)) return Fail("Save folder not found"); - var currentRegion = _settingsManager.Get(_settingsManager.RR_REGION); + // Dolphin uses the save of the game's region, so follow the game file instead of letting the user pick. + var currentRegion = _saveRegions.ResolveActiveRegion(_distributionPaths.SaveFolderPath); if (currentRegion == MarioKartWiiEnums.Regions.None) + return Fail("No valid regions found"); + + var previousRegion = _settingsManager.Get(_settingsManager.RR_REGION); + if (currentRegion != previousRegion) { - // Double check if there's at least one valid region - var validRegions = _saveRegions.GetAvailableRegions(_distributionPaths.SaveFolderPath); - if (validRegions.First() != MarioKartWiiEnums.Regions.None) - { - currentRegion = validRegions.First(); - _settingsManager.Set(_settingsManager.RR_REGION, currentRegion); - } - else - { - return Fail("No valid regions found"); - } + _settingsManager.Set(_settingsManager.RR_REGION, currentRegion); + // The focused slot belonged to the other region's save. + if (previousRegion != MarioKartWiiEnums.Regions.None) + _settingsManager.Set(_settingsManager.FOCUSED_USER, 0); } var saveFileFolder = _fileSystem.Path.Combine(_distributionPaths.SaveFolderPath, GameRegion.GetGameId(currentRegion)); diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameRegion.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameRegion.cs index 3fb888e2..861a51d1 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameRegion.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameRegion.cs @@ -14,4 +14,30 @@ public static string GetGameId(MarioKartWiiEnums.Regions region) => MarioKartWiiEnums.Regions.Korea => "RMCK", _ => throw new ArgumentOutOfRangeException(nameof(region), region, null), }; + + /// The Mario Kart Wii region of a disc's game ID, or None when the disc is not Mario Kart Wii. + public static MarioKartWiiEnums.Regions FromGameId(string? gameId) + { + if (gameId is not { Length: >= 4 } || !gameId.StartsWith("RMC", StringComparison.Ordinal)) + return MarioKartWiiEnums.Regions.None; + + return gameId[3] switch + { + 'E' => MarioKartWiiEnums.Regions.America, + 'P' => MarioKartWiiEnums.Regions.Europe, + 'J' => MarioKartWiiEnums.Regions.Japan, + 'K' => MarioKartWiiEnums.Regions.Korea, + _ => MarioKartWiiEnums.Regions.None, + }; + } + + public static string GetDisplayName(MarioKartWiiEnums.Regions region) => + region switch + { + MarioKartWiiEnums.Regions.Europe => t("region.europe"), + MarioKartWiiEnums.Regions.America => t("region.america"), + MarioKartWiiEnums.Regions.Korea => t("region.south_korea"), + MarioKartWiiEnums.Regions.Japan => t("region.japan"), + _ => t("state.unknown"), + }; } diff --git a/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs b/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs index 7e6a0730..5dceb68c 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs @@ -1,14 +1,25 @@ using System.IO.Abstractions; +using WheelWizard.DiscImages; using WheelWizard.Models.Enums; +using WheelWizard.Settings; namespace WheelWizard.WiiManagement.GameLicense; public interface ISaveRegionService { List GetAvailableRegions(string saveDirectory); + + /// The region of the Mario Kart Wii game file in the settings, or None when it can't be read. + MarioKartWiiEnums.Regions GetGameFileRegion(); + + /// + /// The region whose save Dolphin will use: the game file's region when it can be read, + /// otherwise the last used region, otherwise the first region that has a save. + /// + MarioKartWiiEnums.Regions ResolveActiveRegion(string saveDirectory); } -public sealed class SaveRegionService(IFileSystem fileSystem) : ISaveRegionService +public sealed class SaveRegionService(IFileSystem fileSystem, ISettingsManager settings, IDiscImageService discImages) : ISaveRegionService { public List GetAvailableRegions(string saveDirectory) { @@ -37,4 +48,20 @@ public sealed class SaveRegionService(IFileSystem fileSystem) : ISaveRegionServi validRegions.Add(MarioKartWiiEnums.Regions.None); return validRegions; } + + public MarioKartWiiEnums.Regions GetGameFileRegion() + { + var info = discImages.ReadInfo(settings.Get(settings.GAME_LOCATION)); + return info.IsSuccess ? GameRegion.FromGameId(info.Value.GameId) : MarioKartWiiEnums.Regions.None; + } + + public MarioKartWiiEnums.Regions ResolveActiveRegion(string saveDirectory) + { + var gameRegion = GetGameFileRegion(); + if (gameRegion != MarioKartWiiEnums.Regions.None) + return gameRegion; + + var lastRegion = settings.Get(settings.RR_REGION); + return lastRegion != MarioKartWiiEnums.Regions.None ? lastRegion : GetAvailableRegions(saveDirectory)[0]; + } } diff --git a/WheelWizard/Resources/Languages/cs.yml b/WheelWizard/Resources/Languages/cs.yml index 5785331f..e269a9d3 100644 --- a/WheelWizard/Resources/Languages/cs.yml +++ b/WheelWizard/Resources/Languages/cs.yml @@ -190,7 +190,6 @@ cs: Upozorňujeme, že tato stránka nenabízí žádnou funkci pro přímé připojení k místnosti. Chceš-li se připojit ke konkrétní místnosti, budeš se muset buď připojit prostřednictvím kamaráda, nebo doufat, že se k ní připojíš připojením k online serverům. friends_page_disclaimer: "Chceš-li přidat kamarády, musíš je přidat ve hře." - region_user_selection: "To ukazuje pouze regiony, ve kterých TY jsi hrál" primary_profile: "Primární profil se používá jako reference v klientovi Wheel Wizard" empty_content: no_profiles: "Hru musíš hrát aspoň jednou, abys viděl tvé profily zde uvedené" diff --git a/WheelWizard/Resources/Languages/de.yml b/WheelWizard/Resources/Languages/de.yml index 4598dd58..c997f8b2 100644 --- a/WheelWizard/Resources/Languages/de.yml +++ b/WheelWizard/Resources/Languages/de.yml @@ -220,7 +220,6 @@ de: Bitte beachte, dass diese Seite keine Funktion bietet, um dich direkt mit einem Raum zu verbinden. Um einem bestimmten Raum beizutreten, musst du entweder über einen Freund beitreten oder hoffen, dass du ihm über das normale Beitreten zum Server beitreten kannst. friends_page_disclaimer: "Um Freunde hinzuzufügen, musst du sie im Spiel registrieren." - region_user_selection: "Dies zeigt dir nur Regionen an, in denen DU gespielt hast." primary_profile: "Dein Hauptprofil wird als Referenz im Wheel Wizard Client verwendet." empty_content: no_profiles: "Du musst das Spiel mindestens ein Mal gespielt haben, um deine Profile hier aufgelistet zu sehen." diff --git a/WheelWizard/Resources/Languages/en.yml b/WheelWizard/Resources/Languages/en.yml index 12cbb733..6c5d8aa6 100644 --- a/WheelWizard/Resources/Languages/en.yml +++ b/WheelWizard/Resources/Languages/en.yml @@ -260,7 +260,7 @@ en: Please note that this page does not offer any functionality to join a room directly. To join a specific room, you'll need to either join through a friend, or hope to join it by joining the online servers. friends_page_disclaimer: "To add friends you need to add them in-game." - region_user_selection: "This only shows regions YOU have played on" + region_from_game: "Your save region follows the region of your Mario Kart Wii game file" primary_profile: "The primary profile is used as reference in the wheel wizard client" empty_content: no_profiles: "You have to play the game at least once in order to see your profiles listed here" @@ -527,6 +527,10 @@ en: mod_download_fail: title: "Mod download failed." extra: "An error occurred during download: %{error}" + invalid_game_file: + title: "Not a Mario Kart Wii game" + unreadable: "Wheel Wizard couldn't read this file as a Wii disc image." + wrong_game: "This file is %{name} (%{id}). Please select a Mario Kart Wii disc image." failed_vr_history: "Failed to load VR history" failed_load_leaderboard: "Could not load leaderboard." message_success: diff --git a/WheelWizard/Resources/Languages/es.yml b/WheelWizard/Resources/Languages/es.yml index cfcb819c..c1a49353 100644 --- a/WheelWizard/Resources/Languages/es.yml +++ b/WheelWizard/Resources/Languages/es.yml @@ -208,7 +208,6 @@ es: Atención. Esta página no tiene la funcionalidad de unirte a una sala directamente. Para unirte a una sala en específico, necesitarás unirte mediante un amigo, o con suerte, encontrando la sala en VS Mundial. friends_page_disclaimer: "Para añadir amigos necesitas añadirlos dentro del juego." - region_user_selection: "Esto solo muestra las regiones en las que TÚ has jugado." primary_profile: "El perfil principal es usado como referencia en Wheel Wizard." empty_content: no_profiles: "Tienes que jugar el juego mínimo una vez para poder ver aquí tus perfiles." diff --git a/WheelWizard/Resources/Languages/fi.yml b/WheelWizard/Resources/Languages/fi.yml index fca144a8..82878d4b 100644 --- a/WheelWizard/Resources/Languages/fi.yml +++ b/WheelWizard/Resources/Languages/fi.yml @@ -211,7 +211,6 @@ fi: Huomioi, että tältä sivulta ei voi liittyä huoneeseen suoraan. Liittyäksesi tiettyyn huoneeseen, se tulee tehdä joko kaverin kautta tai toivoa että pääset siihen liittymällä verkkopeliin. friends_page_disclaimer: "Kavereiden lisääminen täytyy tehdä pelin sisällä." - region_user_selection: "Näyttää vain alueet, joilla itse olet pelannut" primary_profile: "Pääprofiilia käytetään viitteenä Wheel Wizardissa" empty_content: no_profiles: "Sinun on pelattava peliä vähintään kerran nähdäksesi profiilisi listalla" diff --git a/WheelWizard/Resources/Languages/fr.yml b/WheelWizard/Resources/Languages/fr.yml index 599cdcd4..67a52e31 100644 --- a/WheelWizard/Resources/Languages/fr.yml +++ b/WheelWizard/Resources/Languages/fr.yml @@ -207,7 +207,6 @@ fr: Attention, cette page ne permet pas de rejoindre une salle directement. Pour rejoindre une salle spécifique, vous devez soit la rejoindre via un ami, soit espérer la rejoindre via les serveurs en ligne. friends_page_disclaimer: "Pour ajouter des amis, rendez-vous en jeu." - region_user_selection: "Ceci montre les régions dans lesquelles VOUS avez joué" primary_profile: "Le profil principal est utilisé en tant que référence dans Wheel Wizard" empty_content: no_profiles: "Vous devez lancer le jeu au moins une fois pour voir vos profils apparaître ici" diff --git a/WheelWizard/Resources/Languages/it.yml b/WheelWizard/Resources/Languages/it.yml index 24ccc194..efb11111 100644 --- a/WheelWizard/Resources/Languages/it.yml +++ b/WheelWizard/Resources/Languages/it.yml @@ -189,7 +189,6 @@ it: Attenzione, questa pagina non offre alcuna funzionalità per unirsi ad una stanza direttamente. Per unirsi ad una specifica stanza, dovrai farlo tramite un amico oppure sperare di unirti tramite i server online. friends_page_disclaimer: "Per aggiungere amici, devi farlo all'interno del gioco." - region_user_selection: "Questo fa vedere solo le regioni in cui TU hai giocato" primary_profile: "Il profilo primario è utilizzato come riferimento in Wheel Wizard" empty_content: no_profiles: "Devi aver giocato almeno una volta per vedere la lista dei tuoi profili qui" diff --git a/WheelWizard/Resources/Languages/ja.yml b/WheelWizard/Resources/Languages/ja.yml index f648b8a1..07582966 100644 --- a/WheelWizard/Resources/Languages/ja.yml +++ b/WheelWizard/Resources/Languages/ja.yml @@ -189,7 +189,6 @@ ja: ルームに直接合流する機能はないことに注意してください。 特定のルームに参加するには、フレンドを通して合流するか、オンラインサーバーに参加して合流する必要があります。 friends_page_disclaimer: "フレンドを追加するためにはゲーム内で追加する必要があります" - region_user_selection: "これはあなたがプレイした地域のみを表示します" primary_profile: "メインのプロファイルはWheel Wizardクライアントで参照されます" empty_content: no_profiles: "ここにプロフィールを載せるには一度プレイする必要があります" diff --git a/WheelWizard/Resources/Languages/ko.yml b/WheelWizard/Resources/Languages/ko.yml index df3fdc49..fa9143c4 100644 --- a/WheelWizard/Resources/Languages/ko.yml +++ b/WheelWizard/Resources/Languages/ko.yml @@ -189,7 +189,6 @@ ko: 주의) 해당 페이지에선 방에 직접 합류하는 기능은 없습니다. 특정 방에 들어가려면, 친구를 통해 들어가거나 온라인 서버에 가입하여 들어가야 합니다. friends_page_disclaimer: "친구 추가는 게임 내에서 추가해야 합니다." - region_user_selection: "플레이한 지역만 표시합니다." primary_profile: "메인 프로필은 휠 위저드 클라이언트에서 참조됩니다." empty_content: no_profiles: "게임을 한 번 이상 실행해야 프로필을 볼 수 있습니다." diff --git a/WheelWizard/Resources/Languages/nl.yml b/WheelWizard/Resources/Languages/nl.yml index 0c111a45..74ffc9e0 100644 --- a/WheelWizard/Resources/Languages/nl.yml +++ b/WheelWizard/Resources/Languages/nl.yml @@ -189,7 +189,6 @@ nl: Let op dat deze pagina er niet voor kan zorgen om direct deel te nemen aan een kamer. Om aan een specifieke kamer deel te nemen, moet je dit óf via een vriend óf via de online servers doen. friends_page_disclaimer: "Om vrienden toe te voegen moet je ze in het spel toevoegen." - region_user_selection: "Dit toont alleen regio's waarop JIJ hebt gespeeld." primary_profile: "Het hoofdprofiel wordt gebruikt als referentie in Wheel Wizard." empty_content: no_profiles: "Je moet het spel minimaal één keer hebben gespeeld om hier jouw profielen te kunnen bekijken." diff --git a/WheelWizard/Resources/Languages/pl.yml b/WheelWizard/Resources/Languages/pl.yml index 08d30cd4..277c3114 100644 --- a/WheelWizard/Resources/Languages/pl.yml +++ b/WheelWizard/Resources/Languages/pl.yml @@ -190,7 +190,6 @@ pl: other: "Jest teraz %{count} przyjaciół online" rooms_page_disclaimer: "Prosze pamiętaj, że ta strona nie daje możliwości dołączenia prosto do pokoju. By dołączyć do specyficznego pokoju musisz dołączyć poprzez przyjaciela lub mieć nadzieje, że uda ci się połączyć poprzez losowy serwer." friends_page_disclaimer: "By dodać przyjaciół musisz ich dodać w grze" - region_user_selection: "To tylko pokazuje regiony, w których TY grałeś." empty_content: no_profiles: "Musisz zagrać w gre przynajmniej raz by zobaczyć tu swoje profle" no_friends_title: "Brak przyjaciół!" diff --git a/WheelWizard/Resources/Languages/pt.yml b/WheelWizard/Resources/Languages/pt.yml index 5417fb36..b632ecbb 100644 --- a/WheelWizard/Resources/Languages/pt.yml +++ b/WheelWizard/Resources/Languages/pt.yml @@ -198,7 +198,6 @@ pt: other: "Tens %{count} amigos online" rooms_page_disclaimer: "Tem em conta que esta página não te oferece a funcionalidade de entrares diretamente numa sala. Para entrares numa sala em específico, terás de entrar através de um amigo, ou então esperas que entres na sala ao entrares nos servers online" friends_page_disclaimer: "Para adicionar amigos precisas de adicioná-los dentro do jogo." - region_user_selection: "Isto só mostra regiões onde TU já jogaste." primary_profile: "O perfil primário é usado como referência no cliente do Wheel Wizard." empty_content: no_profiles: "Tens de jogar o jogo pelo menos uma vez para poderes ver o teu perfil aqui escrito" diff --git a/WheelWizard/Resources/Languages/tr.yml b/WheelWizard/Resources/Languages/tr.yml index ec6e379e..3367bee4 100644 --- a/WheelWizard/Resources/Languages/tr.yml +++ b/WheelWizard/Resources/Languages/tr.yml @@ -189,7 +189,6 @@ tr: Lütfen bu sayfanın doğrudan bir odaya katılma işlevi sunmadığını unutmayın. Belirli bir odaya katılmak için bir arkadaşınız aracılığıyla katılmanız veya çevrimiçi sunuculara katılarak bu odaya girmeyi ummanız gerekecek. friends_page_disclaimer: "Arkadaş eklemek için onları oyunun içinde eklemeniz gerekiyor." - region_user_selection: "Bu sadece SİZİN oynadığıınız bölgeleri gösterir." primary_profile: "Ana profil, Wheel Wizard için referans olarak kullanılır." empty_content: no_profiles: "Profillerinizi burada görmek için oyunu en az bir kez oynamanız gerekiyor." diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index 0bdfde8d..5538e46f 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -5,6 +5,7 @@ using WheelWizard.Branding; using WheelWizard.CustomCharacters; using WheelWizard.CustomDistributions; +using WheelWizard.DiscImages; using WheelWizard.DolphinInstaller; using WheelWizard.Features.Archives; using WheelWizard.Features.Patches; @@ -43,6 +44,7 @@ public static void AddWheelWizardServices(this IServiceCollection services, IApp services.AddGitHub(); services.AddRrRooms(); services.AddWhWzData(); + services.AddDiscImages(); services.AddWiiManagement(); services.AddGameBanana(); services.AddMiiImages(); diff --git a/WheelWizard/Views/Shell/Layout.MacProfiles.cs b/WheelWizard/Views/Shell/Layout.MacProfiles.cs index e125a9b1..fc081a1a 100644 --- a/WheelWizard/Views/Shell/Layout.MacProfiles.cs +++ b/WheelWizard/Views/Shell/Layout.MacProfiles.cs @@ -13,31 +13,18 @@ internal void PopulateMacProfiles(NativeMenu menu) var profiles = GameLicenseService.GetRegionLicenses(); var activeRegion = SettingsService.Get(SettingsService.RR_REGION); var activeSlot = SettingsService.Get(SettingsService.FOCUSED_USER); - foreach ( - var (region, name) in new[] - { - (MarioKartWiiEnums.Regions.Europe, "Europe"), - (MarioKartWiiEnums.Regions.America, "America"), - (MarioKartWiiEnums.Regions.Japan, "Japan"), - (MarioKartWiiEnums.Regions.Korea, "Korea"), - } - ) + // The region follows the game file, so only its profiles can be switched to. + foreach (var profile in profiles.Where(p => p.Region == activeRegion)) { - if (menu.Items.Count > 0) - menu.Add(new NativeMenuItemSeparator()); - menu.Add(new NativeMenuItem(name) { IsEnabled = false }); - foreach (var profile in profiles.Where(p => p.Region == region)) + var label = string.IsNullOrWhiteSpace(profile.Name) ? $"Profile {profile.Slot + 1}" : profile.Name; + var item = new NativeMenuItem($"{label} ({profile.FriendCode})") { - var label = string.IsNullOrWhiteSpace(profile.Name) ? $"Profile {profile.Slot + 1}" : profile.Name; - var item = new NativeMenuItem($"{label} ({profile.FriendCode})") - { - IsEnabled = CompleteContentEnabled, - ToggleType = MenuItemToggleType.Radio, - IsChecked = activeRegion == region && activeSlot == profile.Slot, - }; - item.Click += (_, _) => SelectMacProfile(profile); - menu.Add(item); - } + IsEnabled = CompleteContentEnabled, + ToggleType = MenuItemToggleType.Radio, + IsChecked = activeSlot == profile.Slot, + }; + item.Click += (_, _) => SelectMacProfile(profile); + menu.Add(item); } } @@ -48,7 +35,6 @@ private void SelectMacProfile(RegionLicense profile) || !GameLicenseService.GetRegionLicenses().Any(p => p.Region == profile.Region && p.Slot == profile.Slot) ) return; - SettingsService.Set(SettingsService.RR_REGION, profile.Region); SettingsService.Set(SettingsService.FOCUSED_USER, profile.Slot); GameLicenseService.LoadLicense(); UpdateSidebarProfile(); diff --git a/WheelWizard/WheelWizard.csproj b/WheelWizard/WheelWizard.csproj index 6b88d7c4..40c9a6a6 100644 --- a/WheelWizard/WheelWizard.csproj +++ b/WheelWizard/WheelWizard.csproj @@ -1,122 +1,123 @@ - - - - WheelWizard.Program - WinExe - net10.0 - WheelWizard - enable - true - - true - $(NoWarn);AVLN3001 - true - - - 2.6.2 - This program will manage RetroRewind and mods :) - GNU v3.0 - https://github.com/patchzyy/WheelWizard - car-wheel.ico - - - - $(DefineConstants);WINDOWS - - - - $(DefineConstants);LINUX - - - - $(DefineConstants);MACOS - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - ListActionButton.axaml - Code - - - CommunityCountBadge.axaml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + WheelWizard.Program + WinExe + net10.0 + WheelWizard + enable + true + + true + $(NoWarn);AVLN3001 + true + + + 2.6.2 + This program will manage RetroRewind and mods :) + GNU v3.0 + https://github.com/patchzyy/WheelWizard + car-wheel.ico + + + + $(DefineConstants);WINDOWS + + + + $(DefineConstants);LINUX + + + + $(DefineConstants);MACOS + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + ListActionButton.axaml + Code + + + CommunityCountBadge.axaml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +