From bc51cc2c4dd1bfcb8923f1f0db09442fff918732 Mon Sep 17 00:00:00 2001
From: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
Date: Sun, 19 Jul 2026 12:27:17 -0500
Subject: [PATCH] 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.
---
docs/guest-write-watch.md | 57 +++++
.../Memory/PhysicalVirtualMemory.cs | 11 +
src/SharpEmu.Core/Memory/VirtualMemory.cs | 9 +-
src/SharpEmu.HLE/GuestWriteWatch.cs | 203 ++++++++++++++++++
.../Kernel/KernelMemoryCompatExports.cs | 1 +
src/SharpEmu.Libs/SaveData/SaveDataExports.cs | 37 ++--
.../HLE/GuestWriteWatchTests.cs | 77 +++++++
.../SaveData/SaveDataExportsTests.cs | 63 ++++++
8 files changed, 437 insertions(+), 21 deletions(-)
create mode 100644 docs/guest-write-watch.md
create mode 100644 src/SharpEmu.HLE/GuestWriteWatch.cs
create mode 100644 tests/SharpEmu.Libs.Tests/HLE/GuestWriteWatchTests.cs
diff --git a/docs/guest-write-watch.md b/docs/guest-write-watch.md
new file mode 100644
index 00000000..5ae326f3
--- /dev/null
+++ b/docs/guest-write-watch.md
@@ -0,0 +1,57 @@
+
+
+# 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
` 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` 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.
diff --git a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
index 2eb2f84b..54f39e45 100644
--- a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
+++ b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs
@@ -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 source)
+ {
+ if (GuestWriteWatch.Armed)
+ {
+ GuestWriteWatch.Check(virtualAddress, source);
+ }
+ }
+
private bool TryReadExclusive(ulong virtualAddress, Span 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;
}
diff --git a/src/SharpEmu.Core/Memory/VirtualMemory.cs b/src/SharpEmu.Core/Memory/VirtualMemory.cs
index cb2ff4e0..bd4f2880 100644
--- a/src/SharpEmu.Core/Memory/VirtualMemory.cs
+++ b/src/SharpEmu.Core/Memory/VirtualMemory.cs
@@ -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(
diff --git a/src/SharpEmu.HLE/GuestWriteWatch.cs b/src/SharpEmu.HLE/GuestWriteWatch.cs
new file mode 100644
index 00000000..082067d9
--- /dev/null
+++ b/src/SharpEmu.HLE/GuestWriteWatch.cs
@@ -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 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 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 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;
+ }
+}
diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
index e9b179ea..467ae4e4 100644
--- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
@@ -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;
}
diff --git a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs
index 41ef98a7..0496a36e 100644
--- a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs
+++ b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs
@@ -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",
diff --git a/tests/SharpEmu.Libs.Tests/HLE/GuestWriteWatchTests.cs b/tests/SharpEmu.Libs.Tests/HLE/GuestWriteWatchTests.cs
new file mode 100644
index 00000000..446e27d2
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/HLE/GuestWriteWatchTests.cs
@@ -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));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs
index 4c755f1d..4c655ab2 100644
--- a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs
+++ b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs
@@ -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);
+ }
}