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
new file mode 100644
index 00000000..dce96ce0
--- /dev/null
+++ b/WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs
@@ -0,0 +1,25 @@
+using Avalonia.Media.Imaging;
+using Refit;
+
+namespace WheelWizard.MiiImages.Domain;
+
+public interface IMiiIMagesApi
+{
+ [Get("/miis/image.png")]
+ 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..8a08c6c7
--- /dev/null
+++ b/WheelWizard/Features/MiiImages/Domain/MiiImageSpecifications.cs
@@ -0,0 +1,56 @@
+using System.Numerics;
+using Avalonia.Media;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace WheelWizard.MiiImages.Domain;
+
+public class MiiImageSpecifications
+{
+ public string Name { get; set; } = string.Empty;
+ 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;
+
+ public override string ToString()
+ {
+ // If we put all the things in this string, then the Key at least is unique
+ return $"{Name}_{Size}{Expression}{Type}_{BackgroundColor}{InstanceCount}_{CharacterRotate}{CameraRotate}_{CachePriority}";
+ }
+
+ #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..084b5b2c
--- /dev/null
+++ b/WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs
@@ -0,0 +1,51 @@
+using System.Numerics;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace WheelWizard.MiiImages.Domain;
+
+public static class MiiImageVariants
+{
+ public static readonly MiiImageSpecifications CurrentUserSmall = new()
+ {
+ Name = "CurrentUserSmall",
+ Expression = MiiImageSpecifications.FaceExpression.normal,
+ Type = MiiImageSpecifications.BodyType.face,
+ Size = MiiImageSpecifications.ImageSize.small,
+ CachePriority = CacheItemPriority.High,
+ };
+
+ public static readonly MiiImageSpecifications OnlinePlayerSmall = new()
+ {
+ Name = "OnlinePlayerSmall",
+ Expression = MiiImageSpecifications.FaceExpression.normal,
+ Type = MiiImageSpecifications.BodyType.face,
+ Size = MiiImageSpecifications.ImageSize.small,
+ CachePriority = CacheItemPriority.Low,
+ };
+
+ public static readonly MiiImageSpecifications CurrentUserSideProfile = new()
+ {
+ Expression = MiiImageSpecifications.FaceExpression.normal,
+ Type = MiiImageSpecifications.BodyType.face,
+ Size = MiiImageSpecifications.ImageSize.medium,
+ CharacterRotate = new(350, 15, 355),
+ CameraRotate = new(12, 0, 0),
+ };
+ public static readonly MiiImageSpecifications FriendsSideProfile = new()
+ {
+ Expression = MiiImageSpecifications.FaceExpression.normal,
+ Type = MiiImageSpecifications.BodyType.face,
+ Size = MiiImageSpecifications.ImageSize.medium,
+ CharacterRotate = new(350, 15, 355),
+ CameraRotate = new(12, 0, 0),
+ ExpirationSeconds = TimeSpan.FromMinutes(60),
+ };
+
+ public static readonly MiiImageSpecifications FullBodyCarousel = new()
+ {
+ Expression = MiiImageSpecifications.FaceExpression.normal,
+ Type = MiiImageSpecifications.BodyType.all_body,
+ Size = MiiImageSpecifications.ImageSize.medium,
+ InstanceCount = 8,
+ };
+}
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..74f48062
--- /dev/null
+++ b/WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs
@@ -0,0 +1,69 @@
+using Avalonia.Media.Imaging;
+using Microsoft.Extensions.Caching.Memory;
+using WheelWizard.MiiImages.Domain;
+using WheelWizard.Shared.Services;
+using WheelWizard.WiiManagement.Domain.Mii;
+
+namespace WheelWizard.MiiImages;
+
+public interface IMiiImagesSingletonService
+{
+ Task> GetImageAsync(Mii? mii, MiiImageSpecifications specifications);
+}
+
+public class MiiImagesSingletonService(IApiCaller apiCaller, IMemoryCache cache) : IMiiImagesSingletonService
+{
+ public async Task> GetImageAsync(Mii? mii, MiiImageSpecifications specifications)
+ {
+ var data = MiiStudioDataSerializer.Serialize(mii);
+ if (data.IsFailure)
+ return data.Error;
+
+ 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, MiiImageSpecifications specifications)
+ {
+ 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
+
+ if (memoryStream.Length == 0)
+ throw new InvalidOperationException("Received empty image stream.");
+
+ var bitmap = new Bitmap(memoryStream);
+ return bitmap;
+ }
+}
diff --git a/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs b/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs
new file mode 100644
index 00000000..010d60b4
--- /dev/null
+++ b/WheelWizard/Features/MiiImages/MiiStudioDataSerializer.cs
@@ -0,0 +1,224 @@
+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)
+ {
+ 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.
+ // 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;
+ visualMiiClone.MiiId = 1; // Mii ID cant be 0 if you want to serialize it so...
+
+ 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;
+ }
+}
diff --git a/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs
index 9adaa999..092f439b 100644
--- a/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs
+++ b/WheelWizard/Features/WiiManagement/Domain/Mii/Mii.cs
@@ -1,19 +1,7 @@
-using WheelWizard.Models.MiiImages;
-
-namespace WheelWizard.WiiManagement.Domain.Mii;
+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 +12,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..50275c81 100644
--- a/WheelWizard/Features/WiiManagement/MiiSerializer.cs
+++ b/WheelWizard/Features/WiiManagement/MiiSerializer.cs
@@ -10,9 +10,12 @@ public static class MiiSerializer
public static OperationResult Serialize(Mii? mii)
{
- if (mii == null || mii.MiiId == 0)
+ if (mii == null)
return Fail("Mii cannot be null.");
- byte[] data = new byte[MiiBlockSize];
+ if (mii.MiiId == 0)
+ return Fail("Mii ID cannot be 0.");
+
+ var data = new byte[MiiBlockSize];
// Header (0x00 - 0x01)
ushort header = 0;
@@ -151,11 +154,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 +193,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 +217,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 +227,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 +244,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 +261,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 +291,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 +313,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/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/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
deleted file mode 100644
index 277a61e8..00000000
--- a/WheelWizard/Services/WiiManagement/MiiImageManager.cs
+++ /dev/null
@@ -1,395 +0,0 @@
-using System.Text;
-using Avalonia.Media.Imaging;
-using WheelWizard.Models.MiiImages;
-using WheelWizard.Services.WiiManagement.SaveData;
-
-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 d16748e1..f5b739f8 100644
--- a/WheelWizard/SetupExtensions.cs
+++ b/WheelWizard/SetupExtensions.cs
@@ -1,10 +1,12 @@
using System.IO.Abstractions;
+using Microsoft.Extensions.Caching.Memory;
using Serilog;
using Testably.Abstractions;
using WheelWizard.AutoUpdating;
using WheelWizard.Branding;
using WheelWizard.GameBanana;
using WheelWizard.GitHub;
+using WheelWizard.MiiImages;
using WheelWizard.RrRooms;
using WheelWizard.Shared.Services;
using WheelWizard.WheelWizardData;
@@ -27,10 +29,12 @@ public static void AddWheelWizardServices(this IServiceCollection services)
services.AddWhWzData();
services.AddWiiManagement();
services.AddGameBanana();
+ services.AddMiiImages();
// 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..59ad54ea 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.CurrentUserSmall}"/>
diff --git a/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml b/WheelWizard/Views/Components/WhWzLibrary/DetailedProfileBox.axaml
index 80e77012..128b75c0 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..43ecd02a 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.FriendsSideProfile}" LoadingColor="Transparent"/>
diff --git a/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs b/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs
index 158607d8..ea4e46c5 100644
--- a/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs
+++ b/WheelWizard/Views/Components/WhWzLibrary/MiiImages/BaseMiiImage.cs
@@ -2,7 +2,8 @@
using Avalonia;
using Avalonia.Controls.Primitives;
using Avalonia.Media.Imaging;
-using WheelWizard.Models.MiiImages;
+using WheelWizard.MiiImages;
+using WheelWizard.MiiImages.Domain;
using WheelWizard.WiiManagement.Domain.Mii;
namespace WheelWizard.Views.Components.MiiImages;
@@ -35,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.OnlinePlayerSmall, coerce: CoerceVariant);
- public MiiImageVariants.Variant ImageVariant
+ public MiiImageSpecifications ImageVariant
{
get => GetValue(ImageVariantProperty);
set => SetValue(ImageVariantProperty, value);
@@ -54,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;
@@ -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(MiiImageSpecifications 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, MiiImageSpecifications 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, variant);
- protected void NotifyMiiImageChangedInternally(object? sender, PropertyChangedEventArgs args)
- {
- var variantedImage = Mii?.GetImage(ImageVariant);
- if (args.PropertyName != nameof(variantedImage.Image))
+ if (image.IsFailure)
+ {
+ MiiImage = null;
+ MiiLoaded = true;
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;
+ public event EventHandler? MiiImageLoaded;
#region PropertyChanged
- public event PropertyChangedEventHandler? PropertyChanged;
+ public new event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName)
{
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 @@
-
+
-