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/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/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md
index 10b5fb5..848a454 100644
--- a/openspec/changes/whitemagic-foundation/tasks.md
+++ b/openspec/changes/whitemagic-foundation/tasks.md
@@ -3,7 +3,7 @@
- [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)