Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 25 additions & 25 deletions src/Wisp.App/Tunes/NativeTuneCapture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,29 @@ 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<ulong>();
var wheelIds = new HashSet<uint>();
foreach (var offset in new[] { fields.FirstWheelPointer, fields.SecondWheelPointer,
fields.ThirdWheelPointer, fields.ThirdWheelPointer + 8 })
{
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));
Expand All @@ -62,19 +61,19 @@ internal static TuneDecodeInput Read(INativeHudProcessMemory memory, TuneAssetMe
var carRanges = ImmutableDictionary.CreateBuilder<TuneFieldId, TuneRange>();
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)));
Expand All @@ -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
Expand All @@ -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
};
}

Expand All @@ -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)
Expand All @@ -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);
Expand All @@ -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);
}
Expand All @@ -174,25 +174,25 @@ internal static ImmutableDictionary<TuneQuantity, TuneConversion> 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<TuneQuantity, TuneConversion>();
foreach (var (quantity, category) in Quantities)
{
var unitOverride = read.Int32(units + 0x14D0 + category * 4);
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();
Expand Down
83 changes: 55 additions & 28 deletions src/Wisp.App/Tunes/NativeTunePerformance.cs
Original file line number Diff line number Diff line change
@@ -1,40 +1,55 @@
using System.Buffers.Binary;
using System.IO;
using System.Security.Cryptography;
using Wisp.Core.Tunes;

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)
{
Expand All @@ -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)
{
Expand Down
13 changes: 13 additions & 0 deletions src/Wisp.App/Tunes/NativeTunePerformanceProfiles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 27 additions & 2 deletions src/Wisp.App/Tunes/NativeTuneRead.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/Wisp.App/Tunes/TuneCaptureDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading