Merge feature/native-surface: native P/Invoke surface + Phase 2 core memory

Task 1.4 (Native/ LibraryImport surface, SafeMemoryHandle) and Phase 2
(MarshalCache, MemoryBase, ExternalReader, InProcessReader, string IO,
addressing). Reviewed high-effort: 4 correctness + 3 cleanup fixed and
verified. Deviation D1 (InProcessReader RPM-on-self) reconciled in spec.
Known follow-up: task 2.10 (ReadString UTF-16 null alignment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kbe
2026-07-21 19:31:29 +02:00
co-authored by Claude Opus 4.8
19 changed files with 1985 additions and 14 deletions
+1
View File
@@ -9,3 +9,4 @@ reference/
# Scratch
*.tmp
*.log
+86
View File
@@ -0,0 +1,86 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic;
/// <summary>
/// Out-of-process memory reader that accesses the target's memory through
/// <see cref="NativeMethods.ReadProcessMemory"/> and
/// <see cref="NativeMethods.WriteProcessMemory"/>.
/// </summary>
public sealed class ExternalReader : MemoryBase
{
private readonly SafeMemoryHandle _handle;
private readonly IntPtr _imageBase;
private bool _disposed;
/// <summary>
/// Opens a process for external memory access.
/// </summary>
/// <param name="process">The target process.</param>
/// <param name="desiredAccess">The access rights to request. Defaults to
/// <see cref="ProcessAccess.AllAccess"/>.</param>
public ExternalReader(Process process, ProcessAccess desiredAccess = ProcessAccess.AllAccess)
{
_handle = NativeMethods.OpenProcess(desiredAccess, false, process.Id);
if (_handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"OpenProcess failed for PID {process.Id}: error {error}");
}
_imageBase = process.MainModule?.BaseAddress ?? IntPtr.Zero;
}
/// <inheritdoc />
public override IntPtr ImageBase => _imageBase;
/// <inheritdoc />
public override SafeMemoryHandle Handle => _handle;
/// <inheritdoc />
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
if (isRelative)
address = GetAbsolute(address);
byte[] buffer = new byte[count];
if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead))
{
return [];
}
if ((int)bytesRead != count)
{
Array.Resize(ref buffer, (int)bytesRead);
}
return buffer;
}
/// <inheritdoc />
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
{
if (isRelative)
address = GetAbsolute(address);
if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written))
{
return 0;
}
return (int)written;
}
/// <inheritdoc />
public override void Dispose()
{
if (!_disposed)
{
_disposed = true;
_handle.Dispose();
}
}
}
+90
View File
@@ -0,0 +1,90 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic;
/// <summary>
/// In-process memory reader that accesses the owning process's memory through
/// <see cref="NativeMethods.ReadProcessMemory"/> and
/// <see cref="NativeMethods.WriteProcessMemory"/> on a handle to the current
/// process. Unlike the unsafe-deref approach, this fails softly (returns
/// empty / zero bytes) on invalid or protected addresses instead of crashing
/// the host process with an <see cref="AccessViolationException"/>.
/// </summary>
public sealed class InProcessReader : MemoryBase
{
private readonly SafeMemoryHandle _handle;
private readonly IntPtr _imageBase;
private bool _disposed;
/// <summary>
/// Creates an in-process reader for the current process.
/// </summary>
public InProcessReader()
{
Process current = Process.GetCurrentProcess();
_handle = NativeMethods.OpenProcess(
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation,
false,
current.Id);
if (_handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"OpenProcess failed for PID {current.Id}: error {error}");
}
_imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero;
}
/// <inheritdoc />
public override IntPtr ImageBase => _imageBase;
/// <inheritdoc />
public override SafeMemoryHandle Handle => _handle;
/// <inheritdoc />
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
if (isRelative)
address = GetAbsolute(address);
byte[] buffer = new byte[count];
if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead))
{
return [];
}
if ((int)bytesRead != count)
{
Array.Resize(ref buffer, (int)bytesRead);
}
return buffer;
}
/// <inheritdoc />
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
{
if (isRelative)
address = GetAbsolute(address);
if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written))
{
return 0;
}
return (int)written;
}
/// <inheritdoc />
public override void Dispose()
{
if (!_disposed)
{
_disposed = true;
_handle.Dispose();
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Reflection;
using System.Runtime.InteropServices;
namespace WhiteMagic;
/// <summary>
/// Computes and caches marshal-related metadata for type <typeparamref name="T"/>
/// exactly once. <see cref="MemoryBase.Read{T}"/> and <see cref="MemoryBase.Write{T}"/>
/// branch on these cached flags to decide between blittable <c>Span</c>/<c>MemoryMarshal</c>
/// paths and the fallback marshal path.
/// </summary>
/// <typeparam name="T">The type to cache metadata for.</typeparam>
public static class MarshalCache<T>
{
/// <summary>The unmanaged size of <typeparamref name="T"/> in bytes.</summary>
public static readonly int Size;
/// <summary>The unmanaged size of <typeparamref name="T"/> as an unsigned integer.</summary>
public static readonly uint SizeU;
/// <summary>
/// <see langword="true"/> when <typeparamref name="T"/> has at least one field
/// decorated with <see cref="MarshalAsAttribute"/>, meaning it cannot be copied
/// via a simple pointer dereference.
/// </summary>
public static readonly bool TypeRequiresMarshal;
/// <summary><see langword="true"/> when <typeparamref name="T"/> is <see cref="IntPtr"/>.</summary>
public static readonly bool IsIntPtr;
/// <summary>The underlying type code of <typeparamref name="T"/>.</summary>
public static readonly TypeCode TypeCode;
/// <summary>
/// The effective type that the marshaler uses. For an enum this is the underlying
/// integer type; for all other types it is <typeparamref name="T"/> itself.
/// </summary>
public static readonly Type RealType;
static MarshalCache()
{
TypeCode = Type.GetTypeCode(typeof(T));
if (typeof(T) == typeof(bool))
{
Size = 1;
RealType = typeof(T);
}
else if (typeof(T).IsEnum)
{
Type underlying = typeof(T).GetEnumUnderlyingType();
Size = Marshal.SizeOf(underlying);
RealType = underlying;
TypeCode = Type.GetTypeCode(underlying);
}
else
{
Size = Marshal.SizeOf(typeof(T));
RealType = typeof(T);
}
SizeU = (uint)Size;
IsIntPtr = RealType == typeof(IntPtr);
TypeRequiresMarshal =
RealType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Any(f => f.GetCustomAttributes(typeof(MarshalAsAttribute), true).Length != 0);
}
}
+292
View File
@@ -0,0 +1,292 @@
using WhiteMagic.Native;
using System.Runtime.InteropServices;
using System.Text;
namespace WhiteMagic;
/// <summary>
/// Abstract base for all memory-access readers and writers. Provides typed
/// <see cref="Read{T}"/>/<see cref="Write{T}"/>, array IO, string IO, and
/// relative/absolute addressing. Subclasses implement the concrete
/// <see cref="ReadBytes"/> and <see cref="WriteBytes"/> methods.
/// </summary>
public abstract class MemoryBase : IDisposable
{
/// <summary>The base address of the target process's main module.</summary>
public abstract IntPtr ImageBase { get; }
/// <summary>The native handle to the target process.</summary>
public abstract SafeMemoryHandle Handle { get; }
// ── Raw byte IO ────────────────────────────────────────────────────────
/// <summary>Reads a sequence of bytes from the target address.</summary>
public abstract byte[] ReadBytes(IntPtr address, int count, bool isRelative = false);
/// <summary>Writes a sequence of bytes to the target address.</summary>
/// <returns>The number of bytes written.</returns>
public abstract int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false);
// ── Typed IO ───────────────────────────────────────────────────────────
/// <summary>Reads a value of type <typeparamref name="T"/> from the target address.</summary>
/// <returns>The value, or <c>default(T)</c> when the read fails or returns fewer bytes than
/// <see cref="MarshalCache{T}.Size"/>.</returns>
public T Read<T>(IntPtr address, bool isRelative = false) where T : struct
{
if (isRelative)
address = GetAbsolute(address);
int size = MarshalCache<T>.Size;
byte[] raw = ReadBytes(address, size);
if (raw.Length < size)
return default;
if (MarshalCache<T>.TypeRequiresMarshal)
return MarshalByteArrayToStructure<T>(raw);
return MemoryMarshal.Read<T>(raw.AsSpan());
}
/// <summary>Writes a value of type <typeparamref name="T"/> to the target address.</summary>
/// <returns><see langword="true"/> if all bytes were written.</returns>
public bool Write<T>(IntPtr address, T value, bool isRelative = false) where T : struct
{
if (isRelative)
address = GetAbsolute(address);
int size = MarshalCache<T>.Size;
byte[] raw;
if (MarshalCache<T>.TypeRequiresMarshal)
raw = StructureToByteArray(value, size);
else
{
raw = new byte[size];
MemoryMarshal.Write(raw.AsSpan(), in value);
}
int written = WriteBytes(address, raw, false);
return written == size;
}
/// <summary>Reads an array of values of type <typeparamref name="T"/> from the target address.</summary>
/// <returns>An array of at most <paramref name="count"/> elements. May be shorter when the read
/// returns fewer bytes than expected.</returns>
public T[] Read<T>(IntPtr address, int count, bool isRelative = false) where T : struct
{
if (isRelative)
address = GetAbsolute(address);
int elementSize = MarshalCache<T>.Size;
int totalSize = elementSize * count;
byte[] raw = ReadBytes(address, totalSize);
int actualCount = Math.Min(count, raw.Length / elementSize);
var result = new T[actualCount];
if (actualCount == 0)
return result;
if (MarshalCache<T>.TypeRequiresMarshal)
{
GCHandle pin = GCHandle.Alloc(raw, GCHandleType.Pinned);
try
{
IntPtr basePtr = pin.AddrOfPinnedObject();
for (int i = 0; i < actualCount; i++)
result[i] = Marshal.PtrToStructure<T>(basePtr + (i * elementSize));
}
finally
{
pin.Free();
}
}
else
{
ReadOnlySpan<byte> span = raw;
for (int i = 0; i < actualCount; i++)
result[i] = MemoryMarshal.Read<T>(span.Slice(i * elementSize, elementSize));
}
return result;
}
/// <summary>Writes an array of values of type <typeparamref name="T"/> to the target address.</summary>
/// <returns><see langword="true"/> if all bytes were written.</returns>
public bool Write<T>(IntPtr address, T[] values, bool isRelative = false) where T : struct
{
if (isRelative)
address = GetAbsolute(address);
if (values is null || values.Length == 0)
return true;
int elementSize = MarshalCache<T>.Size;
int totalSize = elementSize * values.Length;
byte[] raw = new byte[totalSize];
Span<byte> span = raw;
for (int i = 0; i < values.Length; i++)
{
Span<byte> slice = span.Slice(i * elementSize, elementSize);
if (MarshalCache<T>.TypeRequiresMarshal)
StructureToByteArray(values[i], slice, elementSize);
else
MemoryMarshal.Write(slice, in values[i]);
}
int written = WriteBytes(address, raw, false);
return written == totalSize;
}
// ── String IO ──────────────────────────────────────────────────────────
/// <summary>Reads a null-terminated string from the target address by scanning in small
/// chunks. Stops at the null terminator, the maximum length, or the first page boundary
/// that fails to read (avoids an atomic failure when a 512-byte window crosses an unmapped
/// region).</summary>
/// <param name="address">The address to read from.</param>
/// <param name="encoding">The text encoding.</param>
/// <param name="maxLength">The maximum number of bytes to read.</param>
/// <param name="relative">If <see langword="true"/>, <paramref name="address"/> is relative
/// to <see cref="ImageBase"/>.</param>
public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false)
{
if (relative)
address = GetAbsolute(address);
// The encoded null terminator. For ASCII/UTF-8 this is a single 0x00 byte;
// for UTF-16 it is two zero bytes (0x00 0x00); for UTF-32 it is four.
byte[] nullTerminator = encoding.GetBytes("\0");
const int chunkSize = 64;
int remaining = maxLength;
var accumulated = new System.Collections.Generic.List<byte[]>();
while (remaining > 0)
{
int take = Math.Min(chunkSize, remaining);
byte[] chunk = ReadBytes(address, take);
if (chunk.Length == 0)
break;
int nullPos = IndexOfPattern(chunk, nullTerminator);
if (nullPos >= 0)
{
if (nullPos > 0)
accumulated.Add(chunk[..nullPos]);
break;
}
accumulated.Add(chunk);
address += take;
remaining -= take;
}
int totalLength = 0;
foreach (byte[] part in accumulated)
totalLength += part.Length;
byte[] combined = new byte[totalLength];
int offset = 0;
foreach (byte[] part in accumulated)
{
part.CopyTo(combined, offset);
offset += part.Length;
}
return encoding.GetString(combined);
}
/// <summary>Writes a null-terminated string to the target address.</summary>
public virtual bool WriteString(IntPtr address, string value, Encoding encoding, bool relative = false)
{
if (value.Length == 0 || value[^1] != '\0')
value += '\0';
byte[] bytes = encoding.GetBytes(value);
int written = WriteBytes(address, bytes, relative);
return written == bytes.Length;
}
// ── Addressing ─────────────────────────────────────────────────────────
/// <summary>Converts a relative offset to an absolute address relative to <see cref="ImageBase"/>.</summary>
public IntPtr GetAbsolute(IntPtr relative)
{
return ImageBase + (nint)relative;
}
/// <summary>Converts an absolute address to a relative offset from <see cref="ImageBase"/>.
/// This is the inverse of <see cref="GetAbsolute"/>: <c>GetAbsolute(GetRelative(a)) == a</c>.</summary>
public IntPtr GetRelative(IntPtr absolute)
{
return (IntPtr)((nint)absolute - (nint)ImageBase);
}
// ── Lifecycle ──────────────────────────────────────────────────────────
/// <inheritdoc />
public virtual void Dispose()
{
Handle?.Dispose();
}
// ── Private helpers ────────────────────────────────────────────────────
private static T MarshalByteArrayToStructure<T>(byte[] bytes) where T : struct
{
GCHandle pin = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
return Marshal.PtrToStructure<T>(pin.AddrOfPinnedObject());
}
finally
{
pin.Free();
}
}
private static byte[] StructureToByteArray<T>(T value, int size) where T : struct
{
byte[] bytes = new byte[size];
GCHandle pin = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false);
}
finally
{
pin.Free();
}
return bytes;
}
private static void StructureToByteArray<T>(T value, Span<byte> destination, int size) where T : struct
{
byte[] bytes = StructureToByteArray(value, size);
bytes.CopyTo(destination);
}
private static int IndexOfPattern(byte[] data, byte[] pattern)
{
int lastStart = data.Length - pattern.Length;
for (int i = 0; i <= lastStart; i++)
{
bool match = true;
for (int j = 0; j < pattern.Length; j++)
{
if (data[i + j] != pattern[j])
{
match = false;
break;
}
}
if (match)
return i;
}
return -1;
}
}
+136
View File
@@ -0,0 +1,136 @@
namespace WhiteMagic.Native;
/// <summary>
/// Access rights that open a process object.
/// </summary>
[Flags]
public enum ProcessAccess : uint
{
/// <summary>The right to terminate the process with TerminateProcess.</summary>
Terminate = 0x0001,
/// <summary>The right to create a thread in the process.</summary>
CreateThread = 0x0002,
/// <summary>The right to operate on the address space of the process.</summary>
VmOperation = 0x0008,
/// <summary>The right to read memory with ReadProcessMemory.</summary>
VmRead = 0x0010,
/// <summary>The right to write memory with WriteProcessMemory.</summary>
VmWrite = 0x0020,
/// <summary>The right to duplicate a handle with DuplicateHandle.</summary>
DupHandle = 0x0040,
/// <summary>The right to set information about the process.</summary>
SetInformation = 0x0200,
/// <summary>The right to read information about the process, such as the exit code.</summary>
QueryInformation = 0x0400,
/// <summary>The right to suspend or resume the process.</summary>
SuspendResume = 0x0800,
/// <summary>The right to read a limited set of information about the process.</summary>
QueryLimitedInformation = 0x1000,
/// <summary>The right to use the process object for synchronization.</summary>
Synchronize = 0x00100000,
/// <summary>All access rights for a process object.</summary>
AllAccess = 0x001F0000 | Synchronize | 0xFFFF,
}
/// <summary>
/// Values that control how VirtualAllocEx allocates memory.
/// </summary>
[Flags]
public enum MemoryAllocationType : uint
{
/// <summary>Commit physical storage for the reserved pages. The pages start as zero.</summary>
Commit = 0x00001000,
/// <summary>Reserve a range of address space without physical storage.</summary>
Reserve = 0x00002000,
/// <summary>Reset the data in the range to indicate that it is no longer of interest.</summary>
Reset = 0x00080000,
/// <summary>Allocate memory at the highest possible address.</summary>
TopDown = 0x00100000,
}
/// <summary>
/// Values that protect a block of memory.
/// </summary>
[Flags]
public enum MemoryProtectionType : uint
{
/// <summary>No access to the committed pages.</summary>
NoAccess = 0x01,
/// <summary>Read access to the committed pages.</summary>
ReadOnly = 0x02,
/// <summary>Read and write access to the committed pages.</summary>
ReadWrite = 0x04,
/// <summary>Copy-on-write access to the committed pages.</summary>
WriteCopy = 0x08,
/// <summary>Execute access to the committed pages.</summary>
Execute = 0x10,
/// <summary>Execute and read access to the committed pages.</summary>
ExecuteRead = 0x20,
/// <summary>Execute, read, and write access to the committed pages.</summary>
ExecuteReadWrite = 0x40,
/// <summary>Execute and copy-on-write access to the committed pages.</summary>
ExecuteWriteCopy = 0x80,
/// <summary>The pages in the range become guard pages.</summary>
Guard = 0x100,
/// <summary>The system does not cache the committed pages.</summary>
NoCache = 0x200,
/// <summary>The system uses write-combined access for the pages.</summary>
WriteCombine = 0x400,
}
/// <summary>
/// Values that control how VirtualFreeEx frees memory.
/// </summary>
[Flags]
public enum MemoryFreeType : uint
{
/// <summary>Decommit the committed pages. The address range stays reserved.</summary>
Decommit = 0x4000,
/// <summary>Release the range of pages. The size must be zero.</summary>
Release = 0x8000,
}
/// <summary>
/// Values that set the initial state of a new thread.
/// </summary>
[Flags]
public enum ThreadCreationFlags : uint
{
/// <summary>The thread runs immediately after creation.</summary>
RunImmediately = 0,
/// <summary>The thread starts in a suspended state. Call ResumeThread to start it.</summary>
CreateSuspended = 0x00000004,
/// <summary>The stack-size parameter sets the reserve size of the stack.</summary>
StackSizeParamIsAReservation = 0x00010000,
}
/// <summary>
/// Flags that select the registers that the thread-context functions read or write.
/// There are separate constants for 32-bit (x86/WOW64) and 64-bit (AMD64) contexts.
/// </summary>
public static class ContextFlags
{
/// <summary>Architecture identifier for x86 contexts.</summary>
public const uint X86 = 0x00010000;
/// <summary>Architecture identifier for AMD64 contexts.</summary>
public const uint Amd64 = 0x00100000;
/// <summary>x86: SS:SP, CS:IP, FLAGS, and BP.</summary>
public const uint X86Control = X86 | 0x01;
/// <summary>x86: AX, BX, CX, DX, SI, and DI.</summary>
public const uint X86Integer = X86 | 0x02;
/// <summary>x86: DS, ES, FS, and GS.</summary>
public const uint X86Segments = X86 | 0x04;
/// <summary>x86: control, integer, and segment registers.</summary>
public const uint X86Full = X86Control | X86Integer | X86Segments;
/// <summary>AMD64: SegSs, Rsp, SegCs, Rip, and EFlags.</summary>
public const uint Amd64Control = Amd64 | 0x01;
/// <summary>AMD64: Rax, Rcx, Rdx, Rbx, Rbp, Rsi, Rdi, and R8 to R15.</summary>
public const uint Amd64Integer = Amd64 | 0x02;
/// <summary>AMD64: SegDs, SegEs, SegFs, and SegGs.</summary>
public const uint Amd64Segments = Amd64 | 0x04;
/// <summary>AMD64: control, integer, and segment registers.</summary>
public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments;
}
+136
View File
@@ -0,0 +1,136 @@
using System.Runtime.InteropServices;
namespace WhiteMagic.Native;
/// <summary>
/// P/Invoke declarations for the Win32 process, memory, thread, and module
/// APIs that WhiteMagic uses. Every declaration uses <see cref="LibraryImportAttribute"/>
/// (source-generated interop). SetLastError is enabled on all calls that the
/// Win32 API documents as setting a thread-local last-error value.
/// </summary>
internal static partial class NativeMethods
{
// ── Process ──────────────────────────────────────────────────────────────
/// <summary>Opens an existing process and returns a handle to it.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial SafeMemoryHandle OpenProcess(
ProcessAccess desiredAccess,
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
int processId);
/// <summary>Closes an open object handle.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CloseHandle(IntPtr handle);
// ── Memory ───────────────────────────────────────────────────────────────
/// <summary>Reads memory from a process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool ReadProcessMemory(
SafeMemoryHandle process,
IntPtr baseAddress,
Span<byte> buffer,
int size,
out nint bytesRead);
/// <summary>Writes memory to a process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool WriteProcessMemory(
SafeMemoryHandle process,
IntPtr baseAddress,
ReadOnlySpan<byte> buffer,
int size,
out nint bytesWritten);
/// <summary>Reserves or commits a region of memory in a process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial IntPtr VirtualAllocEx(
SafeMemoryHandle process,
IntPtr address,
nint size,
MemoryAllocationType allocationType,
MemoryProtectionType protect);
/// <summary>Changes the protection on a committed region of memory.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool VirtualProtectEx(
SafeMemoryHandle process,
IntPtr address,
nint size,
MemoryProtectionType newProtect,
out MemoryProtectionType oldProtect);
/// <summary>Releases or decommits a region of memory in a process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool VirtualFreeEx(
SafeMemoryHandle process,
IntPtr address,
nint size,
MemoryFreeType freeType);
// ── Threading ────────────────────────────────────────────────────────────
/// <summary>Creates a thread that runs in the virtual address space of a process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial SafeMemoryHandle CreateRemoteThread(
SafeMemoryHandle process,
IntPtr threadAttributes,
nint stackSize,
IntPtr startAddress,
IntPtr parameter,
ThreadCreationFlags creationFlags,
out int threadId);
/// <summary>Sets a 64-bit thread context (AMD64).</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetThreadContext(
SafeMemoryHandle thread,
ref Context64 context);
/// <summary>Gets a 64-bit thread context (AMD64).</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool GetThreadContext(
SafeMemoryHandle thread,
ref Context64 context);
/// <summary>Sets a 32-bit (WOW64) thread context.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool Wow64SetThreadContext(
SafeMemoryHandle thread,
ref Context32 context);
/// <summary>Gets a 32-bit (WOW64) thread context.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool Wow64GetThreadContext(
SafeMemoryHandle thread,
ref Context32 context);
// ── Modules ──────────────────────────────────────────────────────────────
/// <summary>Loads a module into the calling process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true, EntryPoint = "LoadLibraryW")]
internal static partial IntPtr LoadLibrary(
[MarshalAs(UnmanagedType.LPWStr)] string lpFileName);
/// <summary>Returns the address of a function or variable from a loaded module.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial IntPtr GetProcAddress(
IntPtr hModule,
[MarshalAs(UnmanagedType.LPStr)] string lpProcName);
/// <summary>Waits until a thread exits and retrieves its exit code.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial int WaitForSingleObject(
SafeMemoryHandle handle,
uint milliseconds);
}
+207
View File
@@ -0,0 +1,207 @@
using System.Runtime.InteropServices;
namespace WhiteMagic.Native;
/// <summary>
/// The x87 and MMX state inside a 32-bit thread context.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public unsafe struct FloatingSaveArea32
{
/// <summary>The x87 FPU control word.</summary>
public uint ControlWord;
/// <summary>The x87 FPU status word.</summary>
public uint StatusWord;
/// <summary>The x87 FPU tag word.</summary>
public uint TagWord;
/// <summary>The offset of the instruction that caused the last FPU exception.</summary>
public uint ErrorOffset;
/// <summary>The selector of the instruction that caused the last FPU exception.</summary>
public uint ErrorSelector;
/// <summary>The offset of the operand that caused the last FPU exception.</summary>
public uint DataOffset;
/// <summary>The selector of the operand that caused the last FPU exception.</summary>
public uint DataSelector;
/// <summary>The 80-byte register area.</summary>
public fixed byte RegisterArea[80];
/// <summary>The CR0 numeric-processor-extension state.</summary>
public uint Cr0NpxState;
}
/// <summary>
/// A 32-bit (x86/WOW64) thread context. Use it with
/// <c>Wow64GetThreadContext</c> and <c>Wow64SetThreadContext</c> to inspect a 32-bit thread.
/// The total size is 716 bytes.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public unsafe struct Context32
{
/// <summary>Selects which parts of the context are valid. See <see cref="ContextFlags"/>.</summary>
public uint ContextFlags;
/// <summary>Debug register 0.</summary>
public uint Dr0;
/// <summary>Debug register 1.</summary>
public uint Dr1;
/// <summary>Debug register 2.</summary>
public uint Dr2;
/// <summary>Debug register 3.</summary>
public uint Dr3;
/// <summary>Debug register 6.</summary>
public uint Dr6;
/// <summary>Debug register 7.</summary>
public uint Dr7;
/// <summary>The floating-point state.</summary>
public FloatingSaveArea32 FloatSave;
/// <summary>The GS segment.</summary>
public uint SegGs;
/// <summary>The FS segment.</summary>
public uint SegFs;
/// <summary>The ES segment.</summary>
public uint SegEs;
/// <summary>The DS segment.</summary>
public uint SegDs;
/// <summary>The EDI register.</summary>
public uint Edi;
/// <summary>The ESI register.</summary>
public uint Esi;
/// <summary>The EBX register.</summary>
public uint Ebx;
/// <summary>The EDX register.</summary>
public uint Edx;
/// <summary>The ECX register.</summary>
public uint Ecx;
/// <summary>The EAX register.</summary>
public uint Eax;
/// <summary>The base (frame) pointer.</summary>
public uint Ebp;
/// <summary>The instruction pointer.</summary>
public uint Eip;
/// <summary>The CS segment.</summary>
public uint SegCs;
/// <summary>The flags register.</summary>
public uint EFlags;
/// <summary>The stack pointer.</summary>
public uint Esp;
/// <summary>The SS segment.</summary>
public uint SegSs;
/// <summary>The extended (processor-specific) registers. The size is 512 bytes.</summary>
public fixed byte ExtendedRegisters[512];
}
/// <summary>
/// A 64-bit (AMD64) thread context. Use it with the native
/// <c>GetThreadContext</c> and <c>SetThreadContext</c> from a 64-bit process.
/// The structure needs 16-byte alignment. The total size is 1232 bytes.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 16)]
public unsafe struct Context64
{
/// <summary>Home storage for a register parameter.</summary>
public ulong P1Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P2Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P3Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P4Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P5Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P6Home;
/// <summary>Selects which parts of the context are valid. See <see cref="ContextFlags"/>.</summary>
public uint ContextFlags;
/// <summary>The MXCSR register.</summary>
public uint MxCsr;
/// <summary>The CS segment.</summary>
public ushort SegCs;
/// <summary>The DS segment.</summary>
public ushort SegDs;
/// <summary>The ES segment.</summary>
public ushort SegEs;
/// <summary>The FS segment.</summary>
public ushort SegFs;
/// <summary>The GS segment.</summary>
public ushort SegGs;
/// <summary>The SS segment.</summary>
public ushort SegSs;
/// <summary>The flags register.</summary>
public uint EFlags;
/// <summary>Debug register 0.</summary>
public ulong Dr0;
/// <summary>Debug register 1.</summary>
public ulong Dr1;
/// <summary>Debug register 2.</summary>
public ulong Dr2;
/// <summary>Debug register 3.</summary>
public ulong Dr3;
/// <summary>Debug register 6.</summary>
public ulong Dr6;
/// <summary>Debug register 7.</summary>
public ulong Dr7;
/// <summary>The RAX register.</summary>
public ulong Rax;
/// <summary>The RCX register.</summary>
public ulong Rcx;
/// <summary>The RDX register.</summary>
public ulong Rdx;
/// <summary>The RBX register.</summary>
public ulong Rbx;
/// <summary>The stack pointer.</summary>
public ulong Rsp;
/// <summary>The base (frame) pointer.</summary>
public ulong Rbp;
/// <summary>The RSI register.</summary>
public ulong Rsi;
/// <summary>The RDI register.</summary>
public ulong Rdi;
/// <summary>The R8 register.</summary>
public ulong R8;
/// <summary>The R9 register.</summary>
public ulong R9;
/// <summary>The R10 register.</summary>
public ulong R10;
/// <summary>The R11 register.</summary>
public ulong R11;
/// <summary>The R12 register.</summary>
public ulong R12;
/// <summary>The R13 register.</summary>
public ulong R13;
/// <summary>The R14 register.</summary>
public ulong R14;
/// <summary>The R15 register.</summary>
public ulong R15;
/// <summary>The instruction pointer.</summary>
public ulong Rip;
/// <summary>The XMM save area. The size is 512 bytes.</summary>
public fixed byte FltSave[512];
/// <summary>The vector registers (26 entries of 16 bytes, stored as 52 entries of 8 bytes).</summary>
public fixed ulong VectorRegister[52];
/// <summary>The vector control register.</summary>
public ulong VectorControl;
/// <summary>The debug-control MSR.</summary>
public ulong DebugControl;
/// <summary>The target RIP of the last branch.</summary>
public ulong LastBranchToRip;
/// <summary>The source RIP of the last branch.</summary>
public ulong LastBranchFromRip;
/// <summary>The target RIP of the last exception.</summary>
public ulong LastExceptionToRip;
/// <summary>The source RIP of the last exception.</summary>
public ulong LastExceptionFromRip;
}
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Win32.SafeHandles;
namespace WhiteMagic.Native;
/// <summary>
/// A Win32 handle (process, thread, or snapshot) with a managed lifetime.
/// The handle closes with <c>CloseHandle</c>, even after an exception or a thread abort.
/// </summary>
/// <remarks>The pattern comes from MemorySharp's SafeMemoryHandle.</remarks>
public sealed class SafeMemoryHandle : SafeHandleZeroOrMinusOneIsInvalid
{
/// <summary>
/// Makes an empty handle. The interop marshaller uses this constructor for a
/// handle that a system call returns (for example, <see cref="NativeMethods.OpenProcess"/>).
/// </summary>
public SafeMemoryHandle() : base(true)
{
}
/// <summary>
/// Wraps a raw handle and takes ownership of the handle.
/// </summary>
/// <param name="handle">The handle to own.</param>
public SafeMemoryHandle(IntPtr handle) : base(true)
{
SetHandle(handle);
}
/// <inheritdoc />
protected override bool ReleaseHandle()
{
return NativeMethods.CloseHandle(handle);
}
}
+4
View File
@@ -9,4 +9,8 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="WhiteMagicTest" />
</ItemGroup>
</Project>
+103
View File
@@ -0,0 +1,103 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Native;
namespace WhiteMagicTest;
/// <summary>
/// Tests for relative/absolute addressing in <see cref="MemoryBase"/>.
/// GetAbsolute(relative) = ImageBase + relative.
/// GetRelative(absolute) = absolute - ImageBase (inverse of GetAbsolute).
/// </summary>
public class AddressingTests
{
private static ExternalReader OpenSelf()
{
return new ExternalReader(
Process.GetCurrentProcess(),
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
}
[Fact]
public void GetAbsolute_resolves_relative_offset()
{
using var reader = OpenSelf();
IntPtr imageBase = reader.ImageBase;
IntPtr result = reader.GetAbsolute((IntPtr)0x1000);
Assert.Equal(imageBase + 0x1000, result);
}
[Fact]
public void GetRelative_returns_absolute_minus_image_base()
{
using var reader = OpenSelf();
IntPtr imageBase = reader.ImageBase;
IntPtr absolute = imageBase + 0x2000;
IntPtr relative = reader.GetRelative(absolute);
Assert.Equal((IntPtr)((nint)absolute - (nint)imageBase), relative);
}
[Fact]
public void GetAbsolute_and_GetRelative_are_inverses()
{
using var reader = OpenSelf();
IntPtr offset = (IntPtr)0x3000;
// Round-trip: offset -> absolute -> back to offset
IntPtr absolute = reader.GetAbsolute(offset);
IntPtr back = reader.GetRelative(absolute);
Assert.Equal(offset, back);
// Reverse round-trip: absolute -> offset -> back to absolute
IntPtr relative = reader.GetRelative(absolute);
IntPtr absoluteAgain = reader.GetAbsolute(relative);
Assert.Equal(absolute, absoluteAgain);
}
[Fact]
public void GetRelative_on_ImageBase_returns_zero()
{
using var reader = OpenSelf();
IntPtr relative = reader.GetRelative(reader.ImageBase);
Assert.Equal(IntPtr.Zero, relative);
}
[Fact]
public void Read_with_isRelative_true_uses_image_base()
{
using var reader = OpenSelf();
// DOS header 'MZ' at the image base
byte firstByte = reader.Read<byte>(IntPtr.Zero, isRelative: true);
Assert.Equal(0x4D, firstByte);
}
[Fact]
public void Write_with_isRelative_true_resolves_correctly()
{
using var reader = OpenSelf();
int slot = 0;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr absolute = pin.AddrOfPinnedObject();
IntPtr relative = reader.GetRelative(absolute);
Assert.True(reader.Write(relative, 42, isRelative: true));
Assert.Equal(42, reader.Read<int>(absolute));
}
finally
{
pin.Free();
}
}
[Fact]
public void ReadBytes_with_isRelative_true_resolves_correctly()
{
using var reader = OpenSelf();
byte[] data = reader.ReadBytes(IntPtr.Zero, 2, isRelative: true);
Assert.Equal(0x4D, data[0]);
Assert.Equal(0x5A, data[1]);
}
}
+155
View File
@@ -0,0 +1,155 @@
using System.Runtime.InteropServices;
using WhiteMagic;
namespace WhiteMagicTest;
/// <summary>
/// Tests for <see cref="InProcessReader"/> — direct pointer dereference against
/// the own process. Verifies the shared <see cref="MemoryBase"/> API works for
/// both external and in-process readers.
/// </summary>
public class InProcessReaderTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void ImageBase_is_nonzero()
{
using var reader = CreateReader();
Assert.NotEqual(IntPtr.Zero, reader.ImageBase);
}
[Fact]
public void Read_int_reads_known_value_from_own_memory()
{
using var reader = CreateReader();
int expected = 0x12345678;
GCHandle pin = GCHandle.Alloc(expected, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
int result = reader.Read<int>(addr);
Assert.Equal(expected, result);
}
finally
{
pin.Free();
}
}
[Fact]
public void Write_int_writes_and_reads_back()
{
using var reader = CreateReader();
int slot = 0;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
Assert.True(reader.Write(addr, unchecked((int)0xCAFEBABE)));
Assert.Equal(unchecked((int)0xCAFEBABE), reader.Read<int>(addr));
}
finally
{
pin.Free();
}
}
[Fact]
public void Read_bytes_reads_known_bytes()
{
using var reader = CreateReader();
byte[] expected = [0x0A, 0x0B, 0x0C, 0x0D];
GCHandle pin = GCHandle.Alloc(expected, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
byte[] result = reader.ReadBytes(addr, 4);
Assert.Equal(expected, result);
}
finally
{
pin.Free();
}
}
[Fact]
public void Write_bytes_writes_and_reads_back()
{
using var reader = CreateReader();
byte[] slot = new byte[4];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
byte[] expected = [0xDE, 0xAD, 0xBE, 0xEF];
int written = reader.WriteBytes(addr, expected);
Assert.Equal(4, written);
byte[] result = reader.ReadBytes(addr, 4);
Assert.Equal(expected, result);
}
finally
{
pin.Free();
}
}
[Fact]
public void Read_struct_via_InProcessReader()
{
using var reader = CreateReader();
var slot = new TestStruct { X = 10, Y = 20 };
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
var result = reader.Read<TestStruct>(addr);
Assert.Equal(10, result.X);
Assert.Equal(20, result.Y);
}
finally
{
pin.Free();
}
}
[Fact]
public void Write_struct_via_InProcessReader()
{
using var reader = CreateReader();
var slot = new TestStruct { X = 1, Y = 2 };
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
Assert.True(reader.Write(addr, new TestStruct { X = 99, Y = 88 }));
var result = reader.Read<TestStruct>(addr);
Assert.Equal(99, result.X);
Assert.Equal(88, result.Y);
}
finally
{
pin.Free();
}
}
[Fact]
public void Dispose_disposes_handle()
{
var reader = CreateReader();
Assert.False(reader.Handle.IsClosed);
reader.Dispose();
Assert.True(reader.Handle.IsClosed);
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.Runtime.InteropServices;
using WhiteMagic;
namespace WhiteMagicTest;
/// <summary>
/// Tests for <see cref="MarshalCache{T}"/>: blittable size, marshal-required flag,
/// IsIntPtr, and computed-once behavior.
/// </summary>
public class MarshalCacheTests
{
[Fact]
public void Size_for_int_is_4()
{
Assert.Equal(4, MarshalCache<int>.Size);
}
[Fact]
public void Size_for_byte_is_1()
{
Assert.Equal(1, MarshalCache<byte>.Size);
}
[Fact]
public void Size_for_IntPtr_matches_native_pointer_size()
{
Assert.Equal(IntPtr.Size, MarshalCache<IntPtr>.Size);
}
[Fact]
public void Size_for_bool_is_1()
{
Assert.Equal(1, MarshalCache<bool>.Size);
}
[Fact]
public void Size_for_enum_matches_underlying_type()
{
Assert.Equal(4, MarshalCache<DayOfWeek>.Size);
}
[Fact]
public void Size_for_blittable_struct_is_accurate()
{
Assert.Equal(8, MarshalCache<BlittableStruct>.Size);
}
[Fact]
public void TypeRequiresMarshal_is_false_for_blittable_types()
{
Assert.False(MarshalCache<int>.TypeRequiresMarshal);
Assert.False(MarshalCache<long>.TypeRequiresMarshal);
Assert.False(MarshalCache<BlittableStruct>.TypeRequiresMarshal);
}
[Fact]
public void TypeRequiresMarshal_is_true_for_types_with_MarshalAs_field()
{
Assert.True(MarshalCache<MarshalAsStruct>.TypeRequiresMarshal);
}
[Fact]
public void IsIntPtr_is_true_for_IntPtr()
{
Assert.True(MarshalCache<IntPtr>.IsIntPtr);
}
[Fact]
public void IsIntPtr_is_false_for_non_IntPtr_types()
{
Assert.False(MarshalCache<int>.IsIntPtr);
Assert.False(MarshalCache<long>.IsIntPtr);
Assert.False(MarshalCache<BlittableStruct>.IsIntPtr);
}
[Fact]
public void All_properties_are_computed_once_and_cached()
{
int size1 = MarshalCache<int>.Size;
bool marshal1 = MarshalCache<int>.TypeRequiresMarshal;
bool intPtr1 = MarshalCache<int>.IsIntPtr;
int size2 = MarshalCache<int>.Size;
bool marshal2 = MarshalCache<int>.TypeRequiresMarshal;
bool intPtr2 = MarshalCache<int>.IsIntPtr;
Assert.Equal(size1, size2);
Assert.Equal(marshal1, marshal2);
Assert.Equal(intPtr1, intPtr2);
}
[Fact]
public void SizeU_matches_Size_as_uint()
{
Assert.Equal((uint)MarshalCache<int>.Size, MarshalCache<int>.SizeU);
}
[StructLayout(LayoutKind.Sequential)]
private struct BlittableStruct
{
public int X;
public int Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct MarshalAsStruct
{
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
public byte[] Data;
}
}
+245
View File
@@ -0,0 +1,245 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using WhiteMagic;
using WhiteMagic.Native;
namespace WhiteMagicTest;
/// <summary>
/// Tests for <see cref="MemoryBase"/> abstract contract and <see cref="ExternalReader"/>
/// round-trip (Read&lt;T&gt;/Write&lt;T&gt;, arrays) using the current process as target.
/// </summary>
public class MemoryBaseTests
{
private static ExternalReader OpenSelf()
{
return new ExternalReader(
Process.GetCurrentProcess(),
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
}
[Fact]
public void ImageBase_is_nonzero_for_self()
{
using var reader = OpenSelf();
Assert.NotEqual(IntPtr.Zero, reader.ImageBase);
}
[Fact]
public void Read_int_writes_and_reads_back()
{
using var reader = OpenSelf();
int slot = 0;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
Assert.True(reader.Write(addr, 0x1BADB002));
Assert.Equal(0x1BADB002, reader.Read<int>(addr));
}
finally
{
pin.Free();
}
}
[Fact]
public void Read_byte_writes_and_reads_back()
{
using var reader = OpenSelf();
byte slot = 0;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
Assert.True(reader.Write(addr, (byte)0xAB));
Assert.Equal(0xAB, reader.Read<byte>(addr));
}
finally
{
pin.Free();
}
}
[Fact]
public void Read_long_writes_and_reads_back()
{
using var reader = OpenSelf();
long slot = 0;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
Assert.True(reader.Write(addr, unchecked((long)0xDEADBEEF_CAFEBABE)));
Assert.Equal(unchecked((long)0xDEADBEEF_CAFEBABE), reader.Read<long>(addr));
}
finally
{
pin.Free();
}
}
[Fact]
public void Read_blittable_struct_writes_and_reads_back()
{
using var reader = OpenSelf();
var slot = new TestStruct { X = 42, Y = 99 };
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
Assert.True(reader.Write(addr, new TestStruct { X = 100, Y = 200 }));
var result = reader.Read<TestStruct>(addr);
Assert.Equal(100, result.X);
Assert.Equal(200, result.Y);
}
finally
{
pin.Free();
}
}
[Fact]
public void Read_bytes_writes_and_reads_back()
{
using var reader = OpenSelf();
byte[] buffer = new byte[16];
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
byte[] expected = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07];
int written = reader.WriteBytes(addr, expected);
Assert.Equal(expected.Length, written);
byte[] actual = reader.ReadBytes(addr, expected.Length);
Assert.Equal(expected, actual);
}
finally
{
pin.Free();
}
}
[Fact]
public void Read_int_array_writes_and_reads_back()
{
using var reader = OpenSelf();
int[] buffer = new int[4];
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
int[] expected = [10, 20, 30, 40];
Assert.True(reader.Write(addr, expected));
int[] actual = reader.Read<int>(addr, 4);
Assert.Equal(expected, actual);
}
finally
{
pin.Free();
}
}
[Fact]
public void Read_struct_array_writes_and_reads_back()
{
using var reader = OpenSelf();
var buffer = new TestStruct[4];
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
var expected = new[]
{
new TestStruct { X = 1, Y = 2 },
new TestStruct { X = 3, Y = 4 },
new TestStruct { X = 5, Y = 6 },
new TestStruct { X = 7, Y = 8 },
};
Assert.True(reader.Write(addr, expected));
var actual = reader.Read<TestStruct>(addr, 4);
Assert.Equal(expected, actual);
}
finally
{
pin.Free();
}
}
[Fact]
public void Write_returns_false_for_invalid_address()
{
using var reader = OpenSelf();
Assert.False(reader.Write(IntPtr.Zero, 42));
}
[Fact]
public void Dispose_closes_handle()
{
var reader = OpenSelf();
Assert.False(reader.Handle.IsClosed);
reader.Dispose();
Assert.True(reader.Handle.IsClosed);
}
[Fact]
public void Double_dispose_does_not_throw()
{
var reader = OpenSelf();
reader.Dispose();
reader.Dispose();
}
// ── Graceful failure on invalid addresses ───────────────────────────────
[Fact]
public void Read_int_on_invalid_address_returns_default()
{
using var reader = OpenSelf();
Assert.Equal(0, reader.Read<int>(IntPtr.Zero));
}
[Fact]
public void Read_struct_on_invalid_address_returns_default()
{
using var reader = OpenSelf();
var result = reader.Read<TestStruct>(IntPtr.Zero);
Assert.Equal(0, result.X);
Assert.Equal(0, result.Y);
}
[Fact]
public void Read_int_array_on_invalid_address_returns_empty()
{
using var reader = OpenSelf();
Assert.Empty(reader.Read<int>(IntPtr.Zero, 10));
}
[Fact]
public void Read_bytes_on_invalid_address_returns_empty()
{
using var reader = OpenSelf();
Assert.Empty(reader.ReadBytes(IntPtr.Zero, 10));
}
}
/// <summary>
/// A simple blittable struct for use in tests.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct TestStruct : IEquatable<TestStruct>
{
public int X;
public int Y;
public bool Equals(TestStruct other) => X == other.X && Y == other.Y;
public override bool Equals(object? obj) => obj is TestStruct other && Equals(other);
public override int GetHashCode() => HashCode.Combine(X, Y);
public override string ToString() => $"({X}, {Y})";
}
+112
View File
@@ -0,0 +1,112 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagicTest.Native;
/// <summary>
/// Integration tests that exercise the P/Invoke surface against the current
/// process. They prove the marshalling signatures are correct end-to-end.
/// </summary>
public class NativeSurfaceTests
{
private static SafeMemoryHandle OpenSelf(ProcessAccess access)
{
SafeMemoryHandle handle = NativeMethods.OpenProcess(access, false, Environment.ProcessId);
Assert.False(handle.IsInvalid, $"OpenProcess failed: {Marshal.GetLastPInvokeError()}");
return handle;
}
[Fact]
public void OpenProcess_on_self_returns_valid_handle_and_closes_on_dispose()
{
SafeMemoryHandle handle = OpenSelf(ProcessAccess.QueryInformation);
Assert.False(handle.IsClosed);
handle.Dispose();
Assert.True(handle.IsClosed);
}
[Fact]
public void ReadProcessMemory_reads_a_known_value_from_own_memory()
{
int value = 0x1BADB002;
GCHandle pin = GCHandle.Alloc(value, GCHandleType.Pinned);
try
{
using SafeMemoryHandle handle = OpenSelf(ProcessAccess.VmRead | ProcessAccess.QueryInformation);
Span<byte> buffer = stackalloc byte[sizeof(int)];
bool ok = NativeMethods.ReadProcessMemory(
handle, pin.AddrOfPinnedObject(), buffer, buffer.Length, out nint read);
Assert.True(ok, $"ReadProcessMemory failed: {Marshal.GetLastPInvokeError()}");
Assert.Equal(sizeof(int), (int)read);
Assert.Equal(value, BitConverter.ToInt32(buffer));
}
finally
{
pin.Free();
}
}
[Fact]
public void WriteProcessMemory_writes_a_value_into_own_memory()
{
int slot = 0;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
using SafeMemoryHandle handle = OpenSelf(
ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
ReadOnlySpan<byte> payload = BitConverter.GetBytes(0x5EED);
bool ok = NativeMethods.WriteProcessMemory(
handle, pin.AddrOfPinnedObject(), payload, payload.Length, out nint written);
Assert.True(ok, $"WriteProcessMemory failed: {Marshal.GetLastPInvokeError()}");
Assert.Equal(payload.Length, (int)written);
Assert.Equal(0x5EED, Marshal.ReadInt32(pin.AddrOfPinnedObject()));
}
finally
{
pin.Free();
}
}
[Fact]
public void VirtualAllocEx_commits_then_protects_then_frees()
{
using SafeMemoryHandle handle = OpenSelf(ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
IntPtr region = NativeMethods.VirtualAllocEx(
handle, IntPtr.Zero, 0x1000,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ReadWrite);
Assert.NotEqual(IntPtr.Zero, region);
bool protect = NativeMethods.VirtualProtectEx(
handle, region, 0x1000, MemoryProtectionType.ExecuteReadWrite, out MemoryProtectionType old);
Assert.True(protect, $"VirtualProtectEx failed: {Marshal.GetLastPInvokeError()}");
Assert.Equal(MemoryProtectionType.ReadWrite, old);
bool free = NativeMethods.VirtualFreeEx(handle, region, 0, MemoryFreeType.Release);
Assert.True(free, $"VirtualFreeEx failed: {Marshal.GetLastPInvokeError()}");
}
[Fact]
public void LoadLibrary_then_GetProcAddress_resolves_an_export()
{
IntPtr module = NativeMethods.LoadLibrary("kernel32.dll");
Assert.NotEqual(IntPtr.Zero, module);
IntPtr proc = NativeMethods.GetProcAddress(module, "CloseHandle");
Assert.NotEqual(IntPtr.Zero, proc);
}
[Theory]
[InlineData(typeof(Context32), 716)]
[InlineData(typeof(Context64), 1232)]
public void Thread_context_struct_has_the_exact_native_size(Type contextType, int expectedSize)
{
Assert.Equal(expectedSize, Marshal.SizeOf(contextType));
}
}
+183
View File
@@ -0,0 +1,183 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using WhiteMagic;
using WhiteMagic.Native;
namespace WhiteMagicTest;
/// <summary>
/// Tests for <see cref="MemoryBase.ReadString"/> and <see cref="MemoryBase.WriteString"/>
/// with encoding, null-terminator stop, and max-length behavior.
/// </summary>
public class StringReadWriteTests
{
private static ExternalReader OpenSelf()
{
return new ExternalReader(
Process.GetCurrentProcess(),
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
}
[Fact]
public void WriteString_ascii_then_ReadString_round_trips()
{
using var reader = OpenSelf();
byte[] slot = new byte[64];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
Assert.True(reader.WriteString(addr, "hello", Encoding.ASCII));
string result = reader.ReadString(addr, Encoding.ASCII);
Assert.Equal("hello", result);
}
finally
{
pin.Free();
}
}
[Fact]
public void WriteString_utf8_then_ReadString_round_trips()
{
using var reader = OpenSelf();
byte[] slot = new byte[64];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
Assert.True(reader.WriteString(addr, "héllo wörld", Encoding.UTF8));
string result = reader.ReadString(addr, Encoding.UTF8);
Assert.Equal("héllo wörld", result);
}
finally
{
pin.Free();
}
}
[Fact]
public void WriteString_unicode_then_ReadString_round_trips()
{
using var reader = OpenSelf();
byte[] slot = new byte[128];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
Assert.True(reader.WriteString(addr, "Hello\u00A9\u00AE\u20AC", Encoding.Unicode));
string result = reader.ReadString(addr, Encoding.Unicode);
Assert.Equal("Hello\u00A9\u00AE\u20AC", result);
}
finally
{
pin.Free();
}
}
[Fact]
public void ReadString_stops_at_null_terminator()
{
using var reader = OpenSelf();
byte[] slot = Encoding.ASCII.GetBytes("hello\0world");
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
string result = reader.ReadString(addr, Encoding.ASCII, maxLength: 64);
Assert.Equal("hello", result);
}
finally
{
pin.Free();
}
}
[Fact]
public void ReadString_respects_max_length()
{
using var reader = OpenSelf();
byte[] slot = Encoding.ASCII.GetBytes("hello world this is a test");
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
string result = reader.ReadString(addr, Encoding.ASCII, maxLength: 5);
Assert.Equal("hello", result);
}
finally
{
pin.Free();
}
}
[Fact]
public void WriteString_appends_null_terminator_automatically()
{
using var reader = OpenSelf();
byte[] slot = new byte[32];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
// Write without terminator
Assert.True(reader.WriteString(addr, "test", Encoding.ASCII));
// The written bytes should end with \0
byte[] read = reader.ReadBytes(addr, 8);
Assert.Equal((byte)'t', read[0]);
Assert.Equal((byte)'e', read[1]);
Assert.Equal((byte)'s', read[2]);
Assert.Equal((byte)'t', read[3]);
Assert.Equal(0, read[4]); // null terminator
}
finally
{
pin.Free();
}
}
[Fact]
public void ReadString_empty_buffer_returns_empty_string()
{
using var reader = OpenSelf();
byte[] slot = new byte[1] { 0 };
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
string result = reader.ReadString(addr, Encoding.ASCII, maxLength: 1);
Assert.Equal("", result);
}
finally
{
pin.Free();
}
}
[Fact]
public void WriteString_empty_string_writes_only_null()
{
using var reader = OpenSelf();
byte[] slot = new byte[8];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
// Write a marker first
reader.WriteBytes(addr, [0xAB, 0xCD, 0xEF, 0x00]);
// Now overwrite with empty string
Assert.True(reader.WriteString(addr, "", Encoding.ASCII));
byte[] read = reader.ReadBytes(addr, 4);
Assert.Equal(0, read[0]); // null
}
finally
{
pin.Free();
}
}
}
@@ -16,7 +16,7 @@ WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuse
**Goals:**
- Single modern (.NET 8, nullable, `Span<byte>`, `SafeHandle`) library that is bitness-agnostic (x86 + x64).
- A **three-tier execution model** whose default path for game-state calls is crash-safe (runs on the target's own thread), while `CreateRemoteThread` remains available for thread-agnostic payloads.
- Dual memory access: out-of-process (RPM/WPM) and in-process (direct deref) behind one abstract `MemoryBase`, with `MarshalCache<T>` for allocation-free typed IO.
- Dual memory access: out-of-process (RPM/WPM) and in-process (RPM-on-self-handle, see D1 revision) behind one abstract `MemoryBase`, with `MarshalCache<T>` for allocation-free typed IO.
- Reversible function hooking (`DetourManager`) and byte patching (`PatchManager`) with auto-restore on dispose.
- Replace FASM with an `IAssembler` seam: hand-emitted convention stubs by default, optional Iced backend for arbitrary assembly. Zero native dependency in the default configuration.
- Port DLL injection (CreateThread + thread-hijack, x86/x64) and pattern scanning + cache from current BlackMagic.
@@ -36,9 +36,9 @@ WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuse
`MemoryBase` defines abstract `ReadBytes`/`WriteBytes`/`Read<T>`/`Write<T>`, relative/absolute addressing, and hosts the `PatchManager`. Two concrete readers:
- `ExternalReader : MemoryBase``ReadProcessMemory`/`WriteProcessMemory` over a `SafeMemoryHandle`. Owns allocation, injection, and the remote-thread + main-thread executors.
- `InProcessReader : MemoryBase``unsafe` direct pointer deref; owns the `DetourManager` and `InProcessInvoker`.
- `InProcessReader : MemoryBase`reads the current process via `ReadProcessMemory`/`WriteProcessMemory` on a self-handle; owns the `DetourManager` and `InProcessInvoker`. **(Revised from `unsafe` direct deref during Phase 2: .NET cannot catch `AccessViolationException`, so a bad deref kills the host with no soft-failure path; RPM-on-self fails soft. The in-process speed win moves to the delegate-call/detour paths, not the reader. See `specs/memory-access`.)**
**Why**: GreyMagic proved this abstraction lets the same higher-level code (pattern scan, patch, high-level API) run in either mode. External is the primary path for a bot host; in-process is the fast/crash-free path once injected.
**Why**: GreyMagic proved this abstraction lets the same higher-level code (pattern scan, patch, high-level API) run in either mode. External is the primary path for a bot host; in-process becomes valuable once injected — not for faster reads (both readers use RPM/WPM, see the D1 revision) but for the delegate-call and detour paths it unlocks (`InProcessInvoker`, `DetourManager`).
**Alternatives considered**: single external-only class (current BlackMagic) — rejected: forecloses the in-process delegate path, which is the cleanest crash-free execution. MemorySharp's factory-per-concern model (`Assembly`, `Threads`, `Windows` factories) — adopted selectively for the high-level surface, but the read/write core stays on `MemoryBase` for GreyMagic-style polymorphism.
@@ -2,7 +2,13 @@
### Requirement: Abstract memory base with two readers
WhiteMagic SHALL expose an abstract `MemoryBase` type defining `ReadBytes`, `WriteBytes`, generic `Read<T>`/`Write<T>`, array read/write, and string read/write, with two concrete implementations: `ExternalReader` (out-of-process via ReadProcessMemory/WriteProcessMemory) and `InProcessReader` (in-process via direct pointer dereference).
WhiteMagic SHALL expose an abstract `MemoryBase` type defining `ReadBytes`, `WriteBytes`, generic `Read<T>`/`Write<T>`, array read/write, and string read/write, with two concrete implementations: `ExternalReader` (out-of-process via ReadProcessMemory/WriteProcessMemory) and `InProcessReader` (in-process, reading the current process through ReadProcessMemory/WriteProcessMemory on a self-handle).
> **Deviation from design D1.** D1 originally specified `InProcessReader` as `unsafe` direct pointer dereference (the "fast/crash-free" path). Implementation revised it to `ReadProcessMemory`/`WriteProcessMemory` on a handle to the current process, because .NET (Core) cannot catch `AccessViolationException` (`HandleProcessCorruptedStateExceptions` is removed), so a raw deref of a bad address terminates the host process with no soft-failure path. RPM on a self-handle fails soft (returns empty) like `ExternalReader`. The in-process performance win therefore moves to the delegate-call and detour paths (`InProcessInvoker`, `DetourManager`), not the reader.
#### Scenario: in-process read fails soft on an invalid address
- **WHEN** an `InProcessReader` reads an unmapped or protected address
- **THEN** it MUST return empty/`default` rather than crash the host process
#### Scenario: external read round-trip
- **WHEN** an `ExternalReader` opens a target process and writes a value with `Write<int>(addr, 0x1234)` then reads it back with `Read<int>(addr)`
+11 -10
View File
@@ -3,20 +3,21 @@
- [x] 1.1 Create `WhiteMagic/WhiteMagic.csproj` targeting `net8.0-windows`, `AllowUnsafeBlocks=true`, nullable enabled, `TreatWarningsAsErrors`, `Platforms=x86;x64;AnyCPU`
- [x] 1.2 Create `WhiteMagicTest/WhiteMagicTest.csproj` (xUnit, `net8.0-windows`) referencing `WhiteMagic`
- [x] 1.3 Create `WhiteMagic.slnx` (SDK 10 default solution format) and add both projects. (Built on SDK 10; `net8.0-windows` targeting pack auto-restored.)
- [ ] 1.4 Add `WhiteMagic/Native/` P/Invoke surface (`LibraryImport`): OpenProcess, Read/WriteProcessMemory, VirtualAllocEx/FreeEx/ProtectEx, CreateRemoteThread, Wow64Get/SetThreadContext, Get/SetThreadContext, LoadLibrary, GetProcAddress; add `SafeMemoryHandle`
- [x] 1.4 Add `WhiteMagic/Native/` P/Invoke surface (`LibraryImport`): OpenProcess, Read/WriteProcessMemory, VirtualAllocEx/FreeEx/ProtectEx, CreateRemoteThread, Wow64Get/SetThreadContext, Get/SetThreadContext, LoadLibrary, GetProcAddress; add `SafeMemoryHandle`
- [x] 1.5 Verify empty projects build: `dotnet build WhiteMagic.slnx` — zero errors, zero warnings
## 2. Core Memory Access (spec: memory-access)
- [ ] 2.1 Add tests for `MarshalCache<T>`: blittable size, marshal-required flag, IsIntPtr, computed-once behavior
- [ ] 2.2 Implement `WhiteMagic/MarshalCache.cs` to pass 2.1
- [ ] 2.3 Add tests for `MemoryBase` abstract contract + `ExternalReader` round-trip (`Read<T>`/`Write<T>`, arrays) using the current process as target
- [ ] 2.4 Implement `WhiteMagic/MemoryBase.cs` (abstract) and `WhiteMagic/ExternalReader.cs` to pass 2.3
- [ ] 2.5 Add tests for string read/write with encoding, null-terminator stop, and max length
- [ ] 2.6 Implement `ReadString`/`WriteString` on `MemoryBase` to pass 2.5
- [ ] 2.7 Add tests for relative/absolute addressing (`GetAbsolute`/`GetRelative`, `isRelative` flag)
- [ ] 2.8 Implement addressing helpers to pass 2.7
- [ ] 2.9 Add tests + `unsafe` implementation for `InProcessReader` (direct deref against own process); verify shared `MemoryBase` API works for both readers
- [x] 2.1 Add tests for `MarshalCache<T>`: blittable size, marshal-required flag, IsIntPtr, computed-once behavior
- [x] 2.2 Implement `WhiteMagic/MarshalCache.cs` to pass 2.1
- [x] 2.3 Add tests for `MemoryBase` abstract contract + `ExternalReader` round-trip (`Read<T>`/`Write<T>`, arrays) using the current process as target
- [x] 2.4 Implement `WhiteMagic/MemoryBase.cs` (abstract) and `WhiteMagic/ExternalReader.cs` to pass 2.3
- [x] 2.5 Add tests for string read/write with encoding, null-terminator stop, and max length
- [x] 2.6 Implement `ReadString`/`WriteString` on `MemoryBase` to pass 2.5
- [x] 2.7 Add tests for relative/absolute addressing (`GetAbsolute`/`GetRelative`, `isRelative` flag)
- [x] 2.8 Implement addressing helpers to pass 2.7
- [x] 2.9 Add tests + implementation for `InProcessReader` (RPM/WPM on a self-handle — see D1 deviation note; direct deref rejected because .NET cannot catch `AccessViolationException`); verify shared `MemoryBase` API works for both readers
- [ ] 2.10 Follow-up (found in review): `ReadString` scans for the null terminator byte-by-byte, so for UTF-16/UTF-32 it can match a **misaligned** multi-byte null across a char boundary (e.g. `"A"`+U+4200 = `41 00 00 42` matches `{00,00}` at offset 1) and can miss a terminator split across the 64-byte chunk boundary. Harmless for ASCII/UTF-8 (the WoW case). Fix: align the scan to the encoding's code-unit width and carry the last `(nullLen-1)` bytes across chunks. Add a UTF-16 test.
## 3. Managed Assembler (spec: managed-assembler)