diff --git a/.gitignore b/.gitignore
index 45355ea..88208ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ reference/
# Scratch
*.tmp
+*.log
diff --git a/WhiteMagic/ExternalReader.cs b/WhiteMagic/ExternalReader.cs
new file mode 100644
index 0000000..b0d6826
--- /dev/null
+++ b/WhiteMagic/ExternalReader.cs
@@ -0,0 +1,86 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic;
+
+///
+/// Out-of-process memory reader that accesses the target's memory through
+/// and
+/// .
+///
+public sealed class ExternalReader : MemoryBase
+{
+ private readonly SafeMemoryHandle _handle;
+ private readonly IntPtr _imageBase;
+ private bool _disposed;
+
+ ///
+ /// Opens a process for external memory access.
+ ///
+ /// The target process.
+ /// The access rights to request. Defaults to
+ /// .
+ 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;
+ }
+
+ ///
+ public override IntPtr ImageBase => _imageBase;
+
+ ///
+ public override SafeMemoryHandle Handle => _handle;
+
+ ///
+ 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;
+ }
+
+ ///
+ public override int WriteBytes(IntPtr address, ReadOnlySpan 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;
+ }
+
+ ///
+ public override void Dispose()
+ {
+ if (!_disposed)
+ {
+ _disposed = true;
+ _handle.Dispose();
+ }
+ }
+}
diff --git a/WhiteMagic/InProcessReader.cs b/WhiteMagic/InProcessReader.cs
new file mode 100644
index 0000000..2e2f947
--- /dev/null
+++ b/WhiteMagic/InProcessReader.cs
@@ -0,0 +1,90 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic;
+
+///
+/// In-process memory reader that accesses the owning process's memory through
+/// and
+/// 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 .
+///
+public sealed class InProcessReader : MemoryBase
+{
+ private readonly SafeMemoryHandle _handle;
+ private readonly IntPtr _imageBase;
+ private bool _disposed;
+
+ ///
+ /// Creates an in-process reader for the current process.
+ ///
+ 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;
+ }
+
+ ///
+ public override IntPtr ImageBase => _imageBase;
+
+ ///
+ public override SafeMemoryHandle Handle => _handle;
+
+ ///
+ 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;
+ }
+
+ ///
+ public override int WriteBytes(IntPtr address, ReadOnlySpan 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;
+ }
+
+ ///
+ public override void Dispose()
+ {
+ if (!_disposed)
+ {
+ _disposed = true;
+ _handle.Dispose();
+ }
+ }
+}
diff --git a/WhiteMagic/MarshalCache.cs b/WhiteMagic/MarshalCache.cs
new file mode 100644
index 0000000..0b34953
--- /dev/null
+++ b/WhiteMagic/MarshalCache.cs
@@ -0,0 +1,69 @@
+using System.Reflection;
+using System.Runtime.InteropServices;
+
+namespace WhiteMagic;
+
+///
+/// Computes and caches marshal-related metadata for type
+/// exactly once. and
+/// branch on these cached flags to decide between blittable Span/MemoryMarshal
+/// paths and the fallback marshal path.
+///
+/// The type to cache metadata for.
+public static class MarshalCache
+{
+ /// The unmanaged size of in bytes.
+ public static readonly int Size;
+
+ /// The unmanaged size of as an unsigned integer.
+ public static readonly uint SizeU;
+
+ ///
+ /// when has at least one field
+ /// decorated with , meaning it cannot be copied
+ /// via a simple pointer dereference.
+ ///
+ public static readonly bool TypeRequiresMarshal;
+
+ /// when is .
+ public static readonly bool IsIntPtr;
+
+ /// The underlying type code of .
+ public static readonly TypeCode TypeCode;
+
+ ///
+ /// The effective type that the marshaler uses. For an enum this is the underlying
+ /// integer type; for all other types it is itself.
+ ///
+ 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);
+ }
+}
diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs
new file mode 100644
index 0000000..88f59d6
--- /dev/null
+++ b/WhiteMagic/MemoryBase.cs
@@ -0,0 +1,292 @@
+using WhiteMagic.Native;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace WhiteMagic;
+
+///
+/// Abstract base for all memory-access readers and writers. Provides typed
+/// /, array IO, string IO, and
+/// relative/absolute addressing. Subclasses implement the concrete
+/// and methods.
+///
+public abstract class MemoryBase : IDisposable
+{
+ /// The base address of the target process's main module.
+ public abstract IntPtr ImageBase { get; }
+
+ /// The native handle to the target process.
+ public abstract SafeMemoryHandle Handle { get; }
+
+ // ── Raw byte IO ────────────────────────────────────────────────────────
+
+ /// Reads a sequence of bytes from the target address.
+ public abstract byte[] ReadBytes(IntPtr address, int count, bool isRelative = false);
+
+ /// Writes a sequence of bytes to the target address.
+ /// The number of bytes written.
+ public abstract int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false);
+
+ // ── Typed IO ───────────────────────────────────────────────────────────
+
+ /// Reads a value of type from the target address.
+ /// The value, or default(T) when the read fails or returns fewer bytes than
+ /// .
+ public T Read(IntPtr address, bool isRelative = false) where T : struct
+ {
+ if (isRelative)
+ address = GetAbsolute(address);
+
+ int size = MarshalCache.Size;
+ byte[] raw = ReadBytes(address, size);
+
+ if (raw.Length < size)
+ return default;
+
+ if (MarshalCache.TypeRequiresMarshal)
+ return MarshalByteArrayToStructure(raw);
+
+ return MemoryMarshal.Read(raw.AsSpan());
+ }
+
+ /// Writes a value of type to the target address.
+ /// if all bytes were written.
+ public bool Write(IntPtr address, T value, bool isRelative = false) where T : struct
+ {
+ if (isRelative)
+ address = GetAbsolute(address);
+
+ int size = MarshalCache.Size;
+
+ byte[] raw;
+ if (MarshalCache.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;
+ }
+
+ /// Reads an array of values of type from the target address.
+ /// An array of at most elements. May be shorter when the read
+ /// returns fewer bytes than expected.
+ public T[] Read(IntPtr address, int count, bool isRelative = false) where T : struct
+ {
+ if (isRelative)
+ address = GetAbsolute(address);
+
+ int elementSize = MarshalCache.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.TypeRequiresMarshal)
+ {
+ GCHandle pin = GCHandle.Alloc(raw, GCHandleType.Pinned);
+ try
+ {
+ IntPtr basePtr = pin.AddrOfPinnedObject();
+ for (int i = 0; i < actualCount; i++)
+ result[i] = Marshal.PtrToStructure(basePtr + (i * elementSize));
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+ else
+ {
+ ReadOnlySpan span = raw;
+ for (int i = 0; i < actualCount; i++)
+ result[i] = MemoryMarshal.Read(span.Slice(i * elementSize, elementSize));
+ }
+
+ return result;
+ }
+
+ /// Writes an array of values of type to the target address.
+ /// if all bytes were written.
+ public bool Write(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.Size;
+ int totalSize = elementSize * values.Length;
+ byte[] raw = new byte[totalSize];
+
+ Span span = raw;
+ for (int i = 0; i < values.Length; i++)
+ {
+ Span slice = span.Slice(i * elementSize, elementSize);
+ if (MarshalCache.TypeRequiresMarshal)
+ StructureToByteArray(values[i], slice, elementSize);
+ else
+ MemoryMarshal.Write(slice, in values[i]);
+ }
+
+ int written = WriteBytes(address, raw, false);
+ return written == totalSize;
+ }
+
+ // ── String IO ──────────────────────────────────────────────────────────
+
+ /// 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).
+ /// The address to read from.
+ /// The text encoding.
+ /// The maximum number of bytes to read.
+ /// If , is relative
+ /// to .
+ 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();
+
+ 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);
+ }
+
+ /// Writes a null-terminated string to the target address.
+ 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 ─────────────────────────────────────────────────────────
+
+ /// Converts a relative offset to an absolute address relative to .
+ public IntPtr GetAbsolute(IntPtr relative)
+ {
+ return ImageBase + (nint)relative;
+ }
+
+ /// Converts an absolute address to a relative offset from .
+ /// This is the inverse of : GetAbsolute(GetRelative(a)) == a.
+ public IntPtr GetRelative(IntPtr absolute)
+ {
+ return (IntPtr)((nint)absolute - (nint)ImageBase);
+ }
+
+ // ── Lifecycle ──────────────────────────────────────────────────────────
+
+ ///
+ public virtual void Dispose()
+ {
+ Handle?.Dispose();
+ }
+
+ // ── Private helpers ────────────────────────────────────────────────────
+
+ private static T MarshalByteArrayToStructure(byte[] bytes) where T : struct
+ {
+ GCHandle pin = GCHandle.Alloc(bytes, GCHandleType.Pinned);
+ try
+ {
+ return Marshal.PtrToStructure(pin.AddrOfPinnedObject());
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ private static byte[] StructureToByteArray(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 value, Span 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;
+ }
+}
diff --git a/WhiteMagic/Native/NativeEnums.cs b/WhiteMagic/Native/NativeEnums.cs
new file mode 100644
index 0000000..47f15fd
--- /dev/null
+++ b/WhiteMagic/Native/NativeEnums.cs
@@ -0,0 +1,136 @@
+namespace WhiteMagic.Native;
+
+///
+/// Access rights that open a process object.
+///
+[Flags]
+public enum ProcessAccess : uint
+{
+ /// The right to terminate the process with TerminateProcess.
+ Terminate = 0x0001,
+ /// The right to create a thread in the process.
+ CreateThread = 0x0002,
+ /// The right to operate on the address space of the process.
+ VmOperation = 0x0008,
+ /// The right to read memory with ReadProcessMemory.
+ VmRead = 0x0010,
+ /// The right to write memory with WriteProcessMemory.
+ VmWrite = 0x0020,
+ /// The right to duplicate a handle with DuplicateHandle.
+ DupHandle = 0x0040,
+ /// The right to set information about the process.
+ SetInformation = 0x0200,
+ /// The right to read information about the process, such as the exit code.
+ QueryInformation = 0x0400,
+ /// The right to suspend or resume the process.
+ SuspendResume = 0x0800,
+ /// The right to read a limited set of information about the process.
+ QueryLimitedInformation = 0x1000,
+ /// The right to use the process object for synchronization.
+ Synchronize = 0x00100000,
+
+ /// All access rights for a process object.
+ AllAccess = 0x001F0000 | Synchronize | 0xFFFF,
+}
+
+///
+/// Values that control how VirtualAllocEx allocates memory.
+///
+[Flags]
+public enum MemoryAllocationType : uint
+{
+ /// Commit physical storage for the reserved pages. The pages start as zero.
+ Commit = 0x00001000,
+ /// Reserve a range of address space without physical storage.
+ Reserve = 0x00002000,
+ /// Reset the data in the range to indicate that it is no longer of interest.
+ Reset = 0x00080000,
+ /// Allocate memory at the highest possible address.
+ TopDown = 0x00100000,
+}
+
+///
+/// Values that protect a block of memory.
+///
+[Flags]
+public enum MemoryProtectionType : uint
+{
+ /// No access to the committed pages.
+ NoAccess = 0x01,
+ /// Read access to the committed pages.
+ ReadOnly = 0x02,
+ /// Read and write access to the committed pages.
+ ReadWrite = 0x04,
+ /// Copy-on-write access to the committed pages.
+ WriteCopy = 0x08,
+ /// Execute access to the committed pages.
+ Execute = 0x10,
+ /// Execute and read access to the committed pages.
+ ExecuteRead = 0x20,
+ /// Execute, read, and write access to the committed pages.
+ ExecuteReadWrite = 0x40,
+ /// Execute and copy-on-write access to the committed pages.
+ ExecuteWriteCopy = 0x80,
+ /// The pages in the range become guard pages.
+ Guard = 0x100,
+ /// The system does not cache the committed pages.
+ NoCache = 0x200,
+ /// The system uses write-combined access for the pages.
+ WriteCombine = 0x400,
+}
+
+///
+/// Values that control how VirtualFreeEx frees memory.
+///
+[Flags]
+public enum MemoryFreeType : uint
+{
+ /// Decommit the committed pages. The address range stays reserved.
+ Decommit = 0x4000,
+ /// Release the range of pages. The size must be zero.
+ Release = 0x8000,
+}
+
+///
+/// Values that set the initial state of a new thread.
+///
+[Flags]
+public enum ThreadCreationFlags : uint
+{
+ /// The thread runs immediately after creation.
+ RunImmediately = 0,
+ /// The thread starts in a suspended state. Call ResumeThread to start it.
+ CreateSuspended = 0x00000004,
+ /// The stack-size parameter sets the reserve size of the stack.
+ StackSizeParamIsAReservation = 0x00010000,
+}
+
+///
+/// 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.
+///
+public static class ContextFlags
+{
+ /// Architecture identifier for x86 contexts.
+ public const uint X86 = 0x00010000;
+ /// Architecture identifier for AMD64 contexts.
+ public const uint Amd64 = 0x00100000;
+
+ /// x86: SS:SP, CS:IP, FLAGS, and BP.
+ public const uint X86Control = X86 | 0x01;
+ /// x86: AX, BX, CX, DX, SI, and DI.
+ public const uint X86Integer = X86 | 0x02;
+ /// x86: DS, ES, FS, and GS.
+ public const uint X86Segments = X86 | 0x04;
+ /// x86: control, integer, and segment registers.
+ public const uint X86Full = X86Control | X86Integer | X86Segments;
+
+ /// AMD64: SegSs, Rsp, SegCs, Rip, and EFlags.
+ public const uint Amd64Control = Amd64 | 0x01;
+ /// AMD64: Rax, Rcx, Rdx, Rbx, Rbp, Rsi, Rdi, and R8 to R15.
+ public const uint Amd64Integer = Amd64 | 0x02;
+ /// AMD64: SegDs, SegEs, SegFs, and SegGs.
+ public const uint Amd64Segments = Amd64 | 0x04;
+ /// AMD64: control, integer, and segment registers.
+ public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments;
+}
diff --git a/WhiteMagic/Native/NativeMethods.cs b/WhiteMagic/Native/NativeMethods.cs
new file mode 100644
index 0000000..bdc07e2
--- /dev/null
+++ b/WhiteMagic/Native/NativeMethods.cs
@@ -0,0 +1,136 @@
+using System.Runtime.InteropServices;
+
+namespace WhiteMagic.Native;
+
+///
+/// P/Invoke declarations for the Win32 process, memory, thread, and module
+/// APIs that WhiteMagic uses. Every declaration uses
+/// (source-generated interop). SetLastError is enabled on all calls that the
+/// Win32 API documents as setting a thread-local last-error value.
+///
+internal static partial class NativeMethods
+{
+ // ── Process ──────────────────────────────────────────────────────────────
+
+ /// Opens an existing process and returns a handle to it.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial SafeMemoryHandle OpenProcess(
+ ProcessAccess desiredAccess,
+ [MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
+ int processId);
+
+ /// Closes an open object handle.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool CloseHandle(IntPtr handle);
+
+ // ── Memory ───────────────────────────────────────────────────────────────
+
+ /// Reads memory from a process.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool ReadProcessMemory(
+ SafeMemoryHandle process,
+ IntPtr baseAddress,
+ Span buffer,
+ int size,
+ out nint bytesRead);
+
+ /// Writes memory to a process.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool WriteProcessMemory(
+ SafeMemoryHandle process,
+ IntPtr baseAddress,
+ ReadOnlySpan buffer,
+ int size,
+ out nint bytesWritten);
+
+ /// Reserves or commits a region of memory in a process.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial IntPtr VirtualAllocEx(
+ SafeMemoryHandle process,
+ IntPtr address,
+ nint size,
+ MemoryAllocationType allocationType,
+ MemoryProtectionType protect);
+
+ /// Changes the protection on a committed region of memory.
+ [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);
+
+ /// Releases or decommits a region of memory in a process.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool VirtualFreeEx(
+ SafeMemoryHandle process,
+ IntPtr address,
+ nint size,
+ MemoryFreeType freeType);
+
+ // ── Threading ────────────────────────────────────────────────────────────
+
+ /// Creates a thread that runs in the virtual address space of a process.
+ [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);
+
+ /// Sets a 64-bit thread context (AMD64).
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool SetThreadContext(
+ SafeMemoryHandle thread,
+ ref Context64 context);
+
+ /// Gets a 64-bit thread context (AMD64).
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool GetThreadContext(
+ SafeMemoryHandle thread,
+ ref Context64 context);
+
+ /// Sets a 32-bit (WOW64) thread context.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool Wow64SetThreadContext(
+ SafeMemoryHandle thread,
+ ref Context32 context);
+
+ /// Gets a 32-bit (WOW64) thread context.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool Wow64GetThreadContext(
+ SafeMemoryHandle thread,
+ ref Context32 context);
+
+ // ── Modules ──────────────────────────────────────────────────────────────
+
+ /// Loads a module into the calling process.
+ [LibraryImport("kernel32.dll", SetLastError = true, EntryPoint = "LoadLibraryW")]
+ internal static partial IntPtr LoadLibrary(
+ [MarshalAs(UnmanagedType.LPWStr)] string lpFileName);
+
+ /// Returns the address of a function or variable from a loaded module.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial IntPtr GetProcAddress(
+ IntPtr hModule,
+ [MarshalAs(UnmanagedType.LPStr)] string lpProcName);
+
+ /// Waits until a thread exits and retrieves its exit code.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial int WaitForSingleObject(
+ SafeMemoryHandle handle,
+ uint milliseconds);
+}
diff --git a/WhiteMagic/Native/NativeStructures.cs b/WhiteMagic/Native/NativeStructures.cs
new file mode 100644
index 0000000..5fef352
--- /dev/null
+++ b/WhiteMagic/Native/NativeStructures.cs
@@ -0,0 +1,207 @@
+using System.Runtime.InteropServices;
+
+namespace WhiteMagic.Native;
+
+///
+/// The x87 and MMX state inside a 32-bit thread context.
+///
+[StructLayout(LayoutKind.Sequential)]
+public unsafe struct FloatingSaveArea32
+{
+ /// The x87 FPU control word.
+ public uint ControlWord;
+ /// The x87 FPU status word.
+ public uint StatusWord;
+ /// The x87 FPU tag word.
+ public uint TagWord;
+ /// The offset of the instruction that caused the last FPU exception.
+ public uint ErrorOffset;
+ /// The selector of the instruction that caused the last FPU exception.
+ public uint ErrorSelector;
+ /// The offset of the operand that caused the last FPU exception.
+ public uint DataOffset;
+ /// The selector of the operand that caused the last FPU exception.
+ public uint DataSelector;
+ /// The 80-byte register area.
+ public fixed byte RegisterArea[80];
+ /// The CR0 numeric-processor-extension state.
+ public uint Cr0NpxState;
+}
+
+///
+/// A 32-bit (x86/WOW64) thread context. Use it with
+/// Wow64GetThreadContext and Wow64SetThreadContext to inspect a 32-bit thread.
+/// The total size is 716 bytes.
+///
+[StructLayout(LayoutKind.Sequential)]
+public unsafe struct Context32
+{
+ /// Selects which parts of the context are valid. See .
+ public uint ContextFlags;
+
+ /// Debug register 0.
+ public uint Dr0;
+ /// Debug register 1.
+ public uint Dr1;
+ /// Debug register 2.
+ public uint Dr2;
+ /// Debug register 3.
+ public uint Dr3;
+ /// Debug register 6.
+ public uint Dr6;
+ /// Debug register 7.
+ public uint Dr7;
+
+ /// The floating-point state.
+ public FloatingSaveArea32 FloatSave;
+
+ /// The GS segment.
+ public uint SegGs;
+ /// The FS segment.
+ public uint SegFs;
+ /// The ES segment.
+ public uint SegEs;
+ /// The DS segment.
+ public uint SegDs;
+
+ /// The EDI register.
+ public uint Edi;
+ /// The ESI register.
+ public uint Esi;
+ /// The EBX register.
+ public uint Ebx;
+ /// The EDX register.
+ public uint Edx;
+ /// The ECX register.
+ public uint Ecx;
+ /// The EAX register.
+ public uint Eax;
+
+ /// The base (frame) pointer.
+ public uint Ebp;
+ /// The instruction pointer.
+ public uint Eip;
+ /// The CS segment.
+ public uint SegCs;
+ /// The flags register.
+ public uint EFlags;
+ /// The stack pointer.
+ public uint Esp;
+ /// The SS segment.
+ public uint SegSs;
+
+ /// The extended (processor-specific) registers. The size is 512 bytes.
+ public fixed byte ExtendedRegisters[512];
+}
+
+///
+/// A 64-bit (AMD64) thread context. Use it with the native
+/// GetThreadContext and SetThreadContext from a 64-bit process.
+/// The structure needs 16-byte alignment. The total size is 1232 bytes.
+///
+[StructLayout(LayoutKind.Sequential, Pack = 16)]
+public unsafe struct Context64
+{
+ /// Home storage for a register parameter.
+ public ulong P1Home;
+ /// Home storage for a register parameter.
+ public ulong P2Home;
+ /// Home storage for a register parameter.
+ public ulong P3Home;
+ /// Home storage for a register parameter.
+ public ulong P4Home;
+ /// Home storage for a register parameter.
+ public ulong P5Home;
+ /// Home storage for a register parameter.
+ public ulong P6Home;
+
+ /// Selects which parts of the context are valid. See .
+ public uint ContextFlags;
+ /// The MXCSR register.
+ public uint MxCsr;
+
+ /// The CS segment.
+ public ushort SegCs;
+ /// The DS segment.
+ public ushort SegDs;
+ /// The ES segment.
+ public ushort SegEs;
+ /// The FS segment.
+ public ushort SegFs;
+ /// The GS segment.
+ public ushort SegGs;
+ /// The SS segment.
+ public ushort SegSs;
+
+ /// The flags register.
+ public uint EFlags;
+
+ /// Debug register 0.
+ public ulong Dr0;
+ /// Debug register 1.
+ public ulong Dr1;
+ /// Debug register 2.
+ public ulong Dr2;
+ /// Debug register 3.
+ public ulong Dr3;
+ /// Debug register 6.
+ public ulong Dr6;
+ /// Debug register 7.
+ public ulong Dr7;
+
+ /// The RAX register.
+ public ulong Rax;
+ /// The RCX register.
+ public ulong Rcx;
+ /// The RDX register.
+ public ulong Rdx;
+ /// The RBX register.
+ public ulong Rbx;
+ /// The stack pointer.
+ public ulong Rsp;
+ /// The base (frame) pointer.
+ public ulong Rbp;
+ /// The RSI register.
+ public ulong Rsi;
+ /// The RDI register.
+ public ulong Rdi;
+ /// The R8 register.
+ public ulong R8;
+ /// The R9 register.
+ public ulong R9;
+ /// The R10 register.
+ public ulong R10;
+ /// The R11 register.
+ public ulong R11;
+ /// The R12 register.
+ public ulong R12;
+ /// The R13 register.
+ public ulong R13;
+ /// The R14 register.
+ public ulong R14;
+ /// The R15 register.
+ public ulong R15;
+
+ /// The instruction pointer.
+ public ulong Rip;
+
+ /// The XMM save area. The size is 512 bytes.
+ public fixed byte FltSave[512];
+
+ /// The vector registers (26 entries of 16 bytes, stored as 52 entries of 8 bytes).
+ public fixed ulong VectorRegister[52];
+
+ /// The vector control register.
+ public ulong VectorControl;
+
+ /// The debug-control MSR.
+ public ulong DebugControl;
+ /// The target RIP of the last branch.
+ public ulong LastBranchToRip;
+ /// The source RIP of the last branch.
+ public ulong LastBranchFromRip;
+ /// The target RIP of the last exception.
+ public ulong LastExceptionToRip;
+ /// The source RIP of the last exception.
+ public ulong LastExceptionFromRip;
+}
diff --git a/WhiteMagic/Native/SafeMemoryHandle.cs b/WhiteMagic/Native/SafeMemoryHandle.cs
new file mode 100644
index 0000000..000c79f
--- /dev/null
+++ b/WhiteMagic/Native/SafeMemoryHandle.cs
@@ -0,0 +1,34 @@
+using Microsoft.Win32.SafeHandles;
+
+namespace WhiteMagic.Native;
+
+///
+/// A Win32 handle (process, thread, or snapshot) with a managed lifetime.
+/// The handle closes with CloseHandle, even after an exception or a thread abort.
+///
+/// The pattern comes from MemorySharp's SafeMemoryHandle.
+public sealed class SafeMemoryHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ ///
+ /// Makes an empty handle. The interop marshaller uses this constructor for a
+ /// handle that a system call returns (for example, ).
+ ///
+ public SafeMemoryHandle() : base(true)
+ {
+ }
+
+ ///
+ /// Wraps a raw handle and takes ownership of the handle.
+ ///
+ /// The handle to own.
+ public SafeMemoryHandle(IntPtr handle) : base(true)
+ {
+ SetHandle(handle);
+ }
+
+ ///
+ protected override bool ReleaseHandle()
+ {
+ return NativeMethods.CloseHandle(handle);
+ }
+}
diff --git a/WhiteMagic/WhiteMagic.csproj b/WhiteMagic/WhiteMagic.csproj
index 2b1816a..edf34ce 100644
--- a/WhiteMagic/WhiteMagic.csproj
+++ b/WhiteMagic/WhiteMagic.csproj
@@ -9,4 +9,8 @@
true
+
+
+
+
diff --git a/WhiteMagicTest/AddressingTests.cs b/WhiteMagicTest/AddressingTests.cs
new file mode 100644
index 0000000..77ea5c2
--- /dev/null
+++ b/WhiteMagicTest/AddressingTests.cs
@@ -0,0 +1,103 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using WhiteMagic;
+using WhiteMagic.Native;
+
+namespace WhiteMagicTest;
+
+///
+/// Tests for relative/absolute addressing in .
+/// GetAbsolute(relative) = ImageBase + relative.
+/// GetRelative(absolute) = absolute - ImageBase (inverse of GetAbsolute).
+///
+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(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(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]);
+ }
+}
diff --git a/WhiteMagicTest/InProcessReaderTests.cs b/WhiteMagicTest/InProcessReaderTests.cs
new file mode 100644
index 0000000..776b5aa
--- /dev/null
+++ b/WhiteMagicTest/InProcessReaderTests.cs
@@ -0,0 +1,155 @@
+using System.Runtime.InteropServices;
+using WhiteMagic;
+
+namespace WhiteMagicTest;
+
+///
+/// Tests for — direct pointer dereference against
+/// the own process. Verifies the shared API works for
+/// both external and in-process readers.
+///
+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(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(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(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(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);
+ }
+}
diff --git a/WhiteMagicTest/MarshalCacheTests.cs b/WhiteMagicTest/MarshalCacheTests.cs
new file mode 100644
index 0000000..6511eac
--- /dev/null
+++ b/WhiteMagicTest/MarshalCacheTests.cs
@@ -0,0 +1,111 @@
+using System.Runtime.InteropServices;
+using WhiteMagic;
+
+namespace WhiteMagicTest;
+
+///
+/// Tests for : blittable size, marshal-required flag,
+/// IsIntPtr, and computed-once behavior.
+///
+public class MarshalCacheTests
+{
+ [Fact]
+ public void Size_for_int_is_4()
+ {
+ Assert.Equal(4, MarshalCache.Size);
+ }
+
+ [Fact]
+ public void Size_for_byte_is_1()
+ {
+ Assert.Equal(1, MarshalCache.Size);
+ }
+
+ [Fact]
+ public void Size_for_IntPtr_matches_native_pointer_size()
+ {
+ Assert.Equal(IntPtr.Size, MarshalCache.Size);
+ }
+
+ [Fact]
+ public void Size_for_bool_is_1()
+ {
+ Assert.Equal(1, MarshalCache.Size);
+ }
+
+ [Fact]
+ public void Size_for_enum_matches_underlying_type()
+ {
+ Assert.Equal(4, MarshalCache.Size);
+ }
+
+ [Fact]
+ public void Size_for_blittable_struct_is_accurate()
+ {
+ Assert.Equal(8, MarshalCache.Size);
+ }
+
+ [Fact]
+ public void TypeRequiresMarshal_is_false_for_blittable_types()
+ {
+ Assert.False(MarshalCache.TypeRequiresMarshal);
+ Assert.False(MarshalCache.TypeRequiresMarshal);
+ Assert.False(MarshalCache.TypeRequiresMarshal);
+ }
+
+ [Fact]
+ public void TypeRequiresMarshal_is_true_for_types_with_MarshalAs_field()
+ {
+ Assert.True(MarshalCache.TypeRequiresMarshal);
+ }
+
+ [Fact]
+ public void IsIntPtr_is_true_for_IntPtr()
+ {
+ Assert.True(MarshalCache.IsIntPtr);
+ }
+
+ [Fact]
+ public void IsIntPtr_is_false_for_non_IntPtr_types()
+ {
+ Assert.False(MarshalCache.IsIntPtr);
+ Assert.False(MarshalCache.IsIntPtr);
+ Assert.False(MarshalCache.IsIntPtr);
+ }
+
+ [Fact]
+ public void All_properties_are_computed_once_and_cached()
+ {
+ int size1 = MarshalCache.Size;
+ bool marshal1 = MarshalCache.TypeRequiresMarshal;
+ bool intPtr1 = MarshalCache.IsIntPtr;
+
+ int size2 = MarshalCache.Size;
+ bool marshal2 = MarshalCache.TypeRequiresMarshal;
+ bool intPtr2 = MarshalCache.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.Size, MarshalCache.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;
+ }
+}
diff --git a/WhiteMagicTest/MemoryBaseTests.cs b/WhiteMagicTest/MemoryBaseTests.cs
new file mode 100644
index 0000000..afc177f
--- /dev/null
+++ b/WhiteMagicTest/MemoryBaseTests.cs
@@ -0,0 +1,245 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using WhiteMagic;
+using WhiteMagic.Native;
+
+namespace WhiteMagicTest;
+
+///
+/// Tests for abstract contract and
+/// round-trip (Read<T>/Write<T>, arrays) using the current process as target.
+///
+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(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(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(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(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(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(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(IntPtr.Zero));
+ }
+
+ [Fact]
+ public void Read_struct_on_invalid_address_returns_default()
+ {
+ using var reader = OpenSelf();
+ var result = reader.Read(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(IntPtr.Zero, 10));
+ }
+
+ [Fact]
+ public void Read_bytes_on_invalid_address_returns_empty()
+ {
+ using var reader = OpenSelf();
+ Assert.Empty(reader.ReadBytes(IntPtr.Zero, 10));
+ }
+}
+
+///
+/// A simple blittable struct for use in tests.
+///
+[StructLayout(LayoutKind.Sequential)]
+public struct TestStruct : IEquatable
+{
+ 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})";
+}
diff --git a/WhiteMagicTest/Native/NativeSurfaceTests.cs b/WhiteMagicTest/Native/NativeSurfaceTests.cs
new file mode 100644
index 0000000..786b48e
--- /dev/null
+++ b/WhiteMagicTest/Native/NativeSurfaceTests.cs
@@ -0,0 +1,112 @@
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagicTest.Native;
+
+///
+/// Integration tests that exercise the P/Invoke surface against the current
+/// process. They prove the marshalling signatures are correct end-to-end.
+///
+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 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 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));
+ }
+}
diff --git a/WhiteMagicTest/StringReadWriteTests.cs b/WhiteMagicTest/StringReadWriteTests.cs
new file mode 100644
index 0000000..6e0cf37
--- /dev/null
+++ b/WhiteMagicTest/StringReadWriteTests.cs
@@ -0,0 +1,183 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using WhiteMagic;
+using WhiteMagic.Native;
+
+namespace WhiteMagicTest;
+
+///
+/// Tests for and
+/// with encoding, null-terminator stop, and max-length behavior.
+///
+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();
+ }
+ }
+}
diff --git a/openspec/changes/whitemagic-foundation/design.md b/openspec/changes/whitemagic-foundation/design.md
index 199ee1c..501da4f 100644
--- a/openspec/changes/whitemagic-foundation/design.md
+++ b/openspec/changes/whitemagic-foundation/design.md
@@ -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`, `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` 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` 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`/`Write`, 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.
diff --git a/openspec/changes/whitemagic-foundation/specs/memory-access/spec.md b/openspec/changes/whitemagic-foundation/specs/memory-access/spec.md
index 94f9e8f..900242f 100644
--- a/openspec/changes/whitemagic-foundation/specs/memory-access/spec.md
+++ b/openspec/changes/whitemagic-foundation/specs/memory-access/spec.md
@@ -2,7 +2,13 @@
### Requirement: Abstract memory base with two readers
-WhiteMagic SHALL expose an abstract `MemoryBase` type defining `ReadBytes`, `WriteBytes`, generic `Read`/`Write`, 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`/`Write`, 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(addr, 0x1234)` then reads it back with `Read(addr)`
diff --git a/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md
index 10b5fb5..e943ad2 100644
--- a/openspec/changes/whitemagic-foundation/tasks.md
+++ b/openspec/changes/whitemagic-foundation/tasks.md
@@ -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`: 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`/`Write`, 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`: 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`/`Write`, 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)