diff --git a/WheelWizard.Test/Features/MiiDbServiceTest.cs b/WheelWizard.Test/Features/MiiDbServiceTest.cs new file mode 100644 index 00000000..5f8f6698 --- /dev/null +++ b/WheelWizard.Test/Features/MiiDbServiceTest.cs @@ -0,0 +1,278 @@ +using WheelWizard.Shared; +using WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.Domain; +using WheelWizard.WiiManagement.Domain.Mii; + +namespace WheelWizard.Test.Features +{ + public class MiiDbServiceTests + { + private OperationResult CreateValidMii(uint id = 1, string name = "TestMii") + { + var miiname = MiiName.Create(name); + var miiId = id; + var height = MiiScale.Create(60); + var weight = MiiScale.Create(50); + var miiFacial = MiiFacialFeatures.Create(MiiFaceShape.Bread, MiiSkinColor.Brown, MiiFacialFeature.Beard, false, false); + var miiHair = MiiHair.Create(1, HairColor.Black, false); + var miiEyebrows = MiiEyebrow.Create(1, 1, EyebrowColor.Black, 1, 1, 1); + var miiEyes = MiiEye.Create(1, 1, 1, EyeColor.Black, 1, 1); + var miiNose = MiiNose.Create(NoseType.Default, 1, 1); + var miiLips = MiiLip.Create(1, LipColor.Pink, 1, 1); + var miiGlasses = MiiGlasses.Create(GlassesType.None, GlassesColor.Blue, 1, 1); + var miiFacialHair = MiiFacialHair.Create(MustacheType.None, BeardType.None, MustacheColor.Black, 1, 1); + var miiMole = MiiMole.Create(true, 1, 1, 1); + var creatorName = MiiName.Create("Creator"); + var miiFavoriteColor = MiiFavoriteColor.Red; + var EveryResult = new List + { + miiname, + height, + weight, + miiFacial, + miiHair, + miiEyebrows, + miiEyes, + miiNose, + miiLips, + miiGlasses, + miiFacialHair, + miiMole, + creatorName + }; + foreach (var result in EveryResult) + { + if (result.IsFailure) + return result.Error; + } + + return new Mii + { + Name = miiname.Value, + MiiId = miiId, + Height = height.Value, + Weight = weight.Value, + MiiFacial = miiFacial.Value, + MiiHair = miiHair.Value, + MiiEyebrows = miiEyebrows.Value, + MiiEyes = miiEyes.Value, + MiiNose = miiNose.Value, + MiiLips = miiLips.Value, + MiiGlasses = miiGlasses.Value, + MiiFacialHair = miiFacialHair.Value, + MiiMole = miiMole.Value, + CreatorName = creatorName.Value, + MiiFavoriteColor = miiFavoriteColor, + }; + } + + private readonly IMiiRepository _repository; + private readonly MiiDbService _service; + + public MiiDbServiceTests() + { + _repository = Substitute.For(); + _service = new MiiDbService(_repository); + } + + [Fact] + public void CreateValidMii_ShouldSerializeAndDeserializeSuccessfully() + { + // Arrange + var original = CreateValidMii(999, "RoundMii"); + if (original.IsFailure) + Assert.True(false, "Failed to create valid Mii for serialization test. + " + original.Error.Message); + + // Act + var serialized = MiiSerializer.Serialize(original.Value); + + // Assert serialization succeeded + Assert.True(serialized.IsSuccess); + + var deserializedResult = MiiSerializer.Deserialize(serialized.Value); + + // Assert deserialization succeeded + Assert.True(deserializedResult.IsSuccess); + + var deserialized = deserializedResult.Value; + + // Assert that key properties match + Assert.Equal(original.Value.MiiId, deserialized.MiiId); + Assert.Equal(original.Value.Name.ToString(), deserialized.Name.ToString()); + Assert.Equal(original.Value.Height.Value, deserialized.Height.Value); + Assert.Equal(original.Value.MiiFacial.FaceShape, deserialized.MiiFacial.FaceShape); + Assert.Equal(original.Value.MiiEyes.Type, deserialized.MiiEyes.Type); + Assert.Equal(original.Value.MiiGlasses.Type, deserialized.MiiGlasses.Type); + Assert.Equal(original.Value.MiiFacialHair.MustacheType, deserialized.MiiFacialHair.MustacheType); + Assert.Equal(original.Value.MiiMole.Exists, deserialized.MiiMole.Exists); + Assert.Equal(original.Value.CreatorName.ToString(), deserialized.CreatorName.ToString()); + Assert.Equal(original.Value.MiiFavoriteColor, deserialized.MiiFavoriteColor); + Assert.Equal(original.Value.Weight.Value, deserialized.Weight.Value); + Assert.Equal(original.Value.MiiHair.HairColor, deserialized.MiiHair.HairColor); + Assert.Equal(original.Value.MiiEyebrows.Color, deserialized.MiiEyebrows.Color); + Assert.Equal(original.Value.MiiNose.Type, deserialized.MiiNose.Type); + } + + [Fact] + public void GetAllMiis_ReturnsValidMiis_WhenRepositoryReturnsValidBlocks() + { + // Arrange: use the helper method to create a fully valid Mii. + var fullMii = CreateValidMii(); + if (fullMii.IsFailure) + Assert.True(false, "Failed to create valid Mii for GetAllMiis test."); + var serialized = MiiSerializer.Serialize(fullMii.Value); + Assert.True(serialized.IsSuccess, "Serialization failed for valid Mii."); + + _repository.LoadAllBlocks().Returns(new List { serialized.Value }); + + // Act + var result = _service.GetAllMiis(); + + // Assert + Assert.Single(result); + Assert.Equal("TestMii", result[0].Name.ToString()); + } + + [Fact] + public void GetByClientId_ReturnsMii_WhenRepositoryReturnsValidBlock() + { + // Arrange: create a valid Mii using the helper. + var fullMii = CreateValidMii(123); + if (fullMii.IsFailure) + Assert.True(false, "Failed to create valid Mii for GetByClientId test."); + var serialized = MiiSerializer.Serialize(fullMii.Value); + Assert.True(serialized.IsSuccess); + + _repository.GetRawBlockByClientId(123).Returns(serialized.Value); + + // Act + var result = _service.GetByClientId(123); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal("TestMii", result.Value.Name.ToString()); + } + + [Fact] + public void GetByClientId_ReturnsFailure_WhenRepositoryReturnsNullOrInvalidLength() + { + // Arrange: repository returns null. + _repository.GetRawBlockByClientId(123).Returns((byte[])null); + + // Act + var resultNull = _service.GetByClientId(123); + + // Assert + Assert.True(resultNull.IsFailure); + Assert.Equal("Mii block not found or invalid.", resultNull.Error.Message); + + // Arrange: repository returns an invalid block (wrong length) + _repository.GetRawBlockByClientId(123).Returns(new byte[10]); + + // Act + var resultInvalid = _service.GetByClientId(123); + + // Assert + Assert.True(resultInvalid.IsFailure); + Assert.Equal("Mii block not found or invalid.", resultInvalid.Error.Message); + } + + [Fact] + public void Update_ReturnsFailure_WhenRepositoryUpdateFails() + { + // Arrange: create a valid Mii using the helper. + var fullMii = CreateValidMii(123); + if (fullMii.IsFailure) + Assert.True(false, "Failed to create valid Mii for Update test."); + var serialized = MiiSerializer.Serialize(fullMii.Value); + Assert.True(serialized.IsSuccess); + + // Simulate repository update failure. + _repository.UpdateBlockByClientId(123, Arg.Any()) + .Returns(Fail("Update failed")); + + + // Act + var result = _service.Update(fullMii.Value); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal("Update failed", result.Error.Message); + } + + [Fact] + public void Update_ReturnsSuccess_WhenRepositoryUpdateSucceeds() + { + // Arrange: create a valid Mii using the helper. + var fullMii = CreateValidMii(321); + if (fullMii.IsFailure) + Assert.True(false, "Failed to create valid Mii for Update test."); + var serialized = MiiSerializer.Serialize(fullMii.Value); + Assert.True(serialized.IsSuccess); + + _repository.UpdateBlockByClientId(321, Arg.Any()) + .Returns(Ok()); + + // Act + var result = _service.Update(fullMii.Value); + + // Assert + Assert.True(result.IsSuccess); + } + + [Fact] + public void UpdateName_ReturnsFailure_WhenGetByClientIdFails() + { + // Arrange: repository returns null for the given clientId. + _repository.GetRawBlockByClientId(111).Returns((byte[])null); + + // Act + var result = _service.UpdateName(111, "NewName"); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal("Mii block not found or invalid.", result.Error.Message); + } + + [Fact] + public void UpdateName_ReturnsFailure_WhenNewNameIsInvalid() + { + // Arrange: valid Mii block exists. + var fullMii = CreateValidMii(222); + if (fullMii.IsFailure) + Assert.True(false, "Failed to create valid Mii for UpdateName test."); + var serialized = MiiSerializer.Serialize(fullMii.Value); + Assert.True(serialized.IsSuccess); + _repository.GetRawBlockByClientId(222).Returns(serialized.Value); + + // Act: attempt to update the name with an invalid value (too long). + var result = _service.UpdateName(222, "ThisNameIsWayTooLong"); + + // Assert: expect failure from MiiName.Create. + Assert.True(result.IsFailure); + Assert.Equal("Mii name too long, maximum is 10 characters", result.Error.Message); + } + + [Fact] + public void UpdateName_ReturnsSuccess_WhenNameIsUpdated() + { + // Arrange: valid Mii block exists. + var fullMii = CreateValidMii(333, "OldName"); + if (fullMii.IsFailure) + Assert.True(false, "Failed to create valid Mii for UpdateName test."); + var serialized = MiiSerializer.Serialize(fullMii.Value); + Assert.True(serialized.IsSuccess); + _repository.GetRawBlockByClientId(333).Returns(serialized.Value); + + // Simulate repository update success. + _repository.UpdateBlockByClientId(333, Arg.Any()) + .Returns(Ok()); + + // Act: update the name. + var result = _service.UpdateName(333, "NewName"); + + // Assert + Assert.True(result.IsSuccess); + } + } +} diff --git a/WheelWizard.Test/Features/MiiSerializerTests.cs b/WheelWizard.Test/Features/MiiSerializerTests.cs new file mode 100644 index 00000000..0a922cf7 --- /dev/null +++ b/WheelWizard.Test/Features/MiiSerializerTests.cs @@ -0,0 +1,126 @@ +using WheelWizard.WiiManagement; + +namespace WheelWizard.Test.Serialization; + +public class MiiSerializerTests +{ + // List of base64 strings that represent 100% valid Mii data blocks. + private readonly string[] dataList = { + "AAAAQgBlAGUAAAAAAAAAAAAAAAAAAEBAgeGIAcKv7BAABEJBMb0oogiMCEgUTbiNAIoAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "wBAASAOzA8EDtQByACADtQB4AAAAAAAAgAAAAAAAAAAgF4+gmVMm1SCSjpgAbWAvAIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gBYDngBxAHUAaQAAAAAAAAAAAAAAAH9QgAAAAAAAAAAAFxAAItQQPBiODhgIZVEPcKBhDSUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "wBbgFwBsAHUAbQBp4BcAAAAAAAAAAF89gAAAAAAAAAAAFTqAmY4IwSCQDngAbWAQZOAAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gAwAUQBGAFMARgBZAFMATQBHAAAAAAAAgAAAAAAAAACgbERAAKQHIEhvCTglXZitAIoAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gAAAbgBvACAAbgBhAG0AZQAAAAAAAEBAgAAAAuz/gtIEF0JAMZQoogiMCFgUTbiNAIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gBAARABhAHgAdABlAHIAAAAAAAAAAG5VgAAAAAAAAAAgF3hAAVQosgiMCFgUTbiNAIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gAgAbgBvACAAbgBhAG0AZQAAAAAAAEBAgAAAAOz/gtIQHogAMZcIogiMCFgUTbiNAIoAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gAoARABlAGUAbgBlAAAAAAAAAAAAAAAmgAAAAAAAAACALE/AuWQoolRRBPgAjUjNJnAAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gAomBgBNAGEAcgO6JmoAAAAAAAAAAEEmgAAAAAAAAAAAF2ZgMZQokgitCFgUVbJtgIoKiiTMAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gAzwYAAAAAAAAAAAAAAAAAAAAAAAAEBAgAAAAAAAAAAEDEIAMYUIogiMCFgTTbhtIGAAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gAgAbgBvACAAbgBhAG0AZQAAAAAAAEBAgAAAAOz/gtIQF4gAMZQIogiMCFgUTbiNAIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "wBbgFwBMA7EAbgBjAGUAWCEiAAAAAEBBgAAAAAAAAAAgFzoAuVMIooxQDlgAfZgPZOMAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "gBYATQBpAG4AaQBuAGcAAAAAAAAAAEBOgAAAAAAAAAAg10KAuRQoopSMSFiiTZhtIIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "wBAAZwBhAG4AZwBuAGUAdwB3AHMDyAAAgAAAAAAAAAAEbDZAqaQosmBsCFgUTQCNAAoAgCIFAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "wBIATABpAGMAbwByAGkAYwBlAAAAAAosgAAAAAAAAAAgTH5AuUUo8kiRCtgAbUALguAAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }; + + // Use MemberData to supply the valid Mii data to our tests. + public static IEnumerable ValidMiiData + { + get + { + yield return new object[] { "AAAAQgBlAGUAAAAAAAAAAAAAAAAAAEBAgeGIAcKv7BAABEJBMb0oogiMCEgUTbiNAIoAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "wBAASAOzA8EDtQByACADtQB4AAAAAAAAgAAAAAAAAAAgF4+gmVMm1SCSjpgAbWAvAIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gBYDngBxAHUAaQAAAAAAAAAAAAAAAH9QgAAAAAAAAAAAFxAAItQQPBiODhgIZVEPcKBhDSUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "wBbgFwBsAHUAbQBp4BcAAAAAAAAAAF89gAAAAAAAAAAAFTqAmY4IwSCQDngAbWAQZOAAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gAwAUQBGAFMARgBZAFMATQBHAAAAAAAAgAAAAAAAAACgbERAAKQHIEhvCTglXZitAIoAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gAAAbgBvACAAbgBhAG0AZQAAAAAAAEBAgAAAAuz/gtIEF0JAMZQoogiMCFgUTbiNAIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gBAARABhAHgAdABlAHIAAAAAAAAAAG5VgAAAAAAAAAAgF3hAAVQosgiMCFgUTbiNAIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gAgAbgBvACAAbgBhAG0AZQAAAAAAAEBAgAAAAOz/gtIQHogAMZcIogiMCFgUTbiNAIoAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gAoARABlAGUAbgBlAAAAAAAAAAAAAAAmgAAAAAAAAACALE/AuWQoolRRBPgAjUjNJnAAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gAomBgBNAGEAcgO6JmoAAAAAAAAAAEEmgAAAAAAAAAAAF2ZgMZQokgitCFgUVbJtgIoKiiTMAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gAzwYAAAAAAAAAAAAAAAAAAAAAAAAEBAgAAAAAAAAAAEDEIAMYUIogiMCFgTTbhtIGAAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gAgAbgBvACAAbgBhAG0AZQAAAAAAAEBAgAAAAOz/gtIQF4gAMZQIogiMCFgUTbiNAIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "wBbgFwBMA7EAbgBjAGUAWCEiAAAAAEBBgAAAAAAAAAAgFzoAuVMIooxQDlgAfZgPZOMAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "gBYATQBpAG4AaQBuAGcAAAAAAAAAAEBOgAAAAAAAAAAg10KAuRQoopSMSFiiTZhtIIoAiiUEAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "wBAAZwBhAG4AZwBuAGUAdwB3AHMDyAAAgAAAAAAAAAAEbDZAqaQosmBsCFgUTQCNAAoAgCIFAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + yield return new object[] { "wBIATABpAGMAbwByAGkAYwBlAAAAAAosgAAAAAAAAAAgTH5AuUUo8kiRCtgAbUALguAAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + } + } + + [Theory] + [MemberData(nameof(ValidMiiData))] + public void Deserialize_ValidMiiData_ShouldSucceed(string base64Data) + { + // Arrange: convert the base64 string into a byte array. + var data = Convert.FromBase64String(base64Data); + Assert.Equal(MiiSerializer.MiiBlockSize, data.Length); + + // Act: deserialize the byte array. + var result = MiiSerializer.Deserialize(data); + + // Assert: deserialization should succeed and key properties should be set. + Assert.True(result.IsSuccess, $"Deserialization failed for data: {base64Data}"); + var mii = result.Value; + Assert.NotNull(mii); + Assert.NotEqual(0u, mii.MiiId); + Assert.NotNull(mii.Name); + } + + [Theory] + [MemberData(nameof(ValidMiiData))] + public void RoundTrip_Serialization_ShouldBeConsistent(string base64Data) + { + // Arrange: decode and deserialize the original base64 data. + var originalBytes = Convert.FromBase64String(base64Data); + var deserializationResult = MiiSerializer.Deserialize(originalBytes); + Assert.True(deserializationResult.IsSuccess, "Deserialization of original data failed."); + var mii = deserializationResult.Value; + + // Act: serialize the Mii back into a byte array. + var serializationResult = MiiSerializer.Serialize(mii); + Assert.True(serializationResult.IsSuccess, "Serialization failed for the deserialized Mii."); + var roundTripBytes = serializationResult.Value; + Assert.Equal(MiiSerializer.MiiBlockSize, roundTripBytes.Length); + + // Re-deserialize the round-trip bytes. + var roundTripDeserialization = MiiSerializer.Deserialize(roundTripBytes); + Assert.True(roundTripDeserialization.IsSuccess, "Deserialization of round-trip data failed."); + var miiRoundTrip = roundTripDeserialization.Value; + + // Assert: key properties should be equal between the original and the round-trip Mii. + Assert.Equal(mii.MiiId, miiRoundTrip.MiiId); + Assert.Equal(mii.Name.ToString(), miiRoundTrip.Name.ToString()); + Assert.Equal(mii.Height.Value, miiRoundTrip.Height.Value); + Assert.Equal(mii.Weight.Value, miiRoundTrip.Weight.Value); + Assert.Equal(mii.MiiFacial.FaceShape, miiRoundTrip.MiiFacial.FaceShape); + Assert.Equal(mii.MiiEyes.Type, miiRoundTrip.MiiEyes.Type); + Assert.Equal(mii.MiiGlasses.Type, miiRoundTrip.MiiGlasses.Type); + Assert.Equal(mii.CreatorName.ToString(), miiRoundTrip.CreatorName.ToString()); + } + + [Fact] + public void Serialize_NullMii_ShouldFail() + { + // Act: attempt to serialize a null FullMii. + var result = MiiSerializer.Serialize(null); + + // Assert: the operation should fail with the proper error message. + Assert.True(result.IsFailure); + Assert.Equal("Mii cannot be null.", result.Error.Message); + } + + [Fact] + public void Deserialize_InvalidLengthData_ShouldFail() + { + // Arrange: create a byte array with an invalid length. + var invalidData = new byte[10]; + + // Act: attempt to deserialize the invalid data. + var result = MiiSerializer.Deserialize(invalidData); + + // Assert: the operation should fail because the length is not 74 bytes. + Assert.True(result.IsFailure); + Assert.Equal("Invalid Mii data length.", result.Error.Message); + } +} diff --git a/WheelWizard.Test/Features/WhWzDataTests.cs b/WheelWizard.Test/Features/WhWzDataTests.cs index 4c0d7e0a..4ff14c51 100644 --- a/WheelWizard.Test/Features/WhWzDataTests.cs +++ b/WheelWizard.Test/Features/WhWzDataTests.cs @@ -29,7 +29,7 @@ public async Task GetStatusAsync_ReturnsStatus_WhenApiCallSucceeds() _apiCaller .CallApiAsync(Arg.Any>>>()) - .Returns(OperationResult.Ok(expectedStatus)); + .Returns(Ok(expectedStatus)); // Act var result = await _service.GetStatusAsync(); @@ -49,7 +49,7 @@ public async Task GetStatusAsync_ReturnsFailure_WhenApiCallFails() _apiCaller .CallApiAsync(Arg.Any>>>()) - .Returns(OperationResult.Fail(expectedError)); + .Returns(Fail(expectedError)); // Act var result = await _service.GetStatusAsync(); @@ -72,7 +72,7 @@ public async Task LoadBadgesAsync_ReturnsSuccess_WhenApiCallSucceeds() _apiCaller .CallApiAsync(Arg.Any>>>>()) - .Returns(OperationResult.Ok(badgeData)); + .Returns(Ok(badgeData)); // Act var result = await _service.LoadBadgesAsync(); @@ -89,7 +89,7 @@ public async Task LoadBadgesAsync_ReturnsFailure_WhenApiCallFails() _apiCaller .CallApiAsync(Arg.Any>>>>()) - .Returns(OperationResult.Fail>(expectedError)); + .Returns(Fail>(expectedError)); // Act var result = await _service.LoadBadgesAsync(); @@ -110,7 +110,7 @@ public async Task GetBadges_ReturnsEmptyArray_WhenFriendCodeNotFound() _apiCaller .CallApiAsync(Arg.Any>>>>()) - .Returns(OperationResult.Ok(badgeData)); + .Returns(Ok(badgeData)); await _service.LoadBadgesAsync(); @@ -132,7 +132,7 @@ public async Task GetBadges_ReturnsBadges_WhenFriendCodeExists() _apiCaller .CallApiAsync(Arg.Any>>>>()) - .Returns(OperationResult.Ok(badgeData)); + .Returns(Ok(badgeData)); await _service.LoadBadgesAsync(); @@ -156,7 +156,7 @@ public async Task GetBadges_FiltersOutNoneBadges_WhenLoadingBadges() _apiCaller .CallApiAsync(Arg.Any>>>>()) - .Returns(OperationResult.Ok(badgeData)); + .Returns(Ok(badgeData)); await _service.LoadBadgesAsync(); @@ -180,7 +180,7 @@ public async Task LoadBadgesAsync_OverwritesExistingBadges_WhenCalledMultipleTim _apiCaller .CallApiAsync(Arg.Any>>>>()) - .Returns(OperationResult.Ok(initialBadgeData)); + .Returns(Ok(initialBadgeData)); await _service.LoadBadgesAsync(); @@ -197,7 +197,7 @@ public async Task LoadBadgesAsync_OverwritesExistingBadges_WhenCalledMultipleTim _apiCaller .CallApiAsync(Arg.Any>>>>()) - .Returns(OperationResult.Ok(updatedBadgeData)); + .Returns(Ok(updatedBadgeData)); // Act await _service.LoadBadgesAsync(); diff --git a/WheelWizard.Test/WheelWizard.Test.csproj b/WheelWizard.Test/WheelWizard.Test.csproj index dfc8c6dd..b332b5a0 100644 --- a/WheelWizard.Test/WheelWizard.Test.csproj +++ b/WheelWizard.Test/WheelWizard.Test.csproj @@ -16,6 +16,7 @@ + all diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs new file mode 100644 index 00000000..e1e0f596 --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs @@ -0,0 +1,46 @@ +using WheelWizard.Models.MiiImages; + +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class Mii +{ + //todo: Remove images out of class + private readonly Dictionary _images = new(); + + public MiiImage GetImage(MiiImageVariants.Variant variant) + { + if (!_images.ContainsKey(variant)) + _images[variant] = new MiiImage(this, variant); + return _images[variant]; + } + + public bool IsInvalid { get; set; } + public bool IsGirl { get; set; } + public DateOnly Date { get; set; } = new(2000, 1, 1); + public MiiFavoriteColor MiiFavoriteColor { get; set; } = MiiFavoriteColor.Black; + public bool IsFavorite { get; set; } + + public MiiName Name { get; set; } = new("no name"); + public MiiScale Height { get; set; } = new MiiScale(1); + public MiiScale Weight { get; set; } = new MiiScale(1); + + public uint MiiId { get; set; } + public byte SystemId0 { get; set; } + public byte SystemId1 { get; set; } + public byte SystemId2 { get; set; } + public byte SystemId3 { get; set; } + + public MiiFacialFeatures MiiFacial { get; set; } = + new MiiFacialFeatures(MiiFaceShape.Bread, MiiSkinColor.Light, MiiFacialFeature.None, false, false); + + public MiiHair MiiHair { get; set; } = new MiiHair(0, HairColor.Black, false); + public MiiEyebrow MiiEyebrows { get; set; } = new MiiEyebrow(0, 0, EyebrowColor.Black, 1, 1, 1); + public MiiEye MiiEyes { get; set; } = new MiiEye(0, 0, 0, EyeColor.Black, 0, 0); + public MiiNose MiiNose { get; set; } = new MiiNose(NoseType.Default, 0, 0); + public MiiLip MiiLips { get; set; } = new MiiLip(0, LipColor.Skin, 0, 0); + public MiiGlasses MiiGlasses { get; set; } = new MiiGlasses(GlassesType.None, GlassesColor.Dark, 0, 0); + public MiiFacialHair MiiFacialHair { get; set; } = new MiiFacialHair(MustacheType.None, BeardType.None, MustacheColor.Black, 0, 0); + public MiiMole MiiMole { get; set; } = new MiiMole(false, 0, 0, 0); + + public MiiName CreatorName { get; set; } = new MiiName("no name"); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEnums.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEnums.cs new file mode 100644 index 00000000..b1ddeadd --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEnums.cs @@ -0,0 +1,29 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public enum MiiFavoriteColor : uint { Red, Orange, Yellow, Green, Blue, LightBlue, Pink, Purple, Brown, White, Black, Gray } + +public enum MiiFaceShape { RoundPointChin, Circle, Oval, BlobFatChin, RightAnglePointChin, Bread, Octagon, Square } + +public enum MiiSkinColor { Light, LightTan, Tan, Pink, DarkBrown, Brown } + +public enum MiiFacialFeature { None, Cheeks, CheekAndEyes, Freckles, BaggyEyes, Chad, Tired, Chin, EyeShadow, Beard, MouthCorners, Old } + +public enum HairColor { Black, Brown, Red, LightRed, Grey, LightBrown, Blonde, White } + +public enum EyebrowColor { Black, Brown, Red, LightRed, Grey, LightBrown, Blonde, White } + +public enum EyeColor : uint { Black, Grey, Red, Gold, Blue, Green } + +public enum NoseType { Default, SemiCircle, Dots, VShape, FullNose, Triangle, FlatC, UpsideDownC, Squidward, ArrowDown, Flat, Tunnel } + +public enum LipColor { Skin, Red, Pink } + +public enum GlassesColor { Dark, DarkGold, Red, Blue, Gold, White } + +public enum GlassesType { None, Square, Rectangle, Circle, Oval, Misses, SadSunGlasses, SunGlasses, CoolSunGlasses } + +public enum MustacheColor { Black, Brown, Red, LightRed, Grey, LightBrown, Blonde, White } + +public enum MustacheType { None, Fat, Thin, Goatee } + +public enum BeardType { None, Thin, Wide, Widest } diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEye.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEye.cs new file mode 100644 index 00000000..52fecac3 --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEye.cs @@ -0,0 +1,29 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiEye +{ + public int Type { get; } + public int Rotation { get; } + public int Vertical { get; } + public EyeColor Color { get; } + public int Size { get; } + public int Spacing { get; } + + public MiiEye(int type, int rotation, int vertical, EyeColor color, int size, int spacing) + { + if (type is < 0 or > 47) throw new ArgumentException("Eye type invalid"); + if (rotation is < 0 or > 7) throw new ArgumentException("Rotation invalid"); + if (vertical is < 0 or > 18) throw new ArgumentException("Vertical position invalid"); + if (size is < 0 or > 7) throw new ArgumentException("Size invalid"); + if (spacing is < 0 or > 12) throw new ArgumentException("Spacing invalid"); + Type = type; + Rotation = rotation; + Vertical = vertical; + Color = color; + Size = size; + Spacing = spacing; + } + + public static OperationResult Create(int type, int rotation, int vertical, EyeColor color, int size, int spacing) + => TryCatch(() => new MiiEye(type, rotation, vertical, color, size, spacing)); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEyebrow.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEyebrow.cs new file mode 100644 index 00000000..49ec01b4 --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiEyebrow.cs @@ -0,0 +1,34 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiEyebrow +{ + public int Type { get; } + public int Rotation { get; } + public EyebrowColor Color { get; } + public int Size { get; } + public int Vertical { get; } + public int Spacing { get; } + + public MiiEyebrow(int type, int rotation, EyebrowColor color, int size, int vertical, int spacing) + { + if (type is < 0 or > 23) + throw new ArgumentException("Eyebrow type invalid"); + if (rotation is < 0 or > 11) + throw new ArgumentException("Rotation invalid"); + if (size is < 0 or > 8) + throw new ArgumentException("Size invalid"); + if (vertical is < 0 or > 18) + throw new ArgumentException("Vertical position invalid"); + if (spacing is < 0 or > 12) + throw new ArgumentException("Spacing invalid"); + Type = type; + Rotation = rotation; + Color = color; + Size = size; + Vertical = vertical; + Spacing = spacing; + } + + public static OperationResult Create(int type, int rotation, EyebrowColor color, int size, int vertical, int spacing) + => TryCatch(() => new MiiEyebrow(type, rotation, color, size, vertical, spacing)); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiFacialFeatures.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiFacialFeatures.cs new file mode 100644 index 00000000..329804af --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiFacialFeatures.cs @@ -0,0 +1,24 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiFacialFeatures +{ + public MiiFaceShape FaceShape { get; } + public MiiSkinColor SkinColor { get; } + public MiiFacialFeature FacialFeature { get; } + public bool MingleOff { get; } + public bool Downloaded { get; } + + public MiiFacialFeatures(MiiFaceShape faceShape, MiiSkinColor skinColor, MiiFacialFeature facialFeature, bool mingleOff, + bool downloaded) + { + FaceShape = faceShape; + SkinColor = skinColor; + FacialFeature = facialFeature; + MingleOff = mingleOff; + Downloaded = downloaded; + } + + public static OperationResult Create(MiiFaceShape faceShape, MiiSkinColor skinColor, MiiFacialFeature facialFeature, + bool mingleOff, bool downloaded) + => TryCatch(() => new MiiFacialFeatures(faceShape, skinColor, facialFeature, mingleOff, downloaded)); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiFacialHair.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiFacialHair.cs new file mode 100644 index 00000000..2a2557c2 --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiFacialHair.cs @@ -0,0 +1,25 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiFacialHair +{ + public MustacheType MustacheType { get; } + public BeardType BeardType { get; } + public MustacheColor Color { get; } + public int Size { get; } + public int Vertical { get; } + + public MiiFacialHair(MustacheType mustacheType, BeardType beardType, MustacheColor color, int size, int vertical) + { + if (size is < 0 or > 8) throw new ArgumentException("Facial hair size invalid"); + if (vertical is < 0 or > 16) throw new ArgumentException("Facial hair vertical position invalid"); + MustacheType = mustacheType; + BeardType = beardType; + Color = color; + Size = size; + Vertical = vertical; + } + + public static OperationResult Create(MustacheType mustacheType, BeardType beardType, MustacheColor color, int size, + int vertical) + => TryCatch(() => new MiiFacialHair(mustacheType, beardType, color, size, vertical)); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiGlasses.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiGlasses.cs new file mode 100644 index 00000000..8db0ceac --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiGlasses.cs @@ -0,0 +1,22 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiGlasses +{ + public GlassesType Type { get; } + public GlassesColor Color { get; } + public int Size { get; } + public int Vertical { get; } + + public MiiGlasses(GlassesType type, GlassesColor color, int size, int vertical) + { + if (size is < 0 or > 7) throw new ArgumentException("Glasses size invalid"); + if (vertical is < 0 or > 20) throw new ArgumentException("Glasses vertical position invalid"); + Type = type; + Color = color; + Size = size; + Vertical = vertical; + } + + public static OperationResult Create(GlassesType type, GlassesColor color, int size, int vertical) + => TryCatch(() => new MiiGlasses(type, color, size, vertical)); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiHair.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiHair.cs new file mode 100644 index 00000000..5e0e1a07 --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiHair.cs @@ -0,0 +1,20 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiHair +{ + public int HairType { get; } + public HairColor HairColor { get; } + public bool HairFlipped { get; } + + public MiiHair(int hairType, HairColor hairColor, bool hairFlipped) + { + if (hairType is < 0 or > 71) + throw new ArgumentException("HairType out of range"); + HairType = hairType; + HairColor = hairColor; + HairFlipped = hairFlipped; + } + + public static OperationResult Create(int hairType, HairColor hairColor, bool hairFlipped) => TryCatch(() + => new MiiHair(hairType, hairColor, hairFlipped)); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiLip.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiLip.cs new file mode 100644 index 00000000..4b9cb054 --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiLip.cs @@ -0,0 +1,24 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiLip +{ + public int Type { get; } + public LipColor Color { get; } + public int Size { get; } + public int Vertical { get; } + + public MiiLip(int type, LipColor color, int size, int vertical) + { + if (type is < 0 or > 23) throw new ArgumentException("Lip type invalid"); + if (size is < 0 or > 8) throw new ArgumentException("Lip size invalid"); + if (vertical is < 0 or > 18) throw new ArgumentException("Lip vertical position invalid"); + + Type = type; + Color = color; + Size = size; + Vertical = vertical; + } + + public static OperationResult Create(int type, LipColor color, int size, int vertical) + => TryCatch(() => new MiiLip(type, color, size, vertical)); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiMole.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiMole.cs new file mode 100644 index 00000000..a7fcea9d --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiMole.cs @@ -0,0 +1,23 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiMole +{ + public bool Exists { get; } + public int Size { get; } + public int Vertical { get; } + public int Horizontal { get; } + + public MiiMole(bool exists, int size, int vertical, int horizontal) + { + if (size is < 0 or > 8) throw new ArgumentException("Mole size invalid"); + if (vertical is < 0 or > 30) throw new ArgumentException("Mole vertical position invalid"); + if (horizontal is < 0 or > 16) throw new ArgumentException("Mole horizontal position invalid"); + Exists = exists; + Size = size; + Vertical = vertical; + Horizontal = horizontal; + } + + public static OperationResult Create(bool exists, int size, int vertical, int horizontal) + => TryCatch(() => new MiiMole(exists, size, vertical, horizontal)); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiName.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiName.cs new file mode 100644 index 00000000..b9e17986 --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiName.cs @@ -0,0 +1,41 @@ +using System.Text; + +namespace WheelWizard.WiiManagement.Domain.Mii; + +/// +/// Represents a Mii name. +/// +public class MiiName +{ + private readonly string _value; + + /// + /// Initializes a new instance of the class with the specified value. + /// + /// The Mii name value. + /// Mii name cannot be empty or longer than 10 characters. + public MiiName(string value) + { + //Mii names are allowed to be empty since creators can be empty + if (value == null) + throw new ArgumentException("Mii name cannot be null"); + + if (value.Length > 10) + throw new ArgumentException("Mii name too long, maximum is 10 characters"); + _value = value; + } + + /// + /// Creates a new instance of the class with the specified value. + /// + /// The Mii name value. + /// An representing the result of the operation. + public static OperationResult Create(string value) => TryCatch(() => new MiiName(value)); + + public byte[] ToBytes() => Encoding.BigEndianUnicode.GetBytes(_value.PadRight(10, '\0')); + + public static MiiName FromBytes(byte[] data, int offset) => + new(Encoding.BigEndianUnicode.GetString(data, offset, 20).TrimEnd('\0')); + + public override string ToString() => _value; +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiNose.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiNose.cs new file mode 100644 index 00000000..e3654cec --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiNose.cs @@ -0,0 +1,20 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiNose +{ + public NoseType Type { get; } + public int Size { get; } + public int Vertical { get; } + + public MiiNose(NoseType type, int size, int vertical) + { + if (size is < 0 or > 8) throw new ArgumentException("Nose size invalid"); + if (vertical is < 0 or > 18) throw new ArgumentException("Nose vertical position invalid"); + Type = type; + Size = size; + Vertical = vertical; + } + + public static OperationResult Create(NoseType type, int size, int vertical) + => TryCatch(() => new MiiNose(type, size, vertical)); +} diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/MiiScale.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiScale.cs new file mode 100644 index 00000000..46e6d15c --- /dev/null +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/MiiScale.cs @@ -0,0 +1,15 @@ +namespace WheelWizard.WiiManagement.Domain.Mii; + +public class MiiScale +{ + public byte Value { get; } + + public MiiScale(byte value) + { + if (value > 127) + throw new ArgumentException("Scale must be between 0 and 127."); + Value = value; + } + + public static OperationResult Create(byte value) => TryCatch(() => new MiiScale(value)); +} diff --git a/WheelWizard/Features/WiiManagement/GameDataLoaderService.cs b/WheelWizard/Features/WiiManagement/GameDataLoaderService.cs new file mode 100644 index 00000000..563339a2 --- /dev/null +++ b/WheelWizard/Features/WiiManagement/GameDataLoaderService.cs @@ -0,0 +1,459 @@ +using System.IO.Abstractions; +using System.Text; +using System.Text.RegularExpressions; +using WheelWizard.Models.Enums; +using WheelWizard.Models.GameData; +using WheelWizard.Services; +using WheelWizard.Services.LiveData; +using WheelWizard.Services.Other; +using WheelWizard.Services.Settings; +using WheelWizard.Services.WiiManagement.SaveData; +using WheelWizard.Utilities.Generators; +using WheelWizard.Utilities.RepeatedTasks; +using WheelWizard.Views; +using WheelWizard.Views.Popups.Generic; +using WheelWizard.WheelWizardData; +using WheelWizard.WiiManagement.Domain.Mii; + +namespace WheelWizard.WiiManagement; + +// big big thanks to https://kazuki-4ys.github.io/web_apps/FaceThief/ for the JS implementation +public interface IGameDataSingletonService +{ + /// + /// Gets the currently loaded . + /// + LicenseCollection LicenseCollection { get; } + + /// + /// Loads the game data from the rksys.dat file. + /// + OperationResult LoadGameData(); + + /// + /// Retrieves the user data for a specific index. + /// + /// Index of user (1-3) + LicenseProfile GetUserData(int index); + + /// + /// Gets the currently selected user. + /// + LicenseProfile CurrentUser { get; } + + /// + /// Gets the list of friends for the currently selected user. + /// + List CurrentFriends { get; } + + + /// + /// Checks if any user is valid (i.e., has a non-empty friend code). + /// + bool HasAnyValidUsers { get; } + + /// + /// Refreshes the online status of the users based on the current live rooms. + /// + void RefreshOnlineStatus(); + + /// + /// Changes the name of a Mii for a specific user index. + /// + OperationResult ChangeMiiName(int userIndex, string newName); + + /// + /// Subscribes a listener to the repeated task manager. + /// + void Subscribe(IRepeatedTaskListener subscriber); +} + +public class GameDataSingletonService : RepeatedTaskManager, IGameDataSingletonService +{ + private readonly IMiiDbService _miiService; + private readonly IFileSystem _fileSystem; + private readonly IWhWzDataSingletonService _whWzDataSingletonService; + private LicenseCollection UserList { get; } + private byte[]? _saveData; + + public GameDataSingletonService(IMiiDbService miiService, IFileSystem fileSystem, IWhWzDataSingletonService whWzDataSingletonService) : + base(40) + { + _miiService = miiService; + _fileSystem = fileSystem; + _whWzDataSingletonService = whWzDataSingletonService; + UserList = new LicenseCollection(); + } + + + private const int RksysSize = 0x2BC000; + private const string RksysMagic = "RKSD0006"; + private const int MaxPlayerNum = 4; + private const int RkpdSize = 0x8CC0; + private const string RkpdMagic = "RKPD"; + private const int MaxFriendNum = 30; + private const int FriendDataOffset = 0x56D0; + private const int FriendDataSize = 0x1C0; + private const int MiiSize = 0x4A; + + /// + /// Returns the "focused" or currently active license/user as determined by the Settings. + /// + public LicenseProfile CurrentUser => UserList.Users[(int)SettingsManager.FOCUSSED_USER.Get()]; + + public List CurrentFriends => UserList.Users[(int)SettingsManager.FOCUSSED_USER.Get()].Friends; + + public LicenseCollection LicenseCollection => UserList; + + public LicenseProfile GetUserData(int index) + => UserList.Users[index]; + + public bool HasAnyValidUsers + => UserList.Users.Any(user => user.FriendCode != "0000-0000-0000"); + + public void RefreshOnlineStatus() + { + var currentRooms = RRLiveRooms.Instance.CurrentRooms; + var onlinePlayers = currentRooms.SelectMany(room => room.Players.Values).ToList(); + foreach (var user in UserList.Users) + { + user.IsOnline = onlinePlayers.Any(player => player.Fc == user.FriendCode); + } + } + + public OperationResult LoadGameData() + { + var loadSaveDataResult = LoadSaveDataFile(); + if (loadSaveDataResult.IsFailure) + _saveData = null; + else + _saveData = loadSaveDataResult.Value; + if (_saveData != null && ValidateMagicNumber()) + { + var result = ParseUsers(); + if (result.IsFailure) + return result; + return Ok(); + } + + // If the file was invalid or not found, create 4 dummy licenses + UserList.Users.Clear(); + for (var i = 0; i < MaxPlayerNum; i++) + UserList.Users.Add(CreateDummyUser()); + return Ok(); + } + + private LicenseProfile CreateDummyUser() + { + var noLicenseName = new MiiName("no license"); + var dummyUser = new LicenseProfile + { + FriendCode = "0000-0000-0000", + MiiData = new MiiData { Mii = new Mii { Name = noLicenseName, }, AvatarId = 0, ClientId = 0 }, + Vr = 5000, + Br = 5000, + TotalRaceCount = 0, + TotalWinCount = 0, + Friends = new List(), + RegionId = 10, // 10 => “unknown” + IsOnline = false + }; + return dummyUser; + } + + private OperationResult ParseUsers() + { + UserList.Users.Clear(); + if (_saveData == null) return new ArgumentNullException(nameof(_saveData)); + + for (var i = 0; i < MaxPlayerNum; i++) + { + var rkpdOffset = RksysMagic.Length + i * RkpdSize; + var rkpdCheck = Encoding.ASCII.GetString(_saveData, rkpdOffset, RkpdMagic.Length) == RkpdMagic; + if (!rkpdCheck) + continue; + + var user = ParseUser(rkpdOffset); + if (user.IsFailure) + continue; + UserList.Users.Add(user.Value); + } + + while (UserList.Users.Count < 4) + { + UserList.Users.Add(CreateDummyUser()); + } + + return Ok(); + } + + private OperationResult ParseUser(int offset) + { + if (_saveData == null) return new ArgumentNullException(nameof(_saveData)); + + var friendCode = FriendCodeGenerator.GetFriendCode(_saveData, offset + 0x5C); + var miiDataResult = ParseMiiData(offset + 0x14); + if (miiDataResult.IsFailure) + return miiDataResult.Error; + var user = new LicenseProfile + { + MiiData = miiDataResult.Value, + FriendCode = friendCode, + Vr = BigEndianBinaryReader.BufferToUint16(_saveData, offset + 0xB0), + Br = BigEndianBinaryReader.BufferToUint16(_saveData, offset + 0xB2), + TotalRaceCount = BigEndianBinaryReader.BufferToUint32(_saveData, offset + 0xB4), + TotalWinCount = BigEndianBinaryReader.BufferToUint32(_saveData, offset + 0xDC), + BadgeVariants = _whWzDataSingletonService.GetBadges(friendCode), + // Region is often found near offset 0x23308 + 0x3802 in RKGD. This code is a partial guess. + // In practice, region might be read differently depending on your rksys layout. + RegionId = BigEndianBinaryReader.BufferToUint16(_saveData, 0x23308 + 0x3802) / 4096, + }; + + ParseFriends(user, offset); + return user; + } + + private OperationResult ParseMiiData(int offset) + { + if (_saveData == null) return new ArgumentNullException(nameof(_saveData)); + + // In Mario Kart Wii's rksys, offset +0x10 => AvatarId, offset +0x14 => ClientId + // The name is big-endian UTF-16 at offset itself (length 10 chars => 20 bytes). + var name = BigEndianBinaryReader.GetUtf16String(_saveData, offset, 10); + var avatarId = BitConverter.ToUInt32(_saveData, offset + 0x10); + var clientId = BitConverter.ToUInt32(_saveData, offset + 0x14); + + var rawMiiResult = _miiService.GetByClientId(clientId); + if (rawMiiResult.IsFailure) + return new FormatException("Failed to parse mii data: " + rawMiiResult.Error.Message); + + var miiData = new MiiData { Mii = rawMiiResult.Value, AvatarId = avatarId, ClientId = clientId }; + return miiData; + } + + private void ParseFriends(LicenseProfile licenseProfile, int userOffset) + { + if (_saveData == null) return; + + var friendOffset = userOffset + FriendDataOffset; + for (var i = 0; i < MaxFriendNum; i++) + { + var currentOffset = friendOffset + i * FriendDataSize; + if (!CheckForMiiData(currentOffset + 0x1A)) continue; + byte[] rawMiiBytes = _saveData.AsSpan(currentOffset + 0x1A, MiiSize).ToArray(); + var friendCode = FriendCodeGenerator.GetFriendCode(_saveData, currentOffset + 4); + var friend = new FriendProfile + { + Vr = BigEndianBinaryReader.BufferToUint16(_saveData, currentOffset + 0x16), + Br = BigEndianBinaryReader.BufferToUint16(_saveData, currentOffset + 0x18), + FriendCode = friendCode, + Wins = BigEndianBinaryReader.BufferToUint16(_saveData, currentOffset + 0x14), + Losses = BigEndianBinaryReader.BufferToUint16(_saveData, currentOffset + 0x12), + CountryCode = _saveData[currentOffset + 0x68], + RegionId = _saveData[currentOffset + 0x69], + BadgeVariants = _whWzDataSingletonService.GetBadges(friendCode), + MiiData = new MiiData { Mii = MiiSerializer.Deserialize(rawMiiBytes).Value, AvatarId = 0, ClientId = 0 }, + }; + licenseProfile.Friends.Add(friend); + } + } + + private bool CheckForMiiData(int offset) + { + // If the entire 0x4A bytes are zero, we treat it as empty / no Mii data + for (var i = 0; i < MiiSize; i++) + { + if (_saveData != null && _saveData[offset + i] != 0) + return true; + } + + return false; + } + + private bool ValidateMagicNumber() + { + if (_saveData == null) return false; + return Encoding.ASCII.GetString(_saveData, 0, RksysMagic.Length) == RksysMagic; + } + + private OperationResult LoadSaveDataFile() + { + try + { + if (!Directory.Exists(PathManager.SaveFolderPath)) + return "Save folder not found"; + + var currentRegion = (MarioKartWiiEnums.Regions)SettingsManager.RR_REGION.Get(); + if (currentRegion == MarioKartWiiEnums.Regions.None) + { + // Double check if there's at least one valid region + var validRegions = RRRegionManager.GetValidRegions(); + if (validRegions.First() != MarioKartWiiEnums.Regions.None) + { + currentRegion = validRegions.First(); + SettingsManager.RR_REGION.Set(currentRegion); + } + else + { + return "No valid regions found"; + } + } + + var saveFileFolder = _fileSystem.Path.Combine(PathManager.SaveFolderPath, RRRegionManager.ConvertRegionToGameId(currentRegion)); + var saveFile = _fileSystem.Directory.GetFiles(saveFileFolder, "rksys.dat", SearchOption.TopDirectoryOnly); + if (saveFile.Length == 0) + return "rksys.dat not found"; + return _fileSystem.File.ReadAllBytes(saveFile[0]); + } + catch + { + return "Failed to load rksys.dat"; + } + } + + /// + /// Calculates the CRC32 of the specified slice of bytes using the + /// standard polynomial (0xEDB88320) in the same way MKWii does. + /// + public static uint ComputeCrc32(byte[] data, int offset, int length) + { + const uint POLY = 0xEDB88320; + var crc = 0xFFFFFFFF; + + for (var i = offset; i < offset + length; i++) + { + var b = data[i]; + crc ^= b; + for (var j = 0; j < 8; j++) + { + if ((crc & 1) != 0) + crc = (crc >> 1) ^ POLY; + else + crc >>= 1; + } + } + + return ~crc; + } + + + /// + /// Fixes the MKWii save file by recalculating and inserting the CRC32 at 0x27FFC. + /// + public static void FixRksysCrc(byte[] rksysData) + { + if (rksysData == null || rksysData.Length < RksysSize) + throw new ArgumentException("Invalid rksys.dat data"); + + var lengthToCrc = 0x27FFC; + var newCrc = ComputeCrc32(rksysData, 0, lengthToCrc); + + // 2) Write CRC at offset 0x27FFC in big-endian. + BigEndianBinaryReader.WriteUInt32BigEndian(rksysData, 0x27FFC, newCrc); + } + + public OperationResult ChangeMiiName(int userIndex, string? newName) + { + if (string.IsNullOrWhiteSpace(newName)) + return "Cannot set name to an empty name."; + if (userIndex is < 0 or >= MaxPlayerNum) + return "Invalid license index. Please select a valid license."; + + var user = UserList.Users[userIndex]; + var miiIsEmptyOrNoName = IsNoNameOrEmptyMii(user); + + + if (miiIsEmptyOrNoName) + return "This license has no Mii data or is incomplete.\n" + + "Please use the Mii Channel to create a Mii first."; + + if (user.MiiData?.Mii == null) + return "This license has no Mii data or is incomplete.\n" + + "Please use the Mii Channel to create a Mii first."; + + + newName = Regex.Replace(newName, @"\s+", " "); + + // Basic checks + if (newName.Length is > 10 or < 3) + return "Names must be between 3 and 10 characters long."; + + if (newName.Length > 10) + newName = newName.Substring(0, 10); + var nameResult = MiiName.Create(newName); + if (nameResult.IsFailure) + return nameResult.Error.Message; + + + user.Mii.Name = nameResult.Value; + var nameWrite = WriteLicenseNameToSaveData(userIndex, newName); + if (nameWrite.IsFailure) + return nameWrite.Error.Message; + var updated = _miiService.UpdateName(user.MiiData.ClientId, newName); + if (updated.IsFailure) + return updated.Error.Message; + var rksysSaveResult = SaveRksysToFile(); + if (rksysSaveResult.IsFailure) + return rksysSaveResult.Error.Message; + + return Ok(); + } + + private bool IsNoNameOrEmptyMii(LicenseProfile user) + { + if (user?.MiiData?.Mii == null) + return true; + + var name = user.MiiData.Mii.Name; + if (name.ToString() == "no name") + return true; + var raw = MiiSerializer.Serialize(user.MiiData.Mii).Value; + if (raw.Length != 74) return true; // Not valid + if (raw.All(b => b == 0)) return true; + + // Otherwise, it’s presumably valid + return false; + } + + private OperationResult WriteLicenseNameToSaveData(int userIndex, string newName) + { + if (_saveData == null || _saveData.Length < RksysSize) + return "Invalid save data"; + var rkpdOffset = 0x8 + userIndex * RkpdSize; + var nameOffset = rkpdOffset + 0x14; + var nameBytes = Encoding.BigEndianUnicode.GetBytes(newName); + for (var i = 0; i < 20; i++) + _saveData[nameOffset + i] = 0; + Array.Copy(nameBytes, 0, _saveData, nameOffset, Math.Min(nameBytes.Length, 20)); + return Ok(); + } + + private OperationResult SaveRksysToFile() + { + if (_saveData == null || !SettingsHelper.PathsSetupCorrectly()) return Fail("Invalid save data or config is not setup properly."); + FixRksysCrc(_saveData); + var currentRegion = (MarioKartWiiEnums.Regions)SettingsManager.RR_REGION.Get(); + var saveFolder = _fileSystem.Path.Combine(PathManager.SaveFolderPath, RRRegionManager.ConvertRegionToGameId(currentRegion)); + var trySaveRksys = TryCatch(() => + { + _fileSystem.Directory.CreateDirectory(saveFolder); + var path = _fileSystem.Path.Combine(saveFolder, "rksys.dat"); + _fileSystem.File.WriteAllBytes(path, _saveData); + }); + if (trySaveRksys.IsFailure) + return trySaveRksys.Error.Message; + return Ok(); + } + + + protected override Task ExecuteTaskAsync() + { + var result = LoadGameData(); + if (result.IsFailure) + { + throw new Exception(result.Error.Message); + } + + return Task.CompletedTask; + } +} diff --git a/WheelWizard/Features/WiiManagement/MiiDbService.cs b/WheelWizard/Features/WiiManagement/MiiDbService.cs new file mode 100644 index 00000000..fff0060e --- /dev/null +++ b/WheelWizard/Features/WiiManagement/MiiDbService.cs @@ -0,0 +1,97 @@ +using WheelWizard.WiiManagement.Domain; +using WheelWizard.WiiManagement.Domain.Mii; + +namespace WheelWizard.WiiManagement; + +/// +/// Provides high-level operations for managing Miis in the Wii Mii database. +/// +public interface IMiiDbService +{ + /// + /// Retrieves all Miis stored in the database. + /// + /// A list of fully deserialized instances. + List GetAllMiis(); + + /// + /// Retrieves a specific Mii from the database using its unique client ID. + /// + /// The unique identifier of the Mii to retrieve. + /// An containing the if found and valid. + OperationResult GetByClientId(uint clientId); + + /// + /// Updates an existing Mii in the database with new data. + /// + /// The updated object to store. + /// An indicating success or failure. + OperationResult Update(Mii updatedMii); + + /// + /// Updates the name of an existing Mii in the database. + /// + /// The unique identifier of the Mii to update. + /// The new name to assign to the Mii. + /// An indicating success or failure. + OperationResult UpdateName(uint clientId, string newName); +} + +public class MiiDbService : IMiiDbService +{ + private readonly IMiiRepository _repository; + + public MiiDbService(IMiiRepository repository) + { + _repository = repository; + } + + public List GetAllMiis() + { + var result = new List(); + var blocks = _repository.LoadAllBlocks(); + + foreach (var block in blocks) + { + var miiResult = MiiSerializer.Deserialize(block); + if (miiResult.IsSuccess) + result.Add(miiResult.Value); + } + + return result; + } + + public OperationResult GetByClientId(uint clientId) + { + var raw = _repository.GetRawBlockByClientId(clientId); + if (raw == null || raw.Length != MiiSerializer.MiiBlockSize) + return "Mii block not found or invalid."; + + return MiiSerializer.Deserialize(raw); + } + + public OperationResult Update(Mii updatedMii) + { + var serialized = MiiSerializer.Serialize(updatedMii); + if (serialized.IsFailure) + return serialized; + var value = _repository.UpdateBlockByClientId(updatedMii.MiiId, serialized.Value); + return value; + } + + public OperationResult UpdateName(uint clientId, string newName) + { + var result = GetByClientId(clientId); + if (result.IsFailure) + return result; + + var mii = result.Value; + + var nameResult = MiiName.Create(newName); + if (nameResult.IsFailure) + return nameResult; + + mii.Name = nameResult.Value; + return Update(mii); + } +} diff --git a/WheelWizard/Features/WiiManagement/MiiRepositoryService.cs b/WheelWizard/Features/WiiManagement/MiiRepositoryService.cs new file mode 100644 index 00000000..ecf2d209 --- /dev/null +++ b/WheelWizard/Features/WiiManagement/MiiRepositoryService.cs @@ -0,0 +1,180 @@ +using System.IO.Abstractions; +using WheelWizard.Services; +using WheelWizard.Services.WiiManagement.SaveData; + +namespace WheelWizard.WiiManagement; + +public interface IMiiRepository +{ + /// + /// Loads all 100 Mii data blocks from the Wii Mii database + /// Returns a list of byte arrays, each representing a Mii block. + /// + List LoadAllBlocks(); + + /// + /// Saves all Mii data blocks to the Wii Mii database. + /// Automatically Pads to 100 entries and calculates CRC. + /// + /// List of a raw 74 Byte-array Representing a Mii. + OperationResult SaveAllBlocks(List blocks); + + /// + /// Retrieves a raw Mii block by its unique client ID. + /// returns null if the Mii is not found. + /// + /// The Mii's unique client Id + byte[]? GetRawBlockByClientId(uint clientId); + + /// + /// Replaces a Mii block in the database that matches the given ID. + /// + /// The unique ID of the mii to search for + /// the new raw Mii data + OperationResult UpdateBlockByClientId(uint clientId, byte[] newBlock); +} + +public class MiiRepositoryService(IFileSystem fileSystem) : IMiiRepository +{ + private const int MiiLength = 74; + private const int MaxMiiSlots = 100; + private const int CrcOffset = 0x1F1DE; + private const int HeaderOffset = 0x04; + private static readonly byte[] EmptyMii = new byte[MiiLength]; + private readonly string _filePath = PathManager.WiiDbFile; + + public List LoadAllBlocks() + { + var result = new List(); + + var database = ReadDatabase(); + if (database.Length < HeaderOffset) + return result; + + using var ms = new MemoryStream(database); + ms.Seek(HeaderOffset, SeekOrigin.Begin); + + for (var i = 0; i < MaxMiiSlots; i++) + { + var block = new byte[MiiLength]; + var read = ms.Read(block, 0, MiiLength); + if (read < MiiLength) + break; + + result.Add(block.SequenceEqual(EmptyMii) ? new byte[MiiLength] : block); + } + + return result; + } + + public OperationResult SaveAllBlocks(List blocks) + { + if (!fileSystem.File.Exists(_filePath)) + return "RFL_DB.dat not found."; + + var db = ReadDatabase(); + using var ms = new MemoryStream(db); + ms.Seek(HeaderOffset, SeekOrigin.Begin); + + for (int i = 0; i < MaxMiiSlots; i++) + { + var block = i < blocks.Count ? blocks[i] : EmptyMii; + ms.Write(block, 0, MiiLength); + } + + if (db.Length >= CrcOffset + 2) + { + var crc = CalculateCrc16(db, 0, CrcOffset); + db[CrcOffset] = (byte)(crc >> 8); + db[CrcOffset + 1] = (byte)(crc & 0xFF); + } + + fileSystem.File.WriteAllBytes(_filePath, db); + return Ok(); + } + + public byte[]? GetRawBlockByClientId(uint clientId) + { + if (clientId == 0) return null; + + var blocks = LoadAllBlocks(); + foreach (var block in blocks) + { + if (block.Length != MiiLength) + continue; + + var thisId = BigEndianBinaryReader.ReadLittleEndianUInt32(block, 0x18); + if (thisId == clientId) + return block; + } + + return null; + } + + public OperationResult UpdateBlockByClientId(uint clientId, byte[] newBlock) + { + if (clientId == 0) + return "Invalid ClientId."; + if (newBlock.Length != MiiLength) + return "Mii block size invalid."; + if (!fileSystem.File.Exists(_filePath)) + return "RFL_DB.dat not found."; + + var allBlocks = LoadAllBlocks(); + var updated = false; + + for (int i = 0; i < allBlocks.Count; i++) + { + var block = allBlocks[i]; + if (block.Length != MiiLength) + continue; + + var thisId = BigEndianBinaryReader.ReadLittleEndianUInt32(block, 0x18); + if (thisId != clientId) + continue; + + Array.Copy(newBlock, 0, allBlocks[i], 0, MiiLength); + updated = true; + break; + } + + if (!updated) + return Fail("Mii not found."); + + return SaveAllBlocks(allBlocks); + } + + private byte[] ReadDatabase() + { + try + { + return fileSystem.File.Exists(_filePath) + ? fileSystem.File.ReadAllBytes(_filePath) + : Array.Empty(); + } + catch + { + return Array.Empty(); + } + } + + private static ushort CalculateCrc16(byte[] data, int offset, int length) + { + const ushort polynomial = 0x1021; + ushort crc = 0x0000; + + for (int i = offset; i < offset + length; i++) + { + crc ^= (ushort)(data[i] << 8); + for (int j = 0; j < 8; j++) + { + if ((crc & 0x8000) != 0) + crc = (ushort)((crc << 1) ^ polynomial); + else + crc <<= 1; + } + } + + return crc; + } +} diff --git a/WheelWizard/Features/WiiManagement/MiiSerializer.cs b/WheelWizard/Features/WiiManagement/MiiSerializer.cs new file mode 100644 index 00000000..6835c0af --- /dev/null +++ b/WheelWizard/Features/WiiManagement/MiiSerializer.cs @@ -0,0 +1,331 @@ +using System.Text; +using WheelWizard.WiiManagement.Domain.Mii; + +namespace WheelWizard.WiiManagement; + +public static class MiiSerializer +{ + public const int MiiBlockSize = 74; + + public static OperationResult Serialize(Mii? mii) + { + if (mii == null || mii.MiiId == 0) + return Fail("Mii cannot be null."); + byte[] data = new byte[MiiBlockSize]; + + // Header (0x00 - 0x01) + ushort header = 0; + if (mii.IsInvalid) header |= 0x8000; + if (mii.IsGirl) header |= 0x4000; + header |= (ushort)((mii.Date.Month & 0x0F) << 10); + header |= (ushort)((mii.Date.Day & 0x1F) << 5); + header |= (ushort)(((int)mii.MiiFavoriteColor & 0x0F) << 1); + if (mii.IsFavorite) header |= 0x1; + data[0] = (byte)(header >> 8); + data[1] = (byte)(header & 0xFF); + + // Name (0x02 - 0x15) + Buffer.BlockCopy(mii.Name.ToBytes(), 0, data, 2, 20); + + // Height & Weight (0x16 - 0x17) + data[0x16] = mii.Height.Value; + data[0x17] = mii.Weight.Value; + + // Mii ID (0x18 - 0x1B) + BitConverter.GetBytes(mii.MiiId).CopyTo(data, 0x18); + + // System ID (0x1C - 0x1F) + data[0x1C] = mii.SystemId0; + data[0x1D] = mii.SystemId1; + data[0x1E] = mii.SystemId2; + data[0x1F] = mii.SystemId3; + + // Face (0x20 - 0x21) + ushort face = 0; + face |= (ushort)(((int)mii.MiiFacial.FaceShape & 0x07) << 13); + face |= (ushort)(((int)mii.MiiFacial.SkinColor & 0x07) << 10); + face |= (ushort)(((int)mii.MiiFacial.FacialFeature & 0x0F) << 6); + face |= (ushort)((mii.MiiFacial.MingleOff ? 1 : 0) << 2); + face |= (ushort)((mii.MiiFacial.Downloaded ? 1 : 0)); + data[0x20] = (byte)(face >> 8); + data[0x21] = (byte)(face & 0xFF); + + // Hair (0x22 - 0x23) + ushort hair = 0; + hair |= (ushort)((mii.MiiHair.HairType & 0x7F) << 9); + hair |= (ushort)(((int)mii.MiiHair.HairColor & 0x07) << 6); + hair |= (ushort)((mii.MiiHair.HairFlipped ? 1 : 0) << 5); + data[0x22] = (byte)(hair >> 8); + data[0x23] = (byte)(hair & 0xFF); + + // Eyebrows (0x24 - 0x27) + uint brow = 0; + brow |= (uint)(mii.MiiEyebrows.Type & 0x1F) << 27; + brow |= (uint)(mii.MiiEyebrows.Rotation & 0x0F) << 22; + brow |= (uint)((int)mii.MiiEyebrows.Color & 0x07) << 13; + brow |= (uint)(mii.MiiEyebrows.Size & 0x0F) << 9; + brow |= (uint)(mii.MiiEyebrows.Vertical & 0x1F) << 4; + brow |= (uint)(mii.MiiEyebrows.Spacing & 0x0F); + data[0x24] = (byte)(brow >> 24); + data[0x25] = (byte)(brow >> 16); + data[0x26] = (byte)(brow >> 8); + data[0x27] = (byte)(brow); + + // Eyes (0x28 - 0x2B) + uint eye = 0; + eye |= (uint)(mii.MiiEyes.Type & 0x3F) << 26; + eye |= (uint)(mii.MiiEyes.Rotation & 0x07) << 21; + eye |= (uint)(mii.MiiEyes.Vertical & 0x1F) << 16; + eye |= (uint)((int)mii.MiiEyes.Color & 0x07) << 13; + eye |= (uint)(mii.MiiEyes.Size & 0x07) << 9; + eye |= (uint)(mii.MiiEyes.Spacing & 0x0F) << 5; + data[0x28] = (byte)(eye >> 24); + data[0x29] = (byte)(eye >> 16); + data[0x2A] = (byte)(eye >> 8); + data[0x2B] = (byte)(eye); + + // Nose (0x2C - 0x2D) + ushort nose = 0; + nose |= (ushort)(((int)mii.MiiNose.Type & 0x0F) << 12); + nose |= (ushort)((mii.MiiNose.Size & 0x0F) << 8); + nose |= (ushort)((mii.MiiNose.Vertical & 0x1F) << 3); + data[0x2C] = (byte)(nose >> 8); + data[0x2D] = (byte)(nose & 0xFF); + + // Lips (0x2E - 0x2F) + ushort lip = 0; + lip |= (ushort)((mii.MiiLips.Type & 0x1F) << 11); + lip |= (ushort)(((int)mii.MiiLips.Color & 0x03) << 9); + lip |= (ushort)((mii.MiiLips.Size & 0x0F) << 5); + lip |= (ushort)((mii.MiiLips.Vertical & 0x1F)); + data[0x2E] = (byte)(lip >> 8); + data[0x2F] = (byte)(lip & 0xFF); + + // Glasses (0x30 - 0x31) + ushort glasses = 0; + glasses |= (ushort)(((int)mii.MiiGlasses.Type & 0x0F) << 12); + glasses |= (ushort)(((int)mii.MiiGlasses.Color & 0x07) << 9); + glasses |= (ushort)((mii.MiiGlasses.Size & 0x07) << 5); + glasses |= (ushort)((mii.MiiGlasses.Vertical & 0x1F)); + data[0x30] = (byte)(glasses >> 8); + data[0x31] = (byte)(glasses & 0xFF); + + // Facial hair (0x32 - 0x33) + ushort facialHair = 0; + facialHair |= (ushort)(((int)mii.MiiFacialHair.MustacheType & 0x03) << 14); + facialHair |= (ushort)(((int)mii.MiiFacialHair.BeardType & 0x03) << 12); + facialHair |= (ushort)(((int)mii.MiiFacialHair.Color & 0x07) << 9); + facialHair |= (ushort)((mii.MiiFacialHair.Size & 0x0F) << 5); + facialHair |= (ushort)((mii.MiiFacialHair.Vertical & 0x1F)); + data[0x32] = (byte)(facialHair >> 8); + data[0x33] = (byte)(facialHair & 0xFF); + + // Mole (0x34 - 0x35) + ushort mole = 0; + mole |= (ushort)((mii.MiiMole.Exists ? 1 : 0) << 15); + mole |= (ushort)((mii.MiiMole.Size & 0x0F) << 11); + mole |= (ushort)((mii.MiiMole.Vertical & 0x1F) << 6); + mole |= (ushort)((mii.MiiMole.Horizontal & 0x1F) << 1); + data[0x34] = (byte)(mole >> 8); + data[0x35] = (byte)(mole & 0xFF); + + // Creator Name (0x36 - 0x49) + Buffer.BlockCopy(mii.CreatorName.ToBytes(), 0, data, 0x36, 20); + + return data; + } + + public static OperationResult Deserialize(byte[]? data) + { + if (data == null || data.Length != 74) + return Fail("Invalid Mii data length."); + + var mii = new Mii(); + + // Header (0x00 - 0x01) + ushort header = (ushort)((data[0] << 8) | data[1]); + mii.IsInvalid = (header & 0x8000) != 0; + mii.IsGirl = (header & 0x4000) != 0; + int month = (header >> 10) & 0x0F; + int day = (header >> 5) & 0x1F; + mii.Date = new DateOnly(2000, Math.Clamp(month, 1, 12), Math.Clamp(day, 1, 31)); + mii.MiiFavoriteColor = (MiiFavoriteColor)((header >> 1) & 0x0F); + mii.IsFavorite = (header & 0x01) != 0; + + // Name (0x02 - 0x15) + mii.Name = MiiName.FromBytes(data, 2); + + // Height & Weight (0x16 - 0x17) + mii.Height = MiiScale.Create(data[0x16]).Value; + mii.Weight = MiiScale.Create(data[0x17]).Value; + + // Mii ID (0x18 - 0x1B) + mii.MiiId = BitConverter.ToUInt32(data, 0x18); + + // System ID (0x1C - 0x1F) + mii.SystemId0 = data[0x1C]; + mii.SystemId1 = data[0x1D]; + mii.SystemId2 = data[0x1E]; + mii.SystemId3 = data[0x1F]; + + // Face (0x20 - 0x21) + ushort face = (ushort)((data[0x20] << 8) | data[0x21]); + + var faceShape = ((face >> 13) & 0x07); + var skinColor = (face >> 10) & 0x07; + var facialFeature = (face >> 6) & 0x0F; + var mingleOff = ((face >> 2) & 0x01) != 0; + var downloaded = (face & 0x01) != 0; + + if (!Enum.IsDefined(typeof(MiiFaceShape), faceShape)) + return new InvalidDataException("Invalid face shape value."); + if (!Enum.IsDefined(typeof(MiiSkinColor), skinColor)) + return new InvalidDataException("Invalid SkinColor"); + if (!Enum.IsDefined(typeof(MiiFacialFeature), facialFeature)) + return new InvalidDataException("Invalid FacialFeature"); + var miiFacialResult = new MiiFacialFeatures( + (MiiFaceShape)faceShape, + (MiiSkinColor)skinColor, + (MiiFacialFeature)facialFeature, + mingleOff, + downloaded + ); + mii.MiiFacial = miiFacialResult; + + // Hair (0x22 - 0x23) + ushort hair = (ushort)((data[0x22] << 8) | data[0x23]); + var hairColor = (hair >> 6) & 0x07; + if (!Enum.IsDefined(typeof(HairColor), hairColor)) + return new InvalidDataException("Invalid HairColor"); + var miiHairResult = MiiHair.Create( + (hair >> 9) & 0x7F, + (HairColor)hairColor, + ((hair >> 5) & 0x01) != 0 + ); + if (miiHairResult.IsFailure) + return miiHairResult.Error; + mii.MiiHair = miiHairResult.Value; + + // Eyebrows (0x24 - 0x27) + uint brow = (uint)((data[0x24] << 24) | (data[0x25] << 16) | (data[0x26] << 8) | data[0x27]); + var eyebrowColor = (int)((brow >> 13) & 0x07); + if (!Enum.IsDefined(typeof(EyebrowColor), eyebrowColor)) + return new InvalidDataException("Invalid EyebrowColor"); + var miiEyebrowsResult = MiiEyebrow.Create( + (int)((brow >> 27) & 0x1F), + (int)((brow >> 22) & 0x0F), + (EyebrowColor)eyebrowColor, + (int)((brow >> 9) & 0x0F), + (int)((brow >> 4) & 0x1F), + (int)(brow & 0x0F) + ); + if (miiEyebrowsResult.IsFailure) + return miiEyebrowsResult.Error; + mii.MiiEyebrows = miiEyebrowsResult.Value; + + // Eyes (0x28 - 0x2B) + uint eye = (uint)((data[0x28] << 24) | (data[0x29] << 16) | (data[0x2A] << 8) | data[0x2B]); + var eyeColor = ((eye >> 13) & 0x07); + if (!Enum.IsDefined(typeof(EyeColor), eyeColor)) + return new InvalidDataException("Invalid EyeColor"); + var miiEyesResult = MiiEye.Create( + (int)((eye >> 26) & 0x3F), + (int)((eye >> 21) & 0x07), + (int)((eye >> 16) & 0x1F), + (EyeColor)(eyeColor), + (int)((eye >> 9) & 0x07), + (int)((eye >> 5) & 0x0F) + ); + if (miiEyesResult.IsFailure) + return miiEyesResult.Error; + mii.MiiEyes = miiEyesResult.Value; + + // Nose (0x2C - 0x2D) + ushort nose = (ushort)((data[0x2C] << 8) | data[0x2D]); + var noseType = (nose >> 12) & 0x0F; + if (!Enum.IsDefined(typeof(NoseType), noseType)) + return new InvalidDataException("Invalid NoseType"); + var miiNoseResult = MiiNose.Create( + (NoseType)noseType, + (int)((nose >> 8) & 0x0F), + (int)((nose >> 3) & 0x1F) + ); + if (miiNoseResult.IsFailure) + return miiNoseResult.Error; + mii.MiiNose = miiNoseResult.Value; + + // Lips (0x2E - 0x2F) + ushort lip = (ushort)((data[0x2E] << 8) | data[0x2F]); + var lipColor = ((lip >> 9) & 0x03); + if (!Enum.IsDefined(typeof(LipColor), lipColor)) + return new InvalidDataException("Invalid LipColor"); + var miiLipResult = MiiLip.Create( + (int)((lip >> 11) & 0x1F), + (LipColor)lipColor, + (int)((lip >> 5) & 0x0F), + (int)(lip & 0x1F) + ); + if (miiLipResult.IsFailure) + return miiLipResult.Error; + mii.MiiLips = miiLipResult.Value; + + // Glasses (0x30 - 0x31) + ushort glasses = (ushort)((data[0x30] << 8) | data[0x31]); + var glassesType = ((glasses >> 12) & 0x0F); + if (!Enum.IsDefined(typeof(GlassesType), glassesType)) + return new InvalidDataException("Invalid GlassesType"); + var glassesColor = ((glasses >> 9) & 0x07); + if (!Enum.IsDefined(typeof(GlassesColor), glassesColor)) + return new InvalidDataException("Invalid GlassesColor"); + var miiGlassesResult = MiiGlasses.Create( + (GlassesType)glassesType, + (GlassesColor)glassesColor, + (int)((glasses >> 5) & 0x07), + (int)(glasses & 0x1F) + ); + if (miiGlassesResult.IsFailure) + return miiGlassesResult.Error; + mii.MiiGlasses = miiGlassesResult.Value; + + // Facial hair (0x32 - 0x33) + ushort facial = (ushort)((data[0x32] << 8) | data[0x33]); + var mustacheType = ((facial >> 14) & 0x03); + if (!Enum.IsDefined(typeof(MustacheType), mustacheType)) + return new InvalidDataException("Invalid MustacheType"); + var beardType = ((facial >> 12) & 0x03); + if (!Enum.IsDefined(typeof(BeardType), beardType)) + return new InvalidDataException("Invalid BeardType"); + var color = ((facial >> 9) & 0x07); + if (!Enum.IsDefined(typeof(MustacheColor), color)) + return new InvalidDataException("Invalid FacialHairColor"); + var miiFacialHairResult = MiiFacialHair.Create( + (MustacheType)mustacheType, + (BeardType)beardType, + (MustacheColor)color, + (int)((facial >> 5) & 0x0F), + (int)(facial & 0x1F) + ); + if (miiFacialHairResult.IsFailure) + return miiFacialHairResult.Error; + mii.MiiFacialHair = miiFacialHairResult.Value; + + // Mole (0x34 - 0x35) + ushort mole = (ushort)((data[0x34] << 8) | data[0x35]); + var miiMoleResult = MiiMole.Create( + ((mole >> 15) & 0x01) != 0, + (mole >> 11) & 0x0F, + (mole >> 6) & 0x1F, + (mole >> 1) & 0x1F + ); + if (miiMoleResult.IsFailure) + return miiMoleResult.Error; + mii.MiiMole = miiMoleResult.Value; + + // Creator Name (0x36 - 0x49) + var creatorNameResult = MiiName.Create(Encoding.BigEndianUnicode.GetString(data, 0x36, 20).TrimEnd('\0')); + if (creatorNameResult.IsFailure) + return creatorNameResult.Error; + mii.CreatorName = creatorNameResult.Value; + return mii; + } +} diff --git a/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs b/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs new file mode 100644 index 00000000..a002aceb --- /dev/null +++ b/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs @@ -0,0 +1,14 @@ +using WheelWizard.WiiManagement.Domain; + +namespace WheelWizard.WiiManagement; + +public static class WiiManagementExtensions +{ + public static IServiceCollection AddWiiManagement(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/WheelWizard/Models/GameData/GameDataFriend.cs b/WheelWizard/Models/GameData/FriendProfile.cs similarity index 86% rename from WheelWizard/Models/GameData/GameDataFriend.cs rename to WheelWizard/Models/GameData/FriendProfile.cs index a1fd3cdd..bcd3b87a 100644 --- a/WheelWizard/Models/GameData/GameDataFriend.cs +++ b/WheelWizard/Models/GameData/FriendProfile.cs @@ -2,7 +2,7 @@ namespace WheelWizard.Models.GameData; -public class GameDataFriend : GameDataPlayer +public class FriendProfile : PlayerProfileBase { public required uint Wins { get; set; } public required uint Losses { get; set; } diff --git a/WheelWizard/Models/GameData/GameData.cs b/WheelWizard/Models/GameData/GameData.cs deleted file mode 100644 index 2d83193e..00000000 --- a/WheelWizard/Models/GameData/GameData.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace WheelWizard.Models.GameData; - -public class GameData -{ - public List Users { get; set; } - - public GameData() - { - Users = new List(4); - } -} diff --git a/WheelWizard/Models/GameData/LicenseCollection.cs b/WheelWizard/Models/GameData/LicenseCollection.cs new file mode 100644 index 00000000..d359c492 --- /dev/null +++ b/WheelWizard/Models/GameData/LicenseCollection.cs @@ -0,0 +1,11 @@ +namespace WheelWizard.Models.GameData; + +public class LicenseCollection +{ + public List Users { get; set; } + + public LicenseCollection() + { + Users = new List(4); + } +} diff --git a/WheelWizard/Models/GameData/GameDataUser.cs b/WheelWizard/Models/GameData/LicenseProfile.cs similarity index 54% rename from WheelWizard/Models/GameData/GameDataUser.cs rename to WheelWizard/Models/GameData/LicenseProfile.cs index c4924b5b..e80789ad 100644 --- a/WheelWizard/Models/GameData/GameDataUser.cs +++ b/WheelWizard/Models/GameData/LicenseProfile.cs @@ -1,8 +1,8 @@ namespace WheelWizard.Models.GameData; -public class GameDataUser : GameDataPlayer +public class LicenseProfile : PlayerProfileBase { public required uint TotalRaceCount { get; set; } public required uint TotalWinCount { get; set; } - public List Friends { get; set; } = new List(); + public List Friends { get; set; } = new List(); } diff --git a/WheelWizard/Models/GameData/MiiData.cs b/WheelWizard/Models/GameData/MiiData.cs index dbea116f..9d76703d 100644 --- a/WheelWizard/Models/GameData/MiiData.cs +++ b/WheelWizard/Models/GameData/MiiData.cs @@ -1,4 +1,4 @@ -using WheelWizard.Models.MiiImages; +using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.Models.GameData; diff --git a/WheelWizard/Models/GameData/GameDataPlayer.cs b/WheelWizard/Models/GameData/PlayerProfileBase.cs similarity index 70% rename from WheelWizard/Models/GameData/GameDataPlayer.cs rename to WheelWizard/Models/GameData/PlayerProfileBase.cs index f88747a1..fca148a9 100644 --- a/WheelWizard/Models/GameData/GameDataPlayer.cs +++ b/WheelWizard/Models/GameData/PlayerProfileBase.cs @@ -3,11 +3,13 @@ using WheelWizard.Models.MiiImages; using WheelWizard.Models.Settings; using WheelWizard.Services.LiveData; +using WheelWizard.WiiManagement; using WheelWizard.WheelWizardData.Domain; +using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.Models.GameData; -public abstract class GameDataPlayer : INotifyPropertyChanged +public abstract class PlayerProfileBase : INotifyPropertyChanged { public required string FriendCode { get; init; } public required uint Vr { get; init; } @@ -41,27 +43,7 @@ public bool IsOnline public BadgeVariant[] BadgeVariants { get; set; } = []; public bool HasBadges => BadgeVariants.Length != 0; - public string MiiName - { - get => MiiData?.Mii?.Name ?? SettingValues.NoName; - set - { - if (MiiData == null) - { - MiiData = new MiiData - { - Mii = new Mii { Data = "", Name = value } - }; - } - else if (MiiData.Mii == null) - MiiData.Mii = new Mii { Data = "", Name = value }; - else - { - MiiData.Mii.Name = value; - OnPropertyChanged(nameof(MiiName)); - } - } - } + public string NameOfMii => Mii?.Name.ToString() ?? string.Empty; #region PropertyChanged public event PropertyChangedEventHandler? PropertyChanged; diff --git a/WheelWizard/Models/MiiImages/Mii.cs b/WheelWizard/Models/MiiImages/Mii.cs deleted file mode 100644 index 5f6b593b..00000000 --- a/WheelWizard/Models/MiiImages/Mii.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace WheelWizard.Models.MiiImages; - -public class Mii -{ - public required string Name { get; set; } - public required string Data { get; set; } - - - private Dictionary images = new (); - - public MiiImage GetImage(MiiImageVariants.Variant variant) - { - if (!images.ContainsKey(variant)) - images[variant] = new MiiImage(this, variant); - return images[variant]; - } -} diff --git a/WheelWizard/Models/MiiImages/MiiImage.cs b/WheelWizard/Models/MiiImages/MiiImage.cs index e5332e0d..e00f0a14 100644 --- a/WheelWizard/Models/MiiImages/MiiImage.cs +++ b/WheelWizard/Models/MiiImages/MiiImage.cs @@ -1,18 +1,34 @@ using Avalonia.Media.Imaging; using System.ComponentModel; +using WheelWizard.RrRooms; using WheelWizard.Services.WiiManagement; +using WheelWizard.Views; +using WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.Domain.Mii; + namespace WheelWizard.Models.MiiImages; public class MiiImage : INotifyPropertyChanged { private Mii Parent { get; } - public string Data => Parent.Data; + + public string? Data + { + get + { + var parsedResult = MiiSerializer.Serialize(Parent); + if (parsedResult.IsFailure) + return null; + return Convert.ToBase64String(parsedResult.Value); + } + } + public MiiImageVariants.Variant Variant { get; } - public MiiImage(Mii parent, MiiImageVariants.Variant variant) => (Parent, Variant) = (parent,variant); + public MiiImage(Mii parent, MiiImageVariants.Variant variant) => (Parent, Variant) = (parent, variant); public string CachingKey => $"{Data}_{Variant}"; - + public bool LoadedImageSuccessfully { get; private set; } // default false, dont set this manually // This will never be set back to false, this is intentional @@ -26,14 +42,14 @@ public Bitmap? Image { if (_image != null || _requestingImage) return _image; // it will set it to true, meaning this code can only be executed once due to the above check - _requestingImage = true; - + _requestingImage = true; + var newImage = MiiImageManager.GetCachedMiiImage(this); if (newImage == null) MiiImageManager.ResetMiiImageAsync(this); else SetImage(newImage.Value.Item1, newImage.Value.Item2); - + return _image; } private set @@ -49,11 +65,15 @@ public void SetImage(Bitmap image, bool loadedSuccessfully) LoadedImageSuccessfully = loadedSuccessfully; Image = image; } + #region PropertyChanged + public event PropertyChangedEventHandler? PropertyChanged; + protected virtual void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } + #endregion } diff --git a/WheelWizard/Models/RRInfo/RrPlayer.cs b/WheelWizard/Models/RRInfo/RrPlayer.cs index 98c0bb05..5fc7ece2 100644 --- a/WheelWizard/Models/RRInfo/RrPlayer.cs +++ b/WheelWizard/Models/RRInfo/RrPlayer.cs @@ -1,5 +1,6 @@ -using WheelWizard.Models.MiiImages; using WheelWizard.WheelWizardData.Domain; +using WheelWizard.WiiManagement.Domain.Mii; + namespace WheelWizard.Models.RRInfo; diff --git a/WheelWizard/Models/RRInfo/RrRoom.cs b/WheelWizard/Models/RRInfo/RrRoom.cs index 3e525dd8..98728baf 100644 --- a/WheelWizard/Models/RRInfo/RrRoom.cs +++ b/WheelWizard/Models/RRInfo/RrRoom.cs @@ -1,5 +1,6 @@ using WheelWizard.Helpers; using WheelWizard.Models.MiiImages; +using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.Models.RRInfo; diff --git a/WheelWizard/Services/LiveData/RRLiveRooms.cs b/WheelWizard/Services/LiveData/RRLiveRooms.cs index 8d82bbeb..85d3993f 100644 --- a/WheelWizard/Services/LiveData/RRLiveRooms.cs +++ b/WheelWizard/Services/LiveData/RRLiveRooms.cs @@ -4,6 +4,8 @@ using WheelWizard.Utilities.RepeatedTasks; using WheelWizard.Views; using WheelWizard.WheelWizardData; +using WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.Services.LiveData; @@ -53,10 +55,17 @@ protected override async Task ExecuteTaskAsync() Ev = p.Value.Ev, Eb = p.Value.Eb, BadgeVariants = whWzService.GetBadges(p.Value.Fc), - Mii = p.Value.Mii.Select(mii => new Mii + // Deserialize each Mii's data into a FullMii object + Mii = p.Value.Mii.Select(mii => { - Name = mii.Name, - Data = mii.Data, + var rawMii = Convert.FromBase64String(mii.Data); + var SerializerResult = MiiSerializer.Deserialize(rawMii); + if (SerializerResult.IsFailure) + { + return new Mii(); + } + + return SerializerResult.Value; }).ToList() }) }).ToList(); diff --git a/WheelWizard/Services/Settings/SettingsManager.cs b/WheelWizard/Services/Settings/SettingsManager.cs index 582e6f93..c15c4dfd 100644 --- a/WheelWizard/Services/Settings/SettingsManager.cs +++ b/WheelWizard/Services/Settings/SettingsManager.cs @@ -91,7 +91,7 @@ public class SettingsManager public static Setting FORCE_WIIMOTE = new WhWzSetting(typeof(bool),"ForceWiimote", false); public static Setting LAUNCH_WITH_DOLPHIN = new WhWzSetting(typeof(bool),"LaunchWithDolphin", false); public static Setting PREFERS_MODS_ROW_VIEW = new WhWzSetting(typeof(bool),"PrefersModsRowView", true); - public static Setting FOCUSSED_USER = new WhWzSetting(typeof(int), "FavoriteUser", 0).SetValidation(value => (int)(value ?? -1) >= 0 && (int)(value ?? -1) <= 4); + public static Setting FOCUSSED_USER = new WhWzSetting(typeof(int), "FavoriteUser", 0).SetValidation(value => (int)(value ?? -1) >= 0 && (int)(value ?? -1) < 4); public static Setting ENABLE_ANIMATIONS = new WhWzSetting(typeof(bool),"EnableAnimations", true); public static Setting SAVED_WINDOW_SCALE = new WhWzSetting(typeof(double), "WindowScale", 1.0).SetValidation(value => (double)(value ?? -1) >= 0.5 && (double)(value ?? -1) <= 2.0); diff --git a/WheelWizard/Services/WiiManagement/SaveData/GameDataLoader.cs b/WheelWizard/Services/WiiManagement/SaveData/GameDataLoader.cs deleted file mode 100644 index eac1cdb7..00000000 --- a/WheelWizard/Services/WiiManagement/SaveData/GameDataLoader.cs +++ /dev/null @@ -1,496 +0,0 @@ -using System.Text; -using System.Text.RegularExpressions; -using WheelWizard.Models.Enums; -using WheelWizard.Models.GameData; -using WheelWizard.Models.MiiImages; -using WheelWizard.Services.LiveData; -using WheelWizard.Services.Other; -using WheelWizard.Services.Settings; -using WheelWizard.Utilities.Generators; -using WheelWizard.Utilities.RepeatedTasks; -using WheelWizard.Views; -using WheelWizard.Views.Popups.Generic; -using WheelWizard.WheelWizardData; - -// big big thanks to https://kazuki-4ys.github.io/web_apps/FaceThief/ for the JS implementation - -namespace WheelWizard.Services.WiiManagement.SaveData; - -public class GameDataLoader : RepeatedTaskManager -{ - public static GameDataLoader Instance { get; } = new(); - - /// - /// The path to where the “rksys.dat” folder structure is expected to live, e.g. - /// ..\path\to\Riivolution\riivolution\save\RetroWFC\RMCP\rksys.dat - /// - private static string? TryCreateSaveFolderPath - { - get - { - if (string.IsNullOrWhiteSpace(PathManager.UserFolderPath)) - return string.Empty; - if (Directory.Exists(PathManager.SaveFolderPath)) - return PathManager.SaveFolderPath; - try - { - Directory.CreateDirectory(PathManager.SaveFolderPath); - } - catch (Exception ex) - { - // Do nothing until user directory is resolved. - return string.Empty; - } - return PathManager.SaveFolderPath; - } - } - - private byte[]? _saveData; - - private GameData GameData { get; } - - private const int RksysSize = 0x2BC000; - private const string RksysMagic = "RKSD0006"; - private const int MaxPlayerNum = 4; - private const int RkpdSize = 0x8CC0; - private const string RkpdMagic = "RKPD"; - private const int MaxFriendNum = 30; - private const int FriendDataOffset = 0x56D0; - private const int FriendDataSize = 0x1C0; - private const int MiiSize = 0x4A; - - /// - /// Returns the "focused" or currently active license/user as determined by the Settings. - /// - public GameDataUser GetCurrentUser - => Instance.GameData.Users[(int)SettingsManager.FOCUSSED_USER.Get()]; - - public List GetCurrentFriends - => Instance.GameData.Users[(int)SettingsManager.FOCUSSED_USER.Get()].Friends; - - public GameData GetGameData - => Instance.GameData; - - public GameDataUser GetUserData(int index) - => GameData.Users[index]; - - public bool HasAnyValidUsers - => GameData.Users.Any(user => user.FriendCode != "0000-0000-0000"); - - private GameDataLoader() : base(40) - { - GameData = new GameData(); - LoadGameData(); - } - - /// - /// Refresh the "IsOnline" status of our local users based on the list of currently online players. - /// - public void RefreshOnlineStatus() - { - var currentRooms = RRLiveRooms.Instance.CurrentRooms; - var onlinePlayers = currentRooms.SelectMany(room => room.Players.Values).ToList(); - foreach (var user in GameData.Users) - { - user.IsOnline = onlinePlayers.Any(player => player.Fc == user.FriendCode); - } - } - - /// - /// Loads the entire rksys.dat file from disk into memory and parses the 4 possible licenses. - /// If the file is invalid or not found, we create dummy users. - /// - public void LoadGameData() - { - try - { - _saveData = LoadSaveDataFile(); - if (_saveData != null && ValidateMagicNumber()) - { - ParseUsers(); - return; - } - - // If the file was invalid or not found, create 4 dummy licenses - GameData.Users.Clear(); - for (var i = 0; i < MaxPlayerNum; i++) - CreateDummyUser(); - - } - catch (Exception e) - { - new MessageBoxWindow() - .SetMessageType(MessageBoxWindow.MessageType.Error) - .SetTitleText("Loading game data failed") - .SetInfoText($"An error occurred while loading the game data: {e.Message}") - .Show(); - } - } - - private void CreateDummyUser() - { - var dummyUser = new GameDataUser - { - FriendCode = "0000-0000-0000", - MiiData = new MiiData - { - Mii = new Mii - { - Name = "no license", - Data = Convert.ToBase64String(new byte[MiiSize]) - }, - AvatarId = 0, - ClientId = 0 - }, - Vr = 5000, - Br = 5000, - TotalRaceCount = 0, - TotalWinCount = 0, - Friends = new List(), - RegionId = 10, // 10 => “unknown” - IsOnline = false - }; - GameData.Users.Add(dummyUser); - } - - private void ParseUsers() - { - GameData.Users.Clear(); - if (_saveData == null) return; - - for (var i = 0; i < MaxPlayerNum; i++) - { - var rkpdOffset = RksysMagic.Length + i * RkpdSize; - if (Encoding.ASCII.GetString(_saveData, rkpdOffset, RkpdMagic.Length) == RkpdMagic) - { - var user = ParseUser(rkpdOffset); - GameData.Users.Add(user); - } - else - { - CreateDummyUser(); - } - } - if (GameData.Users.Count == 0) - CreateDummyUser(); - } - - private GameDataUser ParseUser(int offset) - { - if (_saveData == null) throw new ArgumentNullException(nameof(_saveData)); - - var friendCode = FriendCodeGenerator.GetFriendCode(_saveData, offset + 0x5C); - var user = new GameDataUser - { - MiiData = ParseMiiData(offset + 0x14), - FriendCode = friendCode, - Vr = BigEndianBinaryReader.BufferToUint16(_saveData, offset + 0xB0), - Br = BigEndianBinaryReader.BufferToUint16(_saveData, offset + 0xB2), - TotalRaceCount = BigEndianBinaryReader.BufferToUint32(_saveData, offset + 0xB4), - TotalWinCount = BigEndianBinaryReader.BufferToUint32(_saveData, offset + 0xDC), - BadgeVariants = App.Services.GetRequiredService().GetBadges(friendCode), - // Region is often found near offset 0x23308 + 0x3802 in RKGD. This code is a partial guess. - // In practice, region might be read differently depending on your rksys layout. - RegionId = BigEndianBinaryReader.BufferToUint16(_saveData, 0x23308 + 0x3802) / 4096, - }; - - ParseFriends(user, offset); - return user; - } - - private MiiData ParseMiiData(int offset) - { - if (_saveData == null) throw new ArgumentNullException(nameof(_saveData)); - - // In Mario Kart Wii's rksys, offset +0x10 => AvatarId, offset +0x14 => ClientId - // The name is big-endian UTF-16 at offset itself (length 10 chars => 20 bytes). - var name = BigEndianBinaryReader.GetUtf16String(_saveData, offset, 10); - var avatarId = BitConverter.ToUInt32(_saveData, offset + 0x10); - var clientId = BitConverter.ToUInt32(_saveData, offset + 0x14); - - // Convert the Mii block from RFL_DB if it’s actually valid - var rawMii = InternalMiiManager.GetMiiDataByClientId(clientId); - - var miiData = new MiiData - { - Mii = new Mii - { - Name = name, - Data = Convert.ToBase64String(rawMii) - }, - AvatarId = avatarId, - ClientId = clientId - }; - return miiData; - } - - private void ParseFriends(GameDataUser gameDataUser, int userOffset) - { - if (_saveData == null) return; - - var friendOffset = userOffset + FriendDataOffset; - for (var i = 0; i < MaxFriendNum; i++) - { - var currentOffset = friendOffset + i * FriendDataSize; - if (!CheckMiiData(currentOffset + 0x1A)) continue; - - var friendCode = FriendCodeGenerator.GetFriendCode(_saveData, currentOffset + 4); - var friend = new GameDataFriend - { - Vr = BigEndianBinaryReader.BufferToUint16(_saveData, currentOffset + 0x16), - Br = BigEndianBinaryReader.BufferToUint16(_saveData, currentOffset + 0x18), - FriendCode = friendCode, - Wins = BigEndianBinaryReader.BufferToUint16(_saveData, currentOffset + 0x14), - Losses = BigEndianBinaryReader.BufferToUint16(_saveData, currentOffset + 0x12), - CountryCode = _saveData[currentOffset + 0x68], - RegionId = _saveData[currentOffset + 0x69], - BadgeVariants = App.Services.GetRequiredService().GetBadges(friendCode), - - MiiData = new MiiData - { - Mii = new Mii - { - Name = BigEndianBinaryReader.GetUtf16String(_saveData, currentOffset + 0x1C, 10), - Data = Convert.ToBase64String(_saveData.AsSpan(currentOffset + 0x1A, MiiSize)) - }, - AvatarId = 0, - ClientId = 0 - }, - }; - gameDataUser.Friends.Add(friend); - } - } - - private bool CheckMiiData(int offset) - { - // If the entire 0x4A bytes are zero, we treat it as empty / no Mii data - for (var i = 0; i < MiiSize; i++) - { - if (_saveData != null && _saveData[offset + i] != 0) - return true; - } - return false; - } - - private bool ValidateMagicNumber() - { - if (_saveData == null) return false; - return Encoding.ASCII.GetString(_saveData, 0, RksysMagic.Length) == RksysMagic; - } - - private static byte[]? LoadSaveDataFile() - { - try - { - if (!Directory.Exists(TryCreateSaveFolderPath)) - return null; - - var currentRegion = (MarioKartWiiEnums.Regions)SettingsManager.RR_REGION.Get(); - if (currentRegion == MarioKartWiiEnums.Regions.None) - { - // Double check if there's at least one valid region - var validRegions = RRRegionManager.GetValidRegions(); - if (validRegions.First() != MarioKartWiiEnums.Regions.None) - { - currentRegion = validRegions.First(); - SettingsManager.RR_REGION.Set(currentRegion); - } - else - { - return null; - } - } - - var saveFileFolder = Path.Combine(TryCreateSaveFolderPath, RRRegionManager.ConvertRegionToGameId(currentRegion)); - var saveFile = Directory.GetFiles(saveFileFolder, "rksys.dat", SearchOption.TopDirectoryOnly); - return saveFile.Length == 0 ? null : File.ReadAllBytes(saveFile[0]); - } - catch - { - // If anything fails, return null - return null; - } - } - - /// - /// Calculates the CRC32 of the specified slice of bytes using the - /// standard polynomial (0xEDB88320) in the same way MKWii does. - /// - public static uint ComputeCrc32(byte[] data, int offset, int length) - { - const uint POLY = 0xEDB88320; - var crc = 0xFFFFFFFF; - - for (var i = offset; i < offset + length; i++) - { - var b = data[i]; - crc ^= b; - for (var j = 0; j < 8; j++) - { - if ((crc & 1) != 0) - crc = (crc >> 1) ^ POLY; - else - crc >>= 1; - } - } - - return ~crc; - } - - - /// - /// Fixes the MKWii save file by recalculating and inserting the CRC32 at 0x27FFC. - /// - public static void FixRksysCrc(byte[] rksysData) - { - if (rksysData == null || rksysData.Length < RksysSize) - throw new ArgumentException("Invalid rksys.dat data"); - - var lengthToCrc = 0x27FFC; - var newCrc = ComputeCrc32(rksysData, 0, lengthToCrc); - - // 2) Write CRC at offset 0x27FFC in big-endian. - BigEndianBinaryReader.WriteUInt32BigEndian(rksysData, 0x27FFC, newCrc); - } - public async void PromptLicenseNameChange(int userIndex) - { - if (userIndex is < 0 or >= MaxPlayerNum) - { - InvalidLicenseMessage("Invalid license index. Please select a valid license."); - return; - } - var user = GameData.Users[userIndex]; - var miiIsEmptyOrNoName = IsNoNameOrEmptyMii(user); - - if (miiIsEmptyOrNoName) - { - InvalidLicenseMessage("This license has no Mii data or is incomplete.\n" + - "Please use the Mii Channel to create a Mii first."); - return; - } - if (user.MiiData?.Mii == null) - { - InvalidLicenseMessage("This license has no Mii data or is incomplete.\n" + - "Please use the Mii Channel to create a Mii first."); - return; - } - var currentName = user.MiiData.Mii.Name ?? ""; - var renamePopup = new TextInputWindow() - .SetMainText($"Enter new name") - .SetExtraText($"Changing name from: {currentName}") - .SetAllowCustomChars(true) - .SetInitialText(currentName) - .SetPlaceholderText(currentName); - - var newName = await renamePopup.ShowDialog(); - if (string.IsNullOrWhiteSpace(newName)) return; - newName = Regex.Replace(newName, @"\s+", " "); - - // Basic checks - if (newName.Length is > 10 or < 3) - { - InvalidNameMessage("Names must be between 3 and 10 characters long."); - return; - } - - if (newName.Length > 10) - newName = newName.Substring(0, 10); - user.MiiData.Mii.Name = newName; // This should be updated just in case someone uses it, but its not the one that updates the profile page - user.MiiName = newName; // This is the one with the notification - WriteLicenseNameToSaveData(userIndex, newName); - var updated = InternalMiiManager.UpdateMiiName(user.MiiData.ClientId, newName); - if (!updated) - { - new MessageBoxWindow() - .SetMessageType(MessageBoxWindow.MessageType.Error) - .SetTitleText("Failed to update the Mii name.") - .SetInfoText("It was unable to update the name in the Mii Database file.") - .Show(); - - } - - if (SaveRksysToFile()) - { - new MessageBoxWindow() - .SetMessageType(MessageBoxWindow.MessageType.Message) - .SetTitleText("Successfully updated name") - .SetInfoText($"Successfully updated Mii name to {user.MiiData.Mii.Name}") - .Show(); - } - } - private bool IsNoNameOrEmptyMii(GameDataUser user) - { - if (user?.MiiData?.Mii == null) - return true; - - var name = user.MiiData.Mii.Name?.Trim() ?? ""; - if (name.Equals("no name", StringComparison.OrdinalIgnoreCase)) - return true; - var raw = Convert.FromBase64String(user.MiiData.Mii.Data ?? ""); - if (raw.Length != 74) return true; // Not valid - if (raw.All(b => b == 0)) return true; - - // Otherwise, it’s presumably valid - return false; - } - private void WriteLicenseNameToSaveData(int userIndex, string newName) - { - if (_saveData == null || _saveData.Length < RksysSize) return; - var rkpdOffset = 0x8 + userIndex * RkpdSize; - var nameOffset = rkpdOffset + 0x14; - var nameBytes = Encoding.BigEndianUnicode.GetBytes(newName); - for (var i = 0; i < 20; i++) - _saveData[nameOffset + i] = 0; - Array.Copy(nameBytes, 0, _saveData, nameOffset, Math.Min(nameBytes.Length, 20)); - } - - private bool SaveRksysToFile() - { - if (_saveData == null || string.IsNullOrWhiteSpace(TryCreateSaveFolderPath)) return false; - FixRksysCrc(_saveData); - var currentRegion = (MarioKartWiiEnums.Regions)SettingsManager.RR_REGION.Get(); - var saveFolder = Path.Combine(TryCreateSaveFolderPath, RRRegionManager.ConvertRegionToGameId(currentRegion)); - - try - { - Directory.CreateDirectory(saveFolder); - var path = Path.Combine(saveFolder, "rksys.dat"); - File.WriteAllBytes(path, _saveData); - } - catch (Exception ex) - { - new MessageBoxWindow() - .SetMessageType(MessageBoxWindow.MessageType.Error) - .SetInfoText($"Failed to save rksys.dat.\n{ex.Message}") - .SetTitleText("Failed to save the 'save' file") - .Show(); - return false; - } - - return true; - } - - private void InvalidLicenseMessage(string info) - { - new MessageBoxWindow() - .SetMessageType(MessageBoxWindow.MessageType.Warning) - .SetTitleText("Invalid license.") - .SetInfoText(info) - .Show(); - } - - private void InvalidNameMessage(string info) - { - new MessageBoxWindow() - .SetMessageType(MessageBoxWindow.MessageType.Warning) - .SetTitleText("Invalid Name.") - .SetInfoText(info) - .Show(); - } - - protected override Task ExecuteTaskAsync() - { - LoadGameData(); - return Task.CompletedTask; - } -} diff --git a/WheelWizard/Services/WiiManagement/SaveData/InternalMiiManager.cs b/WheelWizard/Services/WiiManagement/SaveData/InternalMiiManager.cs deleted file mode 100644 index 1f7b4912..00000000 --- a/WheelWizard/Services/WiiManagement/SaveData/InternalMiiManager.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Text; - -namespace WheelWizard.Services.WiiManagement.SaveData; - -public static class InternalMiiManager -{ - private static readonly string WiiDbFile = PathManager.WiiDbFile; - - private const int MiiLength = 74; // Each Mii block is 74 bytes - private static readonly byte[] emptyMii = new byte[MiiLength]; - - /// - /// Reads the entire RFL_DB.dat and returns up to 100 Mii blocks (74 bytes each). - /// - public static List GetAllMiiData() - { - var miis = new List(); - var allMiiData = GetMiiDb(); - if (allMiiData.Length < 4) - return miis; - - using var memoryStream = new MemoryStream(allMiiData); - - // According to some docs, the first 4 bytes is a "Plaza Offset" or "RNCD" magic, skip them - memoryStream.Seek(0x4, SeekOrigin.Begin); - - for (var i = 0; i < 100; i++) - { - var miiData = new byte[MiiLength]; - var bytesRead = memoryStream.Read(miiData, 0, MiiLength); - if (bytesRead < MiiLength) - break; - miis.Add(miiData.SequenceEqual(emptyMii) ? new byte[MiiLength] : miiData); - } - return miis; - } - - /// - /// Overwrites the local RFL_DB.dat with the given list of Mii blocks. - /// Keeps the initial 4 bytes intact, writes or clears up to 100 Mii slots, - /// then recalculates the CRC16 at 0x1F1DE (if large enough). - /// - private static void SaveMiiDb(List allMiis) - { - if (!File.Exists(WiiDbFile)) - return; - - var dbFile = File.ReadAllBytes(WiiDbFile); - using var ms = new MemoryStream(dbFile); - ms.Seek(0x4, SeekOrigin.Begin); - for (var i = 0; i < 100; i++) - { - var block = i < allMiis.Count ? allMiis[i] : emptyMii; - ms.Write(block, 0, MiiLength); - } - const int crcOffset = 0x1F1DE; - if (dbFile.Length >= crcOffset + 2) - { - var crc = CalculateCRC16(dbFile, 0, crcOffset); - dbFile[crcOffset] = (byte)(crc >> 8); - dbFile[crcOffset + 1] = (byte)(crc & 0xFF); - } - File.WriteAllBytes(WiiDbFile, dbFile); - } - - /// - /// Retrieves the *raw* 74-byte Mii block that has the specified ClientId (found at offset 0x18..0x1B). - /// Returns an empty array if none found or if RFL_DB.dat is missing. - /// - public static byte[] GetMiiDataByClientId(uint clientId) - { - if (clientId == 0) return Array.Empty(); - - var allMiis = GetAllMiiData(); - foreach (var block in allMiis) - { - if (block.Length != MiiLength) - continue; - - var thisMiiId = BigEndianBinaryReader.ReadLittleEndianUInt32(block, 0x18); - if (thisMiiId == clientId) - return block; - } - return Array.Empty(); - } - - public static bool UpdateMiiName(uint clientId, string newName) - { - if (clientId == 0) - return false; - - if (!File.Exists(WiiDbFile)) - return false; - - var allMiis = GetAllMiiData(); - var updated = false; - - for (var i = 0; i < allMiis.Count; i++) - { - var block = allMiis[i]; - if (block.Length != MiiLength) - continue; - - var thisMiiId = BigEndianBinaryReader.ReadLittleEndianUInt32(block, 0x18); - if (thisMiiId != clientId) continue; - - // Found the Mii - WriteMiiName(block, newName); - allMiis[i] = block; - updated = true; - break; - } - if (updated) - SaveMiiDb(allMiis); - return updated; - } - - /// - /// Writes a 10-character Mii name into the 74-byte Mii block at offset 0x02, - /// Max length is 10 chars => 20 bytes. - /// - private static void WriteMiiName(byte[] miiBlock, string name) - { - if (name.Length > 10) - name = name.Substring(0, 10); - - // Clear out the old name area - for (var i = 0; i < 20; i++) - miiBlock[2 + i] = 0; - - // Convert to big-endian UTF-16 (this is what Wii expects) - var nameBytes = Encoding.BigEndianUnicode.GetBytes(name); - Array.Copy(nameBytes, 0, miiBlock, 2, Math.Min(nameBytes.Length, 20)); - } - - private static byte[] GetMiiDb() - { - try - { - return !File.Exists(WiiDbFile) ? Array.Empty() : File.ReadAllBytes(WiiDbFile); - } - catch - { - return Array.Empty(); - } - } - - private static ushort CalculateCRC16(byte[] data, int offset, int length) - { - const ushort polynomial = 0x1021; - ushort crc = 0x0000; - - for (var i = offset; i < offset + length; i++) - { - crc ^= (ushort)(data[i] << 8); - for (var j = 0; j < 8; j++) - { - if ((crc & 0x8000) != 0) - crc = (ushort)((crc << 1) ^ polynomial); - else - crc <<= 1; - } - } - return crc; - } -} diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index a80d8ebd..097d4b1d 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -8,6 +8,7 @@ using WheelWizard.Services; using WheelWizard.Shared.Services; using WheelWizard.WheelWizardData; +using WheelWizard.WiiManagement; namespace WheelWizard; @@ -24,6 +25,7 @@ public static void AddWheelWizardServices(this IServiceCollection services) services.AddGitHub(); services.AddRrRooms(); services.AddWhWzData(); + services.AddWiiManagement(); // IO Abstractions services.AddSingleton(); diff --git a/WheelWizard/Utilities/Mockers/MiiFactory.cs b/WheelWizard/Utilities/Mockers/MiiFactory.cs index 1361887f..23311dbc 100644 --- a/WheelWizard/Utilities/Mockers/MiiFactory.cs +++ b/WheelWizard/Utilities/Mockers/MiiFactory.cs @@ -1,12 +1,13 @@ -using WheelWizard.Models.MiiImages; +using WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.Utilities.Mockers; public class MiiFactory : MockingDataFactory { - protected override string DictionaryKeyGenerator(Mii value) => value.Name; + protected override string DictionaryKeyGenerator(Mii value) => value.Name.ToString(); private static int _miiCount = 1; - + private readonly string[] dataList = new[] { "AAAAQgBlAGUAAAAAAAAAAAAAAAAAAEBAgeGIAcKv7BAABEJBMb0oogiMCEgUTbiNAIoAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=", @@ -26,13 +27,12 @@ public class MiiFactory : MockingDataFactory "wBAAZwBhAG4AZwBuAGUAdwB3AHMDyAAAgAAAAAAAAAAEbDZAqaQosmBsCFgUTQCNAAoAgCIFAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "wBIATABpAGMAbwByAGkAYwBlAAAAAAosgAAAAAAAAAAgTH5AuUUo8kiRCtgAbUALguAAiiUFAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; - + public override Mii Create(int? seed = null) { - return new Mii - { - Name = $"Mii {_miiCount++}", - Data = dataList[(int)(Rand(seed).NextDouble() * dataList.Length)] - }; + var deserializerResult = MiiSerializer.Deserialize(Convert.FromBase64String(dataList[_miiCount++ % dataList.Length])); + if (deserializerResult.IsFailure) + throw new Exception("Failed to deserialize Mii data"); + return deserializerResult.Value; } } diff --git a/WheelWizard/Views/App.axaml.cs b/WheelWizard/Views/App.axaml.cs index b7a87296..5daaba97 100644 --- a/WheelWizard/Views/App.axaml.cs +++ b/WheelWizard/Views/App.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Threading; using Microsoft.Extensions.Logging; using WheelWizard.AutoUpdating; using WheelWizard.Services; @@ -8,6 +9,7 @@ using WheelWizard.Services.UrlProtocol; using WheelWizard.Services.WiiManagement.SaveData; using WheelWizard.WheelWizardData; +using WheelWizard.WiiManagement; namespace WheelWizard.Views; @@ -58,6 +60,7 @@ private async void OnInitializedAsync() var updateService = Services.GetRequiredService(); var whWzDataService = Services.GetRequiredService(); + await updateService.CheckForUpdatesAsync(); await whWzDataService.LoadBadgesAsync(); InitializeManagers(); @@ -73,7 +76,6 @@ private static void InitializeManagers() { WhWzStatusManager.Instance.Start(); RRLiveRooms.Instance.Start(); - GameDataLoader.Instance.Start(); } public override void OnFrameworkInitializationCompleted() @@ -81,7 +83,8 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new Layout(); - + var gameDataService = Services.GetRequiredService(); + gameDataService.LoadGameData(); OnInitializedAsync(); } diff --git a/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml.cs b/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml.cs index 4410cc6a..3be589cc 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml.cs +++ b/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml.cs @@ -6,66 +6,76 @@ using WheelWizard.Models.Settings; using WheelWizard.Resources.Languages; using WheelWizard.Services.WiiManagement.SaveData; +using WheelWizard.Shared.DependencyInjection; using WheelWizard.Views.Pages; +using WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.Domain.Mii; + namespace WheelWizard.Views.Components; -public class CurrentUserProfile : TemplatedControl, INotifyPropertyChanged +public class CurrentUserProfile : UserControlBase, INotifyPropertyChanged { + [Inject] private IGameDataSingletonService gameDataService { get; set; } = null!; + public static readonly StyledProperty FriendCodeProperty = AvaloniaProperty.Register(nameof(FriendCode)); + public string FriendCode { get => GetValue(FriendCodeProperty); set => SetValue(FriendCodeProperty, value); } - + public static readonly StyledProperty UserNameProperty = AvaloniaProperty.Register(nameof(UserName)); + public string UserName { get => GetValue(UserNameProperty); set => SetValue(UserNameProperty, value); } - + public static readonly StyledProperty MiiProperty = AvaloniaProperty.Register(nameof(Mii)); + public Mii? Mii { get => GetValue(MiiProperty); - set + set { SetValue(MiiProperty, value); OnPropertyChanged(nameof(Mii)); } } - + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); + gameDataService.RefreshOnlineStatus(); + var currentUser = gameDataService.CurrentUser; - GameDataLoader.Instance.RefreshOnlineStatus(); - var currentUser = GameDataLoader.Instance.GetCurrentUser; - - var name = currentUser.MiiName; + var name = currentUser.NameOfMii; if (name == SettingValues.NoName) name = Online.NoName; if (name == SettingValues.NoLicense) name = Online.NoLicense; - + UserName = name; FriendCode = currentUser.FriendCode; Mii = currentUser.Mii; } protected override void OnPointerPressed(PointerPressedEventArgs e) => NavigationManager.NavigateTo(); - + #region PropertyChanged + public event PropertyChangedEventHandler? PropertyChanged; + protected virtual void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } + #endregion } - diff --git a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs b/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs index a41b68aa..83d6446b 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs +++ b/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs @@ -7,6 +7,7 @@ using WheelWizard.Models.MiiImages; using WheelWizard.Services.Settings; using WheelWizard.Views.Components.MiiImages; +using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.Views.Components; @@ -14,72 +15,83 @@ public class DetailedProfileBox : TemplatedControl, INotifyPropertyChanged { public static readonly StyledProperty MiiProperty = AvaloniaProperty.Register(nameof(Mii)); + public Mii? Mii { get => GetValue(MiiProperty); - set + set { SetValue(MiiProperty, value); OnPropertyChanged(nameof(Mii)); } } - + public static readonly StyledProperty MiiImageProperty = AvaloniaProperty.Register(nameof(MiiImage)); + public Bitmap? MiiImage { get => GetValue(MiiImageProperty); set => SetValue(MiiImageProperty, value); } - + public static readonly StyledProperty IsOnlineProperty = AvaloniaProperty.Register(nameof(IsOnline)); + public bool IsOnline { get => GetValue(IsOnlineProperty); set => SetValue(IsOnlineProperty, value); } - + public static readonly StyledProperty TotalWonProperty = AvaloniaProperty.Register(nameof(TotalWon)); + public string TotalWon { get => GetValue(TotalWonProperty); set => SetValue(TotalWonProperty, value); } + public static readonly StyledProperty TotalRacesProperty = AvaloniaProperty.Register(nameof(TotalRaces)); + public string TotalRaces { get => GetValue(TotalRacesProperty); set => SetValue(TotalRacesProperty, value); } - + public static readonly StyledProperty VrProperty = AvaloniaProperty.Register(nameof(Vr)); + public string Vr { get => GetValue(VrProperty); set => SetValue(VrProperty, value); } + public static readonly StyledProperty BrProperty = AvaloniaProperty.Register(nameof(Br)); + public string Br { get => GetValue(BrProperty); set => SetValue(BrProperty, value); } - + public static readonly StyledProperty FriendCodeProperty = AvaloniaProperty.Register(nameof(FriendCode)); + public string FriendCode { get => GetValue(FriendCodeProperty); set => SetValue(FriendCodeProperty, value); } - + public static readonly StyledProperty UserNameProperty = AvaloniaProperty.Register(nameof(UserName)); + public string UserName { get => GetValue(UserNameProperty); @@ -88,31 +100,35 @@ public string UserName public static readonly StyledProperty IsCheckedProperty = AvaloniaProperty.Register(nameof(IsChecked)); + public bool IsChecked { get => GetValue(IsCheckedProperty); set => SetValue(IsCheckedProperty, value); } - - public static readonly StyledProperty< EventHandler?> OnCheckedProperty = - AvaloniaProperty.Register?>(nameof(OnChecked)); - public EventHandler? OnChecked + + public static readonly StyledProperty?> OnCheckedProperty = + AvaloniaProperty.Register?>(nameof(OnChecked)); + + public EventHandler? OnChecked { get => GetValue(OnCheckedProperty); set => SetValue(OnCheckedProperty, value); } - - public static readonly StyledProperty< EventHandler?> OnRenameProperty = - AvaloniaProperty.Register(nameof(OnRename)); - public EventHandler? OnRename + + public static readonly StyledProperty OnRenameProperty = + AvaloniaProperty.Register(nameof(OnRename)); + + public EventHandler? OnRename { get => GetValue(OnRenameProperty); set => SetValue(OnRenameProperty, value); } - - public static readonly StyledProperty< Action?> ViewRoomActionProperty = - AvaloniaProperty.Register?>(nameof(ViewRoomAction)); - public Action? ViewRoomAction + + public static readonly StyledProperty?> ViewRoomActionProperty = + AvaloniaProperty.Register?>(nameof(ViewRoomAction)); + + public Action? ViewRoomAction { get => GetValue(ViewRoomActionProperty); set => SetValue(ViewRoomActionProperty, value); @@ -122,33 +138,33 @@ public void ViewRoom(object? sender, RoutedEventArgs e) { ViewRoomAction.Invoke(FriendCode); } - + private void CopyFriendCode(object? obj, EventArgs e) { TopLevel.GetTopLevel(this)?.Clipboard?.SetTextAsync(FriendCode); } - + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - + var checkBox = e.NameScope.Find("CheckBox"); if (checkBox != null) checkBox.Checked += OnChecked; - - var viewRoomButton = e.NameScope.Find