mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-22 10:56:20 +08:00
Prevent invalid SaveData writes from damaging guest memory (#444)
Add an optional write monitor so the team can find future memory damage on each supported desktop system.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Guest write watch
|
||||
|
||||
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
|
||||
code and HLE code that damage guest memory. The tool starts only if you set one
|
||||
or more `SHARPEMU_WATCH_*` environment variables.
|
||||
|
||||
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
|
||||
does not monitor stores that native guest code makes directly. Use a platform
|
||||
debugger or a hardware watchpoint to monitor these stores.
|
||||
|
||||
## Watch modes
|
||||
|
||||
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
|
||||
block at the specified guest address.
|
||||
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
|
||||
monitors the first 64 direct mappings that have a size of 64 KiB and
|
||||
protection value `0xF2`.
|
||||
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
|
||||
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
|
||||
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
|
||||
memory range. The tool logs a maximum of 128 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
|
||||
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
|
||||
logs a maximum of 64 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
|
||||
specified upper 32 bits in the destination address.
|
||||
|
||||
For each match, the tool logs the destination address, the data pattern, and the
|
||||
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
|
||||
tag.
|
||||
|
||||
Use these variables together to scan bulk writes in the
|
||||
`0x00000080xxxxxxxx` region.
|
||||
|
||||
macOS and Linux:
|
||||
|
||||
```sh
|
||||
SHARPEMU_WATCH_BULK_TORN=1 \
|
||||
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
|
||||
SharpEmu /path/to/eboot.bin
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:SHARPEMU_WATCH_BULK_TORN = "1"
|
||||
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
|
||||
& .\SharpEmu.exe C:\path\to\game\eboot.bin
|
||||
```
|
||||
|
||||
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
|
||||
address from a crash dump.
|
||||
@@ -919,6 +919,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -944,6 +945,14 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
|
||||
{
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
|
||||
{
|
||||
var region = FindRegion(virtualAddress, (ulong)destination.Length);
|
||||
@@ -1016,6 +1025,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1040,6 +1050,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
@@ -93,8 +94,14 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
}
|
||||
|
||||
CopyToRegions(virtualAddress, source, regionIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryValidateRange(
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
// This tool monitors guest-memory writes only when a watch mode is active.
|
||||
public static class GuestWriteWatch
|
||||
{
|
||||
private const ulong WatchBytes = 8;
|
||||
private const int MaxBulkReports = 64;
|
||||
|
||||
private static readonly ulong WatchBase = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE"));
|
||||
|
||||
private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER");
|
||||
|
||||
private static readonly ulong[] PoolSlots = new ulong[64];
|
||||
private static int _poolSlotCount;
|
||||
|
||||
private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN");
|
||||
|
||||
private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1");
|
||||
|
||||
private const ulong DirectBandLow = 0x100_0000_0000;
|
||||
private const ulong DirectBandHigh = 0x1000_0000_0000;
|
||||
private static int _value1Reports;
|
||||
|
||||
private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN");
|
||||
|
||||
private static readonly ulong BulkDestHigh = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI"));
|
||||
private static int _bulkTornReports;
|
||||
private static int _bulkShiftReports;
|
||||
|
||||
public static bool Armed =>
|
||||
WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn;
|
||||
|
||||
public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection)
|
||||
{
|
||||
if (!WatchPoolHeaders || !IsPoolMapping(length, protection))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = Interlocked.Increment(ref _poolSlotCount) - 1;
|
||||
if (index < PoolSlots.Length)
|
||||
{
|
||||
Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Check(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (WatchBulkTorn &&
|
||||
data.Length >= 8 &&
|
||||
(BulkDestHigh != 0
|
||||
? (address >> 32) == BulkDestHigh
|
||||
: address >= DirectBandLow && address < DirectBandHigh))
|
||||
{
|
||||
for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8)
|
||||
{
|
||||
var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8));
|
||||
var kind = ClassifyBulkValue(qword);
|
||||
if (kind is not null && ReserveBulkReport(kind))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " +
|
||||
$"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " +
|
||||
$"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchValue1 &&
|
||||
address >= DirectBandLow && address < DirectBandHigh &&
|
||||
data.Length is >= 1 and <= 8 &&
|
||||
LittleEndianValue(data) == 1 &&
|
||||
Interlocked.Increment(ref _value1Reports) <= 128)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (WatchValuePattern && data.Length == 8)
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt64LittleEndian(data);
|
||||
if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length);
|
||||
for (var i = 0; i < slots; i++)
|
||||
{
|
||||
var slot = Volatile.Read(ref PoolSlots[i]);
|
||||
if (slot != 0 && Overlaps(address, data.Length, slot))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? ClassifyBulkValue(ulong qword)
|
||||
{
|
||||
var low32 = qword & 0xFFFFFFFF;
|
||||
var high32 = qword >> 32;
|
||||
if (low32 == 1 && high32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
return "torn";
|
||||
}
|
||||
|
||||
var prefix = low32 & 0xFF00_0000;
|
||||
var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000;
|
||||
return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0
|
||||
? "shift"
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static int FirstAlignedOffset(ulong address) =>
|
||||
(int)((8 - (address & 7)) & 7);
|
||||
|
||||
internal static bool IsPoolMapping(ulong length, int protection) =>
|
||||
length == 0x10000 && protection == 0xF2;
|
||||
|
||||
internal static bool Overlaps(ulong address, int length, ulong slot)
|
||||
{
|
||||
if (length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var writeLength = (ulong)length - 1;
|
||||
var writeEnd = address > ulong.MaxValue - writeLength
|
||||
? ulong.MaxValue
|
||||
: address + writeLength;
|
||||
var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1)
|
||||
? ulong.MaxValue
|
||||
: slot + WatchBytes - 1;
|
||||
return address <= slotEnd && slot <= writeEnd;
|
||||
}
|
||||
|
||||
private static ulong LittleEndianValue(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ulong value = 0;
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
value |= (ulong)data[i] << (i * 8);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void Report(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " +
|
||||
$"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
private static bool IsEnabled(string name) =>
|
||||
string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal);
|
||||
|
||||
private static bool ReserveBulkReport(string kind) =>
|
||||
kind == "torn"
|
||||
? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports
|
||||
: Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports;
|
||||
|
||||
internal static ulong Parse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
text = text.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = text[2..];
|
||||
}
|
||||
|
||||
return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -2984,6 +2984,7 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
GuestWriteWatch.OnDirectMapping(mappedAddress, length, protection);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -792,28 +792,15 @@ public static class SaveDataExports
|
||||
|
||||
var id = (uint)Interlocked.Increment(ref _nextTransactionResource);
|
||||
|
||||
// The resource-out pointer's argument slot varies by SDK revision: some
|
||||
// callers pass it in rdx, others in rcx (a 4-arg form where rdx holds a
|
||||
// count/flag). Void Terrarium passes rdx=0x1 (not a pointer) and the
|
||||
// real out-pointer in rcx. Probe the plausible candidates and write the
|
||||
// handle to the first writable one instead of faulting on a bad rdx.
|
||||
// This is a stub-level create (matches shadPS4's return-OK semantics);
|
||||
// never return MEMORY_FAULT for it, or the guest treats savedata init as
|
||||
// failed and never advances.
|
||||
// A small RDX value is a flag, and RCX contains the output address.
|
||||
// A larger RDX value is the output address for the older ABI.
|
||||
var resourceAddress = 0UL;
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
ctx[CpuRegister.Rdx],
|
||||
ctx[CpuRegister.Rcx],
|
||||
ctx[CpuRegister.R8],
|
||||
ctx[CpuRegister.R9],
|
||||
})
|
||||
var selectedAddress = SelectTransactionResourceAddress(
|
||||
ctx[CpuRegister.Rdx],
|
||||
ctx[CpuRegister.Rcx]);
|
||||
if (selectedAddress != 0 && TryWriteUInt32(ctx, selectedAddress, id))
|
||||
{
|
||||
if (candidate != 0 && TryWriteUInt32(ctx, candidate, id))
|
||||
{
|
||||
resourceAddress = candidate;
|
||||
break;
|
||||
}
|
||||
resourceAddress = selectedAddress;
|
||||
}
|
||||
|
||||
TraceSaveData(
|
||||
@@ -822,6 +809,16 @@ public static class SaveDataExports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
internal static ulong SelectTransactionResourceAddress(ulong rdx, ulong rcx)
|
||||
{
|
||||
if (rdx == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return rdx <= ushort.MaxValue ? rcx : rdx;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "lJUQuaKqoKY",
|
||||
ExportName = "sceSaveDataDeleteTransactionResource",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.HLE;
|
||||
|
||||
public sealed class GuestWriteWatchTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0x0000001000000001, "torn")]
|
||||
[InlineData(0x0000FFFF00000001, "torn")]
|
||||
[InlineData(0x0000000008000000, "shift")]
|
||||
[InlineData(0x0000000080015F00, "shift")]
|
||||
[InlineData(0x0000000007FFFFFF, null)]
|
||||
[InlineData(0x0000000009000000, null)]
|
||||
[InlineData(0x000000003F800000, null)]
|
||||
[InlineData(0x0000000080015F01, null)]
|
||||
[InlineData(0x0001000000000001, null)]
|
||||
public void ClassifyBulkValue_RecognizesCorruptionSignatures(ulong value, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.ClassifyBulkValue(value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(1, 7)]
|
||||
[InlineData(3, 5)]
|
||||
[InlineData(7, 1)]
|
||||
[InlineData(8, 0)]
|
||||
public void FirstAlignedOffset_ReturnsTheNextEightByteBoundary(ulong address, int expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.FirstAlignedOffset(address));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x1000, 8, 0x1000, true)]
|
||||
[InlineData(0x0FFF, 1, 0x1000, false)]
|
||||
[InlineData(0x0FFF, 2, 0x1000, true)]
|
||||
[InlineData(0x1008, 1, 0x1000, false)]
|
||||
[InlineData(ulong.MaxValue - 3, 4, ulong.MaxValue - 1, true)]
|
||||
[InlineData(ulong.MaxValue, 1, ulong.MaxValue, true)]
|
||||
[InlineData(0x1000, 0, 0x1000, false)]
|
||||
public void Overlaps_HandlesBoundariesWithoutOverflow(
|
||||
ulong address,
|
||||
int length,
|
||||
ulong slot,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.Overlaps(address, length, slot));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000, 0xF2, true)]
|
||||
[InlineData(0x10001, 0xF2, false)]
|
||||
[InlineData(0x10000, 0xF1, false)]
|
||||
public void IsPoolMapping_RequiresTheExpectedSizeAndProtection(
|
||||
ulong length,
|
||||
int protection,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.IsPoolMapping(length, protection));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 0)]
|
||||
[InlineData("", 0)]
|
||||
[InlineData("not-hex", 0)]
|
||||
[InlineData("80", 0x80)]
|
||||
[InlineData("0x80", 0x80)]
|
||||
[InlineData(" 0X801DB3BBB ", 0x801DB3BBB)]
|
||||
public void Parse_HandlesHexadecimalWatchValues(string? text, ulong expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.Parse(text));
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ public sealed class SaveDataExportsTests : IDisposable
|
||||
private const ulong SyncParam = Base + 0xC80;
|
||||
private const ulong SetupParam = Base + 0xD00;
|
||||
private const ulong SetupResult = Base + 0xD80;
|
||||
private const ulong TransactionOut = Base + 0xE00;
|
||||
private const ulong StaleR8 = Base + 0xE08;
|
||||
private const ulong StaleR9 = Base + 0xE10;
|
||||
|
||||
private const int NoEvent = unchecked((int)0x809F0008);
|
||||
private const int ParameterError = unchecked((int)0x809F0000);
|
||||
@@ -225,4 +228,64 @@ public sealed class SaveDataExportsTests : IDisposable
|
||||
unchecked((int)0x809F0008),
|
||||
SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTransactionResource_WithoutOutPointer_DoesNotProbeStaleRegisters()
|
||||
{
|
||||
const uint sentinel = 0xA5A5A5A5;
|
||||
Assert.True(_ctx.TryWriteUInt32(TransactionOut, sentinel));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR9, sentinel));
|
||||
|
||||
var ctx = Reg(rdi: UserId, rdx: 0, rcx: TransactionOut);
|
||||
ctx[CpuRegister.R8] = StaleR8;
|
||||
ctx[CpuRegister.R9] = StaleR9;
|
||||
|
||||
Assert.Equal(0, SaveDataExports.SaveDataCreateTransactionResource(ctx));
|
||||
Assert.True(_ctx.TryReadUInt32(TransactionOut, out var rcxValue));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR8, out var r8Value));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR9, out var r9Value));
|
||||
Assert.Equal(sentinel, rcxValue);
|
||||
Assert.Equal(sentinel, r8Value);
|
||||
Assert.Equal(sentinel, r9Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTransactionResource_WithOutPointerFlag_WritesOnlyRcx()
|
||||
{
|
||||
const uint sentinel = 0xA5A5A5A5;
|
||||
Assert.True(_ctx.TryWriteUInt32(TransactionOut, 0));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR9, sentinel));
|
||||
|
||||
var ctx = Reg(rdi: UserId, rdx: 1, rcx: TransactionOut);
|
||||
ctx[CpuRegister.R8] = StaleR8;
|
||||
ctx[CpuRegister.R9] = StaleR9;
|
||||
|
||||
Assert.Equal(0, SaveDataExports.SaveDataCreateTransactionResource(ctx));
|
||||
Assert.True(_ctx.TryReadUInt32(TransactionOut, out var resource));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR8, out var r8Value));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR9, out var r9Value));
|
||||
Assert.NotEqual(0u, resource);
|
||||
Assert.Equal(sentinel, r8Value);
|
||||
Assert.Equal(sentinel, r9Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTransactionResource_WithLegacyOutPointer_WritesOnlyRdx()
|
||||
{
|
||||
const uint sentinel = 0xA5A5A5A5;
|
||||
Assert.True(_ctx.TryWriteUInt32(TransactionOut, 0));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel));
|
||||
|
||||
Assert.Equal(
|
||||
0,
|
||||
SaveDataExports.SaveDataCreateTransactionResource(
|
||||
Reg(rdi: UserId, rdx: TransactionOut, rcx: StaleR8)));
|
||||
|
||||
Assert.True(_ctx.TryReadUInt32(TransactionOut, out var resource));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR8, out var rcxValue));
|
||||
Assert.NotEqual(0u, resource);
|
||||
Assert.Equal(sentinel, rcxValue);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user