diff --git a/src/Wisp.App/Tunes/NativeTuneCapture.cs b/src/Wisp.App/Tunes/NativeTuneCapture.cs index e80cebc..c131a05 100644 --- a/src/Wisp.App/Tunes/NativeTuneCapture.cs +++ b/src/Wisp.App/Tunes/NativeTuneCapture.cs @@ -25,18 +25,17 @@ internal static TuneDecodeInput Read(INativeHudProcessMemory memory, TuneAssetMe var fields = pack.Fields; var (source, selected) = ReadLocalCar(memory, read); var ordinal = read.Int32(source + fields.SourceCarOrdinal); - read.Require(ordinal > 0); + read.Require(ordinal > 0, TuneReadCheck.CarOrdinal); var drive = read.Int32(selected + 0xB9C); - read.Require(drive is >= 0 and <= 2); + read.Require(drive is >= 0 and <= 2, TuneReadCheck.Drivetrain); var gearCount = read.Int32(selected + 0x24); - read.Require(gearCount is >= 2 and <= 11); - var ratios = read.Bytes(selected + 0x28, gearCount * 20); + read.Require(gearCount is >= 2 and <= 11, TuneReadCheck.GearCount); for (var i = 0; i < gearCount; i++) { - var ratio = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(ratios.AsSpan(i * 20))); - read.Require(float.IsFinite(ratio) && Math.Abs(ratio) < 30 && (i == 0 ? ratio < 0 : ratio > 0)); + var ratio = BitConverter.Int32BitsToSingle(read.Int32(selected + 0x28 + (ulong)i * 20)); + read.Require(float.IsFinite(ratio) && Math.Abs(ratio) < 30 && (i == 0 ? ratio < 0 : ratio > 0), TuneReadCheck.GearRatio); } - read.Require(read.Single(selected + 0xB68) is > 0 and < 30); + read.Require(read.Single(selected + 0xB68) is > 0 and < 30, TuneReadCheck.FinalDrive); var wheels = new HashSet(); var wheelIds = new HashSet(); foreach (var offset in new[] { fields.FirstWheelPointer, fields.SecondWheelPointer, @@ -44,11 +43,11 @@ internal static TuneDecodeInput Read(INativeHudProcessMemory memory, TuneAssetMe { var wheel = read.Pointer(selected + offset); var id = read.UInt32(wheel + fields.WheelId); - read.Require(id < 4 && wheels.Add(wheel) && wheelIds.Add(id)); + read.Require(id < 4 && wheels.Add(wheel) && wheelIds.Add(id), TuneReadCheck.WheelIdentity); } var descriptor = read.Pointer(source + 0x2E0); - read.Require(read.Int32(descriptor) == ordinal); + read.Require(read.Int32(descriptor) == ordinal, TuneReadCheck.DescriptorCar); // The current-car Tune model uses actor attributes + 0x6EE4. Descriptor // payloads may retain sentinels; VerifyStable rechecks this active block. var activeWords = Words(read.Bytes(source + 0x71F4, 0xB8)); @@ -62,19 +61,19 @@ internal static TuneDecodeInput Read(INativeHudProcessMemory memory, TuneAssetMe var carRanges = ImmutableDictionary.CreateBuilder(); foreach (var (id, low, high) in CarRanges) carRanges.Add(id, Range(attributes, low, high)); - read.Require(selected >= 0x570); + read.Require(selected >= 0x570, TuneReadCheck.SpringOwner); var scale = read.Single(selected - 0x570 + 8); - read.Require(scale is > 0 and < 10000000); + read.Require(scale is > 0 and < 10000000, TuneReadCheck.SpringScale); var locale = read.Pointer(module + layout.Rva(0xA862060)); var preference = read.Int32(locale + 0x34); if (preference == -1) { var profileIndex = read.Int32(locale + 0x28); - read.Require(profileIndex is >= 0 and < 8); + read.Require(profileIndex is >= 0 and < 8, TuneReadCheck.UnitProfile); preference = read.Int32(locale + (ulong)profileIndex * 0x94 + 0x9C); } - read.Require(preference is >= 0 and < 7); + read.Require(preference is >= 0 and < 7, TuneReadCheck.UnitPreference); var conversions = ReadUnitConversions(memory, read, layout, preference); var format = new TuneFormatConstants(read.Single(module + layout.Rva(0x64BA6E8)), read.Double(module + layout.Rva(0x6446D18)), read.Double(module + layout.Rva(0x65AC730)), read.Double(module + layout.Rva(0x64FBE80)), read.Double(module + layout.Rva(0x640EE78))); @@ -83,12 +82,11 @@ internal static TuneDecodeInput Read(INativeHudProcessMemory memory, TuneAssetMe foreach (var (kind, category) in PartCategories) { var index = read.Int32(indices + category * 4); - read.Require(index is >= 0 and < 100); + read.Require(index is >= 0 and < 100, TuneReadCheck.PartIndex); installed.Add(new TunePart(kind, read.Int32(descriptor + 0x10 + (ulong)index * 4), null)); } var resolved = metadata.Resolve(ordinal, installed.ToImmutable()); - read.Require(resolved.All(part => part.Level.HasValue)); - var performance = NativeTunePerformance.TryRead(memory, source, cancellationToken); + var performance = NativeTunePerformance.TryRead(memory, source, cancellationToken, out var performanceSetupKey); read.VerifyStable(); if (verifyLayout) layout.Verify(memory, cancellationToken); return new TuneDecodeInput @@ -115,8 +113,10 @@ internal static TuneDecodeInput Read(INativeHudProcessMemory memory, TuneAssetMe CarRanges = carRanges.ToImmutable(), SpringScale = scale, PartLevelsResolved = resolved.All(part => part.Level.HasValue), + VerifiedPartProjection = true, Parts = resolved, - Performance = performance + Performance = performance, + PerformanceSetupKey = performanceSetupKey }; } @@ -136,7 +136,7 @@ internal static (ulong Actor, ulong Provider) ReadLocalCar(INativeHudProcessMemo } read.Require(begin <= end && end <= capacity && end - begin <= 1024 && begin % 8 == 0 && end % 8 == 0 && capacity % 8 == 0 && - NativeHudProcessMemory.IsValidReadSpan(begin, 8) && NativeHudProcessMemory.IsValidReadSpan(capacity, 8)); + NativeHudProcessMemory.IsValidReadSpan(begin, 8) && NativeHudProcessMemory.IsValidReadSpan(capacity, 8), TuneReadCheck.CarVector); var candidates = new List<(ulong Actor, ulong Provider)>(); var unverifiedProvider = false; if (begin != end) @@ -145,7 +145,7 @@ internal static (ulong Actor, ulong Provider) ReadLocalCar(INativeHudProcessMemo for (var index = 0; index < sources.Length; index += 8) { var actor = BinaryPrimitives.ReadUInt64LittleEndian(sources.AsSpan(index)); - read.Require(NativeHudProcessMemory.IsValidReadSpan(actor, 0x8000)); + read.Require(NativeHudProcessMemory.IsValidReadSpan(actor, 0x8000), TuneReadCheck.CarAddress); var provider = read.UInt64(actor + fields.SourceProvider); if (!NativeHudProcessMemory.IsValidReadSpan(provider, 0xC000)) { unverifiedProvider = true; continue; } var table = read.UInt64(provider); @@ -162,7 +162,7 @@ internal static (ulong Actor, ulong Provider) ReadLocalCar(INativeHudProcessMemo if (candidates.Count != 1) { // A bad provider is not evidence that the player is in a menu. - read.Require(candidates.Count != 0 || !unverifiedProvider); + read.Require(candidates.Count != 0 || !unverifiedProvider, TuneReadCheck.CarProvider); read.VerifyStable(); throw new TuneCarSelectionException(candidates.Count); } @@ -174,7 +174,7 @@ internal static ImmutableDictionary ReadUnitConver { var module = memory.ModuleBase; var units = read.Pointer(module + layout.Rva(0xA862058)); - read.Require(read.Bytes(module + layout.Rva(0xA861342), 1)[0] == 1); + read.Require(read.Bytes(module + layout.Rva(0xA861342), 1)[0] == 1, TuneReadCheck.ConversionReady); var conversions = ImmutableDictionary.CreateBuilder(); foreach (var (quantity, category) in Quantities) { @@ -182,17 +182,17 @@ internal static ImmutableDictionary ReadUnitConver ulong entry; if (unitOverride != -1) { - read.Require(unitOverride is >= 0 and < 256); + read.Require(unitOverride is >= 0 and < 256, TuneReadCheck.UnitOverride); entry = read.Pointer(units + 0x1568) + (ulong)unitOverride * 20; } else entry = units + ((ulong)preference * 0x26 + category) * 20 + 8; var unitId = read.Int32(entry + 4); - read.Require(unitId is >= 0 and < 128); + read.Require(unitId is >= 0 and < 128, TuneReadCheck.UnitIndex); var conversion = module + layout.Rva(0xA861470) + (ulong)unitId * 40; - read.Require(read.Int32(conversion) == unitId); + read.Require(read.Int32(conversion) == unitId, TuneReadCheck.UnitIdentity); var factor = read.Double(conversion + 0x10); var callback = read.UInt64(conversion + 0x18); - read.Require(callback == 0 || callback >= module && callback < module + memory.CompatibilityPack.ImageSize); + read.Require(callback == 0 || callback >= module && callback < module + memory.CompatibilityPack.ImageSize, TuneReadCheck.UnitCallback); conversions.Add(quantity, new TuneConversion(unitId, factor, callback != 0)); } return conversions.ToImmutable(); diff --git a/src/Wisp.App/Tunes/NativeTunePerformance.cs b/src/Wisp.App/Tunes/NativeTunePerformance.cs index 368271c..c8717d7 100644 --- a/src/Wisp.App/Tunes/NativeTunePerformance.cs +++ b/src/Wisp.App/Tunes/NativeTunePerformance.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.IO; +using System.Security.Cryptography; using Wisp.Core.Tunes; namespace Wisp.App.Tunes; @@ -7,34 +8,48 @@ namespace Wisp.App.Tunes; internal static class NativeTunePerformance { internal static TunePerformance? TryRead(INativeHudProcessMemory memory, ulong actor, - CancellationToken cancellationToken) + CancellationToken cancellationToken, out string? setupKey) { + setupKey = null; var profile = NativeTunePerformanceProfiles.Resolve(memory.CompatibilityPack); if (profile is null || memory.CompatibilityPack.GameplayVisibility is not { } ui) return null; try { NativeTuneLayout.VerifyGuards(memory, profile.Guards, cancellationToken); - var read = new NativeTuneRead(memory, cancellationToken, 32 * 1024, TimeSpan.FromMilliseconds(250)); - var module = memory.ModuleBase; - void Table(ulong address, ulong rva) + var setupRead = new NativeTuneRead(memory, cancellationToken, 4096, TimeSpan.FromMilliseconds(250)); + var setup = ReadSetup(setupRead, actor, memory.CompatibilityPack.Fields.SourceCarOrdinal); + if (setup is null) return null; + TunePerformance? result = null; + try { result = ReadPage(); } + catch (Exception error) when (error is IOException or InvalidDataException or OverflowException) { } + setupRead.VerifyStable(); + setupKey = setup.Key; + return result; + + TunePerformance? ReadPage() { - if (read.UInt64(address) != module + rva) throw new InvalidDataException(); + var read = new NativeTuneRead(memory, cancellationToken, 32 * 1024, TimeSpan.FromMilliseconds(250)); + var module = memory.ModuleBase; + void Table(ulong address, ulong rva) + { + if (read.UInt64(address) != module + rva) throw new InvalidDataException(); + } + var service = read.Pointer(module + ui.UiServiceRva); + Table(service, ui.UiServiceVtableRva); + var dependency = read.Pointer(service + ui.ServiceDependencyOffset); + Table(dependency, ui.DependencyVtableRva); + var rootManager = dependency + ui.RootTransitionManagerOffset; + Table(rootManager, ui.TransitionManagerVtableRva); + var root = read.Pointer(rootManager + ui.ManagerCurrentPageOffset); + Table(root, profile.RootPageVtable); + var manager = read.Pointer(root + ui.PageTransitionManagerOffset); + Table(manager, ui.TransitionManagerVtableRva); + var page = read.Pointer(manager + ui.ManagerCurrentPageOffset); + Table(page, profile.TunePageVtable); + var benchmark = read.Pointer(page + 0x4B8); + Table(benchmark, profile.BenchmarkVtable); + return ReadResults(read, benchmark, setup); } - var service = read.Pointer(module + ui.UiServiceRva); - Table(service, ui.UiServiceVtableRva); - var dependency = read.Pointer(service + ui.ServiceDependencyOffset); - Table(dependency, ui.DependencyVtableRva); - var rootManager = dependency + ui.RootTransitionManagerOffset; - Table(rootManager, ui.TransitionManagerVtableRva); - var root = read.Pointer(rootManager + ui.ManagerCurrentPageOffset); - Table(root, profile.RootPageVtable); - var manager = read.Pointer(root + ui.PageTransitionManagerOffset); - Table(manager, ui.TransitionManagerVtableRva); - var page = read.Pointer(manager + ui.ManagerCurrentPageOffset); - Table(page, profile.TunePageVtable); - var benchmark = read.Pointer(page + 0x4B8); - Table(benchmark, profile.BenchmarkVtable); - return ReadResults(read, benchmark, actor, memory.CompatibilityPack.Fields.SourceCarOrdinal); } catch (Exception error) when (error is IOException or InvalidDataException or OverflowException) { @@ -43,18 +58,30 @@ void Table(ulong address, ulong rva) } } - internal static TunePerformance? ReadResults(NativeTuneRead read, ulong benchmark, ulong actor, ulong ordinalOffset) + // The live descriptor has a version word followed by 100 installed part IDs. + private const int PartBytes = sizeof(int) * 101; + private const int BenchmarkPartBytes = 0x188; + private const int SettingBytes = 0xB8; + internal sealed record Setup(int Ordinal, byte[] Payload) + { + internal string Key => Convert.ToHexString(SHA256.HashData(Payload)); + } + + internal static Setup? ReadSetup(NativeTuneRead read, ulong actor, ulong ordinalOffset) { - if (read.Int32(benchmark + 0xB10) != 13 || read.Bytes(benchmark + 0xB14, 1)[0] != 1) - return null; var ordinal = read.Int32(actor + ordinalOffset); var descriptor = read.Pointer(actor + 0x2E0); - if (ordinal <= 0 || read.Int32(descriptor) != ordinal || read.Int32(benchmark + 0x90C) != ordinal) + if (ordinal <= 0 || read.Int32(descriptor) != ordinal) return null; + return new(ordinal, [.. read.Bytes(descriptor + 0xC, PartBytes), .. read.Bytes(actor + 0x71F4, SettingBytes)]); + } + + internal static TunePerformance? ReadResults(NativeTuneRead read, ulong benchmark, Setup setup) + { + if (read.Int32(benchmark + 0xB10) != 13 || read.Bytes(benchmark + 0xB14, 1)[0] != 1 || + read.Int32(benchmark + 0x90C) != setup.Ordinal) return null; - // Compare the complete installed-part payload, including parts outside - // the adjustable Tune categories, and every active normalized setting. - if (!read.Bytes(descriptor + 0xC, 0x188).AsSpan().SequenceEqual(read.Bytes(benchmark + 0x134, 0x188)) || - !read.Bytes(actor + 0x71F4, 0xB8).AsSpan().SequenceEqual(read.Bytes(benchmark + 0x2C8, 0xB8))) + if (!setup.Payload.AsSpan(0, BenchmarkPartBytes).SequenceEqual(read.Bytes(benchmark + 0x134, BenchmarkPartBytes)) || + !setup.Payload.AsSpan(PartBytes).SequenceEqual(read.Bytes(benchmark + 0x2C8, SettingBytes))) return null; float? Result(ulong offset) { diff --git a/src/Wisp.App/Tunes/NativeTunePerformanceProfiles.cs b/src/Wisp.App/Tunes/NativeTunePerformanceProfiles.cs index 5862d08..39f0ce0 100644 --- a/src/Wisp.App/Tunes/NativeTunePerformanceProfiles.cs +++ b/src/Wisp.App/Tunes/NativeTunePerformanceProfiles.cs @@ -27,8 +27,21 @@ internal sealed record Profile(ulong RootPageVtable, ulong TunePageVtable, ulong new(0xA03BD0, 1546, "783ABF233C90C209B69F906D5EA7AC21D9D12870CF51F4C3BFF41E1059E03E83"), ]); + private static readonly Profile Steam479 = new(0x70E8698, 0x716D280, 0x6E41728, + [ + new(0x4103740, 303, "D531ECF78EBC653EB9CF5659268E12B3A2DFF0F5A0CF5B80114D514F0030815C"), + new(0x4103870, 668, "F706243AA98A98883A6F3D14AD6F786DD5A0CEF3BFCA72D1E39CA64710014121"), + new(0x4110270, 255, "C637D3A58CC636645F9EF78B4F42D1A9FEEB89ABCFF3ACD6444511FBBFC1D9B8"), + new(0x4110F70, 1884, "1AC5C0B03509A3C6542284CE50CA22B3801F59CDA757F1F601236FECD31ED4BC"), + new(0x52BE670, 346, "515FFAB8F7190ECFA7B52527DC3CBE06CECBF5933D9BE4890025C5656CEBD0AE"), + new(0xA79D00, 637, "4B4A37ED5CB29BC897E0C1DE1B2EB471444126F9CEDA6122920F90763D4F471F"), + new(0xA7E670, 1546, "DFB541A86C784B115946055F2BC3DBF0750D5A328AC782B34F64A988E8C763C2"), + ]); + internal static Profile? Resolve(NativeHudCompatibilityPack pack) { + if (pack.ImageSize == 188669952 && pack.Matches("6.479.874.0", 184214488, + "28FE0814168D474F162592D5568479554882D82C6CA6575965B25BBBDFFA89F0")) return Steam479; if (pack.StoreIdentity is null && pack.GameVersion == "6.461.691.0" && pack.ExecutableSha256 == "B8C18EC88AB3143DA03C3BB9BD9D0761F23E00F809B1BD9D50CF07496E5D1CE5" && pack.ImageSize == 188481536) return Steam; diff --git a/src/Wisp.App/Tunes/NativeTuneRead.cs b/src/Wisp.App/Tunes/NativeTuneRead.cs index 5199d43..5516e7e 100644 --- a/src/Wisp.App/Tunes/NativeTuneRead.cs +++ b/src/Wisp.App/Tunes/NativeTuneRead.cs @@ -4,6 +4,31 @@ namespace Wisp.App.Tunes; +// Stable FACILITY_ITF codes carried by existing Tune incidents and copied read details. +internal enum TuneReadCheck : uint +{ + CarOrdinal = 0x8004A601, + Drivetrain = 0x8004A602, + GearCount = 0x8004A603, + GearRatio = 0x8004A604, + FinalDrive = 0x8004A605, + WheelIdentity = 0x8004A606, + DescriptorCar = 0x8004A607, + SpringOwner = 0x8004A608, + SpringScale = 0x8004A609, + UnitProfile = 0x8004A60A, + UnitPreference = 0x8004A60B, + PartIndex = 0x8004A60C, + CarVector = 0x8004A60D, + CarAddress = 0x8004A60E, + CarProvider = 0x8004A60F, + ConversionReady = 0x8004A610, + UnitOverride = 0x8004A611, + UnitIndex = 0x8004A612, + UnitIdentity = 0x8004A613, + UnitCallback = 0x8004A614, +} + // Per-request read budget. Nothing here writes to, invokes, or scans the game. internal sealed class NativeTuneRead(IReadOnlyProcessMemory memory, CancellationToken cancellationToken, int maximumBytes = 96 * 1024, TimeSpan? timeout = null) @@ -53,11 +78,11 @@ internal double Double(ulong address) // A check that fails while bytes read so far are changing describes a transition (car or session // change, loading), reported as such; only a stable mismatch is a failure of the reader. - internal void Require(bool condition) + internal void Require(bool condition, TuneReadCheck check) { if (condition) return; VerifyStable(); - throw new InvalidDataException("The current car's tune is not available yet."); + throw new InvalidDataException("The current car's tune is not available yet.") { HResult = unchecked((int)check) }; } internal void VerifyStable() diff --git a/src/Wisp.App/Tunes/TuneCaptureDetails.cs b/src/Wisp.App/Tunes/TuneCaptureDetails.cs index b9703db..e98db67 100644 --- a/src/Wisp.App/Tunes/TuneCaptureDetails.cs +++ b/src/Wisp.App/Tunes/TuneCaptureDetails.cs @@ -74,6 +74,8 @@ internal static string Create(TuneCaptureStage stage, NativeHudCompatibilityPack report.AppendLine($"Error code: 0x{exception.HResult.ToString("X8", CultureInfo.InvariantCulture)}"); report.AppendLine($"Message: {SafeMessage(exception)}"); report.AppendLine($"Method: {SafeMethod(exception)}"); + if (exception is InvalidDataException && Enum.IsDefined((TuneReadCheck)(uint)exception.HResult)) + report.AppendLine($"Tune check: {(TuneReadCheck)(uint)exception.HResult}"); if (exception is Win32Exception native) report.AppendLine(FormattableString.Invariant($"Native error code: {native.NativeErrorCode}")); if (exception is TuneLayoutException layout) diff --git a/src/Wisp.App/Tunes/TuneCaptureService.cs b/src/Wisp.App/Tunes/TuneCaptureService.cs index ef47012..283efc0 100644 --- a/src/Wisp.App/Tunes/TuneCaptureService.cs +++ b/src/Wisp.App/Tunes/TuneCaptureService.cs @@ -37,6 +37,8 @@ public sealed class TuneCaptureService : IAsyncDisposable private long _observedCompatibilityGeneration; private string _session = string.Empty; private int _carOrdinal; + private string? _performanceSetupKey; + private TunePerformance? _verifiedPerformance; private bool _disposed; private bool _featureAvailable = true; private Task? _disposeTask; @@ -236,11 +238,17 @@ async Task Capture() TuneCaptureDetails.Create(stage, build)) { Incident = TuneIncident(stage, "session-changed") }; if (_session.Length != 0 && (_session != memory.SessionIdentity || _carOrdinal != snapshot.Identity.CarOrdinal || - _compatibilityGeneration != compatibility)) + _compatibilityGeneration != compatibility || + (_performanceSetupKey is not null && _performanceSetupKey != input.PerformanceSetupKey))) { _generation++; changed = true; } + if (!changed && _session.Length != 0 && input.PerformanceSetupKey is not null && + input.PerformanceSetupKey == _performanceSetupKey && snapshot.Performance is null && _verifiedPerformance is not null) + snapshot = snapshot with { Performance = _verifiedPerformance }; + _performanceSetupKey = input.PerformanceSetupKey; + _verifiedPerformance = _performanceSetupKey is null ? null : snapshot.Performance; _session = memory.SessionIdentity; _carOrdinal = snapshot.Identity.CarOrdinal; _compatibilityGeneration = compatibility; diff --git a/src/Wisp.App/Tunes/TuneViewModel.cs b/src/Wisp.App/Tunes/TuneViewModel.cs index 00300cd..4115605 100644 --- a/src/Wisp.App/Tunes/TuneViewModel.cs +++ b/src/Wisp.App/Tunes/TuneViewModel.cs @@ -230,13 +230,6 @@ private async Task RefreshCoreAsync(bool selectA = false, bool selectB = false) var result = await _capture(cancellation.Token); if (_disposed || cancellation.IsCancellationRequested || revision != _refreshRevision || !(IsCurrentMode || IsCompareMode)) return; var snapshot = result.Snapshot; - // Forza exposes its benchmark results only while the Tune screen is open. Once read, - // they stay with the current tune until the car, parts or settings change. - if (snapshot is { Performance: null } fresh && _current is { Performance: { } retained } previous && - previous.Identity == fresh.Identity && - !previous.Parts.IsDefault && !fresh.Parts.IsDefault && previous.Parts.SequenceEqual(fresh.Parts) && - !previous.Fields.IsDefault && !fresh.Fields.IsDefault && previous.Fields.SequenceEqual(fresh.Fields)) - snapshot = fresh with { Performance = retained }; _current = snapshot; _currentValid = snapshot is { } current && _isCurrent(current); var selectionUnchanged = selectionRevision == _comparisonSelectionRevision; RefreshCurrentComparison(selectA && selectionUnchanged, selectB && selectionUnchanged); diff --git a/src/Wisp.Core/Tunes/TuneDecoder.cs b/src/Wisp.Core/Tunes/TuneDecoder.cs index 4ff99d4..db16d23 100644 --- a/src/Wisp.Core/Tunes/TuneDecoder.cs +++ b/src/Wisp.Core/Tunes/TuneDecoder.cs @@ -31,6 +31,7 @@ public static bool TryDecode(TuneDecodeInput? input, out TuneSnapshot? snapshot, snapshot = null; failure = ValidateInput(input); if (failure != TuneDecodeFailure.None || input is null) return false; + var useKnownPartLevels = input.PartLevelsResolved || input.VerifiedPartProjection; var parts = input.Parts.ToDictionary(part => part.Kind); var rows = ImmutableArray.CreateBuilder(FieldCount); var words = input.ActiveNormalizedWords.IsDefault ? input.NormalizedCopies[0] : input.ActiveNormalizedWords; @@ -39,7 +40,7 @@ public static bool TryDecode(TuneDecodeInput? input, out TuneSnapshot? snapshot, uint bits = words[definition.Offset / 4]; float normalized = BitConverter.UInt32BitsToSingle(bits); bool applicable = IsApplicable(definition.Id, input.Drivetrain, input.ObservedGearEntryCount - 1); - bool? adjustable = IsAdjustable(definition, parts, input.PartLevelsResolved); + bool? adjustable = IsAdjustable(definition, parts, useKnownPartLevels); var status = !applicable ? TuneFieldStatus.NotApplicable : adjustable is null ? TuneFieldStatus.UnresolvedPartLevel : !adjustable.Value ? TuneFieldStatus.NotAdjustable @@ -77,7 +78,7 @@ public static bool TryDecode(TuneDecodeInput? input, out TuneSnapshot? snapshot, new(input.GameVersion, input.ExecutableSha256?.ToUpperInvariant(), ReaderVersion, input.CarOrdinal, input.Drivetrain, input.ObservedGearEntryCount - 1) { Verification = input.Verification }, input.UnitPreference, - input.Parts.Select(part => input.PartLevelsResolved ? part : part with { Level = null }) + input.Parts.Select(part => useKnownPartLevels ? part : part with { Level = null }) .OrderBy(part => part.Kind).ToImmutableArray(), rows.MoveToImmutable()) { CarName = input.CarName, Performance = input.Performance is { IsValid: true } ? input.Performance : null }; return true; diff --git a/src/Wisp.Core/Tunes/TuneModels.cs b/src/Wisp.Core/Tunes/TuneModels.cs index e9d95cc..162119b 100644 --- a/src/Wisp.Core/Tunes/TuneModels.cs +++ b/src/Wisp.Core/Tunes/TuneModels.cs @@ -54,7 +54,8 @@ public sealed record TuneSnapshot(Guid Id, DateTimeOffset CapturedAtUtc, TuneIde public string? CarName { get; init; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunePerformance? Performance { get; init; } - public bool IsComplete => !Fields.IsDefault && TuneDecoder.TryGetDefinitions(Identity?.ReaderVersion, out var definitions) && + public bool IsComplete => !Parts.IsDefaultOrEmpty && Parts.All(part => part?.Level is not null) && + !Fields.IsDefault && TuneDecoder.TryGetDefinitions(Identity?.ReaderVersion, out var definitions) && Fields.Length == definitions.Length && Fields.All(entry => entry is not null && entry.Status is TuneFieldStatus.Available or TuneFieldStatus.NotApplicable or TuneFieldStatus.NotAdjustable); } @@ -69,6 +70,7 @@ public sealed record TuneDecodeInput public TuneVerification? Verification { get; init; } public string? CarName { get; init; } public TunePerformance? Performance { get; init; } + [JsonIgnore] public string? PerformanceSetupKey { get; init; } public bool CompatibilityDescriptorVerified { get; init; } public bool RuntimeLayoutVerified { get; init; } public required bool CaptureComplete { get; init; } @@ -89,6 +91,8 @@ public sealed record TuneDecodeInput public required ImmutableDictionary CarRanges { get; init; } public required float SpringScale { get; init; } public required bool PartLevelsResolved { get; init; } + // The validated game asset can resolve some installed parts even when another row is missing. + [JsonIgnore] public bool VerifiedPartProjection { get; init; } public required ImmutableArray Parts { get; init; } } diff --git a/tests/Wisp.App.Tests/NativeTuneCaptureTests.cs b/tests/Wisp.App.Tests/NativeTuneCaptureTests.cs index fa35d3e..b19d486 100644 --- a/tests/Wisp.App.Tests/NativeTuneCaptureTests.cs +++ b/tests/Wisp.App.Tests/NativeTuneCaptureTests.cs @@ -122,10 +122,10 @@ public void FailedRequirementIsATransitionWhileBytesChangeAndAReadFailureOnceSta var memory = new Memory(); var read = new NativeTuneRead(memory, CancellationToken.None); _ = read.Bytes(0x10000, 4); - read.Require(true); - Assert.Throws(() => read.Require(false)); + read.Require(true, TuneReadCheck.CarOrdinal); + Assert.Throws(() => read.Require(false, TuneReadCheck.CarOrdinal)); memory.Fill = 1; - Assert.Throws(() => read.Require(false)); + Assert.Throws(() => read.Require(false, TuneReadCheck.CarOrdinal)); } [Fact] @@ -212,6 +212,35 @@ public async Task SameSessionRefreshRetainsReviewedSnapshotButRestartAndInvalida Assert.False(service.IsCurrent(restarted)); } + [Theory] + [InlineData("setup")] + [InlineData("unverified")] + [InlineData("session")] + [InlineData("generation")] + [InlineData("compatibility")] + public async Task UnverifiedOrDifferentSetupDoesNotInheritBenchmarks(string change) + { + var factory = new Factory(); + var performance = new TunePerformance(32f, 90f, 1.1f, 1.2f, 4.5f, 10.5f, 85f, 0.5f, 0.5f, 12f); + var input = Input() with { Performance = performance, PerformanceSetupKey = "first" }; + await using var service = new TuneCaptureService(factory, (_, _) => input); + var first = (await service.RequestSnapshotAsync(TestContext.Current.CancellationToken)).Snapshot!; + Assert.Equal(performance, first.Performance); + input = input with { Performance = null }; + switch (change) + { + case "setup": input = input with { PerformanceSetupKey = "different" }; break; + case "unverified": input = input with { PerformanceSetupKey = null }; break; + case "session": factory.Session = "next-session"; break; + case "generation": service.Invalidate(); break; + case "compatibility": factory.Generation++; break; + } + var current = (await service.RequestSnapshotAsync(TestContext.Current.CancellationToken)).Snapshot!; + Assert.Null(current.Performance); + Assert.False(service.IsCurrent(first)); + Assert.True(service.IsCurrent(current)); + } + [Fact] public async Task CarChangeAndCompatibilityChangeInvalidateOldSnapshots() { @@ -378,7 +407,7 @@ internal static TuneDecodeInput Input() }; } - private sealed class Factory : INativeHudProcessMemoryFactory + internal sealed class Factory : INativeHudProcessMemoryFactory { internal NativeGameVerification Verification = NativeGameVerification.Unavailable; internal int Opens; diff --git a/tests/Wisp.App.Tests/NativeTuneReadBoundaryTests.cs b/tests/Wisp.App.Tests/NativeTuneReadBoundaryTests.cs index 6428918..3f9f291 100644 --- a/tests/Wisp.App.Tests/NativeTuneReadBoundaryTests.cs +++ b/tests/Wisp.App.Tests/NativeTuneReadBoundaryTests.cs @@ -222,6 +222,30 @@ public void UnreadableUnusedSetupPointerDoesNotPreventActiveTuneRead(bool store) Assert.Equal(33, snapshot.Fields.Count(field => field.Status == TuneFieldStatus.Available)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnmappedInstalledSpringLevelStillReturnsOtherVerifiedTuneFields(bool store) + { + var memory = new Memory(store); + var input = MiataInput(); + var metadata = memory.CompleteCar(input); + // CompleteCar maps the spring/damper category to the sixth installed ID. + memory.I32(Memory.Descriptor + 0x10 + 5 * 4, int.MaxValue); + + var actual = NativeTuneCapture.Read(memory, metadata, Token, memory.Layout); + Assert.True(TuneDecoder.TryDecode(actual, out var snapshot, out var failure), failure.ToString()); + Assert.True(TuneDecoder.TryDecode(input, out var reference, out _)); + Assert.NotNull(snapshot); + Assert.NotNull(reference); + Assert.False(snapshot.IsComplete); + var spring = snapshot.Fields.Single(field => field.Id == TuneFieldId.FrontSprings); + Assert.Equal(TuneFieldStatus.UnresolvedPartLevel, spring.Status); + Assert.Null(spring.DisplayValue); + Assert.Equal(reference.Fields.Single(field => field.Id == TuneFieldId.BrakeBalance), + snapshot.Fields.Single(field => field.Id == TuneFieldId.BrakeBalance)); + } + [Theory] [InlineData(false, false)] [InlineData(true, false)] @@ -250,6 +274,32 @@ public void ActiveTuneChangeDuringCaptureStillRejectsTheWholeRead(bool store) Assert.Throws(() => NativeTuneCapture.Read(memory, metadata, Token, memory.Layout)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnusedGearRecordBytesDoNotInvalidateAStableTune(bool store) + { + var memory = new Memory(store) { GearMutationOffset = 4 }; + var input = MiataInput(); + var metadata = memory.CompleteCar(input); + + var actual = NativeTuneCapture.Read(memory, metadata, Token, memory.Layout); + Assert.True(TuneDecoder.TryDecode(actual, out var snapshot, out _)); + Assert.True(snapshot!.IsComplete); + Assert.True(TuneDecoder.TryDecode(input, out var reference, out _)); + Assert.True(reference!.Fields.SequenceEqual(snapshot.Fields)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ChangedGearRatioStillInvalidatesTheCapture(bool store) + { + var memory = new Memory(store) { GearMutationOffset = 0 }; + var metadata = memory.CompleteCar(MiataInput()); + Assert.Throws(() => NativeTuneCapture.Read(memory, metadata, Token, memory.Layout)); + } + private static TuneDecodeInput MiataInput() { using var resource = typeof(NativeTuneReadBoundaryTests).Assembly.GetManifestResourceStream("Wisp.TuneFixtures.miata.json"); @@ -266,6 +316,8 @@ private sealed class Memory(bool store) : INativeHudProcessMemory private int _vectorReads; private int _activeReads; internal ulong? Missing { get; set; } + internal int GearMutationOffset { get; init; } = -1; + private int _gearReads; internal bool ChangeVectorOnRepeat { get; set; } internal bool ChangeActiveOnRepeat { get; set; } internal int Reads { get; private set; } @@ -390,6 +442,8 @@ public bool TryReadBytes(ulong address, Span destination) Reads++; for (var i = 0; i < destination.Length; i++) if (address + (ulong)i == Missing || !_bytes.TryGetValue(address + (ulong)i, out destination[i])) return false; + if (address == Provider + 0x28 && ++_gearReads > 1 && (uint)GearMutationOffset < (uint)destination.Length) + destination[GearMutationOffset] ^= 1; if (address == VectorAddress && ++_vectorReads > 1 && ChangeVectorOnRepeat) destination[0] ^= 8; if (address == Actor + 0x71F4 && ++_activeReads > 1 && ChangeActiveOnRepeat) destination[0x40] ^= 1; return true; diff --git a/tests/Wisp.App.Tests/TuneCaptureServiceIncidentTests.cs b/tests/Wisp.App.Tests/TuneCaptureServiceIncidentTests.cs index eebd86a..229e4a9 100644 --- a/tests/Wisp.App.Tests/TuneCaptureServiceIncidentTests.cs +++ b/tests/Wisp.App.Tests/TuneCaptureServiceIncidentTests.cs @@ -27,6 +27,22 @@ public void TuneReadFailureNamesTheReaderMethodThatFailedWithoutItsMessage() Assert.NotEqual(incident.Signature, elsewhere.Signature); } + [Fact] + public void StableValidationChecksAreDistinguishableWithoutSendingCarData() + { + var read = new NativeTuneRead(new Memory(), CancellationToken.None); + var count = Assert.Throws(() => read.Require(false, TuneReadCheck.GearCount)); + var ratio = Assert.Throws(() => read.Require(false, TuneReadCheck.GearRatio)); + var a = TuneCaptureService.TuneIncident(TuneCaptureStage.ReadTune, "tune-read-failed", count); + var b = TuneCaptureService.TuneIncident(TuneCaptureStage.ReadTune, "tune-read-failed", ratio); + Assert.Equal((uint)TuneReadCheck.GearCount, a.Code); + Assert.Equal((uint)TuneReadCheck.GearRatio, b.Code); + Assert.NotEqual(a.Signature, b.Signature); + Assert.True(SupplementaryIncidentSchema.Valid(a)); + Assert.True(SupplementaryIncidentSchema.Valid(b)); + Assert.Contains("Tune check: GearCount", TuneCaptureDetails.Create(TuneCaptureStage.ReadTune, exception: count)); + } + private sealed class Memory : IReadOnlyProcessMemory { public bool TryReadByte(ulong address, out byte value) { value = 0; return false; } diff --git a/tests/Wisp.App.Tests/TunePerformanceTests.cs b/tests/Wisp.App.Tests/TunePerformanceTests.cs index 4f55422..cb40056 100644 --- a/tests/Wisp.App.Tests/TunePerformanceTests.cs +++ b/tests/Wisp.App.Tests/TunePerformanceTests.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text.Json; +using System.Text.Json.Nodes; using Wisp.App; using Wisp.App.Tunes; using Wisp.Core; @@ -10,6 +11,27 @@ namespace Wisp.App.Tests; public sealed class TunePerformanceTests { + [Theory] + [InlineData("verified", true)] + [InlineData("version", false)] + [InlineData("hash", false)] + [InlineData("length", false)] + [InlineData("image", false)] + public void Steam479PerformanceRequiresTheVerifiedExecutable(string field, bool expected) + { + using var source = typeof(NativeHudBuildContract).Assembly.GetManifestResourceStream("Wisp.NativeCompatibility.UpdatedSteam.json")!; + var document = JsonNode.Parse(source)!.AsObject(); + document["gameVersion"] = field == "version" ? "6.479.875.0" : "6.479.874.0"; + document["executableSha256"] = field == "hash" ? new string('A', 64) : "28FE0814168D474F162592D5568479554882D82C6CA6575965B25BBBDFFA89F0"; + document["executableLength"] = field == "length" ? 184214489L : 184214488L; + document["imageSize"] = field == "image" ? 188669952u + 4096 : 188669952u; + var profile = NativeTunePerformanceProfiles.Resolve(NativeHudCompatibilityPack.FromRuntimeValidation(document)); + if (!expected) { Assert.Null(profile); return; } + Assert.NotNull(profile); + Assert.Equal((0x70E8698UL, 0x716D280UL, 0x6E41728UL), (profile.RootPageVtable, profile.TunePageVtable, profile.BenchmarkVtable)); + Assert.Equal(new[] { 303, 668, 255, 1884, 346, 637, 1546 }, profile.Guards.Select(g => g.Length)); + } + private static TunePerformance Captured => new(16.000036f, 34.689072f, 1.928532f, 2.594379f, 2.1999986f, 3.7833304f, 103.90273f, .504751f, .38999075f, .75537235f); @@ -100,6 +122,20 @@ public void ChangingResultsOrNonfiniteValuesAreRejected(bool changes) else Assert.Throws(() => memory.Read()); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SetupKeyIncludesEveryInstalledPartAndNormalizedSetting(bool changePart) + { + var memory = new Memory(); + var key = memory.SetupKey(); + var address = changePart ? Memory.Descriptor + 0xC + 0x193 : Memory.Actor + 0x71F4 + 0xB7; + memory.Put(address, [1]); + Assert.NotEqual(key, memory.SetupKey()); + memory.Put(address, [0]); + Assert.Equal(key, memory.SetupKey()); + } + private sealed class Memory : IReadOnlyProcessMemory { internal const ulong Actor = 0x200000, Descriptor = 0x300000, Benchmark = 0x400000; @@ -110,13 +146,19 @@ internal Memory() { I32(Actor + 0x100, 3141); I32(Descriptor, 3141); I32(Benchmark + 0x90C, 3141); Put(Actor + 0x2E0, BitConverter.GetBytes(Descriptor)); - Put(Descriptor + 0xC, new byte[0x188]); Put(Benchmark + 0x134, new byte[0x188]); + Put(Descriptor + 0xC, new byte[0x194]); Put(Benchmark + 0x134, new byte[0x188]); Put(Actor + 0x71F4, new byte[0xB8]); Put(Benchmark + 0x2C8, new byte[0xB8]); I32(Benchmark + 0xB10, 13); Put(Benchmark + 0xB14, [1]); foreach (var offset in new ulong[] { 0x954, 0x944, 0x96C, 0x974, 0x914, 0x924, 0x93C, 0xAD8, 0xAD0, 0xAC8 }) { I32(Benchmark + offset, BitConverter.SingleToInt32Bits(offset is 0xAD8 or 0xAD0 ? .5f : 3.5f)); I32(Benchmark + offset + 4, 2); } } - internal TunePerformance? Read() => NativeTunePerformance.ReadResults(new(this, TestContext.Current.CancellationToken), Benchmark, Actor, 0x100); + internal string SetupKey() => NativeTunePerformance.ReadSetup(new(this, TestContext.Current.CancellationToken), Actor, 0x100)!.Key; + internal TunePerformance? Read() + { + var read = new NativeTuneRead(this, TestContext.Current.CancellationToken); + return NativeTunePerformance.ReadSetup(read, Actor, 0x100) is { } setup + ? NativeTunePerformance.ReadResults(read, Benchmark, setup) : null; + } internal void I32(ulong address, int value) => Put(address, BitConverter.GetBytes(value)); internal void Put(ulong address, byte[] data) { for (var i = 0; i < data.Length; i++) _bytes[address + (ulong)i] = data[i]; } diff --git a/tests/Wisp.App.Tests/TuneViewModelTests.cs b/tests/Wisp.App.Tests/TuneViewModelTests.cs index 4d347e3..4fb75ae 100644 --- a/tests/Wisp.App.Tests/TuneViewModelTests.cs +++ b/tests/Wisp.App.Tests/TuneViewModelTests.cs @@ -395,19 +395,23 @@ public void PerformanceResultsStayWithTheCurrentTuneAfterTheTuneScreenCloses() = { using var fixture = new Fixture(); var performance = new TunePerformance(32f, 90f, 1.1f, 1.2f, 4.5f, 10.5f, 85f, 0.5f, 0.5f, 12f); - var captured = Snapshot() with { Performance = performance }; - var later = captured with { Performance = null, CapturedAtUtc = captured.CapturedAtUtc.AddMinutes(5) }; - var calls = 0; - using var model = new TuneViewModel(fixture.Store, - _ => Task.FromResult(new TuneCaptureResult(++calls == 1 ? captured : later, TuneCaptureStatus.Ready, "Ready")), - _ => true, Dispatcher.CurrentDispatcher); + var input = NativeTuneCaptureTests.Input() with { Performance = performance, PerformanceSetupKey = "same" }; + await using var service = new TuneCaptureService(new NativeTuneCaptureTests.Factory(), (_, _) => input); + using var model = new TuneViewModel(fixture.Store, service.RequestSnapshotAsync, service.IsCurrent, + Dispatcher.CurrentDispatcher); await model.InitializeAsync(); await model.RefreshAsync(); model.SelectedCategory = model.Categories.Single(option => option.Category == TuneCategory.Performance); Assert.Empty(model.PerformanceHint); + input = input with { Performance = null, CapturedAtUtc = input.CapturedAtUtc.AddMinutes(5) }; await model.RefreshAsync(); - Assert.Equal(2, calls); Assert.Empty(model.PerformanceHint); - Assert.True(model.HasSnapshot); + Assert.True(model.CanSave); + Assert.Contains(model.ComparisonChoices, choice => choice.Snapshot.Performance == performance); + model.BeginSave(); + model.DialogName = "Track setup"; + await model.ConfirmDialogAsync(); + var saved = Assert.Single(model.Library); + Assert.Equal(performance, (await fixture.Store.LoadAsync(saved.Id, TestContext.Current.CancellationToken)).Snapshot.Performance); }); private static TuneSnapshot Snapshot(string name = "miata") => TuneUiTestData.ValidSnapshot(name); diff --git a/tests/Wisp.Core.Tests/TuneDecoderTests.cs b/tests/Wisp.Core.Tests/TuneDecoderTests.cs index 79b8e4b..993151b 100644 --- a/tests/Wisp.Core.Tests/TuneDecoderTests.cs +++ b/tests/Wisp.Core.Tests/TuneDecoderTests.cs @@ -154,6 +154,32 @@ public void InvalidCopiesRangesPartsAndNumbersAreRejected() Reject(source with { Format = source.Format with { PositiveHalf = .4 } }, TuneDecodeFailure.InvalidFormatter); } + [Theory] + [InlineData(TunePartId.SpringDamper)] + [InlineData(TunePartId.Engine)] + public void MissingInstalledPartPreservesIndependentlyVerifiedSettings(TunePartId missing) + { + var source = Fixture("miata"); + var baseline = Decode(source); + var part = source.Parts.Single(value => value.Kind == missing); + var partial = Decode(source with + { + Parts = source.Parts.SetItem(source.Parts.IndexOf(part), part with { Level = null }), + PartLevelsResolved = false, + VerifiedPartProjection = true + }); + Assert.False(partial.IsComplete); + Assert.True(TuneSnapshotValidator.TryValidate(partial, out var reason), reason); + foreach (var definition in TuneDecoder.Definitions) + { + var expected = Field(baseline, definition.Id); + if (definition.Part == missing && expected.Status != TuneFieldStatus.NotApplicable) + AssertUnavailable(partial, definition.Id, TuneFieldStatus.UnresolvedPartLevel); + else + Assert.Equal(expected, Field(partial, definition.Id)); + } + } + [Fact] public void UnresolvedStatesNeverInventValuesAndRemainUnsavable() {