From eca9cd784da23ddd55406c2433ed5db33e7fb087 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:56:27 +0100 Subject: [PATCH 1/5] fix adding friends --- .../Features/RrRooms/RoomPresenceTests.cs | 2 + .../Features/WiiManagement/FriendCodeTests.cs | 10 + .../Features/WiiManagement/FriendListTests.cs | 438 ++++++++++++++++++ .../WiiManagement/RegionLicenseTests.cs | 2 + .../FriendRegistrationTests.cs | 136 ++++++ .../RrRooms/Views/RoomDetailsPage.axaml | 1 + .../RrRooms/Views/RoomDetailsPage.axaml.cs | 18 - .../Views/FriendsPage.axaml.cs | 3 +- .../Views/LeaderboardPage.axaml | 4 + .../Views/LeaderboardPage.axaml.cs | 18 - .../WiiManagement/FriendCodes/FriendCode.cs | 31 +- .../GameLicense/GameLicenseService.cs | 408 +++++++++------- WheelWizard/Shared/Binary/Crc.cs | 15 + 13 files changed, 878 insertions(+), 208 deletions(-) create mode 100644 WheelWizard.Test/Features/WiiManagement/FriendListTests.cs create mode 100644 WheelWizard.UI.Test/FriendRegistrationTests.cs diff --git a/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs b/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs index 862a195c9..d41ec1132 100644 --- a/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs +++ b/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs @@ -5,6 +5,7 @@ using WheelWizard.RrRooms; using WheelWizard.Settings; using WheelWizard.Shared.Polling; +using WheelWizard.Shared.Processes; using WheelWizard.WiiManagement.GameLicense; using WheelWizard.WiiManagement.GameLicense.Domain; using WheelWizard.WiiManagement.MiiManagement; @@ -40,6 +41,7 @@ public void Refresh_UpdatesBothLicenseAndFriends_AndOnlyNotifiesChanges() Substitute.For(), Substitute.For(), presence, + Substitute.For(), Substitute.For(), TimeProvider.System, NullLogger.Instance diff --git a/WheelWizard.Test/Features/WiiManagement/FriendCodeTests.cs b/WheelWizard.Test/Features/WiiManagement/FriendCodeTests.cs index 4bfa74ad4..40af5c941 100644 --- a/WheelWizard.Test/Features/WiiManagement/FriendCodeTests.cs +++ b/WheelWizard.Test/Features/WiiManagement/FriendCodeTests.cs @@ -1,3 +1,5 @@ +using System.Text; +using WheelWizard.Shared.Binary; using WheelWizard.WiiManagement.FriendCodes; namespace WheelWizard.Test.Features.WiiManagement; @@ -33,4 +35,12 @@ public void ProfileIdToFriendCode_ReturnsExpectedFriendCodeValue() Assert.Equal(348484848484ul, friendCodeValue); } + + [Fact] + public void ComputeCrc8_MatchesTheStandardCheckValue() + { + var data = Encoding.ASCII.GetBytes("123456789"); + + Assert.Equal(0xF4, Crc.ComputeCrc8(data, 0, data.Length)); + } } diff --git a/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs new file mode 100644 index 000000000..7fc17d460 --- /dev/null +++ b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs @@ -0,0 +1,438 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.CustomDistributions; +using WheelWizard.Dolphin.Paths; +using WheelWizard.Models.Enums; +using WheelWizard.RrRooms; +using WheelWizard.Settings; +using WheelWizard.Shared.Binary; +using WheelWizard.Shared.Polling; +using WheelWizard.Shared.Processes; +using WheelWizard.WiiManagement.FriendCodes; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.MiiManagement; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.Test.Features.WiiManagement; + +public class FriendListTests +{ + private const string SavePath = "/saves/RMCP/rksys.dat"; + private const int RkpdOffset = 0x08; + private const uint LicenseProfileId = 12345; + + private const string FriendFc = "3484-8484-8484"; + private const uint FriendPid = 592497508; + private const uint FriendChecksum = 81; + + // Friend code checksum (MD5) of 0 + private const string ZeroChecksumFc = "0009-9999-9999"; + private const uint ZeroChecksumPid = 999999999; + + private static readonly Mii FriendMii = MiiSerializer + .Deserialize("AAAAQgBlAGUAAAAAAAAAAAAAAAAAAEBAgeGIAcKv7BAABEJBMb0oogiMCEgUTbiNAIoAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + .Value; + + [Fact] + public void AddFriend_WritesTheEntryTheGameWritesForAFriendCode() + { + var (service, fs, _) = Create(NewSave()); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii, 29_367).IsSuccess); + + var data = fs.File.ReadAllBytes(SavePath); + var main = MainOffset(0); + Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x51, 0x23, 0x50, 0xCB, 0x64 }, data[main..(main + 8)]); + Assert.Equal(0x0003u, BigEndianBinary.BufferToUint16(data, main + 0x10)); + Assert.Equal(293u, BigEndianBinary.BufferToUint16(data, main + 0x16)); + Assert.Equal(0, data[main + 0x66]); + Assert.All(data[(main + 0x67)..(main + 0x1C0)], b => Assert.Equal(0, b)); + Assert.Equal( + new byte[] { 0x00, 0x00, 0x10, 0x00, 0x23, 0x50, 0xCB, 0x64, 0x00, 0x00, 0x00, 0x51 }, + data[DwcOffset(0)..(DwcOffset(0) + 12)] + ); + + var friend = Assert.Single(service.ActiveCurrentFriends); + Assert.Equal(FriendFc, friend.FriendCode); + Assert.True(friend.IsPending); + } + + [Fact] + public void AddFriend_RejectsProfileIdsTheGamesFriendKeyResolverCannotUse() + { + var save = NewSave(); + var (service, fs, _) = Create(save); + + Assert.True(service.AddFriend(0, FriendCodeOf((uint)int.MaxValue + 1), FriendMii).IsFailure); + + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + Assert.Empty(service.ActiveCurrentFriends); + } + + [Fact] + public void AddFriend_KeepsAZeroChecksumInTheFriendKey() + { + var (service, fs, _) = Create(NewSave()); + + Assert.True(service.AddFriend(0, ZeroChecksumFc, FriendMii).IsSuccess); + + var data = fs.File.ReadAllBytes(SavePath); + Assert.Equal(0x1000u, BigEndianBinary.BufferToUint32(data, DwcOffset(0))); + Assert.Equal(ZeroChecksumPid, BigEndianBinary.BufferToUint32(data, DwcOffset(0) + 4)); + Assert.Equal(0u, BigEndianBinary.BufferToUint32(data, DwcOffset(0) + 8)); + Assert.Equal(0x0003u, BigEndianBinary.BufferToUint16(data, MainOffset(0) + 0x10)); + } + + [Theory] + [InlineData((ushort)0x0003, 0x1000u, false, true)] // Added by the game, waiting for its info + [InlineData((ushort)0x0003, 0x3800u, false, false)] // Registered, info not downloaded yet + [InlineData((ushort)0x0001, 0x3800u, true, false)] // Registered + [InlineData((ushort)0x0001, 0x1000u, true, true)] // Waiting for the friend to add back + [InlineData((ushort)0x0001, 0x1800u, true, true)] // Known profile ID, but not added back + public void LoadLicense_ListsFriendsByFlagsAndDerivesPendingFromDwc(ushort flags, uint dwcType, bool withMii, bool pending) + { + var save = NewSave(); + PutFriend(save, 0, FriendPid, flags, dwcType, withMii); + + var (service, _, _) = Create(save); + + var friend = Assert.Single(service.ActiveCurrentFriends); + Assert.Equal(FriendFc, friend.FriendCode); + Assert.Equal(pending, friend.IsPending); + Assert.Equal(withMii, friend.Mii != null); + } + + [Fact] + public void AddFriend_RepairsAnExistingFriendWhenTheListIsFull() + { + var save = NewSave(); + PutReleasedWheelWizardFriend(save, 0, FriendPid); + for (var slot = 1; slot < 30; slot++) + PutFriend(save, slot, 1000 + (uint)slot, 0x0003, 0x1000, withMii: false); + var (service, fs, _) = Create(save); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsSuccess); + + Assert.Equal(30, service.ActiveCurrentFriends.Count); + Assert.Equal(FriendChecksum, BigEndianBinary.BufferToUint32(fs.File.ReadAllBytes(SavePath), DwcOffset(0) + 8)); + } + + [Theory] + [InlineData(1)] + [InlineData(255)] + public void LoadLicense_UsesTheDwcSlotEvenWhenTheStoredIndexDiffers(int dwcIndex) + { + var save = NewSave(); + PutFriend(save, 0, FriendPid, 0x0001, 0x3800, withMii: false); + PutFriend(save, 1, 2001, 0x0001, 0x3800, withMii: false); + save[MainOffset(0) + 0x66] = (byte)dwcIndex; + var (service, _, _) = Create(save); + + Assert.False(service.ActiveCurrentFriends[0].IsPending); + Assert.False(service.ActiveCurrentFriends[1].IsPending); + } + + [Fact] + public void LoadLicense_KeepsFriendsPendingWhenTheirDwcTokenBelongsToSomeoneElse() + { + var save = NewSave(); + PutFriend(save, 0, FriendPid, 0x0001, 0x3800, withMii: false); + PutFriend(save, 1, 2001, 0x0001, 0x3800, withMii: false); + BigEndianBinary.WriteUInt32BigEndian(save, DwcOffset(0) + 4, 2001); + var (service, _, _) = Create(save); + + Assert.True(service.ActiveCurrentFriends[0].IsPending); + Assert.False(service.ActiveCurrentFriends[1].IsPending); + } + + [Fact] + public void LoadLicense_SkipsEntriesThatAreNotInUse() + { + var save = NewSave(); + PutFriend(save, 0, FriendPid, 0x0000, 0x3800, withMii: true); + + var (service, _, _) = Create(save); + + Assert.Empty(service.ActiveCurrentFriends); + } + + [Fact] + public void AddFriend_RejectsAFriendThatIsStillWaitingForItsInfo() + { + var save = NewSave(); + PutFriend(save, 0, FriendPid, 0x0003, 0x1000, withMii: false); + var (service, fs, _) = Create(save); + + var result = service.AddFriend(0, FriendFc, FriendMii); + + Assert.True(result.IsFailure); + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + } + + [Fact] + public void AddFriend_CountsFriendsWithoutMiiForAFullList() + { + var save = NewSave(); + for (var slot = 0; slot < 30; slot++) + PutFriend(save, slot, 1000 + (uint)slot, 0x0003, 0x1000, withMii: false); + var (service, fs, _) = Create(save); + + Assert.Equal(30, service.ActiveCurrentFriends.Count); + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + } + + [Fact] + public void AddFriend_SkipsSlotsWhoseDwcDataIsInUse() + { + var save = NewSave(); + PutFriend(save, 0, 1000, 0x0001, 0x3800, withMii: true); + // DWC data left behind without a friend entry + BigEndianBinary.WriteUInt32BigEndian(save, DwcOffset(1), 0x3800); + BigEndianBinary.WriteUInt32BigEndian(save, DwcOffset(1) + 4, 1001); + PutFriend(save, 3, 1002, 0x0001, 0x3800, withMii: true); + var (service, fs, _) = Create(save); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsSuccess); + + var data = fs.File.ReadAllBytes(SavePath); + Assert.Equal(FriendPid, BigEndianBinary.BufferToUint32(data, MainOffset(2) + 4)); + Assert.Equal(save[DwcOffset(1)..(DwcOffset(1) + 12)], data[DwcOffset(1)..(DwcOffset(1) + 12)]); + Assert.Equal(save[DwcOffset(3)..(DwcOffset(3) + 12)], data[DwcOffset(3)..(DwcOffset(3) + 12)]); + } + + [Fact] + public void AddFriend_RepairsFriendKeysWrittenWithoutChecksum_AndLeavesOtherEntriesAlone() + { + var crc8ZeroPid = Enumerable + .Range(1, 100_000) + .Select(pid => (uint)pid) + .First(pid => FriendCode.ProfileIdToCrc8Checksum(pid) == 0 && FriendCode.ProfileIdToFriendCode(pid) >> 32 != 0); + + var save = NewSave(); + PutReleasedWheelWizardFriend(save, 0, FriendPid); + PutFriend(save, 1, 2001, 0x0001, 0x3800, withMii: true); + PutReleasedWheelWizardFriend(save, 2, 2002); + // Older friend entries without DWC data, the game makes those itself + BigEndianBinary.WriteUInt32BigEndian(save, DwcOffset(2), 0); + BigEndianBinary.WriteUInt32BigEndian(save, DwcOffset(2) + 4, 0); + PutFriend(save, 3, 2003, 0x0001, 0x3800, withMii: true); + // Development builds wrote the profile ID without the checksum in the friend entry + BigEndianBinary.WriteUInt32BigEndian(save, MainOffset(3), 0); + PutReleasedWheelWizardFriend(save, 4, ZeroChecksumPid); + PutReleasedWheelWizardFriend(save, 5, crc8ZeroPid); + var (service, fs, _) = Create(save); + + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + Assert.True(service.AddFriend(0, FriendCodeOf(2001), FriendMii).IsFailure); + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + + var newFriendFc = FriendCodeOf(3000); + Assert.True(service.AddFriend(0, newFriendFc, FriendMii).IsSuccess); + + var data = fs.File.ReadAllBytes(SavePath); + Assert.Equal(FriendChecksum, BigEndianBinary.BufferToUint32(data, DwcOffset(0) + 8)); + Assert.Equal(0x0003u, BigEndianBinary.BufferToUint16(data, MainOffset(0) + 0x10)); + for (var slot = 1; slot <= 5; slot++) + { + Assert.Equal(save[MainOffset(slot)..(MainOffset(slot) + 0x1C0)], data[MainOffset(slot)..(MainOffset(slot) + 0x1C0)]); + Assert.Equal(save[DwcOffset(slot)..(DwcOffset(slot) + 12)], data[DwcOffset(slot)..(DwcOffset(slot) + 12)]); + } + Assert.Equal(3000u, BigEndianBinary.BufferToUint32(data, MainOffset(6) + 4)); + } + + [Fact] + public void AddFriend_OfAFriendWrittenWithoutChecksum_RepairsIt() + { + var save = NewSave(); + PutReleasedWheelWizardFriend(save, 0, FriendPid); + var (service, fs, _) = Create(save); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsSuccess); + + var data = fs.File.ReadAllBytes(SavePath); + Assert.Equal(FriendChecksum, BigEndianBinary.BufferToUint32(data, DwcOffset(0) + 8)); + Assert.Equal(0u, BigEndianBinary.BufferToUint32(data, MainOffset(1) + 4)); + Assert.Single(service.ActiveCurrentFriends); + } + + [Fact] + public void AddFriend_KeepsChangesTheGameSavedAfterLoading() + { + var (service, fs, _) = Create(NewSave()); + var updated = fs.File.ReadAllBytes(SavePath); + PutFriend(updated, 0, 2001, 0x0003, 0x1000, withMii: false); + Encoding.BigEndianUnicode.GetBytes("Renamed").CopyTo(updated, RkpdOffset + 0x14); + fs.File.WriteAllBytes(SavePath, updated); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsSuccess); + + var data = fs.File.ReadAllBytes(SavePath); + Assert.Equal(updated[MainOffset(0)..(MainOffset(0) + 0x1C0)], data[MainOffset(0)..(MainOffset(0) + 0x1C0)]); + Assert.Equal(updated[(RkpdOffset + 0x14)..(RkpdOffset + 0x28)], data[(RkpdOffset + 0x14)..(RkpdOffset + 0x28)]); + Assert.Equal(FriendPid, BigEndianBinary.BufferToUint32(data, MainOffset(1) + 4)); + Assert.Equal(2, service.ActiveCurrentFriends.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AddFriend_DoesNothingWhenTheSaveOnDiskIsInvalid(bool truncated) + { + var save = NewSave(); + PutFriend(save, 0, 2001, 0x0001, 0x3800, withMii: true); + var (service, fs, _) = Create(save); + var invalid = truncated ? save[..0x1000] : (byte[])save.Clone(); + if (!truncated) + Encoding.ASCII.GetBytes("XXXX").CopyTo(invalid, 0); + fs.File.WriteAllBytes(SavePath, invalid); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.True(service.RemoveFriend(0, FriendCodeOf(2001)).IsFailure); + + Assert.Equal(invalid, fs.File.ReadAllBytes(SavePath)); + Assert.Single(service.ActiveCurrentFriends); + } + + [Fact] + public void FriendChanges_AreRefusedWhileDolphinIsRunning() + { + var save = NewSave(); + PutFriend(save, 0, 2001, 0x0001, 0x3800, withMii: true); + var (service, fs, processes) = Create(save); + processes.GetProcessIds("Dolphin").Returns([1234]); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.True(service.RemoveFriend(0, FriendCodeOf(2001)).IsFailure); + + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + Assert.Single(service.ActiveCurrentFriends); + } + + [Fact] + public void FriendChanges_KeepLoadedDataWhenTheAtomicWriteFails() + { + var save = NewSave(); + PutReleasedWheelWizardFriend(save, 0, FriendPid); + var (service, fs, _) = Create(save); + var loadedFriend = Assert.Single(service.ActiveCurrentFriends); + fs.Directory.CreateDirectory(SavePath + ".tmp"); + + Assert.True(service.AddFriend(0, FriendCodeOf(3000), FriendMii).IsFailure); + Assert.True(service.RemoveFriend(0, FriendFc).IsFailure); + + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + Assert.Same(loadedFriend, Assert.Single(service.ActiveCurrentFriends)); + Assert.True(loadedFriend.IsPending); + } + + [Fact] + public void RemoveFriend_ClearsTheEntryAndItsDwcData_AndKeepsTheOtherSlots() + { + var save = NewSave(); + PutFriend(save, 0, 2001, 0x0001, 0x3800, withMii: true); + PutFriend(save, 2, 2002, 0x0001, 0x3800, withMii: true); + // Friend without Mii whose DWC data is in slot 1 + PutFriend(save, 3, FriendPid, 0x0003, 0x1000, withMii: false, dwcIndex: 1); + var (service, fs, _) = Create(save); + Assert.Equal(3, service.ActiveCurrentFriends.Count); + + Assert.True(service.RemoveFriend(0, FriendFc).IsSuccess); + + var data = fs.File.ReadAllBytes(SavePath); + Assert.All(data[MainOffset(3)..(MainOffset(3) + 0x1C0)], b => Assert.Equal(0, b)); + Assert.All(data[DwcOffset(1)..(DwcOffset(1) + 12)], b => Assert.Equal(0, b)); + foreach (var slot in new[] { 0, 2 }) + { + Assert.Equal(save[MainOffset(slot)..(MainOffset(slot) + 0x1C0)], data[MainOffset(slot)..(MainOffset(slot) + 0x1C0)]); + Assert.Equal(save[DwcOffset(slot)..(DwcOffset(slot) + 12)], data[DwcOffset(slot)..(DwcOffset(slot) + 12)]); + } + Assert.Equal(new[] { FriendCodeOf(2001), FriendCodeOf(2002) }, service.ActiveCurrentFriends.Select(friend => friend.FriendCode)); + } + + private static int MainOffset(int slot) => RkpdOffset + 0x56D0 + slot * 0x1C0; + + private static int DwcOffset(int slot) => RkpdOffset + 0x8B50 + slot * 0x0C; + + private static string FriendCodeOf(uint profileId) + { + var digits = FriendCode.ProfileIdToFriendCode(profileId).ToString("D12"); + return $"{digits[..4]}-{digits[4..8]}-{digits[8..]}"; + } + + private static void PutFriend(byte[] data, int slot, uint profileId, ushort flags, uint dwcType, bool withMii, int? dwcIndex = null) + { + var main = MainOffset(slot); + var friendKey = FriendCode.ProfileIdToFriendCode(profileId); + BigEndianBinary.WriteUInt32BigEndian(data, main, (uint)(friendKey >> 32)); + BigEndianBinary.WriteUInt32BigEndian(data, main + 4, profileId); + BigEndianBinary.WriteUInt16BigEndian(data, main + 0x10, flags); + BigEndianBinary.WriteUInt16BigEndian(data, main + 0x16, 5000); + BigEndianBinary.WriteUInt16BigEndian(data, main + 0x18, 5000); + if (withMii) + MiiSerializer.Serialize(FriendMii).Value.CopyTo(data, main + 0x1A); + data[main + 0x66] = (byte)(dwcIndex ?? slot); + + var dwc = DwcOffset(dwcIndex ?? slot); + BigEndianBinary.WriteUInt32BigEndian(data, dwc, dwcType); + BigEndianBinary.WriteUInt32BigEndian(data, dwc + 4, profileId); + BigEndianBinary.WriteUInt32BigEndian(data, dwc + 8, dwcType == 0x1000 ? (uint)(friendKey >> 32) : 0); + } + + // The entry WheelWizard 2.4.0 and later wrote: no checksum in the DWC friend key. + private static void PutReleasedWheelWizardFriend(byte[] data, int slot, uint profileId) + { + PutFriend(data, slot, profileId, 0x0001, 0x1000, withMii: true); + var main = MainOffset(slot); + data[main + 0x68] = 0xFF; + data[main + 0x69] = 0xFF; + for (var i = 0; i < 10; i++) + BigEndianBinary.WriteUInt32BigEndian(data, main + 0x70 + i * 8, 0xFFFFFFFF); + BigEndianBinary.WriteUInt32BigEndian(data, DwcOffset(slot) + 8, 0); + } + + private static byte[] NewSave() + { + var data = new byte[0x2BC000]; + Encoding.ASCII.GetBytes("RKSD0006").CopyTo(data, 0); + Encoding.ASCII.GetBytes("RKPD").CopyTo(data, RkpdOffset); + BigEndianBinary.WriteUInt32BigEndian(data, RkpdOffset + 0x5C, LicenseProfileId); + return data; + } + + private static (GameLicenseSingletonService, MockFileSystem, IProcessLauncher) Create(byte[] save) + { + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/saves/RMCP"); + fs.File.WriteAllBytes(SavePath, save); + var paths = Substitute.For(); + paths.SaveFolderPath.Returns("/saves"); + var settings = Substitute.For(); + settings.PathsSetupCorrectly().Returns(true); + settings.Get(settings.RR_REGION).Returns(MarioKartWiiEnums.Regions.Europe); + var saveRegions = Substitute.For(); + saveRegions.ResolveActiveRegion(Arg.Any()).Returns(MarioKartWiiEnums.Regions.Europe); + var miis = Substitute.For(); + miis.GetByAvatarId(Arg.Any()).Returns(Fail("Missing Mii")); + var processes = Substitute.For(); + processes.GetProcessIds(Arg.Any()).Returns([]); + var service = new GameLicenseSingletonService( + miis, + fs, + Substitute.For(), + Substitute.For(), + settings, + saveRegions, + Substitute.For(), + paths, + Substitute.For(), + processes, + Substitute.For(), + System.TimeProvider.System, + NullLogger.Instance + ); + Assert.True(service.LoadLicense().IsSuccess); + return (service, fs, processes); + } +} diff --git a/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs b/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs index ca16b7f42..96645e595 100644 --- a/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs +++ b/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs @@ -8,6 +8,7 @@ using WheelWizard.Settings; using WheelWizard.Shared.Binary; using WheelWizard.Shared.Polling; +using WheelWizard.Shared.Processes; using WheelWizard.WiiManagement.GameLicense; using WheelWizard.WiiManagement.MiiManagement; @@ -84,6 +85,7 @@ private static (GameLicenseSingletonService, MockFileSystem, ISettingsManager) C Substitute.For(), paths, Substitute.For(), + Substitute.For(), Substitute.For(), System.TimeProvider.System, NullLogger.Instance diff --git a/WheelWizard.UI.Test/FriendRegistrationTests.cs b/WheelWizard.UI.Test/FriendRegistrationTests.cs new file mode 100644 index 000000000..68a637f54 --- /dev/null +++ b/WheelWizard.UI.Test/FriendRegistrationTests.cs @@ -0,0 +1,136 @@ +using System.IO.Abstractions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.Models.RRInfo; +using WheelWizard.RrRooms.Views; +using WheelWizard.Settings; +using WheelWizard.Shared; +using WheelWizard.Shared.Services; +using WheelWizard.WheelWizardData.Views; +using WheelWizard.WheelWizardData.Views.Dialogs; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.GameLicense.Domain; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.UI.Test; + +public class FriendRegistrationTests +{ + [AvaloniaTheory] + [InlineData(false, 1)] + [InlineData(false, 30)] + [InlineData(true, 1)] + [InlineData(true, 30)] + public async Task AddFriend_OffersConfirmationForAnExistingFriendEvenWhenFull(bool fromRoom, int friendCount) + { + const string friendCode = "3484-8484-8484"; + var game = Substitute.For(); + var licenseCode = WheelWizard.WiiManagement.FriendCodes.FriendCode.ProfileIdToFriendCode(12345).ToString("D12"); + game.ActiveUser.Returns( + new LicenseProfile + { + FriendCode = $"{licenseCode[..4]}-{licenseCode[4..8]}-{licenseCode[8..]}", + Mii = null, + Vr = 5000, + Br = 5000, + RegionId = 0, + TotalRaceCount = 0, + TotalWinCount = 0, + } + ); + var pending = new FriendProfile + { + FriendCode = friendCode, + Mii = null, + Vr = 5000, + Br = 5000, + RegionId = 0, + Wins = 0, + Losses = 0, + CountryCode = 0, + IsPending = true, + }; + game.ActiveCurrentFriends.Returns(Enumerable.Repeat(pending, friendCount).ToList()); + var fs = new MockFileSystem(); + var directory = Path.Combine(Path.GetTempPath(), "wheelwizard-friend-ui"); + fs.Directory.CreateDirectory(directory); + var location = Substitute.For(); + location.DirectoryPath.Returns(directory); + var registrations = new ServiceCollection(); + registrations.AddWheelWizardServices(location); + registrations.AddSingleton(fs); + registrations.AddSingleton(game); + registrations.AddTransient(typeof(IApiCaller<>), typeof(ApplicationCompositionTests.OfflineApiCaller<>)); + using var services = registrations.BuildServiceProvider(); + services.GetRequiredService().Initialize(); + var settings = services.GetRequiredService(); + settings.NAND_ROOT_PATH.Set(directory, skipSave: true); + settings.LOAD_PATH.Set(directory, skipSave: true); + var mii = new Mii { Name = new MiiName("Friend") }; + Control page; + MenuItem add; + if (fromRoom) + { + var player = new RrPlayer + { + Pid = "592497508", + Name = "Friend", + FriendCode = friendCode, + Mii = mii, + }; + var room = new RrRoom + { + Id = "room", + Created = DateTime.UtcNow, + Type = "private", + Suspend = false, + Players = [player], + }; + var roomPage = ActivatorUtilities.CreateInstance(services, room); + var players = roomPage.FindControl("PlayersListView")!; + add = players.ContextMenu!.Items.OfType().First(); + Assert.Contains("friend", add.Header!.ToString()!, StringComparison.OrdinalIgnoreCase); + players.ContextMenu = null; + players.SelectedItem = player; + page = roomPage; + } + else + { + page = ActivatorUtilities.CreateInstance(services); + var menus = page.GetLogicalDescendants() + .OfType() + .Where(control => control.ContextMenu != null) + .Select(control => control.ContextMenu!) + .ToList(); + Assert.Equal(3, menus.Count); + foreach (var menu in menus) + Assert.Contains("friend", menu.Items.OfType().First().Header!.ToString()!, StringComparison.OrdinalIgnoreCase); + add = menus[0].Items.OfType().First(); + add.DataContext = new LeaderboardPlayerItem + { + Rank = 1, + PlacementLabel = "1", + Name = "Friend", + FriendCode = friendCode, + VrText = "5000", + Mii = mii, + }; + } + using var host = new UiTestHost(); + host.Show(); + add.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + var confirmation = Assert.IsType(host.Modals.TopModal); + Assert.Equal(friendCode, confirmation.FriendCode); + confirmation.Close(); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + game.DidNotReceive().AddFriend(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/WheelWizard/Features/RrRooms/Views/RoomDetailsPage.axaml b/WheelWizard/Features/RrRooms/Views/RoomDetailsPage.axaml index 3afda31af..c0f61ebba 100644 --- a/WheelWizard/Features/RrRooms/Views/RoomDetailsPage.axaml +++ b/WheelWizard/Features/RrRooms/Views/RoomDetailsPage.axaml @@ -98,6 +98,7 @@ + diff --git a/WheelWizard/Features/RrRooms/Views/RoomDetailsPage.axaml.cs b/WheelWizard/Features/RrRooms/Views/RoomDetailsPage.axaml.cs index bf9f6b749..a0ed66492 100644 --- a/WheelWizard/Features/RrRooms/Views/RoomDetailsPage.axaml.cs +++ b/WheelWizard/Features/RrRooms/Views/RoomDetailsPage.axaml.cs @@ -165,12 +165,6 @@ private async void AddFriend_OnClick(object sender, RoutedEventArgs e) return; } - if (GameDataService.ActiveCurrentFriends.Count >= 30) - { - ViewUtils.ShowSnackbar(t("snackbar_warning.friend_list_full"), ViewUtils.SnackbarType.Warning); - return; - } - var normalizedFriendCodeResult = NormalizeFriendCode(selectedPlayer.FriendCode); if (normalizedFriendCodeResult.IsFailure) { @@ -186,18 +180,6 @@ private async void AddFriend_OnClick(object sender, RoutedEventArgs e) return; } - var duplicateFriend = GameDataService.ActiveCurrentFriends.Any(friend => - { - var existingPid = FriendCode.FriendCodeToProfileId(friend.FriendCode); - return existingPid != 0 && existingPid == friendProfileId; - }); - - if (duplicateFriend) - { - ViewUtils.ShowSnackbar(t("snackbar_warning.add_existing_friend"), ViewUtils.SnackbarType.Warning); - return; - } - var profile = new PlayerProfileResponse { Name = selectedPlayer.Name, diff --git a/WheelWizard/Features/WheelWizardData/Views/FriendsPage.axaml.cs b/WheelWizard/Features/WheelWizardData/Views/FriendsPage.axaml.cs index f5e13319c..049c7d47a 100644 --- a/WheelWizard/Features/WheelWizardData/Views/FriendsPage.axaml.cs +++ b/WheelWizard/Features/WheelWizardData/Views/FriendsPage.axaml.cs @@ -339,7 +339,8 @@ private void RemoveFriend(FriendProfile? selectedPlayer) UpdateFriendList(); ViewUtils.GetLayout().UpdateFriendCount(); - ViewUtils.ShowSnackbar($"Removed {selectedPlayer.NameOfMii} from your friend list."); + var removedName = string.IsNullOrEmpty(selectedPlayer.NameOfMii) ? selectedPlayer.FriendCode : selectedPlayer.NameOfMii; + ViewUtils.ShowSnackbar($"Removed {removedName} from your friend list."); } private void ViewRoom(string friendCode) diff --git a/WheelWizard/Features/WheelWizardData/Views/LeaderboardPage.axaml b/WheelWizard/Features/WheelWizardData/Views/LeaderboardPage.axaml index c5e27b2fb..bf39b3304 100644 --- a/WheelWizard/Features/WheelWizardData/Views/LeaderboardPage.axaml +++ b/WheelWizard/Features/WheelWizardData/Views/LeaderboardPage.axaml @@ -129,6 +129,7 @@ IsSuspicious="{Binding PodiumSecond.IsSuspicious}"> + @@ -150,6 +151,7 @@ IsSuspicious="{Binding PodiumFirst.IsSuspicious}"> + @@ -171,6 +173,7 @@ IsSuspicious="{Binding PodiumThird.IsSuspicious}"> + @@ -199,6 +202,7 @@ TopLabel="{Binding TopLabel}" TopLabelVariant="{Binding RankBadgeVariant}"> + diff --git a/WheelWizard/Features/WheelWizardData/Views/LeaderboardPage.axaml.cs b/WheelWizard/Features/WheelWizardData/Views/LeaderboardPage.axaml.cs index 32da25626..af16a1450 100644 --- a/WheelWizard/Features/WheelWizardData/Views/LeaderboardPage.axaml.cs +++ b/WheelWizard/Features/WheelWizardData/Views/LeaderboardPage.axaml.cs @@ -502,12 +502,6 @@ private async void AddFriend_OnClick(object sender, RoutedEventArgs e) return; } - if (GameDataService.ActiveCurrentFriends.Count >= 30) - { - ViewUtils.ShowSnackbar(t("snackbar_warning.friend_list_full"), ViewUtils.SnackbarType.Warning); - return; - } - var normalizedFriendCodeResult = NormalizeFriendCode(player.FriendCode); if (normalizedFriendCodeResult.IsFailure) { @@ -523,18 +517,6 @@ private async void AddFriend_OnClick(object sender, RoutedEventArgs e) return; } - var duplicateFriend = GameDataService.ActiveCurrentFriends.Any(friend => - { - var existingPid = FriendCode.FriendCodeToProfileId(friend.FriendCode); - return existingPid != 0 && existingPid == friendProfileId; - }); - - if (duplicateFriend) - { - ViewUtils.ShowSnackbar(t("snackbar_warning.add_existing_friend"), ViewUtils.SnackbarType.Warning); - return; - } - var profile = new PlayerProfileResponse { Name = player.Name, diff --git a/WheelWizard/Features/WiiManagement/FriendCodes/FriendCode.cs b/WheelWizard/Features/WiiManagement/FriendCodes/FriendCode.cs index b82e17953..0421bbc1c 100644 --- a/WheelWizard/Features/WiiManagement/FriendCodes/FriendCode.cs +++ b/WheelWizard/Features/WiiManagement/FriendCodes/FriendCode.cs @@ -29,6 +29,28 @@ public static ulong ProfileIdToFriendCode(uint profileId) if (profileId == 0) return 0; + var data = ChecksumInput(profileId); + + // Calculate MD5 and get checksum (first byte >> 1) + using var md5 = MD5.Create(); + var hashBytes = md5.ComputeHash(data); + var checksum = (uint)(hashBytes[0] >> 1); + + // Return friend code: (checksum << 32) | profileId + return ((ulong)checksum << 32) | profileId; + } + + /// + /// The CRC-8 checksum DWC also accepts in a friend key, next to the MD5 one used for friend codes. + /// + public static uint ProfileIdToCrc8Checksum(uint profileId) + { + var data = ChecksumInput(profileId); + return (uint)(Crc.ComputeCrc8(data, 0, data.Length) & 0x7F); + } + + private static byte[] ChecksumInput(uint profileId) + { // Byte swap both values (same as Python _bswap32) var swappedProfileId = BSwap32(profileId); var swappedGameCode = BSwap32(GameCodeInt); @@ -45,14 +67,7 @@ public static ulong ProfileIdToFriendCode(uint profileId) data[5] = (byte)(swappedGameCode >> 16); data[6] = (byte)(swappedGameCode >> 8); data[7] = (byte)swappedGameCode; - - // Calculate MD5 and get checksum (first byte >> 1) - using var md5 = MD5.Create(); - var hashBytes = md5.ComputeHash(data); - var checksum = (uint)(hashBytes[0] >> 1); - - // Return friend code: (checksum << 32) | profileId - return ((ulong)checksum << 32) | profileId; + return data; } /// diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index afade5263..9d597cf1b 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -11,6 +11,7 @@ using WheelWizard.Shared.Binary; using WheelWizard.Shared.IO; using WheelWizard.Shared.Polling; +using WheelWizard.Shared.Processes; using WheelWizard.WiiManagement.FriendCodes; using WheelWizard.WiiManagement.GameLicense.Domain; using WheelWizard.WiiManagement.MiiManagement; @@ -100,6 +101,8 @@ public class GameLicenseSingletonService : ObservablePollingService, IGameLicens private readonly IDolphinPaths _dolphinPaths; private readonly ICustomDistributionPaths _distributionPaths; private readonly IRoomPresence _presence; + private readonly IProcessLauncher _processes; + private readonly ILogger _logger; private LicenseCollection Licenses { get; } private byte[]? _rksysData; @@ -113,6 +116,7 @@ public GameLicenseSingletonService( IDolphinPaths dolphinPaths, ICustomDistributionPaths distributionPaths, IRoomPresence presence, + IProcessLauncher processes, IPollingScheduler scheduler, TimeProvider timeProvider, ILogger logger @@ -128,6 +132,8 @@ ILogger logger _dolphinPaths = dolphinPaths; _distributionPaths = distributionPaths; _presence = presence; + _processes = processes; + _logger = logger; Licenses = new(); } @@ -143,14 +149,16 @@ ILogger logger private const int FriendSecondaryDataSize = 0x0C; private const int MiiSize = 0x4A; - // Pending one-sided friend entry (bit0 set, bit1 clear). - private const ushort FriendSlotStateAdded = 0x0001; - private const byte DwcFriendControlTypeInvalid = 0x00; - private const byte DwcFriendControlTypeFriendKey = 0x10; - private const ushort DefaultFriendCityId = 0; - private const byte DefaultFriendGameRegion = 0; - private const byte DefaultFriendCountryCode = 0xFF; - private const byte DefaultFriendRegionId = 0xFF; + // Friend flags: bit0 the entry is in use, bit1 the game still has to download the friend's info. + private const ushort FriendFlagAdded = 0x0001; + private const ushort FriendFlagInfoPending = 0x0002; + private const int FriendDwcIndexOffset = 0x66; + + // First word of the DWC friend data: bits 11-12 hold the type, bit 13 is set once the friend added you back. + private const uint DwcFriendTypeMask = 0x1800; + private const uint DwcFriendTypeFriendKey = 0x1000; + private const uint DwcFriendTypeProfileId = 0x1800; + private const uint DwcFriendBuddyFlag = 0x2000; // Retro Rewind stores a friend's VR/BR in rksys.dat as whole hundreds (29,367 VR is stored as 293). private const uint FriendRatingScale = 100; @@ -399,28 +407,26 @@ private void ParseFriends(LicenseProfile licenseProfile, int userOffset) if (_rksysData == null) return; - var friendOffset = userOffset + FriendDataOffset; for (var i = 0; i < MaxFriendNum; i++) { - var currentOffset = friendOffset + i * FriendDataSize; - if (!CheckForMiiData(currentOffset + 0x1A)) + var currentOffset = FriendOffset(userOffset, i); + if (!IsFriendAdded(_rksysData, currentOffset)) continue; - var statusFlags = (ushort)BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x10); - var baseSlotState = (ushort)(statusFlags & 0x0003); - var secondaryOffset = userOffset + FriendSecondaryDataOffset + i * FriendSecondaryDataSize; - var secondaryControlByte = _rksysData[secondaryOffset + 0x2]; - // Treat legacy one-sided entries with control byte 0x00 as pending as well. - var isPending = - baseSlotState == FriendSlotStateAdded - && (secondaryControlByte == DwcFriendControlTypeFriendKey || secondaryControlByte == DwcFriendControlTypeInvalid); + // Until the game has downloaded a friend's info the entry may not have a Mii yet. + Mii? mii = null; + if (HasMiiData(_rksysData, currentOffset)) + { + var miiResult = MiiSerializer.Deserialize(_rksysData.AsSpan(currentOffset + 0x1A, MiiSize).ToArray()); + if (miiResult.IsSuccess) + mii = miiResult.Value; + } - var rawMiiBytes = _rksysData.AsSpan(currentOffset + 0x1A, MiiSize).ToArray(); + var dwcOffset = DwcFriendOffset(userOffset, i); + var isRegistered = + IsRegisteredFriend(BigEndianBinary.BufferToUint32(_rksysData, dwcOffset)) + && DwcFriendProfileId(_rksysData, dwcOffset) == BigEndianBinary.BufferToUint32(_rksysData, currentOffset + 0x04); var friendCode = FriendCode.GetFriendCode(_rksysData, currentOffset + 4); - var miiResult = MiiSerializer.Deserialize(rawMiiBytes); - if (miiResult.IsFailure) - continue; - var friend = new FriendProfile { Vr = FriendRatingFromRksys(BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x16)), @@ -431,8 +437,8 @@ private void ParseFriends(LicenseProfile licenseProfile, int userOffset) CountryCode = _rksysData[currentOffset + 0x68], RegionId = _rksysData[currentOffset + 0x69], BadgeVariants = _badgeService.GetBadges(friendCode), - IsPending = isPending, - Mii = miiResult.Value, + IsPending = !isRegistered, + Mii = mii, }; licenseProfile.Friends.Add(friend); } @@ -479,106 +485,225 @@ public OperationResult ChangeMii(int userIndex, Mii? newMii) public OperationResult AddFriend(int userIndex, string friendCode, Mii friendMii, uint vr = 5000, uint br = 5000) { - if (userIndex is < 0 or >= MaxPlayerNum) - return Fail("Invalid license index. Please select a valid license."); if (friendMii is null) return Fail("Friend Mii cannot be null."); - if (_rksysData is null || _rksysData.Length < RksysSize) - return Fail("Invalid or unloaded rksys.dat data."); var normalizedFriendCode = NormalizeFriendCode(friendCode); if (normalizedFriendCode.IsFailure) return normalizedFriendCode.Error; var friendProfileId = FriendCode.FriendCodeToProfileId(normalizedFriendCode.Value); - if (friendProfileId == 0) + if (friendProfileId is 0 or > int.MaxValue) return Fail("Invalid friend code."); if (!FriendCode.TryParseFriendCode(normalizedFriendCode.Value, out var friendCodeValue)) return Fail("Invalid friend code."); - var selectedLicense = Licenses.Users[userIndex]; - var currentUserPid = FriendCode.FriendCodeToProfileId(selectedLicense.FriendCode); - if (currentUserPid != 0 && currentUserPid == friendProfileId) - return Fail("You cannot add your own friend code."); - - var duplicateFriend = selectedLicense.Friends.Any(friend => - { - var pid = FriendCode.FriendCodeToProfileId(friend.FriendCode); - return pid != 0 && pid == friendProfileId; - }); - if (duplicateFriend) - return Fail("This friend is already in your list."); - var friendMiiDataResult = MiiSerializer.Serialize(friendMii); if (friendMiiDataResult.IsFailure) return friendMiiDataResult.Error; - var rkpdOffset = RksysMagic.Length + userIndex * RkpdSize; - var slotIndex = FindFirstEmptyFriendSlot(rkpdOffset); - if (slotIndex < 0) - return Fail("Your friend list is full. Remove a friend and try again."); - - WriteFriendSlot( - rkpdOffset, - slotIndex, - friendCodeValue, - friendProfileId, - friendMiiDataResult.Value, - FriendRatingToRksys(vr), - FriendRatingToRksys(br) + return EditFriends( + userIndex, + (data, rkpdOffset, repairedProfileIds) => + { + if (BigEndianBinary.BufferToUint32(data, rkpdOffset + 0x5C) == friendProfileId) + return Fail("You cannot add your own friend code."); + + if (HasFriend(data, rkpdOffset, friendProfileId)) + return repairedProfileIds.Contains(friendProfileId) ? Ok() : Fail("This friend is already in your list."); + + var slotIndex = FindFirstEmptyFriendSlot(data, rkpdOffset); + if (slotIndex < 0) + return Fail("Your friend list is full. Remove a friend and try again."); + + WriteFriendSlot( + data, + rkpdOffset, + slotIndex, + friendCodeValue, + friendProfileId, + friendMiiDataResult.Value, + FriendRatingToRksys(vr), + FriendRatingToRksys(br) + ); + return Ok(); + } ); + } - var saveResult = SaveRksysToFile(); + public OperationResult RemoveFriend(int userIndex, string friendCode) + { + var normalizedFriendCode = NormalizeFriendCode(friendCode); + if (normalizedFriendCode.IsFailure) + return normalizedFriendCode.Error; + + var friendProfileId = FriendCode.FriendCodeToProfileId(normalizedFriendCode.Value); + if (friendProfileId == 0) + return Fail("Invalid friend code."); + + return EditFriends( + userIndex, + (data, rkpdOffset, _) => + { + var removed = false; + for (var i = 0; i < MaxFriendNum; i++) + { + var mainOffset = FriendOffset(rkpdOffset, i); + if (IsFriendAdded(data, mainOffset) && BigEndianBinary.BufferToUint32(data, mainOffset + 0x04) == friendProfileId) + { + Array.Clear(data, mainOffset, FriendDataSize); + removed = true; + } + + var dwcOffset = DwcFriendOffset(rkpdOffset, i); + if (DwcFriendProfileId(data, dwcOffset) == friendProfileId) + Array.Clear(data, dwcOffset, FriendSecondaryDataSize); + } + + return removed ? Ok() : Fail("Friend was not found in this license."); + } + ); + } + + // Publish the fresh save only after the atomic write succeeds. + private OperationResult EditFriends(int userIndex, Func, OperationResult> edit) + { + if (userIndex is < 0 or >= MaxPlayerNum || userIndex >= Licenses.Users.Count) + return Fail("Invalid license index. Please select a valid license."); + if (_rksysData is null || !_settingsManager.PathsSetupCorrectly()) + return Fail("Invalid or unloaded rksys.dat data."); + if (IsDolphinRunning()) + return Fail("Close Dolphin before changing your friend list, the game would undo the change."); + + var path = RksysFilePath(); + var readResult = TryCatch(() => _fileSystem.File.ReadAllBytes(path), "Failed to load rksys.dat."); + if (readResult.IsFailure) + return readResult.Error; + + var data = readResult.Value; + if (data.Length != RksysSize || Encoding.ASCII.GetString(data, 0, RksysMagic.Length) != RksysMagic) + return Fail("rksys.dat is invalid, your friend list was not changed."); + + var rkpdOffset = RksysMagic.Length + userIndex * RkpdSize; + var licenseProfileId = BigEndianBinary.BufferToUint32(data, rkpdOffset + 0x5C); + if ( + Encoding.ASCII.GetString(data, rkpdOffset, RkpdMagic.Length) != RkpdMagic + || licenseProfileId == 0 + || licenseProfileId != FriendCode.FriendCodeToProfileId(Licenses.Users[userIndex].FriendCode) + ) + return Fail("This license changed since it was loaded. Please try again."); + + var repairedProfileIds = RepairFriendKeys(data, rkpdOffset); + var editResult = edit(data, rkpdOffset, repairedProfileIds); + if (editResult.IsFailure) + return editResult.Error; + + FixRksysCrc(data); + var saveResult = _fileSystem.WriteAllBytesAtomic(path, data, "Failed to save rksys.dat."); if (saveResult.IsFailure) return saveResult.Error; + _rksysData = data; return ParseUsers(); } - private bool CheckForMiiData(int offset) + // Repair only incomplete friend-key tokens with a matching main entry. + private static HashSet RepairFriendKeys(byte[] data, int rkpdOffset) { - if (_rksysData == null || offset < 0 || offset + MiiSize > _rksysData.Length) - return false; - - // If the entire 0x4A bytes are zero, we treat it as empty / no Mii data - for (var i = 0; i < MiiSize; i++) + var repaired = new HashSet(); + for (var i = 0; i < MaxFriendNum; i++) { - if (_rksysData[offset + i] != 0) - return true; + var mainOffset = FriendOffset(rkpdOffset, i); + var flags = FriendFlags(data, mainOffset); + if ((flags & (FriendFlagAdded | FriendFlagInfoPending)) != FriendFlagAdded || data[mainOffset + FriendDwcIndexOffset] != i) + continue; + + var profileId = BigEndianBinary.BufferToUint32(data, mainOffset + 0x04); + var friendKey = FriendKey(data, mainOffset); + if (profileId == 0 || friendKey != FriendCode.ProfileIdToFriendCode(profileId)) + continue; + + var dwcOffset = DwcFriendOffset(rkpdOffset, i); + if ( + BigEndianBinary.BufferToUint32(data, dwcOffset) != DwcFriendTypeFriendKey + || BigEndianBinary.BufferToUint32(data, dwcOffset + 0x04) != profileId + || BigEndianBinary.BufferToUint32(data, dwcOffset + 0x08) != 0 + ) + continue; + + // A key without checksum still works when either checksum DWC accepts is zero. + var checksum = (uint)(friendKey >> 32); + if (checksum == 0 || FriendCode.ProfileIdToCrc8Checksum(profileId) == 0) + continue; + + BigEndianBinary.WriteUInt32BigEndian(data, dwcOffset + 0x08, checksum); + // Schedule the game's friend-info download. + BigEndianBinary.WriteUInt16BigEndian(data, mainOffset + 0x10, (ushort)(flags | FriendFlagInfoPending)); + repaired.Add(profileId); } - return false; + return repaired; } - private int FindFirstEmptyFriendSlot(int rkpdOffset) + private static int FriendOffset(int rkpdOffset, int index) => rkpdOffset + FriendDataOffset + index * FriendDataSize; + + private static int DwcFriendOffset(int rkpdOffset, int index) => + rkpdOffset + FriendSecondaryDataOffset + index * FriendSecondaryDataSize; + + private static ushort FriendFlags(byte[] data, int friendOffset) => (ushort)BigEndianBinary.BufferToUint16(data, friendOffset + 0x10); + + private static ulong FriendKey(byte[] data, int friendOffset) => + ((ulong)BigEndianBinary.BufferToUint32(data, friendOffset) << 32) | BigEndianBinary.BufferToUint32(data, friendOffset + 0x04); + + private static bool HasMiiData(byte[] data, int friendOffset) => + data.AsSpan(friendOffset + 0x1A, MiiSize).IndexOfAnyExcept((byte)0) >= 0; + + private static bool IsFriendAdded(byte[] data, int friendOffset) => + (FriendFlags(data, friendOffset) & FriendFlagAdded) != 0 && FriendKey(data, friendOffset) != 0; + + private static bool IsFriendSlotEmpty(byte[] data, int friendOffset) => + FriendKey(data, friendOffset) == 0 && (FriendFlags(data, friendOffset) & FriendFlagAdded) == 0 && !HasMiiData(data, friendOffset); + + /// A friend is only registered once both players added each other. + private static bool IsRegisteredFriend(uint dwcFriendData) => + (dwcFriendData & DwcFriendTypeMask) == DwcFriendTypeProfileId && (dwcFriendData & DwcFriendBuddyFlag) != 0; + + /// The profile ID in DWC friend data, or 0 when it holds none. + private static uint DwcFriendProfileId(byte[] data, int dwcOffset) { - if (_rksysData == null) - return -1; + var type = BigEndianBinary.BufferToUint32(data, dwcOffset) & DwcFriendTypeMask; + return type is DwcFriendTypeFriendKey or DwcFriendTypeProfileId ? BigEndianBinary.BufferToUint32(data, dwcOffset + 0x04) : 0; + } - var friendOffset = rkpdOffset + FriendDataOffset; + private static bool HasFriend(byte[] data, int rkpdOffset, uint friendProfileId) + { for (var i = 0; i < MaxFriendNum; i++) { - var currentOffset = friendOffset + i * FriendDataSize; - if (IsFriendSlotEmpty(currentOffset)) - return i; + var mainOffset = FriendOffset(rkpdOffset, i); + if (IsFriendAdded(data, mainOffset) && BigEndianBinary.BufferToUint32(data, mainOffset + 0x04) == friendProfileId) + return true; + if (DwcFriendProfileId(data, DwcFriendOffset(rkpdOffset, i)) == friendProfileId) + return true; } - return -1; + return false; } - private bool IsFriendSlotEmpty(int friendSlotOffset) + private static int FindFirstEmptyFriendSlot(byte[] data, int rkpdOffset) { - if (_rksysData == null) - return true; - - var statusFlags = (ushort)BigEndianBinary.BufferToUint16(_rksysData, friendSlotOffset + 0x10); - var profileId = BigEndianBinary.BufferToUint32(_rksysData, friendSlotOffset + 0x04); - var hasMii = CheckForMiiData(friendSlotOffset + 0x1A); + for (var i = 0; i < MaxFriendNum; i++) + { + var dwcType = BigEndianBinary.BufferToUint32(data, DwcFriendOffset(rkpdOffset, i)) & DwcFriendTypeMask; + if (IsFriendSlotEmpty(data, FriendOffset(rkpdOffset, i)) && dwcType == 0) + return i; + } - return profileId == 0 && (statusFlags & 0x0003) == 0 && !hasMii; + return -1; } - private void WriteFriendSlot( + /// Writes a friend the way the game adds one by friend code. + private static void WriteFriendSlot( + byte[] data, int rkpdOffset, int slotIndex, ulong friendCodeValue, @@ -588,93 +713,46 @@ private void WriteFriendSlot( ushort br ) { - if (_rksysData == null) - return; - - var mainOffset = rkpdOffset + FriendDataOffset + slotIndex * FriendDataSize; - var secondaryOffset = rkpdOffset + FriendSecondaryDataOffset + slotIndex * FriendSecondaryDataSize; + var mainOffset = FriendOffset(rkpdOffset, slotIndex); + var secondaryOffset = DwcFriendOffset(rkpdOffset, slotIndex); - Array.Clear(_rksysData, mainOffset, FriendDataSize); - Array.Clear(_rksysData, secondaryOffset, FriendSecondaryDataSize); + Array.Clear(data, mainOffset, FriendDataSize); + Array.Clear(data, secondaryOffset, FriendSecondaryDataSize); - // Friend identity key (high 32 bits + profile ID low 32 bits). - BigEndianBinary.WriteUInt32BigEndian(_rksysData, mainOffset, (uint)(friendCodeValue >> 32)); - BigEndianBinary.WriteUInt32BigEndian(_rksysData, mainOffset + 0x04, friendProfileId); - BigEndianBinary.WriteUInt16BigEndian(_rksysData, mainOffset + 0x10, FriendSlotStateAdded); - BigEndianBinary.WriteUInt16BigEndian(_rksysData, mainOffset + 0x12, 0); - BigEndianBinary.WriteUInt16BigEndian(_rksysData, mainOffset + 0x14, 0); - BigEndianBinary.WriteUInt16BigEndian(_rksysData, mainOffset + 0x16, vr); - BigEndianBinary.WriteUInt16BigEndian(_rksysData, mainOffset + 0x18, br); + BigEndianBinary.WriteUInt32BigEndian(data, mainOffset, (uint)(friendCodeValue >> 32)); + BigEndianBinary.WriteUInt32BigEndian(data, mainOffset + 0x04, friendProfileId); + BigEndianBinary.WriteUInt16BigEndian(data, mainOffset + 0x10, FriendFlagAdded | FriendFlagInfoPending); + BigEndianBinary.WriteUInt16BigEndian(data, mainOffset + 0x16, vr); + BigEndianBinary.WriteUInt16BigEndian(data, mainOffset + 0x18, br); - Array.Copy(serializedMii, 0, _rksysData, mainOffset + 0x1A, MiiSize); + // Only a preview, the game replaces it with the friend's own info once they added you back. + Array.Copy(serializedMii, 0, data, mainOffset + 0x1A, MiiSize); var miiCrc = Crc.ComputeCrc16Ccitt(serializedMii, 0, serializedMii.Length); - BigEndianBinary.WriteUInt16BigEndian(_rksysData, mainOffset + 0x64, miiCrc); - - _rksysData[mainOffset + 0x66] = (byte)slotIndex; - _rksysData[mainOffset + 0x67] = DefaultFriendGameRegion; - _rksysData[mainOffset + 0x68] = DefaultFriendCountryCode; - _rksysData[mainOffset + 0x69] = DefaultFriendRegionId; - BigEndianBinary.WriteUInt16BigEndian(_rksysData, mainOffset + 0x6A, DefaultFriendCityId); - BigEndianBinary.WriteUInt16BigEndian(_rksysData, mainOffset + 0x6C, 0); - BigEndianBinary.WriteUInt16BigEndian(_rksysData, mainOffset + 0x6E, 0); - - for (var i = 0; i < 10; i++) - { - BigEndianBinary.WriteUInt32BigEndian(_rksysData, mainOffset + 0x70 + i * 8, 0xFFFFFFFF); - } + BigEndianBinary.WriteUInt16BigEndian(data, mainOffset + 0x64, miiCrc); + data[mainOffset + FriendDwcIndexOffset] = (byte)slotIndex; - // One-sided pending friend request token. - _rksysData[secondaryOffset + 0x2] = DwcFriendControlTypeFriendKey; - BigEndianBinary.WriteUInt32BigEndian(_rksysData, secondaryOffset + 0x4, friendProfileId); + // Friend key token: type, profile ID and the friend code checksum. + BigEndianBinary.WriteUInt32BigEndian(data, secondaryOffset, DwcFriendTypeFriendKey); + BigEndianBinary.WriteUInt32BigEndian(data, secondaryOffset + 0x04, friendProfileId); + BigEndianBinary.WriteUInt32BigEndian(data, secondaryOffset + 0x08, (uint)(friendCodeValue >> 32)); } - public OperationResult RemoveFriend(int userIndex, string friendCode) + private bool IsDolphinRunning() { - if (userIndex is < 0 or >= MaxPlayerNum) - return Fail("Invalid license index. Please select a valid license."); - if (_rksysData is null || _rksysData.Length < RksysSize) - return Fail("Invalid or unloaded rksys.dat data."); - - var normalizedFriendCode = NormalizeFriendCode(friendCode); - if (normalizedFriendCode.IsFailure) - return normalizedFriendCode.Error; - - var friendProfileId = FriendCode.FriendCodeToProfileId(normalizedFriendCode.Value); - if (friendProfileId == 0) - return Fail("Invalid friend code."); - - var rkpdOffset = RksysMagic.Length + userIndex * RkpdSize; - var slotIndex = FindFriendSlotByProfileId(rkpdOffset, friendProfileId); - if (slotIndex < 0) - return Fail("Friend was not found in this license."); - - var mainOffset = rkpdOffset + FriendDataOffset + slotIndex * FriendDataSize; - var secondaryOffset = rkpdOffset + FriendSecondaryDataOffset + slotIndex * FriendSecondaryDataSize; - Array.Clear(_rksysData, mainOffset, FriendDataSize); - Array.Clear(_rksysData, secondaryOffset, FriendSecondaryDataSize); - - var saveResult = SaveRksysToFile(); - if (saveResult.IsFailure) - return saveResult.Error; - - return ParseUsers(); - } - - private int FindFriendSlotByProfileId(int rkpdOffset, uint friendProfileId) - { - if (_rksysData == null || friendProfileId == 0) - return -1; - - var friendOffset = rkpdOffset + FriendDataOffset; - for (var i = 0; i < MaxFriendNum; i++) + try { - var currentOffset = friendOffset + i * FriendDataSize; - var currentPid = BigEndianBinary.BufferToUint32(_rksysData, currentOffset + 0x04); - if (currentPid == friendProfileId) - return i; + string[] processNames = ["Dolphin", "dolphin-emu", _fileSystem.Path.GetFileNameWithoutExtension(_dolphinPaths.ExecutablePath)]; + return processNames + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct() + .Any(name => _processes.GetProcessIds(name).Count > 0); + } + catch (Exception e) + { + // Rather refuse the change than risk the game overwriting it. + _logger.LogDebug(e, "Could not check whether Dolphin is running"); + return true; } - - return -1; } private static OperationResult NormalizeFriendCode(string friendCode) @@ -832,10 +910,14 @@ private OperationResult SaveRksysToFile() return Fail($"Refusing to save rksys.dat: expected {RksysSize} bytes but got {_rksysData.Length}."); FixRksysCrc(_rksysData); + return _fileSystem.WriteAllBytesAtomic(RksysFilePath(), _rksysData, "Failed to save rksys.dat."); + } + + private string RksysFilePath() + { var currentRegion = _settingsManager.Get(_settingsManager.RR_REGION); var saveFolder = _fileSystem.Path.Combine(_distributionPaths.SaveFolderPath, GameRegion.GetGameId(currentRegion)); - var path = _fileSystem.Path.Combine(saveFolder, "rksys.dat"); - return _fileSystem.WriteAllBytesAtomic(path, _rksysData, "Failed to save rksys.dat."); + return _fileSystem.Path.Combine(saveFolder, "rksys.dat"); } protected override Task ExecuteTaskAsync(CancellationToken cancellationToken) diff --git a/WheelWizard/Shared/Binary/Crc.cs b/WheelWizard/Shared/Binary/Crc.cs index fbf8baf9e..6682d922d 100644 --- a/WheelWizard/Shared/Binary/Crc.cs +++ b/WheelWizard/Shared/Binary/Crc.cs @@ -17,6 +17,21 @@ public static ushort ComputeCrc16Ccitt(byte[] buffer, int offset, int length) return crc; } + public static byte ComputeCrc8(byte[] buffer, int offset, int length) + { + const byte poly = 0x07; + byte crc = 0x00; + + for (var i = offset; i < offset + length; i++) + { + crc ^= buffer[i]; + for (var bit = 0; bit < 8; bit++) + crc = (crc & 0x80) != 0 ? (byte)((crc << 1) ^ poly) : (byte)(crc << 1); + } + + return crc; + } + public static uint ComputeCrc32(byte[] data, int offset, int length) { const uint poly = 0xEDB88320; From c5360debbedf1f8481bc7b121ff01a58d6202d75 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:05:03 +0100 Subject: [PATCH 2/5] recover leftover friend tokens --- .../Features/WiiManagement/FriendListTests.cs | 43 +++++++++++++++++++ .../GameLicense/GameLicenseService.cs | 18 +++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs index 7fc17d460..6b6a2580d 100644 --- a/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs +++ b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs @@ -202,6 +202,49 @@ public void AddFriend_SkipsSlotsWhoseDwcDataIsInUse() Assert.Equal(save[DwcOffset(3)..(DwcOffset(3) + 12)], data[DwcOffset(3)..(DwcOffset(3) + 12)]); } + [Fact] + public void AddFriend_ReplacesOrphanTokensAndPreservesOtherFriends() + { + var save = NewSave(); + PutFriend(save, 0, 1000, 0x0001, 0x3800, withMii: true); + PutFriend(save, 3, 1003, 0x0003, 0x1000, withMii: false); + foreach (var slot in new[] { 1, 4 }) + { + BigEndianBinary.WriteUInt32BigEndian(save, DwcOffset(slot), 0x3800); + BigEndianBinary.WriteUInt32BigEndian(save, DwcOffset(slot) + 4, FriendPid); + } + var (service, fs, _) = Create(save); + + fs.Directory.CreateDirectory(SavePath + ".tmp"); + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + fs.Directory.Delete(SavePath + ".tmp"); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsSuccess); + + var data = fs.File.ReadAllBytes(SavePath); + Assert.Equal(FriendPid, BigEndianBinary.BufferToUint32(data, MainOffset(1) + 4)); + Assert.Equal(FriendChecksum, BigEndianBinary.BufferToUint32(data, DwcOffset(1) + 8)); + Assert.All(data[DwcOffset(4)..(DwcOffset(4) + 12)], b => Assert.Equal(0, b)); + foreach (var slot in new[] { 0, 3 }) + { + Assert.Equal(save[MainOffset(slot)..(MainOffset(slot) + 0x1C0)], data[MainOffset(slot)..(MainOffset(slot) + 0x1C0)]); + Assert.Equal(save[DwcOffset(slot)..(DwcOffset(slot) + 12)], data[DwcOffset(slot)..(DwcOffset(slot) + 12)]); + } + } + + [Fact] + public void AddFriend_DoesNotClearTokensWhenAMainEntryExistsInAnotherSlot() + { + var save = NewSave(); + PutFriend(save, 3, FriendPid, 0x0003, 0x1000, withMii: false, dwcIndex: 1); + var (service, fs, _) = Create(save); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsFailure); + + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + } + [Fact] public void AddFriend_RepairsFriendKeysWrittenWithoutChecksum_AndLeavesOtherEntriesAlone() { diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index 9d597cf1b..d5903e789 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -512,6 +512,7 @@ public OperationResult AddFriend(int userIndex, string friendCode, Mii friendMii if (HasFriend(data, rkpdOffset, friendProfileId)) return repairedProfileIds.Contains(friendProfileId) ? Ok() : Fail("This friend is already in your list."); + ClearDwcFriendData(data, rkpdOffset, friendProfileId); var slotIndex = FindFirstEmptyFriendSlot(data, rkpdOffset); if (slotIndex < 0) return Fail("Your friend list is full. Remove a friend and try again."); @@ -554,12 +555,9 @@ public OperationResult RemoveFriend(int userIndex, string friendCode) Array.Clear(data, mainOffset, FriendDataSize); removed = true; } - - var dwcOffset = DwcFriendOffset(rkpdOffset, i); - if (DwcFriendProfileId(data, dwcOffset) == friendProfileId) - Array.Clear(data, dwcOffset, FriendSecondaryDataSize); } + ClearDwcFriendData(data, rkpdOffset, friendProfileId); return removed ? Ok() : Fail("Friend was not found in this license."); } ); @@ -682,13 +680,21 @@ private static bool HasFriend(byte[] data, int rkpdOffset, uint friendProfileId) var mainOffset = FriendOffset(rkpdOffset, i); if (IsFriendAdded(data, mainOffset) && BigEndianBinary.BufferToUint32(data, mainOffset + 0x04) == friendProfileId) return true; - if (DwcFriendProfileId(data, DwcFriendOffset(rkpdOffset, i)) == friendProfileId) - return true; } return false; } + private static void ClearDwcFriendData(byte[] data, int rkpdOffset, uint friendProfileId) + { + for (var i = 0; i < MaxFriendNum; i++) + { + var dwcOffset = DwcFriendOffset(rkpdOffset, i); + if (DwcFriendProfileId(data, dwcOffset) == friendProfileId) + Array.Clear(data, dwcOffset, FriendSecondaryDataSize); + } + } + private static int FindFirstEmptyFriendSlot(byte[] data, int rkpdOffset) { for (var i = 0; i < MaxFriendNum; i++) From 84b5e42c776a027267bb20e18a5dc752cba82bea Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:17:33 +0100 Subject: [PATCH 3/5] fix linux dolphin detection --- .../Features/RrRooms/RoomPresenceTests.cs | 2 + .../Features/WiiManagement/FriendListTests.cs | 71 ++++++++++++++++++- .../WiiManagement/RegionLicenseTests.cs | 2 + .../GameLicense/GameLicenseService.cs | 34 ++++++++- .../Shared/Processes/IProcessLauncher.cs | 7 ++ 5 files changed, 111 insertions(+), 5 deletions(-) diff --git a/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs b/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs index d41ec1132..cd8180439 100644 --- a/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs +++ b/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs @@ -4,6 +4,7 @@ using WheelWizard.Dolphin.Paths; using WheelWizard.RrRooms; using WheelWizard.Settings; +using WheelWizard.Shared.Platform; using WheelWizard.Shared.Polling; using WheelWizard.Shared.Processes; using WheelWizard.WiiManagement.GameLicense; @@ -42,6 +43,7 @@ public void Refresh_UpdatesBothLicenseAndFriends_AndOnlyNotifiesChanges() Substitute.For(), presence, Substitute.For(), + Substitute.For(), Substitute.For(), TimeProvider.System, NullLogger.Instance diff --git a/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs index 6b6a2580d..f461b86a7 100644 --- a/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs +++ b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs @@ -7,6 +7,7 @@ using WheelWizard.RrRooms; using WheelWizard.Settings; using WheelWizard.Shared.Binary; +using WheelWizard.Shared.Platform; using WheelWizard.Shared.Polling; using WheelWizard.Shared.Processes; using WheelWizard.WiiManagement.FriendCodes; @@ -338,6 +339,63 @@ public void AddFriend_DoesNothingWhenTheSaveOnDiskIsInvalid(bool truncated) Assert.Single(service.ActiveCurrentFriends); } + [Fact] + public void ProcessLauncher_ReadsTheCurrentExecutable() + { + Assert.Equal(Environment.ProcessPath, new ProcessLauncher().GetExecutablePath(Environment.ProcessId)); + } + + [Theory] + [InlineData("dolphin", "/usr/bin/dolphin", false)] + [InlineData("Dolphin", "/opt/rr/Dolphin", true)] + [InlineData("dolphin-emu", "/usr/bin/dolphin-emu", true)] + [InlineData("Dolphin", "", true)] + public void FriendChanges_OnLinuxDistinguishTheFileManagerFromTheEmulator(string processName, string processPath, bool blocked) + { + var save = NewSave(); + PutFriend(save, 0, 2001, 0x0001, 0x3800, withMii: true); + var (service, fs, processes) = Create(save, linux: true, executable: "/opt/rr/Dolphin"); + processes + .GetProcessIds(Arg.Any()) + .Returns(call => call.Arg().Equals(processName, StringComparison.OrdinalIgnoreCase) ? new[] { 1234 } : []); + processes.GetExecutablePath(1234).Returns(processPath); + + Assert.Equal(blocked, service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.Equal(blocked, service.RemoveFriend(0, FriendCodeOf(2001)).IsFailure); + if (blocked) + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + } + + [Fact] + public void FriendChanges_OnLinuxRecognizeASymlinkToTheConfiguredEmulator() + { + var save = NewSave(); + var (service, fs, processes) = Create(save, linux: true, executable: "/opt/rr/Dolphin"); + fs.Directory.CreateDirectory("/opt/rr"); + fs.Directory.CreateDirectory("/opt/native"); + fs.File.WriteAllText("/opt/native/rr-dolphin", ""); + fs.File.CreateSymbolicLink("/opt/rr/Dolphin", "/opt/native/rr-dolphin"); + processes.GetProcessIds("Dolphin").Returns([1234]); + processes.GetExecutablePath(1234).Returns("/opt/native/rr-dolphin"); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + } + + [Fact] + public void FriendChanges_AreRefusedWhenTheProcessIdentityCannotBeRead() + { + var save = NewSave(); + PutFriend(save, 0, 2001, 0x0001, 0x3800, withMii: true); + var (service, fs, processes) = Create(save, linux: true, executable: "/opt/rr/Dolphin"); + processes.GetProcessIds("Dolphin").Returns([1234]); + processes.GetExecutablePath(1234).Returns(_ => throw new IOException()); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.True(service.RemoveFriend(0, FriendCodeOf(2001)).IsFailure); + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + } + [Fact] public void FriendChanges_AreRefusedWhileDolphinIsRunning() { @@ -444,7 +502,11 @@ private static byte[] NewSave() return data; } - private static (GameLicenseSingletonService, MockFileSystem, IProcessLauncher) Create(byte[] save) + private static (GameLicenseSingletonService, MockFileSystem, IProcessLauncher) Create( + byte[] save, + bool linux = false, + string executable = "" + ) { var fs = new MockFileSystem(); fs.Directory.CreateDirectory("/saves/RMCP"); @@ -458,6 +520,10 @@ private static (GameLicenseSingletonService, MockFileSystem, IProcessLauncher) C saveRegions.ResolveActiveRegion(Arg.Any()).Returns(MarioKartWiiEnums.Regions.Europe); var miis = Substitute.For(); miis.GetByAvatarId(Arg.Any()).Returns(Fail("Missing Mii")); + var environment = Substitute.For(); + environment.IsLinux.Returns(linux); + var dolphinPaths = Substitute.For(); + dolphinPaths.ExecutablePath.Returns(executable); var processes = Substitute.For(); processes.GetProcessIds(Arg.Any()).Returns([]); var service = new GameLicenseSingletonService( @@ -467,10 +533,11 @@ private static (GameLicenseSingletonService, MockFileSystem, IProcessLauncher) C Substitute.For(), settings, saveRegions, - Substitute.For(), + dolphinPaths, paths, Substitute.For(), processes, + environment, Substitute.For(), System.TimeProvider.System, NullLogger.Instance diff --git a/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs b/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs index 96645e595..1de4c5d67 100644 --- a/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs +++ b/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs @@ -7,6 +7,7 @@ using WheelWizard.RrRooms; using WheelWizard.Settings; using WheelWizard.Shared.Binary; +using WheelWizard.Shared.Platform; using WheelWizard.Shared.Polling; using WheelWizard.Shared.Processes; using WheelWizard.WiiManagement.GameLicense; @@ -86,6 +87,7 @@ private static (GameLicenseSingletonService, MockFileSystem, ISettingsManager) C paths, Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), System.TimeProvider.System, NullLogger.Instance diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index d5903e789..deb7efa36 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -10,6 +10,7 @@ using WheelWizard.Settings.Types; using WheelWizard.Shared.Binary; using WheelWizard.Shared.IO; +using WheelWizard.Shared.Platform; using WheelWizard.Shared.Polling; using WheelWizard.Shared.Processes; using WheelWizard.WiiManagement.FriendCodes; @@ -102,6 +103,7 @@ public class GameLicenseSingletonService : ObservablePollingService, IGameLicens private readonly ICustomDistributionPaths _distributionPaths; private readonly IRoomPresence _presence; private readonly IProcessLauncher _processes; + private readonly IRuntimeEnvironment _environment; private readonly ILogger _logger; private LicenseCollection Licenses { get; } private byte[]? _rksysData; @@ -117,6 +119,7 @@ public GameLicenseSingletonService( ICustomDistributionPaths distributionPaths, IRoomPresence presence, IProcessLauncher processes, + IRuntimeEnvironment environment, IPollingScheduler scheduler, TimeProvider timeProvider, ILogger logger @@ -133,6 +136,7 @@ ILogger logger _distributionPaths = distributionPaths; _presence = presence; _processes = processes; + _environment = environment; _logger = logger; Licenses = new(); } @@ -747,11 +751,21 @@ private bool IsDolphinRunning() { try { - string[] processNames = ["Dolphin", "dolphin-emu", _fileSystem.Path.GetFileNameWithoutExtension(_dolphinPaths.ExecutablePath)]; + var executable = _dolphinPaths.ExecutablePath; + var configuredName = _fileSystem.Path.GetFileNameWithoutExtension(executable); + string[] processNames = _environment.IsLinux ? ["dolphin-emu", configuredName] : ["Dolphin", "dolphin-emu", configuredName]; return processNames .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct() - .Any(name => _processes.GetProcessIds(name).Count > 0); + .Distinct(StringComparer.OrdinalIgnoreCase) + .Any(name => + _processes + .GetProcessIds(name) + .Any(processId => + !_environment.IsLinux + || name.Equals("dolphin-emu", StringComparison.OrdinalIgnoreCase) + || IsConfiguredExecutable(processId, executable) + ) + ); } catch (Exception e) { @@ -761,6 +775,20 @@ private bool IsDolphinRunning() } } + private bool IsConfiguredExecutable(int processId, string executable) + { + var runningExecutable = _processes.GetExecutablePath(processId); + if (string.IsNullOrWhiteSpace(runningExecutable)) + throw new InvalidOperationException("The process executable is unavailable."); + + var configuredExecutable = _fileSystem.File.Exists(executable) + ? _fileSystem.File.ResolveLinkTarget(executable, returnFinalTarget: true)?.FullName ?? executable + : executable; + return _fileSystem + .Path.GetFullPath(runningExecutable) + .Equals(_fileSystem.Path.GetFullPath(configuredExecutable), StringComparison.Ordinal); + } + private static OperationResult NormalizeFriendCode(string friendCode) { if (string.IsNullOrWhiteSpace(friendCode)) diff --git a/WheelWizard/Shared/Processes/IProcessLauncher.cs b/WheelWizard/Shared/Processes/IProcessLauncher.cs index de0806ab7..dbf34664e 100644 --- a/WheelWizard/Shared/Processes/IProcessLauncher.cs +++ b/WheelWizard/Shared/Processes/IProcessLauncher.cs @@ -7,6 +7,7 @@ public interface IProcessLauncher void Start(ProcessStartInfo startInfo); void KillByName(string processName); IReadOnlyList GetProcessIds(string processName); + string GetExecutablePath(int processId); } public sealed class ProcessLauncher : IProcessLauncher @@ -49,4 +50,10 @@ public void KillByName(string processName) process.Dispose(); } } + + public string GetExecutablePath(int processId) + { + using var process = Process.GetProcessById(processId); + return process.MainModule?.FileName ?? throw new InvalidOperationException("The process executable is unavailable."); + } } From 7a48cbb4b1e10d3d337db1c522cf0a92366a9819 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:32:31 +0100 Subject: [PATCH 4/5] decode configured dolphin paths --- .../Features/WiiManagement/FriendListTests.cs | 19 +++++++++++++++--- .../Platform/EnvironmentDependencyTests.cs | 20 +++++++++++++++++++ .../GameLicense/GameLicenseService.cs | 2 ++ WheelWizard/Shared/Processes/ShellQuoting.cs | 17 ++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs index f461b86a7..144ef92bd 100644 --- a/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs +++ b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs @@ -339,10 +339,23 @@ public void AddFriend_DoesNothingWhenTheSaveOnDiskIsInvalid(bool truncated) Assert.Single(service.ActiveCurrentFriends); } - [Fact] - public void ProcessLauncher_ReadsTheCurrentExecutable() + [Theory] + [InlineData("/opt/rr/Dolphin", "/opt/rr/Dolphin", true)] + [InlineData("/opt/rr space/Dolphin", "/opt/rr space/Dolphin", true)] + [InlineData("/opt/rr's/Dolphin", "/opt/rr's/Dolphin", true)] + [InlineData("/opt/rr/Dolphin", "/usr/bin/dolphin", false)] + public void FriendChanges_OnLinuxDecodePickerPaths(string selectedPath, string processPath, bool blocked) { - Assert.Equal(Environment.ProcessPath, new ProcessLauncher().GetExecutablePath(Environment.ProcessId)); + var save = NewSave(); + PutFriend(save, 0, 2001, 0x0001, 0x3800, withMii: true); + var (service, fs, processes) = Create(save, linux: true, executable: ShellQuoting.QuoteUnixArgument(selectedPath)); + processes.GetProcessIds("Dolphin").Returns([1234]); + processes.GetExecutablePath(1234).Returns(processPath); + + Assert.Equal(blocked, service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.Equal(blocked, service.RemoveFriend(0, FriendCodeOf(2001)).IsFailure); + if (blocked) + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); } [Theory] diff --git a/WheelWizard.Test/Shared/Platform/EnvironmentDependencyTests.cs b/WheelWizard.Test/Shared/Platform/EnvironmentDependencyTests.cs index 4ef42e5f5..303210d51 100644 --- a/WheelWizard.Test/Shared/Platform/EnvironmentDependencyTests.cs +++ b/WheelWizard.Test/Shared/Platform/EnvironmentDependencyTests.cs @@ -87,6 +87,26 @@ public void ShellQuoting_UsesTargetShellRules_ForApostrophesAndMetacharacters() Assert.Equal("\"folder with spaces\"", ShellQuoting.QuoteArgument("folder with spaces", true)); } + [Fact] + public void ShellQuoting_DecodesItsOwnArgumentWithoutEvaluatingIt() + { + const string path = "a'b $value; file"; + + Assert.True(ShellQuoting.TryUnquoteUnixArgument(ShellQuoting.QuoteUnixArgument(path), out var decoded)); + Assert.Equal(path, decoded); + } + + [Theory] + [InlineData("dolphin-emu")] + [InlineData("'/opt/Dolphin' --flag")] + [InlineData("'one' 'two'")] + [InlineData("'unclosed")] + public void ShellQuoting_LeavesOtherCommandFormatsAlone(string command) + { + Assert.False(ShellQuoting.TryUnquoteUnixArgument(command, out var decoded)); + Assert.Equal(command, decoded); + } + [Fact] public void PackageManagerDetection_PreservesPriorityAndFallsBackWhenUnavailable() { diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index deb7efa36..18c4a6c66 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -752,6 +752,8 @@ private bool IsDolphinRunning() try { var executable = _dolphinPaths.ExecutablePath; + if (!_environment.IsWindows && ShellQuoting.TryUnquoteUnixArgument(executable, out var unquotedExecutable)) + executable = unquotedExecutable; var configuredName = _fileSystem.Path.GetFileNameWithoutExtension(executable); string[] processNames = _environment.IsLinux ? ["dolphin-emu", configuredName] : ["Dolphin", "dolphin-emu", configuredName]; return processNames diff --git a/WheelWizard/Shared/Processes/ShellQuoting.cs b/WheelWizard/Shared/Processes/ShellQuoting.cs index 07e30d0b4..3de22e45e 100644 --- a/WheelWizard/Shared/Processes/ShellQuoting.cs +++ b/WheelWizard/Shared/Processes/ShellQuoting.cs @@ -6,5 +6,22 @@ public static class ShellQuoting public static string QuoteUnixArgument(string value) => $"'{value.Replace("'", "'\\''")}'"; + // Decode only the format emitted by QuoteUnixArgument. + public static bool TryUnquoteUnixArgument(string value, out string argument) + { + if (value.Length >= 2 && value[0] == '\'' && value[^1] == '\'') + { + var decoded = value[1..^1].Replace("'\\''", "'"); + if (QuoteUnixArgument(decoded) == value) + { + argument = decoded; + return true; + } + } + + argument = value; + return false; + } + public static string QuotePowerShellArgument(string value) => $"'{value.Replace("'", "''")}'"; } From 4ce47d5849a76b8fdeb95d91aa7c4523cd448f16 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:43:47 +0100 Subject: [PATCH 5/5] resolve full dolphin paths --- .../Features/RrRooms/RoomPresenceTests.cs | 1 + .../Features/WiiManagement/FriendListTests.cs | 87 ++++++++++++++++++- .../WiiManagement/RegionLicenseTests.cs | 1 + .../GameLicense/GameLicenseService.cs | 16 +++- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs b/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs index cd8180439..7ced96b28 100644 --- a/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs +++ b/WheelWizard.Test/Features/RrRooms/RoomPresenceTests.cs @@ -44,6 +44,7 @@ public void Refresh_UpdatesBothLicenseAndFriends_AndOnlyNotifiesChanges() presence, Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), TimeProvider.System, NullLogger.Instance diff --git a/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs index 144ef92bd..fa1bccabf 100644 --- a/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs +++ b/WheelWizard.Test/Features/WiiManagement/FriendListTests.cs @@ -383,7 +383,12 @@ public void FriendChanges_OnLinuxDistinguishTheFileManagerFromTheEmulator(string public void FriendChanges_OnLinuxRecognizeASymlinkToTheConfiguredEmulator() { var save = NewSave(); - var (service, fs, processes) = Create(save, linux: true, executable: "/opt/rr/Dolphin"); + var (service, fs, processes) = Create( + save, + linux: true, + executable: "/opt/rr/Dolphin", + unixProcesses: CanonicalPathResolver("/opt/native/rr-dolphin") + ); fs.Directory.CreateDirectory("/opt/rr"); fs.Directory.CreateDirectory("/opt/native"); fs.File.WriteAllText("/opt/native/rr-dolphin", ""); @@ -395,6 +400,51 @@ public void FriendChanges_OnLinuxRecognizeASymlinkToTheConfiguredEmulator() Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); } + [Fact] + public void FriendChanges_OnLinuxRecognizeASymlinkedParentDirectory() + { + var save = NewSave(); + var resolver = CanonicalPathResolver("/opt/native/Dolphin"); + var (service, fs, processes) = Create(save, linux: true, executable: "/opt/current/Dolphin", unixProcesses: resolver); + fs.Directory.CreateDirectory("/opt/native"); + fs.File.WriteAllText("/opt/native/Dolphin", ""); + fs.Directory.CreateSymbolicLink("/opt/current", "/opt/native"); + processes.GetProcessIds("Dolphin").Returns([1234]); + processes.GetExecutablePath(1234).Returns("/opt/native/Dolphin"); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + resolver + .Received(1) + .Run( + "/usr/bin/env", + Arg.Is>(args => args.SequenceEqual(new[] { "realpath", "-e", "-z", "--", "/opt/current/Dolphin" })), + out Arg.Any(), + out Arg.Any() + ); + } + + [Theory] + [InlineData("/opt/rr/Dolphin", 1)] + [InlineData("", 0)] + public void FriendChanges_AreRefusedWhenTheConfiguredPathCannotBeResolved(string resolvedPath, int exitCode) + { + var save = NewSave(); + PutFriend(save, 0, 2001, 0x0001, 0x3800, withMii: true); + var (service, fs, processes) = Create( + save, + linux: true, + executable: "/opt/rr/Dolphin", + unixProcesses: CanonicalPathResolver(resolvedPath, exitCode) + ); + processes.GetProcessIds("Dolphin").Returns([1234]); + processes.GetExecutablePath(1234).Returns("/opt/native/Dolphin"); + + Assert.True(service.AddFriend(0, FriendFc, FriendMii).IsFailure); + Assert.True(service.RemoveFriend(0, FriendCodeOf(2001)).IsFailure); + Assert.Equal(save, fs.File.ReadAllBytes(SavePath)); + } + [Fact] public void FriendChanges_AreRefusedWhenTheProcessIdentityCannotBeRead() { @@ -515,10 +565,30 @@ private static byte[] NewSave() return data; } + private static IUnixProcessService CanonicalPathResolver(string path, int exitCode = 0) + { + var resolver = Substitute.For(); + resolver + .Run( + "/usr/bin/env", + Arg.Is>(args => args.Take(4).SequenceEqual(new[] { "realpath", "-e", "-z", "--" })), + out Arg.Any(), + out Arg.Any() + ) + .Returns(call => + { + call[2] = path + "\0"; + call[3] = ""; + return Ok(exitCode); + }); + return resolver; + } + private static (GameLicenseSingletonService, MockFileSystem, IProcessLauncher) Create( byte[] save, bool linux = false, - string executable = "" + string executable = "", + IUnixProcessService? unixProcesses = null ) { var fs = new MockFileSystem(); @@ -539,6 +609,18 @@ private static (GameLicenseSingletonService, MockFileSystem, IProcessLauncher) C dolphinPaths.ExecutablePath.Returns(executable); var processes = Substitute.For(); processes.GetProcessIds(Arg.Any()).Returns([]); + if (unixProcesses == null) + { + unixProcesses = Substitute.For(); + unixProcesses + .Run("/usr/bin/env", Arg.Any>(), out Arg.Any(), out Arg.Any()) + .Returns(call => + { + call[2] = call.Arg>().Last() + "\0"; + call[3] = ""; + return Ok(0); + }); + } var service = new GameLicenseSingletonService( miis, fs, @@ -551,6 +633,7 @@ private static (GameLicenseSingletonService, MockFileSystem, IProcessLauncher) C Substitute.For(), processes, environment, + unixProcesses, Substitute.For(), System.TimeProvider.System, NullLogger.Instance diff --git a/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs b/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs index 1de4c5d67..ad1ba67b1 100644 --- a/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs +++ b/WheelWizard.Test/Features/WiiManagement/RegionLicenseTests.cs @@ -88,6 +88,7 @@ private static (GameLicenseSingletonService, MockFileSystem, ISettingsManager) C Substitute.For(), Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), System.TimeProvider.System, NullLogger.Instance diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index 18c4a6c66..7cfd495c4 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -104,6 +104,7 @@ public class GameLicenseSingletonService : ObservablePollingService, IGameLicens private readonly IRoomPresence _presence; private readonly IProcessLauncher _processes; private readonly IRuntimeEnvironment _environment; + private readonly IUnixProcessService _unixProcesses; private readonly ILogger _logger; private LicenseCollection Licenses { get; } private byte[]? _rksysData; @@ -120,6 +121,7 @@ public GameLicenseSingletonService( IRoomPresence presence, IProcessLauncher processes, IRuntimeEnvironment environment, + IUnixProcessService unixProcesses, IPollingScheduler scheduler, TimeProvider timeProvider, ILogger logger @@ -137,6 +139,7 @@ ILogger logger _presence = presence; _processes = processes; _environment = environment; + _unixProcesses = unixProcesses; _logger = logger; Licenses = new(); } @@ -783,9 +786,16 @@ private bool IsConfiguredExecutable(int processId, string executable) if (string.IsNullOrWhiteSpace(runningExecutable)) throw new InvalidOperationException("The process executable is unavailable."); - var configuredExecutable = _fileSystem.File.Exists(executable) - ? _fileSystem.File.ResolveLinkTarget(executable, returnFinalTarget: true)?.FullName ?? executable - : executable; + var resolution = _unixProcesses.Run( + "/usr/bin/env", + new[] { "realpath", "-e", "-z", "--", executable }, + out var resolvedPath, + out _ + ); + if (resolution.IsFailure || resolution.Value != 0 || string.IsNullOrEmpty(resolvedPath)) + throw new IOException("The configured executable could not be resolved."); + + var configuredExecutable = resolvedPath.TrimEnd('\0'); return _fileSystem .Path.GetFullPath(runningExecutable) .Equals(_fileSystem.Path.GetFullPath(configuredExecutable), StringComparison.Ordinal);