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/MemoryBase.cs b/WhiteMagic/MemoryBase.cs
new file mode 100644
index 0000000..2f7ef33
--- /dev/null
+++ b/WhiteMagic/MemoryBase.cs
@@ -0,0 +1,215 @@
+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.
+ public T Read(IntPtr address, bool isRelative = false) where T : struct
+ {
+ if (isRelative)
+ address = GetAbsolute(address);
+
+ int size = MarshalCache.Size;
+ Span buffer = stackalloc byte[size];
+ byte[] raw = ReadBytes(address, size);
+ raw.CopyTo(buffer);
+
+ if (MarshalCache.TypeRequiresMarshal)
+ {
+ return MarshalByteArrayToStructure(raw);
+ }
+
+ return MemoryMarshal.Read(buffer);
+ }
+
+ /// 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;
+ Span buffer = stackalloc byte[size];
+
+ if (MarshalCache.TypeRequiresMarshal)
+ {
+ StructureToByteArray(value, buffer, size);
+ }
+ else
+ {
+ MemoryMarshal.Write(buffer, in value);
+ }
+
+ int written = WriteBytes(address, buffer, false);
+ return written == size;
+ }
+
+ /// Reads an array of values of type from the target address.
+ 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);
+
+ var result = new T[count];
+
+ if (MarshalCache.TypeRequiresMarshal)
+ {
+ for (int i = 0; i < count; i++)
+ {
+ var elementBytes = new ReadOnlySpan(raw, i * elementSize, elementSize);
+ result[i] = MarshalByteArrayToStructure(elementBytes.ToArray());
+ }
+ }
+ else
+ {
+ ReadOnlySpan span = raw;
+ for (int i = 0; i < count; 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.
+ public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false)
+ {
+ byte[] buffer = ReadBytes(address, maxLength, relative);
+ int nullIndex = Array.IndexOf(buffer, 0);
+ if (nullIndex >= 0)
+ {
+ return encoding.GetString(buffer, 0, nullIndex);
+ }
+ return encoding.GetString(buffer);
+ }
+
+ /// Writes a null-terminated string to the target address.
+ public virtual bool WriteString(IntPtr address, string value, Encoding encoding, bool relative = false)
+ {
+ // Ensure null terminator
+ 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 + (int)relative;
+ }
+
+ /// Converts an absolute address to a relative offset from .
+ public IntPtr GetRelative(IntPtr absolute)
+ {
+ return (IntPtr)((int)ImageBase - (int)absolute);
+ }
+
+ // ── 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 void StructureToByteArray(T value, Span destination, int size) where T : struct
+ {
+ byte[] temp = destination.ToArray();
+ GCHandle pin = GCHandle.Alloc(temp, GCHandleType.Pinned);
+ try
+ {
+ Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false);
+ temp.CopyTo(destination);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+}
diff --git a/WhiteMagicTest/MemoryBaseTests.cs b/WhiteMagicTest/MemoryBaseTests.cs
new file mode 100644
index 0000000..223ba5a
--- /dev/null
+++ b/WhiteMagicTest/MemoryBaseTests.cs
@@ -0,0 +1,219 @@
+using WhiteMagic.Native;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using WhiteMagic;
+
+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();
+
+ // Pin a local int to use as our "remote" address
+ 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();
+
+ // Write a new value
+ 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(); // Should not throw
+ }
+}
+
+///
+/// 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})";
+}