From d14e0c163b753003b46f6e94b346926cbfa4376a Mon Sep 17 00:00:00 2001 From: WantToBeeMe <93130991+WantToBeeMe@users.noreply.github.com> Date: Sun, 27 Apr 2025 13:11:30 +0200 Subject: [PATCH 1/6] . --- .../MiiImages/Domain/IMiiIMagesApi.cs | 10 ++++++ .../Features/MiiImages/MiiImagesExtensions.cs | 16 ++++++++++ .../MiiImages/MiiImagesSingletonService.cs | 32 +++++++++++++++++++ WheelWizard/Services/Endpoints.cs | 6 +++- .../Services/WiiManagement/MiiImageManager.cs | 4 ++- WheelWizard/SetupExtensions.cs | 2 ++ 6 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs create mode 100644 WheelWizard/Features/MiiImages/MiiImagesExtensions.cs create mode 100644 WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs diff --git a/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs b/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs new file mode 100644 index 00000000..891a18c4 --- /dev/null +++ b/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs @@ -0,0 +1,10 @@ +using Avalonia.Media.Imaging; +using Refit; + +namespace WheelWizard.MiiImages.Domain; + +public interface IMiiIMagesApi +{ + [Get("/miis/image.png")] + Task GetImageAsync(string data); +} diff --git a/WheelWizard/Features/MiiImages/MiiImagesExtensions.cs b/WheelWizard/Features/MiiImages/MiiImagesExtensions.cs new file mode 100644 index 00000000..550b548e --- /dev/null +++ b/WheelWizard/Features/MiiImages/MiiImagesExtensions.cs @@ -0,0 +1,16 @@ +using WheelWizard.MiiImages.Domain; +using WheelWizard.Services; + +namespace WheelWizard.MiiImages; + +public static class MiiImagesExtensions +{ + public static IServiceCollection AddMiiImages(this IServiceCollection services) + { + services.AddWhWzRefitApi(Endpoints.MiiImageAddress); + + services.AddSingleton(); + + return services; + } +} diff --git a/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs new file mode 100644 index 00000000..7b4f3019 --- /dev/null +++ b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs @@ -0,0 +1,32 @@ +using Avalonia.Media.Imaging; +using WheelWizard.MiiImages.Domain; +using WheelWizard.Shared.Services; + +namespace WheelWizard.MiiImages; + +public interface IMiiImagesSingletonService +{ + Task> GetImageAsync(string data); +} + +public class MiiImagesSingletonService(IApiCaller apiCaller) : IMiiImagesSingletonService +{ + public async Task> GetImageAsync(string data) + { + return await apiCaller.CallApiAsync(api => GetBitmapAsync(api, data)); + } + + private static async Task GetBitmapAsync(IMiiIMagesApi api, string data) + { + var result = await api.GetImageAsync(data); + using var memoryStream = new MemoryStream(); + await result.CopyToAsync(memoryStream); + memoryStream.Position = 0; // Reset stream position for Bitmap constructor + + if (memoryStream.Length == 0) + throw new InvalidOperationException("Received empty image stream."); + + var bitmap = new Bitmap(memoryStream); + return bitmap; + } +} diff --git a/WheelWizard/Services/Endpoints.cs b/WheelWizard/Services/Endpoints.cs index 05169fdf..7f7c8a8e 100644 --- a/WheelWizard/Services/Endpoints.cs +++ b/WheelWizard/Services/Endpoints.cs @@ -22,6 +22,11 @@ public static class Endpoints /// public const string GitHubAddress = "https://api.github.com"; + /// + /// The address for the Mii image + /// + public const string MiiImageAddress = "https://studio.mii.nintendo.com"; + // TODO: Refactor all the URLs seen below // Retro Rewind @@ -37,7 +42,6 @@ public static class Endpoints public const string SupportLink = "https://ko-fi.com/wheelwizard"; // Other - public const string MiiStudioUrl = "https://qrcode.rc24.xyz/cgi-bin/studio.cgi"; public const string MiiImageUrl = "https://studio.mii.nintendo.com/miis/image.png"; public const string MiiChannelWAD = "-"; diff --git a/WheelWizard/Services/WiiManagement/MiiImageManager.cs b/WheelWizard/Services/WiiManagement/MiiImageManager.cs index 277a61e8..12f5d3fe 100644 --- a/WheelWizard/Services/WiiManagement/MiiImageManager.cs +++ b/WheelWizard/Services/WiiManagement/MiiImageManager.cs @@ -1,7 +1,9 @@ using System.Text; using Avalonia.Media.Imaging; +using WheelWizard.MiiImages; using WheelWizard.Models.MiiImages; using WheelWizard.Services.WiiManagement.SaveData; +using WheelWizard.Views; namespace WheelWizard.Services.WiiManagement; @@ -363,7 +365,7 @@ MiiImageVariants.Variant variant Console.WriteLine($"Received empty image stream from {fullImageUrl}."); return (null, false); } - + var bitmap = new Bitmap(memoryStream); // Bitmap constructor reads from the stream return (bitmap, true); // Success } diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index d16748e1..a97ab756 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -5,6 +5,7 @@ using WheelWizard.Branding; using WheelWizard.GameBanana; using WheelWizard.GitHub; +using WheelWizard.MiiImages; using WheelWizard.RrRooms; using WheelWizard.Shared.Services; using WheelWizard.WheelWizardData; @@ -27,6 +28,7 @@ public static void AddWheelWizardServices(this IServiceCollection services) services.AddWhWzData(); services.AddWiiManagement(); services.AddGameBanana(); + services.AddMiiImages(); // IO Abstractions services.AddSingleton(); From dbda7157496f82192bd22006115f3db7b8545b55 Mon Sep 17 00:00:00 2001 From: WantToBeeMe <93130991+WantToBeeMe@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:34:12 +0200 Subject: [PATCH 2/6] steppie closer --- .../MiiImages/MiiImagesSingletonService.cs | 11 +- .../MiiImages/MiiStudioDataSerializer.cs | 220 ++++++++++++++++++ 2 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs diff --git a/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs index 7b4f3019..61229879 100644 --- a/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs +++ b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs @@ -1,19 +1,24 @@ using Avalonia.Media.Imaging; using WheelWizard.MiiImages.Domain; using WheelWizard.Shared.Services; +using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.MiiImages; public interface IMiiImagesSingletonService { - Task> GetImageAsync(string data); + Task> GetImageAsync(Mii mii); } public class MiiImagesSingletonService(IApiCaller apiCaller) : IMiiImagesSingletonService { - public async Task> GetImageAsync(string data) + public async Task> GetImageAsync(Mii mii) { - return await apiCaller.CallApiAsync(api => GetBitmapAsync(api, data)); + var data = MiiStudioDataSerializer.Serialize(mii); + if (data.IsFailure) + return data.Error; + + return await apiCaller.CallApiAsync(api => GetBitmapAsync(api, data.Value)); } private static async Task GetBitmapAsync(IMiiIMagesApi api, string data) diff --git a/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs b/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs new file mode 100644 index 00000000..bf7101cc --- /dev/null +++ b/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs @@ -0,0 +1,220 @@ +using System.Text; +using WheelWizard.Services.WiiManagement.SaveData; +using WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.Domain.Mii; + +namespace WheelWizard.MiiImages; + +public class MiiStudioDataSerializer +{ + private static readonly int[] MakeupMap = [0, 1, 6, 9, 0, 0, 0, 0, 0, 10, 0, 0]; + private static readonly int[] WrinklesMap = [0, 0, 0, 0, 5, 2, 3, 7, 8, 0, 9, 11]; + + /// + /// Serialize the Mii in t the encoded data string required by the Nintendo Mii Image URL. + /// + public static OperationResult Serialize(Mii mii) + { + // First we create a clone of the Mii that only contains features that are visual + // This means no name, date or other non-visual features. + // That way when you change a feature that is not visual, it will not result in different bytes. + Mii visualMiiClone = new(); + visualMiiClone.MiiEyebrows = mii.MiiEyebrows; + visualMiiClone.MiiEyes = mii.MiiEyes; + visualMiiClone.MiiFacialHair = mii.MiiFacialHair; + visualMiiClone.MiiGlasses = mii.MiiGlasses; + visualMiiClone.MiiHair = mii.MiiHair; + visualMiiClone.MiiLips = mii.MiiLips; + visualMiiClone.MiiNose = mii.MiiNose; + visualMiiClone.Height = mii.Height; + visualMiiClone.Weight = mii.Weight; + visualMiiClone.IsGirl = mii.IsGirl; + visualMiiClone.MiiMole = mii.MiiMole; + visualMiiClone.MiiFavoriteColor = mii.MiiFavoriteColor; + visualMiiClone.MiiFacial = mii.MiiFacial; + + var serialized = MiiSerializer.Serialize(visualMiiClone); + if(serialized.IsFailure) + return serialized.Error; + + var bytes = GenerateStudioDataArray(serialized.Value); + return Ok(EncodeStudioData(bytes)); + } + + /// + /// Encodes the studio data array into the hex string format required by the API. + /// Based on the encodeStudio function in the provided JS. + /// + private static string EncodeStudioData(byte[] studioData) + { + byte n = 0; + var dest = new StringBuilder("00", (studioData.Length + 1) * 2); // Preallocate buffer ("00" + 2 chars per byte) + + foreach (var b in studioData) + { + var eo = (byte)((7 + (b ^ n)) & 0xFF); + n = eo; // Update n *after* calculating eo, using the new eo + dest.Append(eo.ToString("x2")); // Append hex representation + } + return dest.ToString(); + } + + /// + /// Parses the Wii Mii data and generates the 46-byte studio data array. + /// Based on the miiFileRead logic for Wii data in the provided JS. + /// Uses BigEndianBinaryReader for reading values. + /// + private static byte[] GenerateStudioDataArray(byte[] buf) + { + var studio = new byte[46]; // Size of studio data array + + // Parse Wii data fields and map them to studio array indices + // Offsets and logic match the 'else' block (Wii part) of miiFileRead + + // --- Basic Info --- + var tmpU16_0 = BigEndianBinaryReader.BufferToUint16(buf, 0); + var isGirl = ((tmpU16_0 >> 14) & 1) == 1; + var favColor = (int)((tmpU16_0 >> 1) & 0xF); + int height = buf[0x16]; + int weight = buf[0x17]; + + studio[0x16] = (byte)(isGirl ? 1 : 0); // Gender + studio[0x15] = (byte)favColor; // Favorite Color + studio[0x1E] = (byte)height; // Height + studio[2] = (byte)weight; // Weight (mapped to index 2 in studio) + + // --- Face --- + var tmpU16_20 = BigEndianBinaryReader.BufferToUint16(buf, 0x20); + var faceShape = (int)(tmpU16_20 >> 13); + var skinColor = (int)((tmpU16_20 >> 10) & 7); + var facialFeature = (int)((tmpU16_20 >> 6) & 0xF); // Note: JS uses 0xF mask here, map to makeup/wrinkles + var makeup = MakeupMap.Length > facialFeature ? MakeupMap[facialFeature] : 0; + var wrinkles = WrinklesMap.Length > facialFeature ? WrinklesMap[facialFeature] : 0; + + studio[0x13] = (byte)faceShape; + studio[0x11] = (byte)skinColor; + studio[0x14] = (byte)wrinkles; + studio[0x12] = (byte)makeup; + + // --- Hair --- + var tmpU16_22 = BigEndianBinaryReader.BufferToUint16(buf, 0x22); + var hairStyle = (int)(tmpU16_22 >> 9); + var hairColor = (int)((tmpU16_22 >> 6) & 7); + var flipHair = (int)((tmpU16_22 >> 5) & 1); + + studio[0x1D] = (byte)hairStyle; + studio[0x1B] = (byte)(hairColor == 0 ? 8 : hairColor); // Map color 0 to 8 + studio[0x1C] = (byte)flipHair; + + // --- Eyebrows --- + var tmpU32_24 = BigEndianBinaryReader.BufferToUint32(buf, 0x24); + var eyebrowStyle = (int)(tmpU32_24 >> 27); + var eyebrowRotation = (int)((tmpU32_24 >> 22) & 0xF); // Note: JS uses 0xF mask + var eyebrowColor = (int)((tmpU32_24 >> 13) & 7); + var eyebrowScale = (int)((tmpU32_24 >> 9) & 0xF); + var eyebrowYscale = 3; // Hardcoded in JS + var eyebrowYposition = (int)((tmpU32_24 >> 4) & 0x1F); + var eyebrowXspacing = (int)(tmpU32_24 & 0xF); + + studio[0xE] = (byte)eyebrowStyle; + studio[0xC] = (byte)eyebrowRotation; + studio[0xB] = (byte)(eyebrowColor == 0 ? 8 : eyebrowColor); // Map color 0 to 8 + studio[0xD] = (byte)eyebrowScale; + studio[0xA] = (byte)eyebrowYscale; + studio[0x10] = (byte)eyebrowYposition; + studio[0xF] = (byte)eyebrowXspacing; + + // --- Eyes --- + var tmpU32_28 = BigEndianBinaryReader.BufferToUint32(buf, 0x28); + var eyeStyle = (int)(tmpU32_28 >> 26); + var eyeRotation = (int)((tmpU32_28 >> 21) & 7); // Note: JS uses 7 (0b111) mask + var eyeYposition = (int)((tmpU32_28 >> 16) & 0x1F); + var eyeColor = (int)((tmpU32_28 >> 13) & 7); + var eyeScale = (int)((tmpU32_28 >> 9) & 7); // Note: JS uses 7 mask + var eyeYscale = 3; // Hardcoded in JS + var eyeXspacing = (int)((tmpU32_28 >> 5) & 0xF); + // int unknownEyeBit = (int)(tmpU32_28 & 0x1F); // Lower 5 bits unused in JS mapping + + studio[7] = (byte)eyeStyle; + studio[5] = (byte)eyeRotation; + studio[9] = (byte)eyeYposition; + studio[4] = (byte)(eyeColor + 8); // Map color 0-7 to 8-15 + studio[6] = (byte)eyeScale; + studio[3] = (byte)eyeYscale; + studio[8] = (byte)eyeXspacing; + + // --- Nose --- + var tmpU16_2C = BigEndianBinaryReader.BufferToUint16(buf, 0x2C); + var noseStyle = (int)(tmpU16_2C >> 12); + var noseScale = (int)((tmpU16_2C >> 8) & 0xF); + var noseYposition = (int)((tmpU16_2C >> 3) & 0x1F); + // int unknownNoseBits = (int)(tmpU16_2C & 7); // Lower 3 bits unused + + studio[0x2C] = (byte)noseStyle; + studio[0x2B] = (byte)noseScale; + studio[0x2D] = (byte)noseYposition; + + // --- Mouth --- + var tmpU16_2E = BigEndianBinaryReader.BufferToUint16(buf, 0x2E); + var mouseStyle = (int)(tmpU16_2E >> 11); + var mouseColor = (int)((tmpU16_2E >> 9) & 3); // Lip color (0-3) + var mouseScale = (int)((tmpU16_2E >> 5) & 0xF); + var mouseYscale = 3; // Hardcoded in JS + var mouseYposition = (int)(tmpU16_2E & 0x1F); + + studio[0x26] = (byte)mouseStyle; + studio[0x24] = (byte)(mouseColor < 4 ? mouseColor + 19 : 0); // Map 0-3 to 19-22, else 0 + studio[0x25] = (byte)mouseScale; + studio[0x23] = (byte)mouseYscale; + studio[0x27] = (byte)mouseYposition; + + // --- Beard / Mustache --- + var tmpU16_32 = BigEndianBinaryReader.BufferToUint16(buf, 0x32); + var mustacheStyle = (int)(tmpU16_32 >> 14); + var beardStyle = (int)((tmpU16_32 >> 12) & 3); + var facialHairColor = (int)((tmpU16_32 >> 9) & 7); + var mustacheScale = (int)((tmpU16_32 >> 5) & 0xF); + var mustacheYposition = (int)(tmpU16_32 & 0x1F); + + studio[0x29] = (byte)mustacheStyle; + studio[1] = (byte)beardStyle; // Mapped to index 1 + studio[0] = (byte)(facialHairColor == 0 ? 8 : facialHairColor); // Map color 0 to 8, Mapped to index 0 + studio[0x28] = (byte)mustacheScale; + studio[0x2A] = (byte)mustacheYposition; + + // --- Glasses --- + var tmpU16_30 = BigEndianBinaryReader.BufferToUint16(buf, 0x30); + var glassesStyle = (int)(tmpU16_30 >> 12); + var glassesColor = (int)((tmpU16_30 >> 9) & 7); + var glassesScale = (int)((tmpU16_30 >> 5) & 7); // Note: JS uses 7 mask + var glassesYposition = (int)(tmpU16_30 & 0x1F); + // int unknownGlassesBits = (int)((tmpU16_30 >> 12) & 7); // Middle bits unused + + studio[0x19] = (byte)glassesStyle; + byte mappedGlassesColor; + if (glassesColor == 0) + mappedGlassesColor = 8; // black -> 8 + else if (glassesColor < 6) + mappedGlassesColor = (byte)(glassesColor + 13); // 1-5 -> 14-18 + else + mappedGlassesColor = 0; // 6, 7 -> 0 (no mapping?) + studio[0x17] = mappedGlassesColor; + studio[0x18] = (byte)glassesScale; + studio[0x1A] = (byte)glassesYposition; + + // --- Mole --- + var tmpU16_34 = BigEndianBinaryReader.BufferToUint16(buf, 0x34); + var enableMole = (int)(tmpU16_34 >> 15); + var moleScale = (int)((tmpU16_34 >> 11) & 0xF); + var moleYposition = (int)((tmpU16_34 >> 6) & 0x1F); + var moleXposition = (int)((tmpU16_34 >> 1) & 0x1F); + // int unknownMoleBit = (int)(tmpU16_34 & 1); // Lowest bit unused + + studio[0x20] = (byte)enableMole; + studio[0x1F] = (byte)moleScale; + studio[0x22] = (byte)moleYposition; + studio[0x21] = (byte)moleXposition; + + return studio; + } +} From 94c307e7617fbce5c5ecf51b849dbda207fd1c24 Mon Sep 17 00:00:00 2001 From: WantToBeeMe <93130991+WantToBeeMe@users.noreply.github.com> Date: Fri, 2 May 2025 20:52:43 +0200 Subject: [PATCH 3/6] i did a little cooking --- .../MiiImages/MiiImagesSingletonService.cs | 6 +- .../MiiImages/MiiStudioDataSerializer.cs | 68 ++++++++++--------- .../Features/WiiManagement/Domain/Mii/Mii.cs | 14 +--- .../Features/WiiManagement/MiiSerializer.cs | 40 +++++------ .../Services/WiiManagement/MiiImageManager.cs | 2 +- .../WhWzLibrary/MiiImages/BaseMiiImage.cs | 50 +++++++------- 6 files changed, 87 insertions(+), 93 deletions(-) diff --git a/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs index 61229879..e06b38f7 100644 --- a/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs +++ b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs @@ -7,17 +7,17 @@ namespace WheelWizard.MiiImages; public interface IMiiImagesSingletonService { - Task> GetImageAsync(Mii mii); + Task> GetImageAsync(Mii? mii); } public class MiiImagesSingletonService(IApiCaller apiCaller) : IMiiImagesSingletonService { - public async Task> GetImageAsync(Mii mii) + public async Task> GetImageAsync(Mii? mii) { var data = MiiStudioDataSerializer.Serialize(mii); if (data.IsFailure) return data.Error; - + return await apiCaller.CallApiAsync(api => GetBitmapAsync(api, data.Value)); } diff --git a/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs b/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs index bf7101cc..010d60b4 100644 --- a/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs +++ b/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs @@ -9,14 +9,17 @@ public class MiiStudioDataSerializer { private static readonly int[] MakeupMap = [0, 1, 6, 9, 0, 0, 0, 0, 0, 10, 0, 0]; private static readonly int[] WrinklesMap = [0, 0, 0, 0, 5, 2, 3, 7, 8, 0, 9, 11]; - + /// /// Serialize the Mii in t the encoded data string required by the Nintendo Mii Image URL. /// - public static OperationResult Serialize(Mii mii) + public static OperationResult Serialize(Mii? mii) { + if (mii == null) + return Fail("Mii cannot be null."); + // First we create a clone of the Mii that only contains features that are visual - // This means no name, date or other non-visual features. + // This means no name, date or other non-visual features. // That way when you change a feature that is not visual, it will not result in different bytes. Mii visualMiiClone = new(); visualMiiClone.MiiEyebrows = mii.MiiEyebrows; @@ -29,18 +32,19 @@ public static OperationResult Serialize(Mii mii) visualMiiClone.Height = mii.Height; visualMiiClone.Weight = mii.Weight; visualMiiClone.IsGirl = mii.IsGirl; - visualMiiClone.MiiMole = mii.MiiMole; + visualMiiClone.MiiMole = mii.MiiMole; visualMiiClone.MiiFavoriteColor = mii.MiiFavoriteColor; visualMiiClone.MiiFacial = mii.MiiFacial; - + visualMiiClone.MiiId = 1; // Mii ID cant be 0 if you want to serialize it so... + var serialized = MiiSerializer.Serialize(visualMiiClone); - if(serialized.IsFailure) + if (serialized.IsFailure) return serialized.Error; var bytes = GenerateStudioDataArray(serialized.Value); return Ok(EncodeStudioData(bytes)); } - + /// /// Encodes the studio data array into the hex string format required by the API. /// Based on the encodeStudio function in the provided JS. @@ -58,7 +62,7 @@ private static string EncodeStudioData(byte[] studioData) } return dest.ToString(); } - + /// /// Parses the Wii Mii data and generates the 46-byte studio data array. /// Based on the miiFileRead logic for Wii data in the provided JS. @@ -112,36 +116,36 @@ private static byte[] GenerateStudioDataArray(byte[] buf) var eyebrowRotation = (int)((tmpU32_24 >> 22) & 0xF); // Note: JS uses 0xF mask var eyebrowColor = (int)((tmpU32_24 >> 13) & 7); var eyebrowScale = (int)((tmpU32_24 >> 9) & 0xF); - var eyebrowYscale = 3; // Hardcoded in JS - var eyebrowYposition = (int)((tmpU32_24 >> 4) & 0x1F); - var eyebrowXspacing = (int)(tmpU32_24 & 0xF); + var eyebrowYScale = 3; // Hardcoded in JS + var eyebrowYPosition = (int)((tmpU32_24 >> 4) & 0x1F); + var eyebrowXSpacing = (int)(tmpU32_24 & 0xF); studio[0xE] = (byte)eyebrowStyle; studio[0xC] = (byte)eyebrowRotation; studio[0xB] = (byte)(eyebrowColor == 0 ? 8 : eyebrowColor); // Map color 0 to 8 studio[0xD] = (byte)eyebrowScale; - studio[0xA] = (byte)eyebrowYscale; - studio[0x10] = (byte)eyebrowYposition; - studio[0xF] = (byte)eyebrowXspacing; + studio[0xA] = (byte)eyebrowYScale; + studio[0x10] = (byte)eyebrowYPosition; + studio[0xF] = (byte)eyebrowXSpacing; // --- Eyes --- var tmpU32_28 = BigEndianBinaryReader.BufferToUint32(buf, 0x28); var eyeStyle = (int)(tmpU32_28 >> 26); var eyeRotation = (int)((tmpU32_28 >> 21) & 7); // Note: JS uses 7 (0b111) mask - var eyeYposition = (int)((tmpU32_28 >> 16) & 0x1F); + var eyeYPosition = (int)((tmpU32_28 >> 16) & 0x1F); var eyeColor = (int)((tmpU32_28 >> 13) & 7); var eyeScale = (int)((tmpU32_28 >> 9) & 7); // Note: JS uses 7 mask - var eyeYscale = 3; // Hardcoded in JS - var eyeXspacing = (int)((tmpU32_28 >> 5) & 0xF); + var eyeYScale = 3; // Hardcoded in JS + var eyeXSpacing = (int)((tmpU32_28 >> 5) & 0xF); // int unknownEyeBit = (int)(tmpU32_28 & 0x1F); // Lower 5 bits unused in JS mapping studio[7] = (byte)eyeStyle; studio[5] = (byte)eyeRotation; - studio[9] = (byte)eyeYposition; + studio[9] = (byte)eyeYPosition; studio[4] = (byte)(eyeColor + 8); // Map color 0-7 to 8-15 studio[6] = (byte)eyeScale; - studio[3] = (byte)eyeYscale; - studio[8] = (byte)eyeXspacing; + studio[3] = (byte)eyeYScale; + studio[8] = (byte)eyeXSpacing; // --- Nose --- var tmpU16_2C = BigEndianBinaryReader.BufferToUint16(buf, 0x2C); @@ -159,14 +163,14 @@ private static byte[] GenerateStudioDataArray(byte[] buf) var mouseStyle = (int)(tmpU16_2E >> 11); var mouseColor = (int)((tmpU16_2E >> 9) & 3); // Lip color (0-3) var mouseScale = (int)((tmpU16_2E >> 5) & 0xF); - var mouseYscale = 3; // Hardcoded in JS - var mouseYposition = (int)(tmpU16_2E & 0x1F); + var mouseYScale = 3; // Hardcoded in JS + var mouseYPosition = (int)(tmpU16_2E & 0x1F); studio[0x26] = (byte)mouseStyle; studio[0x24] = (byte)(mouseColor < 4 ? mouseColor + 19 : 0); // Map 0-3 to 19-22, else 0 studio[0x25] = (byte)mouseScale; - studio[0x23] = (byte)mouseYscale; - studio[0x27] = (byte)mouseYposition; + studio[0x23] = (byte)mouseYScale; + studio[0x27] = (byte)mouseYPosition; // --- Beard / Mustache --- var tmpU16_32 = BigEndianBinaryReader.BufferToUint16(buf, 0x32); @@ -174,20 +178,20 @@ private static byte[] GenerateStudioDataArray(byte[] buf) var beardStyle = (int)((tmpU16_32 >> 12) & 3); var facialHairColor = (int)((tmpU16_32 >> 9) & 7); var mustacheScale = (int)((tmpU16_32 >> 5) & 0xF); - var mustacheYposition = (int)(tmpU16_32 & 0x1F); + var mustacheYPosition = (int)(tmpU16_32 & 0x1F); studio[0x29] = (byte)mustacheStyle; studio[1] = (byte)beardStyle; // Mapped to index 1 studio[0] = (byte)(facialHairColor == 0 ? 8 : facialHairColor); // Map color 0 to 8, Mapped to index 0 studio[0x28] = (byte)mustacheScale; - studio[0x2A] = (byte)mustacheYposition; + studio[0x2A] = (byte)mustacheYPosition; // --- Glasses --- var tmpU16_30 = BigEndianBinaryReader.BufferToUint16(buf, 0x30); var glassesStyle = (int)(tmpU16_30 >> 12); var glassesColor = (int)((tmpU16_30 >> 9) & 7); var glassesScale = (int)((tmpU16_30 >> 5) & 7); // Note: JS uses 7 mask - var glassesYposition = (int)(tmpU16_30 & 0x1F); + var glassesYPosition = (int)(tmpU16_30 & 0x1F); // int unknownGlassesBits = (int)((tmpU16_30 >> 12) & 7); // Middle bits unused studio[0x19] = (byte)glassesStyle; @@ -200,20 +204,20 @@ private static byte[] GenerateStudioDataArray(byte[] buf) mappedGlassesColor = 0; // 6, 7 -> 0 (no mapping?) studio[0x17] = mappedGlassesColor; studio[0x18] = (byte)glassesScale; - studio[0x1A] = (byte)glassesYposition; + studio[0x1A] = (byte)glassesYPosition; // --- Mole --- var tmpU16_34 = BigEndianBinaryReader.BufferToUint16(buf, 0x34); var enableMole = (int)(tmpU16_34 >> 15); var moleScale = (int)((tmpU16_34 >> 11) & 0xF); - var moleYposition = (int)((tmpU16_34 >> 6) & 0x1F); - var moleXposition = (int)((tmpU16_34 >> 1) & 0x1F); + var moleYPosition = (int)((tmpU16_34 >> 6) & 0x1F); + var moleXPosition = (int)((tmpU16_34 >> 1) & 0x1F); // int unknownMoleBit = (int)(tmpU16_34 & 1); // Lowest bit unused studio[0x20] = (byte)enableMole; studio[0x1F] = (byte)moleScale; - studio[0x22] = (byte)moleYposition; - studio[0x21] = (byte)moleXposition; + studio[0x22] = (byte)moleYPosition; + studio[0x21] = (byte)moleXPosition; return studio; } diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs index 9adaa999..a168800d 100644 --- a/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs @@ -4,16 +4,6 @@ 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(this, variant); - return _images[variant]; - } - public bool IsInvalid { get; set; } public bool IsGirl { get; set; } public DateOnly Date { get; set; } = new(2000, 1, 1); @@ -24,10 +14,10 @@ public MiiImage GetImage(MiiImageVariants.Variant variant) public MiiScale Height { get; set; } = new(1); public MiiScale Weight { get; set; } = new(1); - //Mii ID is also refered as Avatar ID + //Mii ID is also referred as Avatar ID public uint MiiId { get; set; } - //This is also refferd as Client ID + //This is also referred as Client ID public byte SystemId0 { get; set; } public byte SystemId1 { get; set; } public byte SystemId2 { get; set; } diff --git a/WheelWizard/Features/WiiManagement/MiiSerializer.cs b/WheelWizard/Features/WiiManagement/MiiSerializer.cs index ea730f15..f555a43a 100644 --- a/WheelWizard/Features/WiiManagement/MiiSerializer.cs +++ b/WheelWizard/Features/WiiManagement/MiiSerializer.cs @@ -11,8 +11,8 @@ public static class MiiSerializer public static OperationResult Serialize(Mii? mii) { if (mii == null || mii.MiiId == 0) - return Fail("Mii cannot be null."); - byte[] data = new byte[MiiBlockSize]; + return Fail("Mii cannot be null. (no have a Mii ID of 0)"); + var data = new byte[MiiBlockSize]; // Header (0x00 - 0x01) ushort header = 0; @@ -151,11 +151,11 @@ public static OperationResult Deserialize(byte[]? data) var mii = new Mii(); // Header (0x00 - 0x01) - ushort header = (ushort)((data[0] << 8) | data[1]); + var 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; + var month = (header >> 10) & 0x0F; + var day = (header >> 5) & 0x1F; mii.Date = new(2000, Math.Clamp(month, 1, 12), Math.Clamp(day, 1, 31)); var miiFavoriteColor = (uint)(header >> 1) & 0x0F; if (!Enum.IsDefined(typeof(MiiFavoriteColor), miiFavoriteColor)) @@ -190,7 +190,7 @@ public static OperationResult Deserialize(byte[]? data) mii.SystemId3 = data[0x1F]; // Face (0x20 - 0x21) - ushort face = (ushort)((data[0x20] << 8) | data[0x21]); + var face = (ushort)((data[0x20] << 8) | data[0x21]); var faceShape = ((face >> 13) & 0x07); var skinColor = (face >> 10) & 0x07; @@ -214,7 +214,7 @@ public static OperationResult Deserialize(byte[]? data) mii.MiiFacial = miiFacialResult; // Hair (0x22 - 0x23) - ushort hair = (ushort)((data[0x22] << 8) | data[0x23]); + var hair = (ushort)((data[0x22] << 8) | data[0x23]); var hairColor = (hair >> 6) & 0x07; if (!Enum.IsDefined(typeof(HairColor), hairColor)) return new InvalidDataException("Invalid HairColor"); @@ -224,7 +224,7 @@ public static OperationResult Deserialize(byte[]? data) mii.MiiHair = miiHairResult.Value; // Eyebrows (0x24 - 0x27) - uint brow = (uint)((data[0x24] << 24) | (data[0x25] << 16) | (data[0x26] << 8) | data[0x27]); + var 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"); @@ -241,7 +241,7 @@ public static OperationResult Deserialize(byte[]? data) mii.MiiEyebrows = miiEyebrowsResult.Value; // Eyes (0x28 - 0x2B) - uint eye = (uint)((data[0x28] << 24) | (data[0x29] << 16) | (data[0x2A] << 8) | data[0x2B]); + var 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"); @@ -258,27 +258,27 @@ public static OperationResult Deserialize(byte[]? data) mii.MiiEyes = miiEyesResult.Value; // Nose (0x2C - 0x2D) - ushort nose = (ushort)((data[0x2C] << 8) | data[0x2D]); + var 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)); + var miiNoseResult = MiiNose.Create((NoseType)noseType, ((nose >> 8) & 0x0F), ((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 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)); + var miiLipResult = MiiLip.Create(((lip >> 11) & 0x1F), (LipColor)lipColor, ((lip >> 5) & 0x0F), (lip & 0x1F)); if (miiLipResult.IsFailure) return miiLipResult.Error; mii.MiiLips = miiLipResult.Value; // Glasses (0x30 - 0x31) - ushort glasses = (ushort)((data[0x30] << 8) | data[0x31]); + var glasses = (ushort)((data[0x30] << 8) | data[0x31]); var glassesType = ((glasses >> 12) & 0x0F); if (!Enum.IsDefined(typeof(GlassesType), glassesType)) return new InvalidDataException("Invalid GlassesType"); @@ -288,15 +288,15 @@ public static OperationResult Deserialize(byte[]? data) var miiGlassesResult = MiiGlasses.Create( (GlassesType)glassesType, (GlassesColor)glassesColor, - (int)((glasses >> 5) & 0x07), - (int)(glasses & 0x1F) + ((glasses >> 5) & 0x07), + (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 facial = (ushort)((data[0x32] << 8) | data[0x33]); var mustacheType = ((facial >> 14) & 0x03); if (!Enum.IsDefined(typeof(MustacheType), mustacheType)) return new InvalidDataException("Invalid MustacheType"); @@ -310,15 +310,15 @@ public static OperationResult Deserialize(byte[]? data) (MustacheType)mustacheType, (BeardType)beardType, (MustacheColor)color, - (int)((facial >> 5) & 0x0F), - (int)(facial & 0x1F) + ((facial >> 5) & 0x0F), + (facial & 0x1F) ); if (miiFacialHairResult.IsFailure) return miiFacialHairResult.Error; mii.MiiFacialHair = miiFacialHairResult.Value; // Mole (0x34 - 0x35) - ushort mole = (ushort)((data[0x34] << 8) | data[0x35]); + var 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; diff --git a/WheelWizard/Services/WiiManagement/MiiImageManager.cs b/WheelWizard/Services/WiiManagement/MiiImageManager.cs index 12f5d3fe..91c81063 100644 --- a/WheelWizard/Services/WiiManagement/MiiImageManager.cs +++ b/WheelWizard/Services/WiiManagement/MiiImageManager.cs @@ -365,7 +365,7 @@ MiiImageVariants.Variant variant Console.WriteLine($"Received empty image stream from {fullImageUrl}."); return (null, false); } - + var bitmap = new Bitmap(memoryStream); // Bitmap constructor reads from the stream return (bitmap, true); // Success } diff --git a/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs b/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs index 158607d8..4247dc0a 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs +++ b/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Controls.Primitives; using Avalonia.Media.Imaging; +using WheelWizard.MiiImages; using WheelWizard.Models.MiiImages; using WheelWizard.WiiManagement.Domain.Mii; @@ -66,44 +67,43 @@ private static MiiImageVariants.Variant CoerceVariant(AvaloniaObject o, MiiImage return value; } - protected void OnVariantChanged(MiiImageVariants.Variant newValue) - { - ReloadImage(Mii?.GetImage(ImageVariant), Mii?.GetImage(newValue)); - } + protected void OnVariantChanged(MiiImageVariants.Variant newValue) => ReloadImage(Mii, newValue); - protected void OnMiiChanged(Mii? newValue) - { - ReloadImage(Mii?.GetImage(ImageVariant), newValue?.GetImage(ImageVariant)); - } + protected void OnMiiChanged(Mii? newValue) => ReloadImage(newValue, ImageVariant); - protected void ReloadImage(MiiImage? oldImage, MiiImage? newImage) + protected async void ReloadImage(Mii? newMii, MiiImageVariants.Variant variant) { - if (oldImage != null) - oldImage.PropertyChanged -= NotifyMiiImageChangedInternally; + // If the mii was already null, it did not actually change (even if the variant did change). + if (newMii == null && Mii != null) + return; - if (newImage != null) - newImage.PropertyChanged += NotifyMiiImageChangedInternally; - MiiImage = newImage?.Image; - MiiLoaded = newImage?.LoadedImageSuccessfully == true; + MiiLoaded = false; + if (newMii == null) + { + MiiImage = null; + MiiLoaded = true; + return; + } - MiiChanged?.Invoke(this, EventArgs.Empty); - } + var imageService = App.Services.GetService()!; + var image = await imageService.GetImageAsync(newMii); - protected void NotifyMiiImageChangedInternally(object? sender, PropertyChangedEventArgs args) - { - var variantedImage = Mii?.GetImage(ImageVariant); - if (args.PropertyName != nameof(variantedImage.Image)) + if (image.IsFailure) + { + MiiImage = null; + MiiLoaded = false; return; - MiiImage = Mii?.GetImage(ImageVariant).Image; - MiiLoaded = Mii?.GetImage(ImageVariant).LoadedImageSuccessfully == true; + } + + MiiImage = image.Value; + MiiLoaded = true; } - public event EventHandler MiiChanged; public event EventHandler MiiImageLoaded; #region PropertyChanged - public event PropertyChangedEventHandler? PropertyChanged; + public new event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged(string propertyName) { From fe342cd03ae962520f007b309aa134599945bb32 Mon Sep 17 00:00:00 2001 From: WantToBeeMe <93130991+WantToBeeMe@users.noreply.github.com> Date: Sat, 3 May 2025 01:10:00 +0200 Subject: [PATCH 4/6] I COOKED --- WheelWizard.Test/WheelWizard.Test.csproj | 1 + .../MiiImages/Domain/IMiiIMagesApi.cs | 17 +- .../Domain/MiiImageSpecifications.cs | 55 +++ .../MiiImages/Domain/MiiImageVariants.cs | 30 ++ .../MiiImages/MiiImagesSingletonService.cs | 44 +- .../Features/WiiManagement/Domain/Mii/Mii.cs | 4 +- .../Features/WiiManagement/MiiSerializer.cs | 9 +- WheelWizard/Models/MiiImages/MiiImage.cs | 79 ---- .../Models/MiiImages/MiiImageVariants.cs | 104 ----- .../Services/WiiManagement/MiiImageManager.cs | 397 ------------------ WheelWizard/SetupExtensions.cs | 2 + .../WhWzLibrary/CurrentUserProfile.axaml | 3 +- .../WhWzLibrary/DetailedProfileBox.axaml | 4 +- .../WhWzLibrary/DetailedProfileBox.axaml.cs | 19 - .../WhWzLibrary/FriendsListItem.axaml | 3 +- .../WhWzLibrary/MiiImages/BaseMiiImage.cs | 20 +- .../WhWzLibrary/MiiImages/MiiCarousel.axaml | 14 +- .../MiiImages/MiiCarousel.axaml.cs | 4 +- .../MiiImages/MiiImageLoader.axaml | 7 +- .../WhWzLibrary/PlayerListItem.axaml | 3 +- .../Views/Popups/DevToolWindow.axaml.cs | 10 +- WheelWizard/WheelWizard.csproj | 1 + 22 files changed, 186 insertions(+), 644 deletions(-) create mode 100644 WheelWizard/Features/MiiImages/Domain/MiiImageSpecifications.cs create mode 100644 WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs delete mode 100644 WheelWizard/Models/MiiImages/MiiImage.cs delete mode 100644 WheelWizard/Models/MiiImages/MiiImageVariants.cs delete mode 100644 WheelWizard/Services/WiiManagement/MiiImageManager.cs diff --git a/WheelWizard.Test/WheelWizard.Test.csproj b/WheelWizard.Test/WheelWizard.Test.csproj index b332b5a0..4b17a4a4 100644 --- a/WheelWizard.Test/WheelWizard.Test.csproj +++ b/WheelWizard.Test/WheelWizard.Test.csproj @@ -14,6 +14,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs b/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs index 891a18c4..dce96ce0 100644 --- a/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs +++ b/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs @@ -6,5 +6,20 @@ namespace WheelWizard.MiiImages.Domain; public interface IMiiIMagesApi { [Get("/miis/image.png")] - Task GetImageAsync(string data); + Task GetImageAsync( + string data, + string type, + string expression, + int width, + int characterXRotate = 0, + int characterYRotate = 0, + int characterZRotate = 0, + string bgColor = "FFFFFF00", + int instanceCount = 1, + int cameraXRotate = 0, + int cameraYRotate = 0, + int cameraZRotate = 0, + string lightDirectionMode = "none", + string instanceRotationMode = "model" + ); } diff --git a/WheelWizard/Features/MiiImages/Domain/MiiImageSpecifications.cs b/WheelWizard/Features/MiiImages/Domain/MiiImageSpecifications.cs new file mode 100644 index 00000000..b6e1f56d --- /dev/null +++ b/WheelWizard/Features/MiiImages/Domain/MiiImageSpecifications.cs @@ -0,0 +1,55 @@ +using System.Numerics; +using Avalonia.Media; +using Microsoft.Extensions.Caching.Memory; + +namespace WheelWizard.MiiImages.Domain; + +public class MiiImageSpecifications +{ + public override string ToString() + { + // If we put all the things in this string, then the Key at least is unique + return $"{Size}{Expression}{Type}_{BackgroundColor}{InstanceCount}_{CharacterRotate}{CameraRotate}"; + } + + public ImageSize Size { get; set; } = ImageSize.small; + public FaceExpression Expression { get; set; } = FaceExpression.normal; + public BodyType Type { get; set; } = BodyType.face; + public string BackgroundColor = "FFFFFF00"; + public int InstanceCount { get; set; } = 1; + + // All between 0 and 360, obviously + public Vector3 CharacterRotate { get; set; } = Vector3.Zero; + public Vector3 CameraRotate { get; set; } = Vector3.Zero; + + public TimeSpan? ExpirationSeconds { get; set; } = TimeSpan.FromMinutes(30); + public CacheItemPriority CachePriority { get; set; } = CacheItemPriority.Normal; + + #region Enums + + // IMPORTANT: keep these enums lowercase and with underscores + public enum FaceExpression + { + normal, + smile, + frustrated, + anger, + blink, + sorrow, + surprise, + } + + public enum ImageSize + { + small = 270, + medium = 512, + } + + public enum BodyType + { + face, + all_body, + } + + #endregion +} diff --git a/WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs b/WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs new file mode 100644 index 00000000..0bad2955 --- /dev/null +++ b/WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs @@ -0,0 +1,30 @@ +using System.Numerics; + +namespace WheelWizard.MiiImages.Domain; + +public static class MiiImageVariants +{ + public static MiiImageSpecifications Small = new() + { + Expression = MiiImageSpecifications.FaceExpression.normal, + Type = MiiImageSpecifications.BodyType.face, + Size = MiiImageSpecifications.ImageSize.small, + }; + + public static MiiImageSpecifications FullBodyCarousel = new() + { + Expression = MiiImageSpecifications.FaceExpression.normal, + Type = MiiImageSpecifications.BodyType.all_body, + Size = MiiImageSpecifications.ImageSize.medium, + InstanceCount = 8, + }; + + public static MiiImageSpecifications SlightSideProfile = new() + { + Expression = MiiImageSpecifications.FaceExpression.normal, + Type = MiiImageSpecifications.BodyType.face, + Size = MiiImageSpecifications.ImageSize.medium, + CharacterRotate = new(350, 15, 355), + CameraRotate = new(12, 0, 0), + }; +} diff --git a/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs index e06b38f7..74f48062 100644 --- a/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs +++ b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs @@ -1,4 +1,5 @@ using Avalonia.Media.Imaging; +using Microsoft.Extensions.Caching.Memory; using WheelWizard.MiiImages.Domain; using WheelWizard.Shared.Services; using WheelWizard.WiiManagement.Domain.Mii; @@ -7,23 +8,54 @@ namespace WheelWizard.MiiImages; public interface IMiiImagesSingletonService { - Task> GetImageAsync(Mii? mii); + Task> GetImageAsync(Mii? mii, MiiImageSpecifications specifications); } -public class MiiImagesSingletonService(IApiCaller apiCaller) : IMiiImagesSingletonService +public class MiiImagesSingletonService(IApiCaller apiCaller, IMemoryCache cache) : IMiiImagesSingletonService { - public async Task> GetImageAsync(Mii? mii) + public async Task> GetImageAsync(Mii? mii, MiiImageSpecifications specifications) { var data = MiiStudioDataSerializer.Serialize(mii); if (data.IsFailure) return data.Error; - return await apiCaller.CallApiAsync(api => GetBitmapAsync(api, data.Value)); + var miiConfigKey = data.Value + specifications; + var isCached = cache.TryGetValue(miiConfigKey, out Bitmap? cachedValue); + if (isCached) + return cachedValue ?? Fail("Cached image is null."); + + var newImageResult = await apiCaller.CallApiAsync(api => GetBitmapAsync(api, data.Value, specifications)); + Bitmap? newImage = null; + if (newImageResult.IsSuccess) + newImage = newImageResult.Value; + + using (var entry = cache.CreateEntry(miiConfigKey)) + { + entry.Value = newImage; + entry.SlidingExpiration = specifications.ExpirationSeconds; + entry.Priority = specifications.CachePriority; + } + + return newImage ?? Fail("Failed to get new image."); } - private static async Task GetBitmapAsync(IMiiIMagesApi api, string data) + private static async Task GetBitmapAsync(IMiiIMagesApi api, string data, MiiImageSpecifications specifications) { - var result = await api.GetImageAsync(data); + var result = await api.GetImageAsync( + data, + specifications.Type.ToString(), + specifications.Expression.ToString(), + (int)specifications.Size, + characterXRotate: (int)specifications.CharacterRotate.X, + characterYRotate: (int)specifications.CharacterRotate.Y, + characterZRotate: (int)specifications.CharacterRotate.Z, + bgColor: specifications.BackgroundColor, + instanceCount: specifications.InstanceCount, + cameraXRotate: (int)specifications.CameraRotate.X, + cameraYRotate: (int)specifications.CameraRotate.Y, + cameraZRotate: (int)specifications.CameraRotate.Z + ); + using var memoryStream = new MemoryStream(); await result.CopyToAsync(memoryStream); memoryStream.Position = 0; // Reset stream position for Bitmap constructor diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs index a168800d..092f439b 100644 --- a/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs +++ b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs @@ -1,6 +1,4 @@ -using WheelWizard.Models.MiiImages; - -namespace WheelWizard.WiiManagement.Domain.Mii; +namespace WheelWizard.WiiManagement.Domain.Mii; public class Mii { diff --git a/WheelWizard/Features/WiiManagement/MiiSerializer.cs b/WheelWizard/Features/WiiManagement/MiiSerializer.cs index f555a43a..50275c81 100644 --- a/WheelWizard/Features/WiiManagement/MiiSerializer.cs +++ b/WheelWizard/Features/WiiManagement/MiiSerializer.cs @@ -10,8 +10,11 @@ public static class MiiSerializer public static OperationResult Serialize(Mii? mii) { - if (mii == null || mii.MiiId == 0) - return Fail("Mii cannot be null. (no have a Mii ID of 0)"); + if (mii == null) + return Fail("Mii cannot be null."); + if (mii.MiiId == 0) + return Fail("Mii ID cannot be 0."); + var data = new byte[MiiBlockSize]; // Header (0x00 - 0x01) @@ -310,7 +313,7 @@ public static OperationResult Deserialize(byte[]? data) (MustacheType)mustacheType, (BeardType)beardType, (MustacheColor)color, - ((facial >> 5) & 0x0F), + ((facial >> 5) & 0x0F), (facial & 0x1F) ); if (miiFacialHairResult.IsFailure) diff --git a/WheelWizard/Models/MiiImages/MiiImage.cs b/WheelWizard/Models/MiiImages/MiiImage.cs deleted file mode 100644 index c354ccef..00000000 --- a/WheelWizard/Models/MiiImages/MiiImage.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.ComponentModel; -using Avalonia.Media.Imaging; -using WheelWizard.Services.WiiManagement; -using WheelWizard.WiiManagement; -using WheelWizard.WiiManagement.Domain.Mii; - -namespace WheelWizard.Models.MiiImages; - -public class MiiImage : INotifyPropertyChanged -{ - private Mii Parent { get; } - - 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 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 - // This is to ensure that it will never request the image again after the first time - private bool _requestingImage; - private Bitmap? _image; - - public Bitmap? Image - { - get - { - 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; - - var newImage = MiiImageManager.GetCachedMiiImage(this); - if (newImage == null) - MiiImageManager.ResetMiiImageAsync(this); - else - SetImage(newImage.Value.Item1, newImage.Value.Item2); - - return _image; - } - private set - { - if (_image == value) - return; - _image = value; - OnPropertyChanged(nameof(Image)); - } - } - - 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(propertyName)); - } - - #endregion -} diff --git a/WheelWizard/Models/MiiImages/MiiImageVariants.cs b/WheelWizard/Models/MiiImages/MiiImageVariants.cs deleted file mode 100644 index 752e0cea..00000000 --- a/WheelWizard/Models/MiiImages/MiiImageVariants.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Numerics; - -namespace WheelWizard.Models.MiiImages; - -public static class MiiImageVariants -{ - public enum Variant - { - SMALL, - SLIGHT_SIDE_PROFILE_DEFAULT, - SLIGHT_SIDE_PROFILE_HOVER, - SLIGHT_SIDE_PROFILE_INTERACT, - FULL_BODY_CAROUSEL, - } - - private static Dictionary> _variantMap = new() - { - [Variant.SMALL] = GetMiiImageUrlFromResponse(Expression.NORMAL, BodyType.FACE, ImageSize.SMALL), - [Variant.FULL_BODY_CAROUSEL] = GetMiiImageUrlFromResponse(Expression.NORMAL, BodyType.ALL_BODY, ImageSize.MEDIUM, instanceCount: 8), - [Variant.SLIGHT_SIDE_PROFILE_DEFAULT] = GetMiiImageUrlFromResponse( - Expression.NORMAL, - BodyType.FACE, - ImageSize.MEDIUM, - characterRotation: new(350, 15, 355), - cameraTilt: 12 - ), - [Variant.SLIGHT_SIDE_PROFILE_HOVER] = GetMiiImageUrlFromResponse( - Expression.SMILE, - BodyType.FACE, - ImageSize.MEDIUM, - characterRotation: new(350, 15, 355), - cameraTilt: 12 - ), - - [Variant.SLIGHT_SIDE_PROFILE_INTERACT] = GetMiiImageUrlFromResponse( - Expression.FRUSTRATED, - BodyType.FACE, - ImageSize.MEDIUM, - characterRotation: new(350, 15, 355), - cameraTilt: 12 - ), - }; - - #region SETUP - public static Func Get(Variant variant) => _variantMap[variant]; - - private static Func GetMiiImageUrlFromResponse( - Expression expression, - BodyType type, - ImageSize size, - Vector3 characterRotation = new(), - int cameraTilt = 0, - int instanceCount = 1 - ) - { - return (miiData) => - { - var queryParams = new List - { - $"data={miiData}", - $"type={type.ToString().ToLower()}", - $"expression={expression.ToString().ToLower()}", - $"width={(int)size}", - "bgColor=FFFFFF00", - "clothesColor=default", - $"cameraXRotate={cameraTilt}", - "cameraYRotate=0", - "cameraZRotate=0", - $"characterXRotate={(int)characterRotation.X}", - $"characterYRotate={(int)characterRotation.Y}", - $"characterZRotate={(int)characterRotation.Z}", - "lightDirectionMode=none", - $"instanceCount={instanceCount}", - "instanceRotationMode=model", - }; - return string.Join("&", queryParams); - }; - } - - private enum Expression - { - NORMAL, - SMILE, - FRUSTRATED, - ANGER, - BLINK, - SORROW, - SURPRISE, - } - - private enum BodyType - { - FACE, - ALL_BODY, - } - - private enum ImageSize - { - SMALL = 270, - MEDIUM = 512, - } - - #endregion -} diff --git a/WheelWizard/Services/WiiManagement/MiiImageManager.cs b/WheelWizard/Services/WiiManagement/MiiImageManager.cs deleted file mode 100644 index 91c81063..00000000 --- a/WheelWizard/Services/WiiManagement/MiiImageManager.cs +++ /dev/null @@ -1,397 +0,0 @@ -using System.Text; -using Avalonia.Media.Imaging; -using WheelWizard.MiiImages; -using WheelWizard.Models.MiiImages; -using WheelWizard.Services.WiiManagement.SaveData; -using WheelWizard.Views; - -namespace WheelWizard.Services.WiiManagement; - -public static class MiiImageManager -{ - private const int WiiMiiFileSize = 0x4A; - - public static int ParsedMiiDataCount => Images.Count; - - private static readonly int[] MakeupMap = { 0, 1, 6, 9, 0, 0, 0, 0, 0, 10, 0, 0 }; - private static readonly int[] WrinklesMap = { 0, 0, 0, 0, 5, 2, 3, 7, 8, 0, 9, 11 }; - - // Shared HttpClient instance (better performance than creating new ones) - private static readonly HttpClient MiiImageHttpClient = new(); - - #region Mii Studio Data Generation (Replaces MiiStudioUrl call) - - /// - /// Generates the encoded data string required by the Nintendo Mii Image URL. - /// - /// Raw byte data of the Wii Mii (must be 74 bytes). - /// The encoded data string, or null if input data is invalid. - private static string? GenerateEncodedStudioData(byte[] wiiMiiData) - { - if (wiiMiiData == null || wiiMiiData.Length != WiiMiiFileSize) - { - // Invalid data length for a Wii Mii - Console.WriteLine($"Invalid Wii Mii data length: Expected {WiiMiiFileSize}, got {(wiiMiiData?.Length ?? 0)}"); - return null; - } - - try - { - var studio = GenerateStudioDataArray(wiiMiiData); - return EncodeStudioData(studio); - } - catch (Exception ex) // Catch potential errors during parsing (e.g., ArgumentOutOfRangeException) - { - // Log the error if needed - Console.WriteLine($"Error generating studio data: {ex.Message}"); - return null; - } - } - - /// - /// Parses the Wii Mii data and generates the 46-byte studio data array. - /// Based on the miiFileRead logic for Wii data in the provided JS. - /// Uses BigEndianBinaryReader for reading values. - /// - private static byte[] GenerateStudioDataArray(byte[] buf) - { - var studio = new byte[46]; // Size of studio data array - - // Parse Wii data fields and map them to studio array indices - // Offsets and logic match the 'else' block (Wii part) of miiFileRead - - // --- Basic Info --- - var tmpU16_0 = BigEndianBinaryReader.BufferToUint16(buf, 0); - var isGirl = ((tmpU16_0 >> 14) & 1) == 1; - var favColor = (int)((tmpU16_0 >> 1) & 0xF); - int height = buf[0x16]; - int weight = buf[0x17]; - - studio[0x16] = (byte)(isGirl ? 1 : 0); // Gender - studio[0x15] = (byte)favColor; // Favorite Color - studio[0x1E] = (byte)height; // Height - studio[2] = (byte)weight; // Weight (mapped to index 2 in studio) - - // --- Face --- - var tmpU16_20 = BigEndianBinaryReader.BufferToUint16(buf, 0x20); - var faceShape = (int)(tmpU16_20 >> 13); - var skinColor = (int)((tmpU16_20 >> 10) & 7); - var facialFeature = (int)((tmpU16_20 >> 6) & 0xF); // Note: JS uses 0xF mask here, map to makeup/wrinkles - var makeup = MakeupMap.Length > facialFeature ? MakeupMap[facialFeature] : 0; - var wrinkles = WrinklesMap.Length > facialFeature ? WrinklesMap[facialFeature] : 0; - - studio[0x13] = (byte)faceShape; - studio[0x11] = (byte)skinColor; - studio[0x14] = (byte)wrinkles; - studio[0x12] = (byte)makeup; - - // --- Hair --- - var tmpU16_22 = BigEndianBinaryReader.BufferToUint16(buf, 0x22); - var hairStyle = (int)(tmpU16_22 >> 9); - var hairColor = (int)((tmpU16_22 >> 6) & 7); - var flipHair = (int)((tmpU16_22 >> 5) & 1); - - studio[0x1D] = (byte)hairStyle; - studio[0x1B] = (byte)(hairColor == 0 ? 8 : hairColor); // Map color 0 to 8 - studio[0x1C] = (byte)flipHair; - - // --- Eyebrows --- - var tmpU32_24 = BigEndianBinaryReader.BufferToUint32(buf, 0x24); - var eyebrowStyle = (int)(tmpU32_24 >> 27); - var eyebrowRotation = (int)((tmpU32_24 >> 22) & 0xF); // Note: JS uses 0xF mask - var eyebrowColor = (int)((tmpU32_24 >> 13) & 7); - var eyebrowScale = (int)((tmpU32_24 >> 9) & 0xF); - var eyebrowYscale = 3; // Hardcoded in JS - var eyebrowYposition = (int)((tmpU32_24 >> 4) & 0x1F); - var eyebrowXspacing = (int)(tmpU32_24 & 0xF); - - studio[0xE] = (byte)eyebrowStyle; - studio[0xC] = (byte)eyebrowRotation; - studio[0xB] = (byte)(eyebrowColor == 0 ? 8 : eyebrowColor); // Map color 0 to 8 - studio[0xD] = (byte)eyebrowScale; - studio[0xA] = (byte)eyebrowYscale; - studio[0x10] = (byte)eyebrowYposition; - studio[0xF] = (byte)eyebrowXspacing; - - // --- Eyes --- - var tmpU32_28 = BigEndianBinaryReader.BufferToUint32(buf, 0x28); - var eyeStyle = (int)(tmpU32_28 >> 26); - var eyeRotation = (int)((tmpU32_28 >> 21) & 7); // Note: JS uses 7 (0b111) mask - var eyeYposition = (int)((tmpU32_28 >> 16) & 0x1F); - var eyeColor = (int)((tmpU32_28 >> 13) & 7); - var eyeScale = (int)((tmpU32_28 >> 9) & 7); // Note: JS uses 7 mask - var eyeYscale = 3; // Hardcoded in JS - var eyeXspacing = (int)((tmpU32_28 >> 5) & 0xF); - // int unknownEyeBit = (int)(tmpU32_28 & 0x1F); // Lower 5 bits unused in JS mapping - - studio[7] = (byte)eyeStyle; - studio[5] = (byte)eyeRotation; - studio[9] = (byte)eyeYposition; - studio[4] = (byte)(eyeColor + 8); // Map color 0-7 to 8-15 - studio[6] = (byte)eyeScale; - studio[3] = (byte)eyeYscale; - studio[8] = (byte)eyeXspacing; - - // --- Nose --- - var tmpU16_2C = BigEndianBinaryReader.BufferToUint16(buf, 0x2C); - var noseStyle = (int)(tmpU16_2C >> 12); - var noseScale = (int)((tmpU16_2C >> 8) & 0xF); - var noseYposition = (int)((tmpU16_2C >> 3) & 0x1F); - // int unknownNoseBits = (int)(tmpU16_2C & 7); // Lower 3 bits unused - - studio[0x2C] = (byte)noseStyle; - studio[0x2B] = (byte)noseScale; - studio[0x2D] = (byte)noseYposition; - - // --- Mouth --- - var tmpU16_2E = BigEndianBinaryReader.BufferToUint16(buf, 0x2E); - var mouseStyle = (int)(tmpU16_2E >> 11); - var mouseColor = (int)((tmpU16_2E >> 9) & 3); // Lip color (0-3) - var mouseScale = (int)((tmpU16_2E >> 5) & 0xF); - var mouseYscale = 3; // Hardcoded in JS - var mouseYposition = (int)(tmpU16_2E & 0x1F); - - studio[0x26] = (byte)mouseStyle; - studio[0x24] = (byte)(mouseColor < 4 ? mouseColor + 19 : 0); // Map 0-3 to 19-22, else 0 - studio[0x25] = (byte)mouseScale; - studio[0x23] = (byte)mouseYscale; - studio[0x27] = (byte)mouseYposition; - - // --- Beard / Mustache --- - var tmpU16_32 = BigEndianBinaryReader.BufferToUint16(buf, 0x32); - var mustacheStyle = (int)(tmpU16_32 >> 14); - var beardStyle = (int)((tmpU16_32 >> 12) & 3); - var facialHairColor = (int)((tmpU16_32 >> 9) & 7); - var mustacheScale = (int)((tmpU16_32 >> 5) & 0xF); - var mustacheYposition = (int)(tmpU16_32 & 0x1F); - - studio[0x29] = (byte)mustacheStyle; - studio[1] = (byte)beardStyle; // Mapped to index 1 - studio[0] = (byte)(facialHairColor == 0 ? 8 : facialHairColor); // Map color 0 to 8, Mapped to index 0 - studio[0x28] = (byte)mustacheScale; - studio[0x2A] = (byte)mustacheYposition; - - // --- Glasses --- - var tmpU16_30 = BigEndianBinaryReader.BufferToUint16(buf, 0x30); - var glassesStyle = (int)(tmpU16_30 >> 12); - var glassesColor = (int)((tmpU16_30 >> 9) & 7); - var glassesScale = (int)((tmpU16_30 >> 5) & 7); // Note: JS uses 7 mask - var glassesYposition = (int)(tmpU16_30 & 0x1F); - // int unknownGlassesBits = (int)((tmpU16_30 >> 12) & 7); // Middle bits unused - - studio[0x19] = (byte)glassesStyle; - byte mappedGlassesColor; - if (glassesColor == 0) - mappedGlassesColor = 8; // black -> 8 - else if (glassesColor < 6) - mappedGlassesColor = (byte)(glassesColor + 13); // 1-5 -> 14-18 - else - mappedGlassesColor = 0; // 6, 7 -> 0 (no mapping?) - studio[0x17] = mappedGlassesColor; - studio[0x18] = (byte)glassesScale; - studio[0x1A] = (byte)glassesYposition; - - // --- Mole --- - var tmpU16_34 = BigEndianBinaryReader.BufferToUint16(buf, 0x34); - var enableMole = (int)(tmpU16_34 >> 15); - var moleScale = (int)((tmpU16_34 >> 11) & 0xF); - var moleYposition = (int)((tmpU16_34 >> 6) & 0x1F); - var moleXposition = (int)((tmpU16_34 >> 1) & 0x1F); - // int unknownMoleBit = (int)(tmpU16_34 & 1); // Lowest bit unused - - studio[0x20] = (byte)enableMole; - studio[0x1F] = (byte)moleScale; - studio[0x22] = (byte)moleYposition; - studio[0x21] = (byte)moleXposition; - - return studio; - } - - /// - /// Encodes the studio data array into the hex string format required by the API. - /// Based on the encodeStudio function in the provided JS. - /// - private static string EncodeStudioData(byte[] studioData) - { - byte n = 0; - var dest = new StringBuilder("00", (studioData.Length + 1) * 2); // Preallocate buffer ("00" + 2 chars per byte) - - foreach (var b in studioData) - { - var eo = (byte)((7 + (b ^ n)) & 0xFF); - n = eo; // Update n *after* calculating eo, using the new eo - dest.Append(eo.ToString("x2")); // Append hex representation - } - return dest.ToString(); - } - - #endregion - - #region Mii Images Cache - - private const int MaxCachedImages = 126; - private static readonly Dictionary Images = new(); - private static readonly Queue ImageOrder = new(); - public static int ImageCount { get; private set; } = 0; - - public static (Bitmap? image, bool success)? GetCachedMiiImage(MiiImage miiConfig) => - Images.TryGetValue(miiConfig.CachingKey, out var image) ? image : null; - - private static void AddMiiImage(MiiImage miiConfig, (Bitmap? image, bool success) imageResult) - { - // Don't cache if the image is null and success is true (shouldn't happen, but safety check) - // Do cache if success is false (means we failed and shouldn't retry immediately) - if (imageResult.image == null && imageResult.success) - { - // Console.WriteLine($"Skipping cache add for successful null image: {miiConfig.CachingKey}"); // Debugging - return; - } - - if (!Images.ContainsKey(miiConfig.CachingKey)) - { - ImageOrder.Enqueue(miiConfig.CachingKey); - } - // Overwrite existing entry if present (Dispose old image if it exists) - if (Images.TryGetValue(miiConfig.CachingKey, out var oldImageResult)) - { - if (!ReferenceEquals(oldImageResult.image, imageResult.image)) // Only dispose if it's a different bitmap instance - { - oldImageResult.image?.Dispose(); - // Console.WriteLine($"Disposed old image for key: {miiConfig.CachingKey}"); // Debugging - } - } - - Images[miiConfig.CachingKey] = imageResult; - // Console.WriteLine($"Added/Updated cache entry for key: {miiConfig.CachingKey}"); // Debugging - - - // Enforce cache limit - while (Images.Count > MaxCachedImages && ImageOrder.Count > 0) - { - var oldestKey = ImageOrder.Dequeue(); - if (Images.TryGetValue(oldestKey, out var oldestImageResult)) - { - // Dispose the old bitmap before removing it from the cache - oldestImageResult.image?.Dispose(); - Images.Remove(oldestKey); - // Console.WriteLine($"Removed oldest image from cache: {oldestKey}"); // Debugging - } - // If TryGetValue fails, the key might have been removed elsewhere or was already replaced. - } - ImageCount = Images.Count; - // Console.WriteLine($"Cache count: {ImageCount}"); // Debugging - } - - // Creates a new image of this Mii and adds it to the cache (if it was loaded successfully) - // Note: Changed return type to Task to allow awaiting the image generation/fetch - public static async Task ResetMiiImageAsync(MiiImage miiImage) - { - if (string.IsNullOrEmpty(miiImage.Data)) - { - miiImage.SetImage(null, false); // Cannot reset if no data - return; - } - - byte[] rawMiiData; - try - { - rawMiiData = Convert.FromBase64String(miiImage.Data); - } - catch (FormatException) - { - miiImage.SetImage(null, false); // Mark as failed - return; - } - - // Always request a fresh image - var newImageResult = await RequestMiiImageAsync(miiImage.CachingKey, rawMiiData, miiImage.Variant); - - // Add *before* comparing/setting to update cache regardless - AddMiiImage(miiImage, newImageResult); - - // Only update the MiiImage object if the new image is different or the success status changed - if (!ReferenceEquals(miiImage.Image, newImageResult.image) || miiImage.LoadedImageSuccessfully != newImageResult.success) - { - miiImage.SetImage(newImageResult.image, newImageResult.success); - } - } - - // Return the image, and a bool indicating if the image was loaded successfully - private static async Task<(Bitmap? image, bool success)> RequestMiiImageAsync( - string cacheKey, - byte[] rawMiiData, - MiiImageVariants.Variant variant - ) - { - // 1. Generate the encoded studio data string locally - var encodedStudioData = GenerateEncodedStudioData(rawMiiData); - if (string.IsNullOrEmpty(encodedStudioData)) - { - Console.WriteLine($"Failed to generate studio data for cache key: {cacheKey}"); - return (null, false); // Indicate failure due to data generation issue - } - - // 2. Construct the final image URL query parameters - var miiImageUrlParams = MiiImageVariants.Get(variant)(encodedStudioData); - if (string.IsNullOrEmpty(miiImageUrlParams)) - { - Console.WriteLine($"Failed to get image URL parameters for variant {variant}"); - return (null, false); - } - var fullImageUrl = $"{Endpoints.MiiImageUrl}?{miiImageUrlParams}"; - - // 3. Fetch the image from the Nintendo server - try - { - // Use MiiImageHttpClient - using var imageResponse = await MiiImageHttpClient.GetAsync(fullImageUrl); - - if (!imageResponse.IsSuccessStatusCode) - { - Console.WriteLine($"Failed to fetch Mii image from {fullImageUrl}. Status: {imageResponse.StatusCode}"); - return (null, false); // Indicate failure - } - - // Read the image stream and create the Bitmap - // Important: Copy stream to memory stream because Bitmap takes ownership and original stream might be disposed by HttpClient - await using var imageStream = await imageResponse.Content.ReadAsStreamAsync(); - using var memoryStream = new MemoryStream(); - await imageStream.CopyToAsync(memoryStream); - memoryStream.Position = 0; // Reset stream position for Bitmap constructor - - // Check if stream is empty (sometimes API returns success but empty content) - if (memoryStream.Length == 0) - { - Console.WriteLine($"Received empty image stream from {fullImageUrl}."); - return (null, false); - } - - var bitmap = new Bitmap(memoryStream); // Bitmap constructor reads from the stream - return (bitmap, true); // Success - } - catch (HttpRequestException httpEx) - { - Console.WriteLine($"HTTP request error fetching Mii image from {fullImageUrl}: {httpEx.Message}"); - return (null, false); - } - catch (Exception ex) // Catch other potential errors (network issues, Bitmap creation errors) - { - Console.WriteLine($"Error processing Mii image request for {fullImageUrl}: {ex.Message}"); - return (null, false); - } - } - - #endregion - - public static void ClearImageCache() - { - // Dispose all cached bitmaps before clearing - foreach (var kvp in Images) - { - kvp.Value.image?.Dispose(); - } - Images.Clear(); - ImageOrder.Clear(); - ImageCount = 0; - } -} diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index a97ab756..f5b739f8 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -1,4 +1,5 @@ using System.IO.Abstractions; +using Microsoft.Extensions.Caching.Memory; using Serilog; using Testably.Abstractions; using WheelWizard.AutoUpdating; @@ -33,6 +34,7 @@ public static void AddWheelWizardServices(this IServiceCollection services) // IO Abstractions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(_ => new MemoryCache(new MemoryCacheOptions())); // Logging services.AddTransient(); diff --git a/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml b/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml index e7c570bf..4730ba53 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml +++ b/WheelWizard/Views/Components/WhWzLibrary/CurrentUserProfile.axaml @@ -1,6 +1,7 @@  @@ -28,7 +29,7 @@ LoadingColor="{StaticResource Neutral600}" Mii="{TemplateBinding Mii}" x:Name="Part_MiiImage" FallBackColor="{StaticResource Neutral600}" - ImageVariant="SMALL"/> + ImageVariant="{x:Static miiVars:MiiImageVariants.Small}"/> diff --git a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml b/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml index 80e77012..643f74b4 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml +++ b/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml @@ -3,6 +3,8 @@ xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="using:WheelWizard.Views.Components" xmlns:components1="clr-namespace:WheelWizard.Views.Components" + + xmlns:miiVars="using:WheelWizard.MiiImages.Domain" xmlns:miiImages="clr-namespace:WheelWizard.Views.Components.MiiImages"> @@ -60,7 +62,7 @@ Background="{StaticResource Neutral950}"> diff --git a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs b/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs index 2f24253b..691ebaa8 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs +++ b/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml.cs @@ -4,9 +4,6 @@ using Avalonia.Controls.Primitives; using Avalonia.Interactivity; using Avalonia.Media.Imaging; -using WheelWizard.Models.MiiImages; -using WheelWizard.Services.Settings; -using WheelWizard.Views.Components.MiiImages; using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.Views.Components; @@ -169,22 +166,6 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) var copyFcButton = e.NameScope.Find("CopyFcButton"); if (copyFcButton != null) copyFcButton.Click += CopyFriendCode; - - var miiImageLoader = e.NameScope.Find("MiiFaceImageLoader"); - var animationsEnabled = (bool)SettingsManager.ENABLE_ANIMATIONS.Get(); - if (miiImageLoader != null && animationsEnabled) - { - // We set them all at least one, just to make sure the request is being send. - // sometimes this still works goofy though, for some reason - miiImageLoader.ImageVariant = MiiImageVariants.Variant.SLIGHT_SIDE_PROFILE_HOVER; - miiImageLoader.ImageVariant = MiiImageVariants.Variant.SLIGHT_SIDE_PROFILE_INTERACT; - miiImageLoader.ImageVariant = MiiImageVariants.Variant.SLIGHT_SIDE_PROFILE_DEFAULT; - - miiImageLoader.PointerEntered += (_, _) => miiImageLoader.ImageVariant = MiiImageVariants.Variant.SLIGHT_SIDE_PROFILE_HOVER; - miiImageLoader.PointerExited += (_, _) => miiImageLoader.ImageVariant = MiiImageVariants.Variant.SLIGHT_SIDE_PROFILE_DEFAULT; - miiImageLoader.PointerPressed += (_, _) => miiImageLoader.ImageVariant = MiiImageVariants.Variant.SLIGHT_SIDE_PROFILE_INTERACT; - miiImageLoader.PointerReleased += (_, _) => miiImageLoader.ImageVariant = MiiImageVariants.Variant.SLIGHT_SIDE_PROFILE_HOVER; - } } public event PropertyChangedEventHandler? PropertyChanged; diff --git a/WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml b/WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml index 67695038..00106ee1 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml +++ b/WheelWizard/Views/Components/WhWzLibrary/FriendsListItem.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:lang="clr-namespace:WheelWizard.Resources.Languages" xmlns:components="clr-namespace:WheelWizard.Views.Components" + xmlns:miiVars="using:WheelWizard.MiiImages.Domain" xmlns:miiImages="clr-namespace:WheelWizard.Views.Components.MiiImages"> @@ -52,7 +53,7 @@ Margin="-10,-5,0,-5" CornerRadius="7,0,0,7"/> + ImageVariant="{x:Static miiVars:MiiImageVariants.SlightSideProfile}" LoadingColor="Transparent"/> diff --git a/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs b/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs index 4247dc0a..42d61014 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs +++ b/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs @@ -3,7 +3,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Media.Imaging; using WheelWizard.MiiImages; -using WheelWizard.Models.MiiImages; +using WheelWizard.MiiImages.Domain; using WheelWizard.WiiManagement.Domain.Mii; namespace WheelWizard.Views.Components.MiiImages; @@ -36,12 +36,12 @@ protected Bitmap? MiiImage } } - public static readonly StyledProperty ImageVariantProperty = AvaloniaProperty.Register< + public static readonly StyledProperty ImageVariantProperty = AvaloniaProperty.Register< BaseMiiImage, - MiiImageVariants.Variant - >(nameof(ImageVariant), MiiImageVariants.Variant.SMALL, coerce: CoerceVariant); + MiiImageSpecifications + >(nameof(ImageVariant), MiiImageVariants.Small, coerce: CoerceVariant); - public MiiImageVariants.Variant ImageVariant + public MiiImageSpecifications ImageVariant { get => GetValue(ImageVariantProperty); set => SetValue(ImageVariantProperty, value); @@ -55,7 +55,7 @@ public Mii? Mii set => SetValue(MiiProperty, value); } - private static MiiImageVariants.Variant CoerceVariant(AvaloniaObject o, MiiImageVariants.Variant value) + private static MiiImageSpecifications CoerceVariant(AvaloniaObject o, MiiImageSpecifications value) { ((BaseMiiImage)o).OnVariantChanged(value); return value; @@ -67,11 +67,11 @@ private static MiiImageVariants.Variant CoerceVariant(AvaloniaObject o, MiiImage return value; } - protected void OnVariantChanged(MiiImageVariants.Variant newValue) => ReloadImage(Mii, newValue); + protected void OnVariantChanged(MiiImageSpecifications newValue) => ReloadImage(Mii, newValue); protected void OnMiiChanged(Mii? newValue) => ReloadImage(newValue, ImageVariant); - protected async void ReloadImage(Mii? newMii, MiiImageVariants.Variant variant) + protected async void ReloadImage(Mii? newMii, MiiImageSpecifications variant) { // If the mii was already null, it did not actually change (even if the variant did change). if (newMii == null && Mii != null) @@ -86,12 +86,12 @@ protected async void ReloadImage(Mii? newMii, MiiImageVariants.Variant variant) } var imageService = App.Services.GetService()!; - var image = await imageService.GetImageAsync(newMii); + var image = await imageService.GetImageAsync(newMii, variant); if (image.IsFailure) { MiiImage = null; - MiiLoaded = false; + MiiLoaded = true; return; } diff --git a/WheelWizard/Views/Components/WhWzLibrary/MiiImages/MiiCarousel.axaml b/WheelWizard/Views/Components/WhWzLibrary/MiiImages/MiiCarousel.axaml index 9d774313..6331f052 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/MiiImages/MiiCarousel.axaml +++ b/WheelWizard/Views/Components/WhWzLibrary/MiiImages/MiiCarousel.axaml @@ -1,24 +1,20 @@  - + -