From ebce51040903cca62f0fa1f6dc7694194f508120 Mon Sep 17 00:00:00 2001 From: Joelmatic Date: Thu, 2 Apr 2026 21:44:52 +0100 Subject: [PATCH 1/4] added ghost data --- .../Converters/GhostDisplayConverters.cs | 259 +++++++++ WheelWizard/Models/GhostLeaderboard.cs | 171 ++++++ WheelWizard/Models/GhostTrack.cs | 35 ++ WheelWizard/Models/LocalGhostData.cs | 84 +++ WheelWizard/Models/WorldRecordsResponse.cs | 86 +++ .../Services/GhostLeaderboardService.cs | 53 ++ WheelWizard/Services/GhostTrackService.cs | 132 +++++ WheelWizard/Services/LocalGhostService.cs | 224 ++++++++ WheelWizard/Services/PathManager.cs | 1 + WheelWizard/Services/RkgParser.cs | 325 +++++++++++ .../Services/TrackHexMappingService.cs | 360 ++++++++++++ WheelWizard/SetupExtensions.cs | 7 + WheelWizard/Views/App.axaml | 1 + .../Views/Components/LapTimesGraph.axaml | 117 ++++ .../Views/Components/LapTimesGraph.axaml.cs | 249 +++++++++ WheelWizard/Views/Layout.axaml | 3 + WheelWizard/Views/Pages/GhostTimesPage.axaml | 412 ++++++++++++++ .../Views/Pages/GhostTimesPage.axaml.cs | 528 ++++++++++++++++++ WheelWizard/Views/Pages/GhostsPage.axaml | 109 ++++ WheelWizard/Views/Pages/GhostsPage.axaml.cs | 163 ++++++ .../Views/Patterns/TrackListItem.axaml | 138 +++++ .../Views/Patterns/TrackListItem.axaml.cs | 117 ++++ .../Views/Popups/GhostDetailsWindow.axaml | 152 +++++ .../Views/Popups/GhostDetailsWindow.axaml.cs | 131 +++++ 24 files changed, 3857 insertions(+) create mode 100644 WheelWizard/Converters/GhostDisplayConverters.cs create mode 100644 WheelWizard/Models/GhostLeaderboard.cs create mode 100644 WheelWizard/Models/GhostTrack.cs create mode 100644 WheelWizard/Models/LocalGhostData.cs create mode 100644 WheelWizard/Models/WorldRecordsResponse.cs create mode 100644 WheelWizard/Services/GhostLeaderboardService.cs create mode 100644 WheelWizard/Services/GhostTrackService.cs create mode 100644 WheelWizard/Services/LocalGhostService.cs create mode 100644 WheelWizard/Services/RkgParser.cs create mode 100644 WheelWizard/Services/TrackHexMappingService.cs create mode 100644 WheelWizard/Views/Components/LapTimesGraph.axaml create mode 100644 WheelWizard/Views/Components/LapTimesGraph.axaml.cs create mode 100644 WheelWizard/Views/Pages/GhostTimesPage.axaml create mode 100644 WheelWizard/Views/Pages/GhostTimesPage.axaml.cs create mode 100644 WheelWizard/Views/Pages/GhostsPage.axaml create mode 100644 WheelWizard/Views/Pages/GhostsPage.axaml.cs create mode 100644 WheelWizard/Views/Patterns/TrackListItem.axaml create mode 100644 WheelWizard/Views/Patterns/TrackListItem.axaml.cs create mode 100644 WheelWizard/Views/Popups/GhostDetailsWindow.axaml create mode 100644 WheelWizard/Views/Popups/GhostDetailsWindow.axaml.cs diff --git a/WheelWizard/Converters/GhostDisplayConverters.cs b/WheelWizard/Converters/GhostDisplayConverters.cs new file mode 100644 index 00000000..f74a4d30 --- /dev/null +++ b/WheelWizard/Converters/GhostDisplayConverters.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia.Data.Converters; + +namespace WheelWizard.Converters; + +public class RankToMedalConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int rank) + { + return rank switch + { + 1 => "๐Ÿฅ‡", + 2 => "๐Ÿฅˆ", + 3 => "๐Ÿฅ‰", + _ => $"#{rank}" + }; + } + return "#?"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class CountryCodeToFlagConverter : IValueConverter +{ + private static readonly Dictionary CountryFlags = new() + { + { "US", "๐Ÿ‡บ๐Ÿ‡ธ" }, { "GB", "๐Ÿ‡ฌ๐Ÿ‡ง" }, { "CA", "๐Ÿ‡จ๐Ÿ‡ฆ" }, { "FR", "๐Ÿ‡ซ๐Ÿ‡ท" }, { "DE", "๐Ÿ‡ฉ๐Ÿ‡ช" }, + { "IT", "๐Ÿ‡ฎ๐Ÿ‡น" }, { "ES", "๐Ÿ‡ช๐Ÿ‡ธ" }, { "JP", "๐Ÿ‡ฏ๐Ÿ‡ต" }, { "KR", "๐Ÿ‡ฐ๐Ÿ‡ท" }, { "CN", "๐Ÿ‡จ๐Ÿ‡ณ" }, + { "AU", "๐Ÿ‡ฆ๐Ÿ‡บ" }, { "BR", "๐Ÿ‡ง๐Ÿ‡ท" }, { "MX", "๐Ÿ‡ฒ๐Ÿ‡ฝ" }, { "NL", "๐Ÿ‡ณ๐Ÿ‡ฑ" }, { "SE", "๐Ÿ‡ธ๐Ÿ‡ช" }, + { "NO", "๐Ÿ‡ณ๐Ÿ‡ด" }, { "DK", "๐Ÿ‡ฉ๐Ÿ‡ฐ" }, { "FI", "๐Ÿ‡ซ๐Ÿ‡ฎ" }, { "CH", "๐Ÿ‡จ๐Ÿ‡ญ" }, { "AT", "๐Ÿ‡ฆ๐Ÿ‡น" }, + { "BE", "๐Ÿ‡ง๐Ÿ‡ช" }, { "PT", "๐Ÿ‡ต๐Ÿ‡น" }, { "PL", "๐Ÿ‡ต๐Ÿ‡ฑ" }, { "RU", "๐Ÿ‡ท๐Ÿ‡บ" }, { "CZ", "๐Ÿ‡จ๐Ÿ‡ฟ" }, + { "HU", "๐Ÿ‡ญ๐Ÿ‡บ" }, { "GR", "๐Ÿ‡ฌ๐Ÿ‡ท" }, { "TR", "๐Ÿ‡น๐Ÿ‡ท" }, { "IL", "๐Ÿ‡ฎ๐Ÿ‡ฑ" }, { "IN", "๐Ÿ‡ฎ๐Ÿ‡ณ" }, + { "TH", "๐Ÿ‡น๐Ÿ‡ญ" }, { "SG", "๐Ÿ‡ธ๐Ÿ‡ฌ" }, { "MY", "๐Ÿ‡ฒ๐Ÿ‡พ" }, { "PH", "๐Ÿ‡ต๐Ÿ‡ญ" }, { "ID", "๐Ÿ‡ฎ๐Ÿ‡ฉ" }, + { "VN", "๐Ÿ‡ป๐Ÿ‡ณ" }, { "TW", "๐Ÿ‡น๐Ÿ‡ผ" }, { "HK", "๐Ÿ‡ญ๐Ÿ‡ฐ" }, { "NZ", "๐Ÿ‡ณ๐Ÿ‡ฟ" }, { "ZA", "๐Ÿ‡ฟ๐Ÿ‡ฆ" }, + { "EG", "๐Ÿ‡ช๐Ÿ‡ฌ" }, { "NG", "๐Ÿ‡ณ๐Ÿ‡ฌ" }, { "KE", "๐Ÿ‡ฐ๐Ÿ‡ช" }, { "MA", "๐Ÿ‡ฒ๐Ÿ‡ฆ" }, { "AR", "๐Ÿ‡ฆ๐Ÿ‡ท" }, + { "CL", "๐Ÿ‡จ๐Ÿ‡ฑ" }, { "CO", "๐Ÿ‡จ๐Ÿ‡ด" }, { "PE", "๐Ÿ‡ต๐Ÿ‡ช" }, { "VE", "๐Ÿ‡ป๐Ÿ‡ช" }, { "UY", "๐Ÿ‡บ๐Ÿ‡พ" }, + { "EC", "๐Ÿ‡ช๐Ÿ‡จ" }, { "BO", "๐Ÿ‡ง๐Ÿ‡ด" }, { "PY", "๐Ÿ‡ต๐Ÿ‡พ" }, { "CR", "๐Ÿ‡จ๐Ÿ‡ท" }, { "PA", "๐Ÿ‡ต๐Ÿ‡ฆ" }, + { "GT", "๐Ÿ‡ฌ๐Ÿ‡น" }, { "HN", "๐Ÿ‡ญ๐Ÿ‡ณ" }, { "SV", "๐Ÿ‡ธ๐Ÿ‡ป" }, { "NI", "๐Ÿ‡ณ๐Ÿ‡ฎ" }, { "BZ", "๐Ÿ‡ง๐Ÿ‡ฟ" }, + { "JM", "๐Ÿ‡ฏ๐Ÿ‡ฒ" }, { "TT", "๐Ÿ‡น๐Ÿ‡น" }, { "BB", "๐Ÿ‡ง๐Ÿ‡ง" }, { "GY", "๐Ÿ‡ฌ๐Ÿ‡พ" }, { "SR", "๐Ÿ‡ธ๐Ÿ‡ท" }, + { "UE", "๐Ÿ‡ช๐Ÿ‡บ" }, { "IE", "๐Ÿ‡ฎ๐Ÿ‡ช" }, { "IS", "๐Ÿ‡ฎ๐Ÿ‡ธ" }, { "LU", "๐Ÿ‡ฑ๐Ÿ‡บ" }, { "MC", "๐Ÿ‡ฒ๐Ÿ‡จ" }, + { "AD", "๐Ÿ‡ฆ๐Ÿ‡ฉ" }, { "SM", "๐Ÿ‡ธ๐Ÿ‡ฒ" }, { "VA", "๐Ÿ‡ป๐Ÿ‡ฆ" }, { "MT", "๐Ÿ‡ฒ๐Ÿ‡น" }, { "CY", "๐Ÿ‡จ๐Ÿ‡พ" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string countryCode && !string.IsNullOrEmpty(countryCode)) + { + return CountryFlags.TryGetValue(countryCode.ToUpper(), out var flag) ? flag : countryCode; + } + return "๐ŸŒ"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class CharacterIdToNameConverter : IValueConverter +{ + private static readonly Dictionary CharacterNames = new() + { + // https://wiki.tockdom.com/wiki/List_of_Identifiers + { 0x00, "Mario" }, + { 0x01, "Baby Peach" }, + { 0x02, "Waluigi" }, + { 0x03, "Bowser" }, + { 0x04, "Baby Daisy" }, + { 0x05, "Dry Bones" }, + { 0x06, "Baby Mario" }, + { 0x07, "Luigi" }, + { 0x08, "Toad" }, + { 0x09, "Donkey Kong" }, + { 0x0A, "Yoshi" }, + { 0x0B, "Wario" }, + { 0x0C, "Baby Luigi" }, + { 0x0D, "Toadette" }, + { 0x0E, "Koopa Troopa" }, + { 0x0F, "Daisy" }, + { 0x10, "Peach" }, + { 0x11, "Birdo" }, + { 0x12, "Diddy Kong" }, + { 0x13, "King Boo" }, + { 0x14, "Bowser Jr." }, + { 0x15, "Dry Bowser" }, + { 0x16, "Funky Kong" }, + { 0x17, "Rosalina" }, + { 0x18, "Small Mii A (Male)" }, + { 0x19, "Small Mii A (Female)" }, + { 0x1A, "Small Mii B (Male)" }, + { 0x1B, "Small Mii B (Female)" }, + { 0x1C, "Small Mii C (Male)" }, + { 0x1D, "Small Mii C (Female)" }, + { 0x1E, "Medium Mii A (Male)" }, + { 0x1F, "Medium Mii A (Female)" }, + { 0x20, "Medium Mii B (Male)" }, + { 0x21, "Medium Mii B (Female)" }, + { 0x22, "Medium Mii C (Male)" }, + { 0x23, "Medium Mii C (Female)" }, + { 0x24, "Large Mii A (Male)" }, + { 0x25, "Large Mii A (Female)" }, + { 0x26, "Large Mii B (Male)" }, + { 0x27, "Large Mii B (Female)" }, + { 0x28, "Large Mii C (Male)" }, + { 0x29, "Large Mii C (Female)" }, + { 0x2A, "Medium Mii" }, + { 0x2B, "Small Mii" }, + { 0x2C, "Large Mii" }, + { 0x2D, "Peach" }, + { 0x2E, "Daisy" }, + { 0x2F, "Rosalina" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int characterId) + { + return CharacterNames.TryGetValue(characterId, out var name) ? name : $"Character {characterId:X2}"; + } + return "Unknown"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class VehicleIdToNameConverter : IValueConverter +{ + private static readonly Dictionary VehicleNames = new() + { + // https://wiki.tockdom.com/wiki/List_of_Identifiers + { 0x00, "Standard Kart S" }, + { 0x01, "Standard Kart M" }, + { 0x02, "Standard Kart L" }, + { 0x03, "Booster Seat" }, + { 0x04, "Classic Dragster" }, + { 0x05, "Offroader" }, + { 0x06, "Mini Beast" }, + { 0x07, "Wild Wing" }, + { 0x08, "Flame Flyer" }, + { 0x09, "Cheep Charger" }, + { 0x0A, "Super Blooper" }, + { 0x0B, "Piranha Prowler" }, + { 0x0C, "Tiny Titan" }, + { 0x0D, "Daytripper" }, + { 0x0E, "Jetsetter" }, + { 0x0F, "Blue Falcon" }, + { 0x10, "Sprinter" }, + { 0x11, "Honeycoupe" }, + { 0x12, "Standard Bike S" }, + { 0x13, "Standard Bike M" }, + { 0x14, "Standard Bike L" }, + { 0x15, "Bullet Bike" }, + { 0x16, "Mach Bike" }, + { 0x17, "Flame Runner" }, + { 0x18, "Bit Bike" }, + { 0x19, "Sugarscoot" }, + { 0x1A, "Wario Bike" }, + { 0x1B, "Quacker" }, + { 0x1C, "Zip Zip" }, + { 0x1D, "Shooting Star" }, + { 0x1E, "Magikruiser" }, + { 0x1F, "Sneakster" }, + { 0x20, "Spear" }, + { 0x21, "Jet Bubble" }, + { 0x22, "Dolphin Dasher" }, + { 0x23, "Phantom" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int vehicleId) + { + return VehicleNames.TryGetValue(vehicleId, out var name) ? name : $"Vehicle {vehicleId:X2}"; + } + return "Unknown"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class DriftTypeToNameConverter : IValueConverter +{ + private static readonly Dictionary DriftTypes = new() + { + { 0, "Manual" }, { 1, "Automatic" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int driftType) + { + return DriftTypes.TryGetValue(driftType, out var name) ? name : $"Drift {driftType}"; + } + return "Unknown"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class ControllerTypeToNameConverter : IValueConverter +{ + private static readonly Dictionary ControllerTypes = new() + { + { 0, "Wii Wheel" }, { 1, "Nunchuk" }, { 2, "Classic Controller" }, + { 3, "GameCube Controller" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int controllerType) + { + return ControllerTypes.TryGetValue(controllerType, out var name) ? name : $"Controller {controllerType}"; + } + return "Unknown"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class LapTimesToAverageConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is List lapTimesMs && lapTimesMs.Count > 0) + { + var averageMs = lapTimesMs.Average(); + var minutes = (int)(averageMs / 60000); + var seconds = (averageMs % 60000) / 1000; + return $"{minutes}:{seconds:00.000}"; + } + return "0:00.000"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/WheelWizard/Models/GhostLeaderboard.cs b/WheelWizard/Models/GhostLeaderboard.cs new file mode 100644 index 00000000..88c6fe82 --- /dev/null +++ b/WheelWizard/Models/GhostLeaderboard.cs @@ -0,0 +1,171 @@ +using System.Text.Json.Serialization; + +namespace WheelWizard.Models; + +public class GhostLeaderboardResponse +{ + [JsonPropertyName("track")] + public GhostTrackInfo Track { get; set; } = new(); + + [JsonPropertyName("cc")] + public int Cc { get; set; } + + [JsonPropertyName("glitchAllowed")] + public bool GlitchAllowed { get; set; } + + [JsonPropertyName("shroomless")] + public bool? Shroomless { get; set; } + + [JsonPropertyName("vehicleFilter")] + public string? VehicleFilter { get; set; } + + [JsonPropertyName("isFlap")] + public bool IsFlap { get; set; } + + [JsonPropertyName("submissions")] + public List Submissions { get; set; } = new(); + + [JsonPropertyName("totalSubmissions")] + public int TotalSubmissions { get; set; } + + [JsonPropertyName("currentPage")] + public int CurrentPage { get; set; } + + [JsonPropertyName("pageSize")] + public int PageSize { get; set; } + + [JsonPropertyName("totalPages")] + public int TotalPages { get; set; } + + [JsonPropertyName("fastestLapMs")] + public int? FastestLapMs { get; set; } + + [JsonPropertyName("fastestLapDisplay")] + public string FastestLapDisplay { get; set; } = string.Empty; +} + +public class GhostTrackInfo +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("courseId")] + public int CourseId { get; set; } + + [JsonPropertyName("category")] + public string Category { get; set; } = string.Empty; + + [JsonPropertyName("laps")] + public int Laps { get; set; } + + [JsonPropertyName("supportsGlitch")] + public bool SupportsGlitch { get; set; } + + [JsonPropertyName("sortOrder")] + public int SortOrder { get; set; } +} + +public class GhostSubmission +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("trackId")] + public int TrackId { get; set; } + + [JsonPropertyName("trackName")] + public string TrackName { get; set; } = string.Empty; + + [JsonPropertyName("ttProfileId")] + public int TtProfileId { get; set; } + + [JsonPropertyName("playerName")] + public string PlayerName { get; set; } = string.Empty; + + [JsonPropertyName("countryCode")] + public int CountryCode { get; set; } + + [JsonPropertyName("countryAlpha2")] + public string? CountryAlpha2 { get; set; } + + [JsonPropertyName("countryName")] + public string? CountryName { get; set; } + + [JsonPropertyName("cc")] + public int Cc { get; set; } + + [JsonPropertyName("finishTimeMs")] + public int FinishTimeMs { get; set; } + + [JsonPropertyName("finishTimeDisplay")] + public string FinishTimeDisplay { get; set; } = string.Empty; + + [JsonPropertyName("vehicleId")] + public int VehicleId { get; set; } + + [JsonPropertyName("characterId")] + public int CharacterId { get; set; } + + [JsonPropertyName("controllerType")] + public int ControllerType { get; set; } + + [JsonPropertyName("driftType")] + public int DriftType { get; set; } + + [JsonPropertyName("shroomless")] + public bool Shroomless { get; set; } + + [JsonPropertyName("glitch")] + public bool Glitch { get; set; } + + [JsonPropertyName("isFlap")] + public bool IsFlap { get; set; } + + [JsonPropertyName("driftCategory")] + public int DriftCategory { get; set; } + + [JsonPropertyName("miiName")] + public string MiiName { get; set; } = string.Empty; + + [JsonPropertyName("lapCount")] + public int LapCount { get; set; } + + [JsonPropertyName("lapSplitsMs")] + public List LapSplitsMs { get; set; } = new(); + + [JsonPropertyName("lapSplitsDisplay")] + public List LapSplitsDisplay { get; set; } = new(); + + [JsonPropertyName("fastestLapMs")] + public int? FastestLapMs { get; set; } + + [JsonPropertyName("fastestLapDisplay")] + public string FastestLapDisplay { get; set; } = string.Empty; + + [JsonPropertyName("ghostFilePath")] + public string GhostFilePath { get; set; } = string.Empty; + + [JsonPropertyName("dateSet")] + public string DateSet { get; set; } = string.Empty; + + [JsonPropertyName("submittedAt")] + public DateTime SubmittedAt { get; set; } + + [JsonPropertyName("rank")] + public int Rank { get; set; } +} + +public enum GhostCc +{ + Cc150 = 150, + Cc200 = 200 +} + +public enum GhostLeaderboardType +{ + Regular, + Flap +} \ No newline at end of file diff --git a/WheelWizard/Models/GhostTrack.cs b/WheelWizard/Models/GhostTrack.cs new file mode 100644 index 00000000..5427a30f --- /dev/null +++ b/WheelWizard/Models/GhostTrack.cs @@ -0,0 +1,35 @@ +namespace WheelWizard.Models; + +public class GhostTrack +{ + public int TrackId { get; set; } // Added API track ID for leaderboard calls + public string Name { get; set; } = string.Empty; + public string HexValue { get; set; } = string.Empty; + public string Console { get; set; } = string.Empty; + public GhostTrackType TrackType { get; set; } + + public bool IsCustomTrack => TrackType == GhostTrackType.Custom; + public bool IsRetro => TrackType == GhostTrackType.Retro; + public string DisplayName => IsCustomTrack ? Name : $"{Console} {Name}"; +} + +public enum GhostTrackType +{ + All, + Retro, + Custom +} + +public enum GhostLocation +{ + All, + Online, + Local +} + +public enum GhostLocationFilter +{ + All, + Online, + Local +} \ No newline at end of file diff --git a/WheelWizard/Models/LocalGhostData.cs b/WheelWizard/Models/LocalGhostData.cs new file mode 100644 index 00000000..ec0ba822 --- /dev/null +++ b/WheelWizard/Models/LocalGhostData.cs @@ -0,0 +1,84 @@ +using System.Linq; + +namespace WheelWizard.Models; + +/// +/// Represents a local ghost record parsed from an RKG file +/// +public class LocalGhostData +{ + public string FilePath { get; set; } = string.Empty; + public string FileName => Path.GetFileNameWithoutExtension(FilePath); + + // Track information + public uint TrackId { get; set; } + public string TrackName { get; set; } = string.Empty; + + // Character/Vehicle setup + public byte CharacterId { get; set; } + public string CharacterName { get; set; } = string.Empty; + public byte VehicleId { get; set; } + public string VehicleName { get; set; } = string.Empty; + public byte ControllerType { get; set; } + public string ControllerName { get; set; } = string.Empty; + + // Timing data + public uint TotalTimeMs { get; set; } + public string TotalTimeDisplay => FormatTime(TotalTimeMs); + public List LapSplitsMs { get; set; } = new(); + public List LapSplitsDisplay => LapSplitsMs.Select(FormatTime).ToList(); + public uint FastestLapMs => LapSplitsMs.Count > 0 ? LapSplitsMs.Min() : 0; + public string FastestLapDisplay => FormatTime(FastestLapMs); + public double AverageLapMs => LapSplitsMs.Count > 0 ? LapSplitsMs.Select(x => (double)x).Average() : 0; + public string AverageLapDisplay => FormatTime((uint)AverageLapMs); + + // Metadata + public DateTime Date { get; set; } + public string DateDisplay => Date.ToString("MM/dd/yyyy"); + public ushort Country { get; set; } + public string CountryName { get; set; } = string.Empty; + public string MiiData { get; set; } = string.Empty; + + // CC information (derived from folder structure) + public int Cc { get; set; } = 150; // 150 or 200 + public bool IsVariant { get; set; } // true if in variant subfolder + + // Ranking information (set when loading ghosts) + public int Rank { get; set; } = 0; + + private static string FormatTime(uint timeMs) + { + if (timeMs == 0) return "0:00.000"; + + var totalSeconds = timeMs / 1000.0; + var minutes = (int)(totalSeconds / 60); + var seconds = totalSeconds % 60; + + return $"{minutes}:{seconds:00.000}"; + } +} + +/// +/// Represents a group of local ghost records for a specific track +/// +public class LocalTrackGhosts +{ + public uint TrackId { get; set; } + public string TrackName { get; set; } = string.Empty; + public string HexFolderName { get; set; } = string.Empty; + public List Ghosts150 { get; set; } = new(); + public List Ghosts200 { get; set; } = new(); + public List VariantGhosts150 { get; set; } = new(); // For variant tracks + public List VariantGhosts200 { get; set; } = new(); + + public int TotalGhostCount => Ghosts150.Count + Ghosts200.Count + VariantGhosts150.Count + VariantGhosts200.Count; + public bool HasGhosts => TotalGhostCount > 0; + + public LocalGhostData? BestTime150 => Ghosts150.Concat(VariantGhosts150) + .OrderBy(g => g.TotalTimeMs) + .FirstOrDefault(); + + public LocalGhostData? BestTime200 => Ghosts200.Concat(VariantGhosts200) + .OrderBy(g => g.TotalTimeMs) + .FirstOrDefault(); +} \ No newline at end of file diff --git a/WheelWizard/Models/WorldRecordsResponse.cs b/WheelWizard/Models/WorldRecordsResponse.cs new file mode 100644 index 00000000..2aa215cc --- /dev/null +++ b/WheelWizard/Models/WorldRecordsResponse.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization; + +namespace WheelWizard.Models; + +public class WorldRecordsResponse +{ + [JsonPropertyName("trackId")] + public int TrackId { get; set; } + + [JsonPropertyName("trackName")] + public string TrackName { get; set; } = string.Empty; + + [JsonPropertyName("activeWorldRecord")] + public GhostSubmission ActiveWorldRecord { get; set; } = new(); +} + +public class TrackInfo +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("courseId")] + public int CourseId { get; set; } + + [JsonPropertyName("category")] + public string Category { get; set; } = string.Empty; + + [JsonPropertyName("laps")] + public int Laps { get; set; } + + [JsonPropertyName("supportsGlitch")] + public bool SupportsGlitch { get; set; } + + [JsonPropertyName("sortOrder")] + public int SortOrder { get; set; } +} + +public class ApiTrack +{ + public int TrackId { get; set; } + public string Name { get; set; } = string.Empty; + public string Console { get; set; } = string.Empty; + public string HexValue { get; set; } = string.Empty; + public GhostTrackType TrackType { get; set; } + public GhostSubmission? WorldRecord { get; set; } + + public bool IsCustomTrack => TrackType == GhostTrackType.Custom; + public bool IsRetroTrack => TrackType == GhostTrackType.Retro; + + public static ApiTrack FromWorldRecordAndTrackInfo(WorldRecordsResponse wr, TrackInfo? trackInfo) + { + var track = new ApiTrack + { + TrackId = wr.TrackId, + Name = wr.TrackName, + WorldRecord = wr.ActiveWorldRecord + }; + + if (trackInfo != null) + { + track.TrackType = trackInfo.Category.ToLowerInvariant() switch + { + "retro" => GhostTrackType.Retro, + "custom" => GhostTrackType.Custom, + _ => GhostTrackType.All + }; + + track.Console = track.TrackType switch + { + GhostTrackType.Retro => "Retro", + GhostTrackType.Custom => "Custom", + _ => "Standard" + }; + } + else + { + track.TrackType = GhostTrackType.All; + track.Console = "Unknown"; + } + + return track; + } +} \ No newline at end of file diff --git a/WheelWizard/Services/GhostLeaderboardService.cs b/WheelWizard/Services/GhostLeaderboardService.cs new file mode 100644 index 00000000..9229ca6b --- /dev/null +++ b/WheelWizard/Services/GhostLeaderboardService.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using WheelWizard.Models; +using Serilog; + +namespace WheelWizard.Services; + +public class GhostLeaderboardService +{ + private readonly HttpClient _httpClient; + private readonly string _baseUrl = "https://rwfc.net/api/timetrial/leaderboard"; + + public GhostLeaderboardService() + { + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "WheelWizard/1.0"); + } + + public async Task GetLeaderboardAsync( + int trackId, + bool glitchAllowed, + GhostCc cc, + GhostLeaderboardType leaderboardType, + int page = 1, + int pageSize = 10) + { + try + { + var endpoint = leaderboardType == GhostLeaderboardType.Flap ? $"{_baseUrl}/flap" : _baseUrl; + var url = $"{endpoint}?glitchAllowed={glitchAllowed.ToString().ToLower()}&trackId={trackId}&cc={((int)cc)}&page={page}&pageSize={pageSize}"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + return JsonSerializer.Deserialize(json, options); + } + catch (Exception ex) + { + Log.Error(ex, $"Failed to load track info from API: {ex.Message}"); + return null; + } + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} \ No newline at end of file diff --git a/WheelWizard/Services/GhostTrackService.cs b/WheelWizard/Services/GhostTrackService.cs new file mode 100644 index 00000000..c2abd9eb --- /dev/null +++ b/WheelWizard/Services/GhostTrackService.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using WheelWizard.Models; +using Serilog; + +namespace WheelWizard.Services +{ + public class GhostTrackService + { + private readonly HttpClient _httpClient; + private readonly TrackHexMappingService _hexMappingService; + private List _allTracks = new(); + private Dictionary _trackInfoCache = new(); + private bool _tracksLoaded = false; + private bool _trackInfoLoaded = false; + + public GhostTrackService(HttpClient httpClient, TrackHexMappingService hexMappingService) + { + _httpClient = httpClient; + _hexMappingService = hexMappingService; + } + + public async Task> GetAllTracksAsync() + { + if (_tracksLoaded && _allTracks.Count > 0) + { + Log.Debug("Returning cached track data ({Count} tracks)", _allTracks.Count); + return _allTracks; + } + + try + { + Log.Information("Loading tracks from API (first time)"); + + await LoadTrackInfoAsync(); + + const string worldRecordsUrl = "https://rwfc.net/api/timetrial/worldrecords/all?glitchAllowed=true&cc=150"; + var response = await _httpClient.GetStringAsync(worldRecordsUrl); + + var worldRecords = JsonSerializer.Deserialize(response, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (worldRecords != null) + { + _allTracks = worldRecords + .Where(wr => wr.TrackId > 0) + .Select(wr => + { + _trackInfoCache.TryGetValue(wr.TrackId, out var trackInfo); + var track = ApiTrack.FromWorldRecordAndTrackInfo(wr, trackInfo); + + track.HexValue = _hexMappingService.GetHexValueForTrack(wr.TrackName) ?? + _hexMappingService.GetHexValueForTrack(track.Console, wr.TrackName) ?? + wr.TrackId.ToString("X8"); + + return track; + }) + .OrderBy(t => t.TrackType) + .ThenBy(t => t.Name) + .ToList(); + _tracksLoaded = true; + Log.Information("Loaded and cached {Count} tracks from API with category data", _allTracks.Count); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load tracks from API"); + } + + return _allTracks; + } + + private async Task LoadTrackInfoAsync() + { + if (_trackInfoLoaded && _trackInfoCache.Count > 0) + { + Log.Debug("Using cached track info ({Count} tracks)", _trackInfoCache.Count); + return; + } + + try + { + Log.Information("Loading track info from tracks API (first time)"); + const string tracksUrl = "https://rwfc.net/api/timetrial/tracks"; + var response = await _httpClient.GetStringAsync(tracksUrl); + + var tracks = JsonSerializer.Deserialize(response, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (tracks != null) + { + _trackInfoCache = tracks.ToDictionary(t => t.Id); + _trackInfoLoaded = true; + Log.Information("Loaded and cached track info for {Count} tracks", _trackInfoCache.Count); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load track info from API"); + } + } + + public List GetFilteredTracks(GhostTrackType trackFilter, GhostLocation locationFilter, string searchText = "") + { + var filtered = _allTracks.AsEnumerable(); + + filtered = trackFilter switch + { + GhostTrackType.Retro => filtered.Where(t => t.TrackType == GhostTrackType.Retro), + GhostTrackType.Custom => filtered.Where(t => t.TrackType == GhostTrackType.Custom), + _ => filtered + }; + + if (!string.IsNullOrWhiteSpace(searchText)) + { + filtered = filtered.Where(t => + t.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + t.Console.Contains(searchText, StringComparison.OrdinalIgnoreCase)); + } + + return filtered.ToList(); + } + } +} \ No newline at end of file diff --git a/WheelWizard/Services/LocalGhostService.cs b/WheelWizard/Services/LocalGhostService.cs new file mode 100644 index 00000000..f78e89ca --- /dev/null +++ b/WheelWizard/Services/LocalGhostService.cs @@ -0,0 +1,224 @@ +using WheelWizard.Models; +using WheelWizard.Services; +using Serilog; + +namespace WheelWizard.Services; + +/// +/// Service for discovering and parsing local ghost files from the Dolphin/Wii file system +/// +public class LocalGhostService +{ + private readonly Dictionary _cachedTrackGhosts = new(); + private bool _hasScannedFolder = false; + + /// + /// Scans the Dolphin ghosts folder and returns local ghost data for all tracks + /// + public async Task> GetAllLocalGhostsAsync() + { + if (_hasScannedFolder && _cachedTrackGhosts.Count > 0) + { + Log.Information("Using cached local ghost data for {TrackCount} tracks", _cachedTrackGhosts.Count); + return _cachedTrackGhosts; + } + + await Task.Run(() => ScanGhostsFolder()); + _hasScannedFolder = true; + + Log.Information("Loaded local ghost data for {TrackCount} tracks", _cachedTrackGhosts.Count); + return _cachedTrackGhosts; + } + + /// + /// Gets local ghost data for a specific track by its ID + /// + public async Task GetTrackGhostsAsync(uint trackId, string hexValue) + { + var allGhosts = await GetAllLocalGhostsAsync(); + + if (allGhosts.TryGetValue(trackId, out var trackGhosts)) + { + return trackGhosts; + } + + return ScanTrackFolder(trackId, hexValue); + } + + /// + /// Opens the ghost folder for a specific track in file explorer + /// + public void OpenTrackFolderInExplorer(string hexValue) + { + try + { + var trackFolderPath = Path.Combine(PathManager.GhostsFolderPath, hexValue.ToLowerInvariant()); + + if (!Directory.Exists(trackFolderPath)) + { + Log.Warning("Track ghost folder does not exist: {FolderPath}", trackFolderPath); + Directory.CreateDirectory(trackFolderPath); + } + + FilePickerHelper.OpenFolderInFileManager(trackFolderPath); + Log.Information("Opened track ghost folder: {FolderPath}", trackFolderPath); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to open track ghost folder for hex: {HexValue}", hexValue); + } + } + + private void ScanGhostsFolder() + { + _cachedTrackGhosts.Clear(); + + try + { + var ghostsPath = PathManager.GhostsFolderPath; + + if (!Directory.Exists(ghostsPath)) + { + Log.Information("Ghosts folder does not exist: {Path}", ghostsPath); + return; + } + + var hexFolders = Directory.GetDirectories(ghostsPath) + .Where(dir => IsValidHexFolder(Path.GetFileName(dir))) + .ToList(); + + Log.Information("Scanning {FolderCount} hex folders in ghosts directory", hexFolders.Count); + + foreach (var hexFolder in hexFolders) + { + var hexName = Path.GetFileName(hexFolder).ToLowerInvariant(); + var trackGhosts = ScanHexFolder(hexFolder, hexName); + + if (trackGhosts?.HasGhosts == true) + { + _cachedTrackGhosts[trackGhosts.TrackId] = trackGhosts; + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Error scanning ghosts folder: {Path}", PathManager.GhostsFolderPath); + } + } + + private LocalTrackGhosts? ScanTrackFolder(uint trackId, string hexValue) + { + try + { + var hexFolder = Path.Combine(PathManager.GhostsFolderPath, hexValue.ToLowerInvariant()); + + if (!Directory.Exists(hexFolder)) + return null; + + return ScanHexFolder(hexFolder, hexValue.ToLowerInvariant()); + } + catch (Exception ex) + { + Log.Error(ex, "Error scanning track folder for track {TrackId}, hex {HexValue}", trackId, hexValue); + return null; + } + } + + private LocalTrackGhosts? ScanHexFolder(string hexFolderPath, string hexName) + { + var trackGhosts = new LocalTrackGhosts + { + HexFolderName = hexName + }; + + try + { + ScanCcFolder(Path.Combine(hexFolderPath, "150"), trackGhosts.Ghosts150, 150, false); + ScanCcFolder(Path.Combine(hexFolderPath, "200"), trackGhosts.Ghosts200, 200, false); + + var variantPath = Path.Combine(hexFolderPath, "1"); + if (Directory.Exists(variantPath)) + { + ScanCcFolder(Path.Combine(variantPath, "150"), trackGhosts.VariantGhosts150, 150, true); + ScanCcFolder(Path.Combine(variantPath, "200"), trackGhosts.VariantGhosts200, 200, true); + } + + var firstGhost = trackGhosts.Ghosts150.FirstOrDefault() ?? + trackGhosts.Ghosts200.FirstOrDefault() ?? + trackGhosts.VariantGhosts150.FirstOrDefault() ?? + trackGhosts.VariantGhosts200.FirstOrDefault(); + + if (firstGhost != null) + { + trackGhosts.TrackId = firstGhost.TrackId; + trackGhosts.TrackName = firstGhost.TrackName; + } + + return trackGhosts.HasGhosts ? trackGhosts : null; + } + catch (Exception ex) + { + Log.Error(ex, "Error scanning hex folder: {FolderPath}", hexFolderPath); + return null; + } + } + + private void ScanCcFolder(string ccFolderPath, List ghostList, int cc, bool isVariant) + { + try + { + if (!Directory.Exists(ccFolderPath)) + { + Log.Debug("CC folder does not exist: {FolderPath}", ccFolderPath); + return; + } + + var rkgFiles = Directory.GetFiles(ccFolderPath, "*.rkg", SearchOption.TopDirectoryOnly); + Log.Debug("Found {FileCount} .rkg files in {FolderPath}", rkgFiles.Length, ccFolderPath); + + var successfullyParsed = 0; + foreach (var rkgFile in rkgFiles) + { + Log.Debug("Attempting to parse RKG file: {FilePath}", rkgFile); + var ghostData = RkgParser.ParseRkgFile(rkgFile); + if (ghostData != null) + { + ghostData.Cc = cc; + ghostData.IsVariant = isVariant; + ghostList.Add(ghostData); + successfullyParsed++; + Log.Debug("Successfully parsed RKG: {FilePath} -> Track: {TrackName}, Time: {Time}", + rkgFile, ghostData.TrackName, ghostData.TotalTimeDisplay); + } + else + { + Log.Warning("Failed to parse RKG file: {FilePath}", rkgFile); + } + } + + Log.Debug("Parsed {SuccessCount}/{TotalCount} RKG files in {FolderPath}", + successfullyParsed, rkgFiles.Length, ccFolderPath); + } + catch (Exception ex) + { + Log.Error(ex, "Error scanning CC folder: {FolderPath}", ccFolderPath); + } + } + + private static bool IsValidHexFolder(string folderName) + { + return !string.IsNullOrEmpty(folderName) && + folderName.Length == 8 && + folderName.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')); + } + + /// + /// Force a rescan of the ghosts folder (clears cache) + /// + public void RefreshGhostData() + { + _cachedTrackGhosts.Clear(); + _hasScannedFolder = false; + Log.Information("Cleared local ghost cache, will rescan on next request"); + } +} \ No newline at end of file diff --git a/WheelWizard/Services/PathManager.cs b/WheelWizard/Services/PathManager.cs index b53ea83f..4ae4e695 100644 --- a/WheelWizard/Services/PathManager.cs +++ b/WheelWizard/Services/PathManager.cs @@ -83,6 +83,7 @@ public static bool IsUsingCustomWheelWizardAppdataPath public static string WiiDbFolder => Path.Combine(WiiFolderPath, "shared2", "menu", "FaceLib"); public static string MiiDbFile => Path.Combine(WiiDbFolder, "RFL_DB.dat"); public static string RRratingFilePath => Path.Combine(WiiFolderPath, "shared2", "Pulsar", "RetroRewind6", "RRRating.pul"); + public static string GhostsFolderPath => Path.Combine(WiiFolderPath, "shared2", "Pulsar", "RetroRewind6", "Ghosts"); #region Wheel Wizard Appdata Override diff --git a/WheelWizard/Services/RkgParser.cs b/WheelWizard/Services/RkgParser.cs new file mode 100644 index 00000000..11ad8495 --- /dev/null +++ b/WheelWizard/Services/RkgParser.cs @@ -0,0 +1,325 @@ +using WheelWizard.Models; +using System.Text; +using Serilog; + +namespace WheelWizard.Services; + +public class RkgParser +{ + public static LocalGhostData? ParseRkgFile(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + Log.Debug("RKG file does not exist: {FilePath}", filePath); + return null; + } + + var data = File.ReadAllBytes(filePath); + Log.Debug("Reading RKG file: {FilePath}, Size: {Size} bytes", filePath, data.Length); + + if (data.Length < 0x88) + { + Log.Warning("RKG file too small ({Size} bytes, minimum 136): {FilePath}", data.Length, filePath); + return null; + } + + if (!IsValidRkgFile(data)) + { + Log.Warning("Invalid RKG header in file: {FilePath}", filePath); + return null; + } + + var ghostData = new LocalGhostData + { + FilePath = filePath, + TrackId = ParseTrackId(data), + CharacterId = ParseCharacterId(data), + VehicleId = ParseVehicleId(data), + ControllerType = ParseControllerType(data), + TotalTimeMs = ParseFinishTime(data), + Date = ParseDate(data, filePath), + LapSplitsMs = ParseLapSplits(data), + Country = ParseCountryCode(data), + MiiData = ParseMiiData(data) + }; + + Log.Debug("RKG Parse Results: TrackId={TrackId}, CharId={CharId}, VehicleId={VehicleId}, ControllerType={ControllerType}, Time={Time}ms, Date={Date}, Country={Country}, Mii='{Mii}'", + ghostData.TrackId, ghostData.CharacterId, ghostData.VehicleId, ghostData.ControllerType, + ghostData.TotalTimeMs, ghostData.Date, ghostData.Country, ghostData.MiiData); + + Log.Debug("Raw bytes: 0x04={B04:X2}, 0x05={B05:X2}, 0x06={B06:X2}, 0x07={B07:X2}, 0x08={B08:X2}, 0x09={B09:X2}, 0x0A={B0A:X2}, 0x0B={B0B:X2}, 0x10={B10:X2}", + data[0x04], data[0x05], data[0x06], data[0x07], data[0x08], data[0x09], data[0x0A], data[0x0B], data[0x10]); + + var timeMinutes = ExtractBits(data, 0x04, 0, 7); + var timeSeconds = ExtractBits(data, 0x04, 7, 7); + var timeMs = ExtractBits(data, 0x05, 6, 10); + Log.Debug("Time bit extraction: {Minutes}m {Seconds}s {Milliseconds}ms", timeMinutes, timeSeconds, timeMs); + + var vehicleId = ExtractBits(data, 0x08, 0, 6); + var characterId = ExtractBits(data, 0x08, 6, 6); + Log.Debug("Character/Vehicle extraction: VehicleId={VehicleId}, CharacterId={CharacterId}", vehicleId, characterId); + + Log.Debug("Successfully parsed RKG: {FilePath} -> TrackId: 0x{TrackId:X8}, Time: {Time}ms, Laps: {LapCount}", + filePath, ghostData.TrackId, ghostData.TotalTimeMs, ghostData.LapSplitsMs.Count); + + return ghostData; + } + catch (Exception ex) + { + Log.Warning(ex, "Error parsing RKG file {FilePath}: {Message}", filePath, ex.Message); + return null; + } + } + + private static bool IsValidRkgFile(byte[] data) + { + // RKG files should start with "RKGD" magic bytes + return data.Length >= 4 && + data[0] == 0x52 && data[1] == 0x4B && data[2] == 0x47 && data[3] == 0x44; // "RKGD" + } + + /// + /// Bit reader class for continuous bitstream reading + /// + private class BitReader + { + private readonly byte[] data; + private int bitOffset; + + public BitReader(byte[] data, int startByteOffset = 0) + { + this.data = data; + this.bitOffset = startByteOffset * 8; + } + + public uint ReadBits(int bitCount) + { + uint result = 0; + + for (int i = 0; i < bitCount; i++) + { + int byteIndex = bitOffset / 8; + int bitIndex = bitOffset % 8; + + if (byteIndex >= data.Length) break; + + uint bit = (uint)((data[byteIndex] >> (7 - bitIndex)) & 1); + result = (result << 1) | bit; + + bitOffset++; + } + + return result; + } + } + + /// + /// Extract bits from byte array with precise bit-level control + /// + private static uint ExtractBits(byte[] data, int byteOffset, int bitOffset, int bitCount) + { + uint result = 0; + int currentByte = byteOffset; + int currentBit = bitOffset; + + for (int i = 0; i < bitCount; i++) + { + if (currentByte >= data.Length) break; + + uint bit = (uint)((data[currentByte] >> (7 - currentBit)) & 1); + result = (result << 1) | bit; + + currentBit++; + if (currentBit >= 8) + { + currentBit = 0; + currentByte++; + } + } + + return result; + } + + /// + /// Parse track ID from offset 0x07 (6 bits, bits 0-5) + /// + private static uint ParseTrackId(byte[] data) + { + return ExtractBits(data, 0x07, 0, 6); // 6 bits starting at bit 0 of byte 0x07 + } + + /// + /// Parse vehicle ID from offset 0x08 (6 bits, bits 0-5) + /// + private static byte ParseVehicleId(byte[] data) + { + return (byte)ExtractBits(data, 0x08, 0, 6); // 6 bits starting at bit 0 of byte 0x08 + } + + /// + /// Parse character ID from offset 0x08 (next 6 bits after vehicle ID) + /// + private static byte ParseCharacterId(byte[] data) + { + return (byte)ExtractBits(data, 0x08, 6, 6); // 6 bits starting at bit 6 of byte 0x08 + } + + /// + /// Parse controller type from offset 0x0B.4 (4 bits, upper 4 bits) + /// + private static byte ParseControllerType(byte[] data) + { + return (byte)ExtractBits(data, 0x0B, 0, 4); // 4 bits starting at bit 0 of byte 0x0B + } + + /// + /// Parse finish time using precise bit extraction + /// 7 bits minutes + 7 bits seconds + 10 bits milliseconds = 24 bits total starting at 0x04 + /// + private static uint ParseFinishTime(byte[] data) + { + // Extract time fields using bit-level precision + uint minutes = ExtractBits(data, 0x04, 0, 7); // 7 bits for minutes + uint seconds = ExtractBits(data, 0x04, 7, 7); // Next 7 bits for seconds + uint milliseconds = ExtractBits(data, 0x05, 6, 10); // Next 10 bits for milliseconds + + return (minutes * 60 * 1000) + (seconds * 1000) + milliseconds; + } + + /// + /// Parse date using precise bit extraction + /// 7 bits year + 4 bits month + 5 bits day starting at 0x09.4 + /// + private static DateTime ParseDate(byte[] data, string filePath) + { + try + { + uint year = 2000 + ExtractBits(data, 0x09, 4, 7); // 7 bits starting at bit 4 of 0x09 + uint month = ExtractBits(data, 0x0A, 3, 4); // 4 bits starting at bit 3 of 0x0A + uint day = ExtractBits(data, 0x0A, 7, 5); // 5 bits starting at bit 7 of 0x0A (spans to 0x0B) + + // Validate date ranges + if (year >= 2000 && year <= DateTime.Now.Year + 1 && + month >= 1 && month <= 12 && + day >= 1 && day <= 31) + { + return new DateTime((int)year, (int)month, (int)day); + } + + Log.Debug("Invalid date in RKG: Year={Year}, Month={Month}, Day={Day}", year, month, day); + } + catch (Exception ex) + { + Log.Debug(ex, "Failed to parse date from RKG file"); + } + + return File.GetCreationTime(filePath); + } + + /// + /// Parse lap splits from offset 0x11 using continuous bitstream reading + /// Each split: 7 bits minutes + 7 bits seconds + 10 bits milliseconds (24 bits total) + /// CRITICAL: Must read as continuous bitstream, NOT byte-aligned! + /// + private static List ParseLapSplits(byte[] data) + { + var lapSplits = new List(); + + // Get lap count from offset 0x10 + if (data.Length <= 0x10) return lapSplits; + int lapCount = Math.Min((int)data[0x10], 5); // Max 5 lap splits stored + + // Create bit reader starting at offset 0x11 + var bitReader = new BitReader(data, 0x11); + + Log.Debug("Parsing {LapCount} lap splits starting at offset 0x11", lapCount); + + for (int i = 0; i < lapCount; i++) + { + // Read exactly 24 bits per lap split (7+7+10) + uint minutes = bitReader.ReadBits(7); + uint seconds = bitReader.ReadBits(7); + uint milliseconds = bitReader.ReadBits(10); + + uint lapTimeMs = (minutes * 60 * 1000) + (seconds * 1000) + milliseconds; + + Log.Debug("Lap {Index}: {Minutes}m {Seconds}s {Milliseconds}ms = {TotalMs}ms", + i + 1, minutes, seconds, milliseconds, lapTimeMs); + + if (lapTimeMs > 0) + { + lapSplits.Add(lapTimeMs); + } + } + + return lapSplits; + } + + /// + /// Parse country code from offset 0x34 + /// + private static ushort ParseCountryCode(byte[] data) + { + if (0x34 < data.Length) + return data[0x34]; + return 0xFF; + } + + /// + /// Parse Mii data from offset 0x3C (74 bytes) + /// The Mii name is stored as UTF-16 Big Endian at offset 0x02 within the Mii structure (20 bytes, 10 characters max) + /// + private static string ParseMiiData(byte[] data) + { + try + { + const int miiDataOffset = 0x3C; + const int nameOffsetInMii = 0x02; + const int nameLength = 20; // 10 UTF-16 characters = 20 bytes + + if (data.Length < miiDataOffset + nameOffsetInMii + nameLength) + { + Log.Debug("RKG file too small for Mii name data"); + return "Unknown"; + } + + var nameBytes = new byte[nameLength]; + Array.Copy(data, miiDataOffset + nameOffsetInMii, nameBytes, 0, nameLength); + + Log.Debug("Mii name bytes: {Bytes}", Convert.ToHexString(nameBytes)); + + var miiName = Encoding.BigEndianUnicode.GetString(nameBytes) + .TrimEnd('\0') + .TrimEnd(); + + if (!string.IsNullOrEmpty(miiName)) + { + miiName = new string(miiName.Where(c => !char.IsControl(c) && c != '\0' && (c >= 32 && c <= 126 || c > 127)).ToArray()); + } + + if (string.IsNullOrWhiteSpace(miiName) || miiName.All(c => c < 32 || (c > 127 && c < 160))) + { + Log.Debug("UTF-16 BE failed, trying UTF-16 LE for Mii name"); + miiName = Encoding.Unicode.GetString(nameBytes) + .TrimEnd('\0') + .TrimEnd(); + + if (!string.IsNullOrEmpty(miiName)) + { + miiName = new string(miiName.Where(c => !char.IsControl(c) && c != '\0' && (c >= 32 && c <= 126 || c > 127)).ToArray()); + } + } + + Log.Debug("Parsed Mii name: '{Name}' (length: {Length})", miiName, miiName?.Length ?? 0); + + return string.IsNullOrWhiteSpace(miiName) ? "Unknown" : miiName.Trim(); + } + catch (Exception ex) + { + Log.Debug(ex, "Error parsing Mii data"); + return "Unknown"; + } + } +} \ No newline at end of file diff --git a/WheelWizard/Services/TrackHexMappingService.cs b/WheelWizard/Services/TrackHexMappingService.cs new file mode 100644 index 00000000..1f4274d4 --- /dev/null +++ b/WheelWizard/Services/TrackHexMappingService.cs @@ -0,0 +1,360 @@ +using System.Collections.Generic; + +namespace WheelWizard.Services; + +/// +/// Service that maps track names to their hex folder values for ghost file organization +/// Note these was all taken from the discord surly there has to be a better way of getting this info +/// +public class TrackHexMappingService +{ + private static readonly Dictionary _trackNameToHexMap = new() + { + // SNES Tracks + {"SNES Mario Circuit 1", "44E5205F"}, + {"SNES Donut Plains 1", "EB586DFF"}, + {"SNES Ghost Valley 1", "DE75621F"}, + {"SNES Bowser Castle 1", "D87A17D8"}, + {"SNES Mario Circuit 2", "D9E184B1"}, + {"SNES Choco Island 1", "CAE3C36D"}, + {"SNES Ghost Valley 2", "8301A1DB"}, + {"SNES Donut Plains 2", "CA066F62"}, + {"SNES Bowser Castle 2", "971A5C34"}, + {"SNES Mario Circuit 3", "E50ED939"}, + {"SNES Koopa Beach 1", "038C74CF"}, + {"SNES Choco Island 2", "F520145E"}, + {"SNES Vanilla Lake 1", "26383B16"}, + {"SNES Bowser Castle 3", "2F793D6A"}, + {"SNES Mario Circuit 4", "B2E12767"}, + {"SNES Donut Plains 3", "9EB3ABD9"}, + {"SNES Koopa Beach 2", "68165DDB"}, + {"SNES Ghost Valley 3", "F3122981"}, + {"SNES Vanilla Lake 2", "F4CA3226"}, + {"SNES Rainbow Road", "13BA92A6"}, + + // N64 Tracks + {"N64 Luigi Raceway", "D233950F"}, + {"N64 Moo Moo Farm", "A1D9CBF4"}, + {"N64 Koopa Troopa Beach", "DFBF7765"}, + {"N64 Kalimari Desert", "DD799A8B"}, + {"N64 Toad's Turnpike", "A13B369E"}, + {"N64 Frappe Snowland", "638CD778"}, + {"N64 Choco Mountain", "3E613DFD"}, + {"N64 Mario Raceway", "2BC0BC7F"}, + {"N64 Wario Stadium", "9C687079"}, + {"N64 Sherbet Land", "5CA26075"}, + {"N64 Royal Raceway", "1A34960A"}, + {"N64 Bowser's Castle", "B6EF6CDC"}, + {"N64 DK's Jungle Parkway", "E747148E"}, + {"N64 Yoshi Valley", "EA7143FA"}, + {"N64 Banshee Boardwalk", "D5FD5530"}, + {"N64 Rainbow Road", "887ED844"}, + + // GBA Tracks + {"GBA Peach Circuit", "250F872B"}, + {"GBA Shy Guy Beach", "A3D2E690"}, + {"GBA Riverside Park", "7FF67CF4"}, + {"GBA Bowser Castle 1", "99906E32"}, + {"GBA Mario Circuit", "2D62D5AB"}, + {"GBA Boo Lake", "CBD96C35"}, + {"GBA Cheese Land", "6F599A06"}, + {"GBA Bowser Castle 2", "405C4AAD"}, + {"GBA Luigi Circuit", "C50E4B19"}, + {"GBA Sky Garden", "7BABCBE2"}, + {"GBA Cheep-Cheep Island", "5571E08D"}, + {"GBA Sunset Wilds", "B1CB0053"}, + {"GBA Snow Land", "C453E8D1"}, + {"GBA Ribbon Road", "5C3A3924"}, + {"GBA Yoshi Desert", "03C5CC88"}, + {"GBA Bowser Castle 3", "EAEB9FF5"}, + {"GBA Lakeside Park", "5C8AE5E6"}, + {"GBA Broken Pier", "2B007603"}, + {"GBA Bowser Castle 4", "48AC0DEF"}, + {"GBA Rainbow Road", "5109EC5A"}, + + // GCN Tracks + {"GCN Luigi Circuit", "DE5B4372"}, + {"GCN Peach Beach", "B6548636"}, + {"GCN Baby Park", "D6CAC6A4"}, + {"GCN Dry Dry Desert", "7278C3E5"}, + {"GCN Mushroom Bridge", "BB02C680"}, + {"GCN Mario Circuit", "A3EB62E4"}, + {"GCN Daisy Cruiser", "263AEA09"}, + {"GCN Waluigi Stadium", "2AB5DADE"}, + {"GCN Sherbet Land", "6E9C9A9F"}, + {"GCN Mushroom City", "6813B49F"}, + {"GCN Yoshi Circuit", "F352BF51"}, + {"GCN DK Mountain", "40F755DE"}, + {"GCN Wario Colosseum", "01A52CBA"}, + {"GCN Dino Dino Jungle", "5207422E"}, + {"GCN Bowser's Castle", "E1EF4FAE"}, + {"GCN Rainbow Road", "31BFD489"}, + + // DS Tracks + {"DS Figure 8 Circuit", "3B0B90BD"}, + {"DS Yoshi Falls", "CCB71442"}, + {"DS Cheep Cheep Beach", "C3018CB9"}, + {"DS Luigi's Mansion", "C364863D"}, + {"DS Desert Hills", "108C1986"}, + {"DS Delfino Square", "7F08B08C"}, + {"DS Waluigi Pinball", "23C4CB09"}, + {"DS Shroom Ridge", "3F104183"}, + {"DS DK Pass", "37187F25"}, + {"DS Tick-Tock Clock", "3CFBC7A4"}, + {"DS Mario Circuit", "315444E0"}, + {"DS Airship Fortress", "1DB6DCEA"}, + {"DS Wario Stadium", "EC1DB9B9"}, + {"DS Peach Gardens", "94C0207E"}, + {"DS Bowser Castle", "6128C751"}, + {"DS Rainbow Road", "0DA7DECC"}, + + // Wii Tracks + {"Wii Luigi Circuit", "95AB4053"}, + {"Wii Moo Moo Meadows", "1CD97A59"}, + {"Wii Mushroom Gorge", "3B74F7C0"}, + {"Wii Toad's Factory", "359C708C"}, + {"Wii Mario Circuit", "08FFED33"}, + {"Wii Coconut Mall", "64580E20"}, + {"Wii DK Summit", "E96F4A07"}, + {"Wii Wario's Gold Mine", "19BC903B"}, + {"Wii Daisy Circuit", "17B39F40"}, + {"Wii Koopa Cape", "D66ED3F6"}, + {"Wii Maple Treeway", "C8F7895A"}, + {"Wii Grumble Volcano", "A2E5580D"}, + {"Wii Dry Dry Ruins", "70BC1733"}, + {"Wii Moonview Highway", "B27AE292"}, + {"Wii Bowser's Castle", "250C90C7"}, + {"Wii Rainbow Road", "33C6D0A8"}, + + // 3DS Tracks + {"3DS Toad Circuit", "1BD14032"}, + {"3DS Daisy Hills", "2E8D060C"}, + {"3DS Cheep Cheep Lagoon", "B847F4AD"}, + {"3DS Shy Guy Bazaar", "03ED2EEE"}, + {"3DS Wuhu Loop", "783E8785"}, + {"3DS Mario Circuit", "B3B581FA"}, + {"3DS Music Park", "42EF53CC"}, + {"3DS Rock Rock Mountain", "41A721A4"}, + {"3DS Piranha Plant Slide", "3581D820"}, + {"3DS Wario's Shipyard", "634E554E"}, + {"3DS Neo Bowser City", "E333C032"}, + {"3DS Maka Wuhu", "766E361D"}, + {"3DS DK Jungle", "76234567"}, + {"3DS Rosalina's Ice World", "3AD2B35D"}, + {"3DS Bowser's Castle", "C4F54EA5"}, + {"3DS Rainbow Road", "3867D46B"}, + + // Wii U Tracks + {"Wii U Mario Kart Stadium", "05455418"}, + {"Wii U Sweet Sweet Canyon", "1E5898DC"}, + {"Wii U Thwomp Ruins", "50468F87"}, + {"Wii U Mario Circuit", "9B4BF180"}, + {"Wii U Toad Harbor", "D43A9EDE"}, + {"Wii U Twisted Mansion", "14356546"}, + {"Wii U Shy Guy Falls", "5A41F8ED"}, + {"Wii U Dolphin Shoals", "AD34E224"}, + {"Wii U Electrodrome", "5267BEC1"}, + {"Wii U Mount Wario", "94AE9BD1"}, + {"Wii U Cloudtop Cruise", "FD364D78"}, + {"Wii U Bone-Dry Dunes", "AD6EEB84"}, + {"Wii U Bowser's Castle", "E0AE04FB"}, + {"Wii U Rainbow Road", "F46537E0"}, + {"Wii U Mute City", "718663CD"}, + {"Wii U Excitebike Arena", "F3F0E87A"}, + {"Wii U Hyrule Circuit", "7B5EA129"}, + {"Wii U Wild Woods", "80C444DB"}, + {"Wii U Animal Crossing", "36336689"}, + {"Wii U Super Bell Subway", "FD915536"}, + {"Wii U Big Blue", "3F802444"}, + {"SW Sky High Sundae", "CEA6E34A"}, + {"SW Yoshi's Island", "B448BECA"}, + + // Tour Tracks + {"Tour Tokyo Blur", "5EF5C68C"}, + {"Tour Vancouver Velocity", "DAEECD56"}, + {"Tour Singapore Speedway", "F2509519"}, + {"Tour Madrid Drive", "8FA4A735"}, + {"Tour Merry Mountain", "61BDF4BD"}, + {"Tour Ninja Hideaway", "55141E9B"}, + {"RMX Mario Circuit 1", "3FA252C1"}, + {"RMX Choco Island 1", "9739C08D"}, + {"RMX Rainbow Road 1", "EB9CA56D"}, + {"RMX Rainbow Road 2", "31504EEB"}, + {"RMX Vanilla Lake 1", "B64FA39B"}, + {"RMX Vanilla Lake 2", "B9AAE71A"}, + {"RMX Ghost Valley 1", "025BB3EB"}, + {"RMX Bowser Castle 1", "209B2E7C"}, + {"RMX Donut Plains 1", "41480970"}, + + // GP Tracks + {"GP Mario Beach", "BE601CD3"}, + {"GP Bananan Ruins", "8FA1D2F9"}, + {"GP Snow Panic", "90F239F5"}, + {"GP Bowser's Castle", "57EDADD6"}, + {"GP Rainbow Coaster", "924F73EE"}, + + // SW2 Tracks + {"SW2 Mario Bros Circuit", "8FC68FE2"}, + {"SW2 Crown City", "A06EB422"}, + {"SW2 Whistlestop Summit", "E2EE948D"}, + {"SW2 Faraway Oasis", "F556BF4C"}, + {"SW2 Peach Resort", "5C879204"}, + {"SW2 Great ? Block Ruins", "EEAB29D7"}, + {"SW2 Cheep Cheep Falls", "D48A3506"}, + {"SW2 Dandelion Depths", "12BC7970"}, + {"SW2 Boo Cinema", "179E7823"}, + {"SW2 Ghost Valley", "4EB70D51"}, + {"SW2 Bowser's Castle", "4BF31278"}, + {"SW2 Mario Circuit", "47701DE8"}, + {"SW2 Peach Stadium", "71102C00"}, + {"SW2 Rainbow Road", "92023DD6"}, + + // Beta Tracks + {"Beta Choco Island 3", "66CD4E55"}, + {"Beta Dokan Course", "7A539095"}, + {"Beta Nokonoko Beach", "B18E18B7"}, + + // Custom Tracks + {"Phendrana Frostbite", "57F96209"}, + {"Honeymoon Lune", "0BB85D95"}, + {"Pokรฉ Floats", "605EFF4B"}, + {"DK Jungle Tour", "F69AA04A"}, + {"Terra Ursae", "663C97E6"}, + {"Confectionery Cliffs", "A1196212"}, + {"WP Tanks!", "B7A52AFA"}, + {"Cloud Mist Castle", "D05E811A"}, + {"Rooster Island", "E71329EC"}, + {"Wario's Cosmic Construction", "2117925D"}, + {"Jiyuu Village", "DC256B66"}, + {"Cargo Bay", "38A2BDEA"}, + {"Jigsaw Circuit", "B75A74EE"}, + {"Cruel Angel's Thesis", "F0176BA1"}, + {"Bowser's Termination Station", "7CE8E29A"}, + {"Stargaze Summit", "AACD272B"}, + {"Good Egg Drive", "A5977F84"}, + {"Starry Cityscape", "3506F19B"}, + {"Stickerbush Sunset", "D9E9E850"}, + {"Alone and Incomplete", "B2F093A9"}, + {"Gallant Grotto", "E6354900"}, + {"Fort Francis", "2668F932"}, + {"Bowser Jr.'s Fort", "0832F7D8"}, + {"Backside of the TV", "B5DC9D88"}, + {"Garden of Dreams", "810DE2B6"}, + {"Overgrown Temple", "5EB53D91"}, + {"Lava Lake", "59406428"}, + {"Timber Rapids", "96DCC931"}, + {"Musical Cliff", "336BCD4C"}, + {"Toad's Temple", "09225C9B"}, + {"Luncheon Tour", "C617D846"}, + {"Shy Guy Lumber Co.", "1180A148"}, + {"Super Marine World", "F008353B"}, + {"Icebound Stronghold", "8C760E8F"}, + {"Kamek's Library", "55AF8516"}, + {"Wario Circuit", "FA433BA9"}, + {"Sunrise Slopes", "0C2DA3C4"}, + {"Banished Courtyard", "CA26B98F"}, + {"Shadow Woods", "40828870"}, + {"Hells Dimension", "18BF40A9"}, + {"Botania", "15154EFB"}, + {"Cascade Kingdom", "68F30C84"}, + {"Sandcastle Park", "3FD07D4F"}, + {"Emerald Coast", "181FB8F2"}, + {"Super Bell Circuit", "F0A265E7"}, + {"Midnight Museum", "0231B08C"}, + {"Crystal Canopy", "2CF9A752"}, + {"Shy Guy Skies", "4476CA85"}, + {"Forgotten Facility", "D963BD23"}, + {"Kitayama Keep", "6242ADB2"}, + {"Dolphin Harbor", "46E50ABD"}, + {"Aqua Dungeon", "45B613F8"}, + {"Divine Paradise", "4046963A"}, + {"Moonlit Grounds", "9710B84E"}, + {"Frigid Freezeway", "29E90C9E"}, + {"Honeyhive Falls", "C0E1BEDA"}, + {"Heavenly Sanctuary", "C2B07486"}, + {"Luigi's Island", "A7EE45EA"}, + {"Mystic Tangle", "9BE97F6C"}, + {"Maplewood Caverns", "8414F594"}, + {"Fiery Factory, Fading Frost", "C1D1A9A1"}, + {"Sunset Forest", "4B2200E9"}, + {"Pandora Palace", "F4126175"}, + {"Comet Starway", "798094B1"}, + {"The Seamlands", "251431B2"}, + {"Northern Heights", "0EC1C1DD"}, + {"Thunder City", "080FF38B"}, + {"Goomba Circuit", "E3573373"}, + {"Propeller Paradise", "03B4BECD"}, + {"ยตTorrent's Divinity", "D66ACE2B"}, + {"Ghostly Gulch", "56C93A3A"}, + {"Cubing Conundrum", "C4B0DE9E"}, + {"Daisy Gardens", "BA84A7B4"}, + {"Lunar Lights", "7053298B"}, + {"Fruity Folly", "69AF68D7"}, + {"Thunder Canyon", "BA7FFC8E"}, + {"Fungal Jungle", "29BD08BD"}, + {"Cherry Blossom Garden", "5DF12D4D"}, + {"Frostburn Fortress", "2B367EEB"}, + {"Seaside Palace", "302EEC77"}, + {"Vile Isle", "C89E6232"}, + {"Blooper Ruins", "2C00A068"}, + {"Blizzerno Fortress", "21B34D42"}, + {"Desert Fort", "9B64FB97"}, + {"Spectral Station", "B4D55DAB"}, + {"Bouldergeist's Ghostly Manor", "202D8BE2"}, + {"Forest Creek", "81FAF60D"}, + {"Mushlight's Reprise", "67D05E23"}, + {"Call of the Glassheart", "A3EE136D"}, + {"Anthill & Apian Apts.", "0EF54323"}, + {"Breathing Bosque", "F78A9E7B"}, + {"Hot Air Skyway", "45799355"}, + {"Redwood Village", "10BBE9B5"}, + {"Corrupted Maelstrom", "4B4DDDBE"}, + {"Bubblegum Skyline", "8A01E15A"}, + {"Cool Shroom Castle", "D7D7DDE2"}, + {"Sugar Rush", "58F3E600"}, + {"Stereo Madness", "86151331"}, + {"Neuron Jungle", "65747950"}, + {"Digital Refinery", "D43387E6"}, + {"Lunar Lantern Festival", "5319A1EB"}, + {"Green Hill Zone", "D71607E0"}, + {"Honeybee Hideout", "F1EBC08C"}, + {"Grave Danger", "3F065B14"}, + {"Welkin Cluster", "3E71D120"}, + {"Dark Star Castle", "D00E1853"}, + {"Lantern Light Terrace", "2E668FE1"}, + {"Dark Matter Fortress", "18D235DC"}, + {"King Bob-omb's Factory", "0FF9B316"}, + {"Glistening Highway", "0BC2346C"}, + {"Kinoko Cave", "D8BFDDBA"}, + {"Icecream Sweetland", "22E6FA20"}, + {"Toxic Mushroom Falls", "BAAB358E"}, + {"NYC Zoo", "BEDF929C"}, + {"Thump Bump Forest", "01E2B8BC"}, + {"Gleamhaven", "BB463D79"}, + {"Leviathan Conflagration", "BA1F03D1"}, + {"Area 64", "EC2965EF"}, + {"Bowser's Flooded Castle", "E04728E2"}, + {"ElectriCity", "18FA4707"}, + {"Ruinated Peach's Castle", "8C07A8D4"}, + {"Conkdor Ruins", "27604A73"}, + {"Reactive Factory", "B6DE0C18"}, + {"Crash Cove", "19482648"} + }; + + public string? GetHexValueForTrack(string trackName) + { + _trackNameToHexMap.TryGetValue(trackName, out var hexValue); + return hexValue; + } + + public string? GetHexValueForTrack(string console, string trackName) + { + var fullTrackName = $"{console} {trackName}"; + return GetHexValueForTrack(fullTrackName); + } + + public Dictionary GetAllMappings() + { + return new Dictionary(_trackNameToHexMap); + } +} \ No newline at end of file diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index 88f5c6bc..319fcf2a 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -42,6 +42,13 @@ public static void AddWheelWizardServices(this IServiceCollection services) services.AddMiiImages(); services.AddCustomDistributionService(); + // Ghost services + services.AddHttpClient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddSingleton(); + // IO Abstractions services.AddSingleton(); services.AddSingleton(); diff --git a/WheelWizard/Views/App.axaml b/WheelWizard/Views/App.axaml index 1b9991a0..822d1b62 100644 --- a/WheelWizard/Views/App.axaml +++ b/WheelWizard/Views/App.axaml @@ -67,6 +67,7 @@ + diff --git a/WheelWizard/Views/Components/LapTimesGraph.axaml b/WheelWizard/Views/Components/LapTimesGraph.axaml new file mode 100644 index 00000000..2a2a6be0 --- /dev/null +++ b/WheelWizard/Views/Components/LapTimesGraph.axaml @@ -0,0 +1,117 @@ +๏ปฟ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/Components/LapTimesGraph.axaml.cs b/WheelWizard/Views/Components/LapTimesGraph.axaml.cs new file mode 100644 index 00000000..696fb5cb --- /dev/null +++ b/WheelWizard/Views/Components/LapTimesGraph.axaml.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Avalonia.Threading; + +namespace WheelWizard.Views.Components; + +public partial class LapTimesGraph : UserControl, INotifyPropertyChanged +{ + private const double GraphWidth = 400; + private const double GraphHeight = 120; + + private List _lapTimesMs = new(); + private Geometry? _graphPath; + private Geometry? _graphAreaPath; + private string _fastestLapText = "--"; + private string _slowestLapText = "--"; + private string _averageLapText = "--"; + private bool _hasData; + + public static readonly StyledProperty?> LapTimesProperty = AvaloniaProperty.Register?>( + nameof(LapTimes), + new List() + ); + + static LapTimesGraph() + { + LapTimesProperty.Changed.AddClassHandler((graph, _) => graph.UpdateGraph()); + } + + public List? LapTimes + { + get => GetValue(LapTimesProperty); + set => SetValue(LapTimesProperty, value); + } + + public Geometry? GraphPath + { + get => _graphPath; + set + { + _graphPath = value; + OnPropertyChanged(nameof(GraphPath)); + } + } + + public Geometry? GraphAreaPath + { + get => _graphAreaPath; + set + { + _graphAreaPath = value; + OnPropertyChanged(nameof(GraphAreaPath)); + } + } + + public string FastestLapText + { + get => _fastestLapText; + set + { + _fastestLapText = value; + OnPropertyChanged(nameof(FastestLapText)); + } + } + + public string SlowestLapText + { + get => _slowestLapText; + set + { + _slowestLapText = value; + OnPropertyChanged(nameof(SlowestLapText)); + } + } + + public string AverageLapText + { + get => _averageLapText; + set + { + _averageLapText = value; + OnPropertyChanged(nameof(AverageLapText)); + } + } + + public bool HasData + { + get => _hasData; + set + { + _hasData = value; + OnPropertyChanged(nameof(HasData)); + } + } + + public LapTimesGraph() + { + InitializeComponent(); + } + + private void UpdateGraph() + { + var lapTimes = LapTimes; + if (lapTimes == null || lapTimes.Count == 0) + { + HasData = false; + ResetGraph(); + return; + } + + _lapTimesMs = lapTimes.ToList(); + + if (_lapTimesMs.Count == 0) + { + HasData = false; + ResetGraph(); + return; + } + + var minTime = _lapTimesMs.Min(); + var maxTime = _lapTimesMs.Max(); + var timeRange = Math.Max(1, maxTime - minTime); + var averageTime = _lapTimesMs.Average(); + + FastestLapText = FormatTime(minTime); + SlowestLapText = FormatTime(maxTime); + AverageLapText = FormatTime((int)averageTime); + + var points = new List(); + for (int i = 0; i < _lapTimesMs.Count; i++) + { + var x = (double)i / Math.Max(1, _lapTimesMs.Count - 1) * GraphWidth; + var y = GraphHeight - ((_lapTimesMs[i] - minTime) / (double)timeRange * GraphHeight); + points.Add(new Point(x, y)); + } + + if (_lapTimesMs.Count == 1) + { + points[0] = new Point(GraphWidth / 2, GraphHeight / 2); + } + + GraphPath = BuildLineGeometry(points); + GraphAreaPath = BuildAreaGeometry(points); + HasData = points.Count > 0; + + Dispatcher.UIThread.Post(() => UpdateDataPoints(points), DispatcherPriority.Loaded); + } + + private void UpdateDataPoints(IReadOnlyList points) + { + if (this.FindControl("DataPointsCanvas") is not Canvas canvas) + return; + + canvas.Children.Clear(); + + var fastestLapIndex = _lapTimesMs.IndexOf(_lapTimesMs.Min()); + + for (int i = 0; i < points.Count; i++) + { + var point = points[i]; + var circle = new Ellipse + { + Width = 6, + Height = 6, + Fill = i == fastestLapIndex ? App.Current?.Resources["Primary"] as IBrush : App.Current?.Resources["Neutral300"] as IBrush, + Stroke = App.Current?.Resources["Neutral950"] as IBrush, + StrokeThickness = 1 + }; + + Canvas.SetLeft(circle, point.X - 3); + Canvas.SetTop(circle, point.Y - 3); + canvas.Children.Add(circle); + } + } + + private void ResetGraph() + { + GraphPath = null; + GraphAreaPath = null; + FastestLapText = "--"; + SlowestLapText = "--"; + AverageLapText = "--"; + } + + private static string FormatTime(int milliseconds) + { + var totalSeconds = milliseconds / 1000.0; + var minutes = (int)totalSeconds / 60; + var seconds = totalSeconds % 60; + return minutes > 0 ? $"{minutes}:{seconds:00.000}" : $"{seconds:0.000}"; + } + + private static Geometry? BuildLineGeometry(IReadOnlyList points) + { + if (points.Count == 0) + return null; + + var figure = new PathFigure + { + StartPoint = points[0], + IsFilled = false, + IsClosed = false, + Segments = [], + }; + + for (var i = 1; i < points.Count; i++) + figure.Segments.Add(new LineSegment { Point = points[i] }); + + var geometry = new PathGeometry { Figures = [] }; + geometry.Figures.Add(figure); + return geometry; + } + + private static Geometry? BuildAreaGeometry(IReadOnlyList points) + { + if (points.Count == 0) + return null; + + var figure = new PathFigure + { + StartPoint = new Point(points[0].X, GraphHeight), + IsFilled = true, + IsClosed = true, + Segments = [], + }; + + foreach (var point in points) + figure.Segments.Add(new LineSegment { Point = point }); + + figure.Segments.Add(new LineSegment { Point = new Point(points[^1].X, GraphHeight) }); + + var geometry = new PathGeometry { Figures = [] }; + geometry.Figures.Add(figure); + return geometry; + } + + public new event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/WheelWizard/Views/Layout.axaml b/WheelWizard/Views/Layout.axaml index d2886b41..41697534 100644 --- a/WheelWizard/Views/Layout.axaml +++ b/WheelWizard/Views/Layout.axaml @@ -139,6 +139,9 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/Pages/GhostsPage.axaml.cs b/WheelWizard/Views/Pages/GhostsPage.axaml.cs new file mode 100644 index 00000000..9b7a171b --- /dev/null +++ b/WheelWizard/Views/Pages/GhostsPage.axaml.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Interactivity; +using WheelWizard.Models; +using WheelWizard.Services; +using WheelWizard.Views; +using WheelWizard.Views.Patterns; +using WheelWizard.Shared.DependencyInjection; +using Serilog; + +namespace WheelWizard.Views.Pages; + +public partial class GhostsPage : UserControlBase, INotifyPropertyChanged +{ + [Inject] private GhostTrackService _ghostTrackService { get; set; } = null!; + private ObservableCollection _tracks = new(); + private ObservableCollection _filteredTracks = new(); + private string _searchText = string.Empty; + private bool _isLoading = false; + + public ObservableCollection FilteredTracks + { + get => _filteredTracks; + set + { + _filteredTracks = value; + OnPropertyChanged(nameof(FilteredTracks)); + UpdateTrackCount(); + } + } + + public bool IsLoading + { + get => _isLoading; + set + { + _isLoading = value; + OnPropertyChanged(nameof(IsLoading)); + } + } + + public GhostsPage() + { + InitializeComponent(); + DataContext = this; + + TrackListBox.SelectionChanged += TrackListBox_SelectionChanged; + _ = LoadTracksAsync(); + } + + private void UpdateTrackCount() + { + if (TrackCount != null) + { + TrackCount.Text = _filteredTracks.Count.ToString(); + } + } + + private void TrackListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (TrackListBox.SelectedItem != null) + { + TrackListBox.SelectedItem = null; + } + } + + private async Task LoadTracksAsync() + { + try + { + IsLoading = true; + Log.Information("Loading tracks from API"); + + var allTracks = await _ghostTrackService.GetAllTracksAsync(); + var trackFilter = GetSelectedTrackFilter(); + var locationFilter = GetSelectedLocationFilter(); + + var filteredTracks = _ghostTrackService.GetFilteredTracks(trackFilter, locationFilter, _searchText); + + _tracks = new ObservableCollection(allTracks); + FilteredTracks = new ObservableCollection(filteredTracks); + + Log.Information("Loaded {Count} tracks", allTracks.Count); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load tracks"); + } + finally + { + IsLoading = false; + } + } + + private void ApplySearchFilter() + { + if (_tracks == null) + { + FilteredTracks = new ObservableCollection(); + return; + } + + var trackFilter = GetSelectedTrackFilter(); + var locationFilter = GetSelectedLocationFilter(); + var filteredTracks = _ghostTrackService.GetFilteredTracks(trackFilter, locationFilter, _searchText); + + FilteredTracks = new ObservableCollection(filteredTracks); + } + + private GhostTrackType GetSelectedTrackFilter() + { + if (RetroTracksRadio?.IsChecked == true) return GhostTrackType.Retro; + if (CustomTracksRadio?.IsChecked == true) return GhostTrackType.Custom; + return GhostTrackType.All; + } + + private GhostLocation GetSelectedLocationFilter() + { + return GhostLocation.All; + } + + private void FilterChanged(object? sender, RoutedEventArgs e) + { + if (_isLoading) return; + ApplySearchFilter(); + } + + private void SearchTextBox_TextChanged(object? sender, Avalonia.Controls.TextChangedEventArgs e) + { + if (sender is TextBox textBox) + { + _searchText = textBox.Text ?? string.Empty; + ApplySearchFilter(); + } + } + + private void ViewTimesButton_Click(object? sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is ApiTrack track) + { + var ghostTrack = new GhostTrack + { + TrackId = track.TrackId, + Name = track.Name, + Console = track.Console, + TrackType = track.TrackType, + HexValue = track.HexValue + }; + NavigationManager.NavigateTo(ghostTrack); + } + } + + public new event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/WheelWizard/Views/Patterns/TrackListItem.axaml b/WheelWizard/Views/Patterns/TrackListItem.axaml new file mode 100644 index 00000000..a3ffbc93 --- /dev/null +++ b/WheelWizard/Views/Patterns/TrackListItem.axaml @@ -0,0 +1,138 @@ +๏ปฟ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/Patterns/TrackListItem.axaml.cs b/WheelWizard/Views/Patterns/TrackListItem.axaml.cs new file mode 100644 index 00000000..0bfced62 --- /dev/null +++ b/WheelWizard/Views/Patterns/TrackListItem.axaml.cs @@ -0,0 +1,117 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; + +namespace WheelWizard.Views.Patterns; + +public class TrackListItem : TemplatedControl +{ + public static readonly StyledProperty TrackNameProperty = + AvaloniaProperty.Register(nameof(TrackName), string.Empty); + + public static readonly StyledProperty ConsoleProperty = + AvaloniaProperty.Register(nameof(Console), string.Empty); + + public static readonly StyledProperty ConsoleColorProperty = + AvaloniaProperty.Register(nameof(ConsoleColor), string.Empty); + + public static readonly StyledProperty IsCustomTrackProperty = + AvaloniaProperty.Register(nameof(IsCustomTrack), false); + + public static readonly StyledProperty IsRetroTrackProperty = + AvaloniaProperty.Register(nameof(IsRetroTrack), false); + + public static readonly StyledProperty HexValueProperty = + AvaloniaProperty.Register(nameof(HexValue), string.Empty); + + public string TrackName + { + get => GetValue(TrackNameProperty); + set => SetValue(TrackNameProperty, value); + } + + public string Console + { + get => GetValue(ConsoleProperty); + set => SetValue(ConsoleProperty, value); + } + + public string ConsoleColor + { + get => GetValue(ConsoleColorProperty); + set => SetValue(ConsoleColorProperty, value); + } + + public bool IsCustomTrack + { + get => GetValue(IsCustomTrackProperty); + set => SetValue(IsCustomTrackProperty, value); + } + + public bool IsRetroTrack + { + get => GetValue(IsRetroTrackProperty); + set => SetValue(IsRetroTrackProperty, value); + } + + public string HexValue + { + get => GetValue(HexValueProperty); + set => SetValue(HexValueProperty, value); + } + + public event EventHandler? ViewTimesClick; + + private TextBlock? _consoleLabel; + private Border? _customBadge; + private Border? _retroBadge; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + var viewTimesButton = e.NameScope.Find