diff --git a/WhiteMagic/Discovery/PatternScanner.cs b/WhiteMagic/Discovery/PatternScanner.cs
new file mode 100644
index 0000000..a4316d0
--- /dev/null
+++ b/WhiteMagic/Discovery/PatternScanner.cs
@@ -0,0 +1,201 @@
+using System.ComponentModel;
+using System.Diagnostics;
+
+namespace WhiteMagic.Discovery;
+
+///
+/// Scans process memory for a byte pattern with an optional wildcard mask.
+///
+public static class PatternScanner
+{
+ ///
+ /// Scans a memory range for the first occurrence of a pattern with an optional wildcard mask.
+ ///
+ /// The memory accessor.
+ /// The byte pattern to search for.
+ ///
+ /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
+ /// If , all bytes are treated as 'x' (exact match).
+ ///
+ /// The starting address of the scan range.
+ /// The ending address (exclusive) of the scan range.
+ /// The address of the first match, or if not found.
+ ///
+ /// is empty, or length does not match
+ /// length, or contains invalid characters.
+ ///
+ /// Memory read fails with an unexpected error.
+ public static IntPtr Find(
+ MemoryBase memory,
+ byte[] pattern,
+ string? mask,
+ IntPtr start,
+ IntPtr end)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ ArgumentNullException.ThrowIfNull(pattern);
+
+ if (pattern.Length == 0)
+ throw new ArgumentException("Pattern cannot be empty.", nameof(pattern));
+
+ // Validate and normalize mask
+ if (mask is not null)
+ {
+ if (mask.Length != pattern.Length)
+ throw new ArgumentException(
+ $"Mask length ({mask.Length}) must match pattern length ({pattern.Length}).",
+ nameof(mask));
+
+ foreach (char c in mask)
+ {
+ if (c != 'x' && c != '?')
+ throw new ArgumentException(
+ $"Mask may contain only 'x' (match) or '?' (wildcard); found '{c}'.",
+ nameof(mask));
+ }
+ }
+
+ // Null mask means treat all bytes as 'x' (exact match)
+ mask ??= new string('x', pattern.Length);
+
+ // Scan range in reasonable chunks (64 KB to avoid massive single reads)
+ const int chunkSize = 64 * 1024;
+ int patternLen = pattern.Length;
+ long rangeSize = (long)end - (long)start;
+
+ if (rangeSize <= 0)
+ return IntPtr.Zero;
+
+ // For small ranges, read all at once
+ if (rangeSize <= chunkSize)
+ {
+ byte[] buffer = memory.ReadBytes(start, (int)rangeSize);
+ return FindInBuffer(buffer, pattern, mask, start);
+ }
+
+ // For larger ranges, scan in chunks
+ long remaining = rangeSize;
+ IntPtr current = start;
+
+ while (remaining > 0)
+ {
+ int toRead = (int)Math.Min(chunkSize, remaining);
+ byte[] chunk = memory.ReadBytes(current, toRead);
+
+ // Empty read means we hit an unmapped region or read failure
+ if (chunk.Length == 0)
+ {
+ // Skip past this unreadable region
+ current += toRead;
+ remaining -= toRead;
+ continue;
+ }
+
+ // Search in this chunk
+ IntPtr found = FindInBuffer(chunk, pattern, mask, current);
+ if (found != IntPtr.Zero)
+ return found;
+
+ // Move to next chunk, leaving room for pattern that might straddle boundary
+ // We advance by (chunkSize - patternLen + 1) to ensure we don't miss matches
+ int advance = toRead - patternLen + 1;
+ if (advance <= 0)
+ advance = toRead;
+
+ current += advance;
+ remaining -= advance;
+ }
+
+ return IntPtr.Zero;
+ }
+
+ ///
+ /// Scans a module's memory region (from its base address through its size) for a pattern.
+ ///
+ /// The memory accessor.
+ /// The byte pattern to search for.
+ ///
+ /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
+ /// If , all bytes are treated as 'x' (exact match).
+ ///
+ /// The module to scan.
+ /// The address of the first match, or if not found.
+ public static IntPtr FindInModule(
+ MemoryBase memory,
+ byte[] pattern,
+ string? mask,
+ ProcessModule module)
+ {
+ ArgumentNullException.ThrowIfNull(module);
+
+ IntPtr start = module.BaseAddress;
+ IntPtr end = start + module.ModuleMemorySize;
+
+ return Find(memory, pattern, mask, start, end);
+ }
+
+ ///
+ /// Scans multiple modules for a pattern, returning the first match found.
+ ///
+ /// The memory accessor.
+ /// The byte pattern to search for.
+ ///
+ /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
+ /// If , all bytes are treated as 'x' (exact match).
+ ///
+ /// The modules to scan, in order.
+ /// The address of the first match, or if not found.
+ public static IntPtr FindInModules(
+ MemoryBase memory,
+ byte[] pattern,
+ string? mask,
+ IEnumerable modules)
+ {
+ ArgumentNullException.ThrowIfNull(modules);
+
+ foreach (var module in modules)
+ {
+ IntPtr found = FindInModule(memory, pattern, mask, module);
+ if (found != IntPtr.Zero)
+ return found;
+ }
+
+ return IntPtr.Zero;
+ }
+
+ ///
+ /// Searches a buffer for the first pattern match given a mask.
+ ///
+ private static IntPtr FindInBuffer(
+ byte[] buffer,
+ byte[] pattern,
+ string mask,
+ IntPtr bufferBase)
+ {
+ if (buffer.Length < pattern.Length)
+ return IntPtr.Zero;
+
+ int patternLen = pattern.Length;
+ int maxOffset = buffer.Length - patternLen;
+
+ for (int offset = 0; offset <= maxOffset; offset++)
+ {
+ bool match = true;
+
+ for (int i = 0; i < patternLen; i++)
+ {
+ // Only compare if mask says 'x' (exact match required)
+ if (mask[i] == 'x' && buffer[offset + i] != pattern[i])
+ {
+ match = false;
+ break;
+ }
+ }
+
+ if (match)
+ return bufferBase + offset;
+ }
+
+ return IntPtr.Zero;
+ }
+}
diff --git a/WhiteMagic/Discovery/PatternScannerCache.cs b/WhiteMagic/Discovery/PatternScannerCache.cs
new file mode 100644
index 0000000..8843012
--- /dev/null
+++ b/WhiteMagic/Discovery/PatternScannerCache.cs
@@ -0,0 +1,157 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+
+namespace WhiteMagic.Discovery;
+
+///
+/// Caches pattern scan results to avoid repeated scans of the same memory range.
+///
+public sealed class PatternScannerCache
+{
+ private readonly ConcurrentDictionary _cache = new();
+ private readonly MemoryBase _memory;
+
+ ///
+ /// Creates a new cache for the given memory accessor.
+ ///
+ /// The memory accessor to scan.
+ public PatternScannerCache(MemoryBase memory)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ _memory = memory;
+ }
+
+ ///
+ /// Finds a pattern, returning a cached result if available.
+ ///
+ /// The byte pattern to search for.
+ ///
+ /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
+ /// If , all bytes are treated as 'x' (exact match).
+ ///
+ /// The starting address of the scan range.
+ /// The ending address (exclusive) of the scan range.
+ ///
+ /// The address of the first match from cache or memory, or if not found.
+ ///
+ public IntPtr FindCached(
+ byte[] pattern,
+ string? mask,
+ IntPtr start,
+ IntPtr end)
+ {
+ var key = new CacheKey(pattern, mask, start, end);
+
+ // Try to get from cache first
+ if (_cache.TryGetValue(key, out IntPtr cached))
+ return cached;
+
+ // Not in cache, perform the scan
+ IntPtr found = PatternScanner.Find(_memory, pattern, mask, start, end);
+
+ // Cache the result (even if Zero)
+ _cache[key] = found;
+
+ return found;
+ }
+
+ ///
+ /// Finds a pattern within a module, returning a cached result if available.
+ ///
+ /// The byte pattern to search for.
+ ///
+ /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
+ /// If , all bytes are treated as 'x' (exact match).
+ ///
+ /// The module to scan.
+ ///
+ /// The address of the first match from cache or memory, or if not found.
+ ///
+ public IntPtr FindInModuleCached(
+ byte[] pattern,
+ string? mask,
+ ProcessModule module)
+ {
+ ArgumentNullException.ThrowIfNull(module);
+
+ IntPtr start = module.BaseAddress;
+ IntPtr end = start + module.ModuleMemorySize;
+
+ return FindCached(pattern, mask, start, end);
+ }
+
+ ///
+ /// Finds a pattern across multiple modules, returning a cached result if available.
+ ///
+ /// The byte pattern to search for.
+ ///
+ /// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
+ /// If , all bytes are treated as 'x' (exact match).
+ ///
+ /// The modules to scan, in order.
+ ///
+ /// The address of the first match from cache or memory, or if not found.
+ ///
+ public IntPtr FindInModulesCached(
+ byte[] pattern,
+ string? mask,
+ IEnumerable modules)
+ {
+ // For multiple modules, we use a combined key (all modules hashed together)
+ // This is less granular but still useful for repeated queries
+ var moduleList = modules.ToList();
+ var key = new CacheKey(pattern, mask, IntPtr.Zero, IntPtr.Zero, Modules: moduleList);
+ if (_cache.TryGetValue(key, out IntPtr cached))
+ return cached;
+
+ IntPtr found = PatternScanner.FindInModules(_memory, pattern, mask, moduleList);
+
+ _cache[key] = found;
+ return found;
+ }
+
+ ///
+ /// Clears all cached scan results.
+ ///
+ public void Clear()
+ {
+ _cache.Clear();
+ }
+
+ ///
+ /// Cache key combining pattern, mask, and address range.
+ ///
+ private sealed record CacheKey(
+ byte[] Pattern,
+ string? Mask,
+ IntPtr Start,
+ IntPtr End,
+ IReadOnlyList? Modules = null) : IEquatable
+ {
+ // Override GetHashCode to hash the contents, not references
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+
+ // Hash pattern bytes
+ foreach (byte b in Pattern)
+ hash.Add(b);
+
+ // Hash mask
+ hash.Add(Mask?.GetHashCode() ?? 0);
+
+ // Hash address range
+ hash.Add(Start.GetHashCode());
+ hash.Add(End.GetHashCode());
+
+ // Hash modules if present (by base address)
+ if (Modules is not null)
+ {
+ foreach (var m in Modules)
+ hash.Add(m.BaseAddress.GetHashCode());
+ }
+
+ return hash.ToHashCode();
+ }
+ }
+}
diff --git a/WhiteMagic/Discovery/PeHeaderParser.cs b/WhiteMagic/Discovery/PeHeaderParser.cs
new file mode 100644
index 0000000..723ba70
--- /dev/null
+++ b/WhiteMagic/Discovery/PeHeaderParser.cs
@@ -0,0 +1,225 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Discovery;
+
+///
+/// Represents a section in a PE file.
+///
+public readonly record struct PeSection
+{
+ ///
+ /// The 8-byte null-terminated section name (e.g., ".text", ".data").
+ ///
+ public string Name { get; init; }
+
+ ///
+ /// The virtual address of the section when loaded into memory (RVA).
+ ///
+ public IntPtr VirtualAddress { get; init; }
+
+ ///
+ /// The size of the section in memory.
+ ///
+ public int VirtualSize { get; init; }
+}
+
+///
+/// Parses PE headers to expose section information and entry points.
+///
+public sealed class PeHeaderParser
+{
+ private readonly MemoryBase _memory;
+ private readonly IntPtr _baseAddress;
+
+ ///
+ /// Creates a new PE header parser for the module at the specified base address.
+ ///
+ /// The memory accessor.
+ /// The base address of the module.
+ public PeHeaderParser(MemoryBase memory, IntPtr baseAddress)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ if (baseAddress == IntPtr.Zero)
+ throw new ArgumentException("Base address cannot be zero.", nameof(baseAddress));
+
+ _memory = memory;
+ _baseAddress = baseAddress;
+ }
+
+ ///
+ /// Gets the entry point RVA (Relative Virtual Address) of the PE file.
+ ///
+ /// The entry point RVA, or if unavailable.
+ /// Reading memory fails.
+ /// The PE headers are invalid.
+ public IntPtr EntryPoint
+ {
+ get
+ {
+ // Read and parse PE headers
+ var (optionalHeader, _) = ParseOptionalHeader();
+
+ if (optionalHeader is null)
+ return IntPtr.Zero;
+
+ // Entry point is at different offsets for PE32 vs PE32+
+ bool isPe32Plus = IsPe32Plus();
+
+ if (isPe32Plus)
+ {
+ // PE32+: AddressOfEntryPoint is at offset 16 in OPTIONAL_HEADER (64-bit)
+ return (IntPtr)BitConverter.ToUInt32(
+ optionalHeader.AsSpan(16, 4));
+ }
+ else
+ {
+ // PE32: AddressOfEntryPoint is at offset 16 in OPTIONAL_HEADER (32-bit)
+ return (IntPtr)BitConverter.ToUInt32(
+ optionalHeader.AsSpan(16, 4));
+ }
+ }
+ }
+
+ ///
+ /// Enumerates all sections in the PE file.
+ ///
+ /// An enumerable of PE sections.
+ /// Reading memory fails.
+ /// The PE headers are invalid.
+ public IEnumerable Sections
+ {
+ get
+ {
+ var (optionalHeader, sectionHeaders) = ParseOptionalHeaderAndSectionHeaders();
+
+ if (sectionHeaders is null || sectionHeaders.Length == 0)
+ yield break;
+
+ foreach (var sectionHeader in sectionHeaders)
+ {
+ // Parse section name (8-byte, null-terminated)
+ string name = ParseSectionName(sectionHeader);
+
+ // VirtualAddress and VirtualSize
+ uint virtualAddress = BitConverter.ToUInt32(sectionHeader, 12);
+ uint virtualSize = BitConverter.ToUInt32(sectionHeader, 8);
+
+ yield return new PeSection
+ {
+ Name = name,
+ VirtualAddress = (IntPtr)virtualAddress,
+ VirtualSize = (int)virtualSize
+ };
+ }
+ }
+ }
+
+ ///
+ /// Parses the DOS header, PE signature, and optional header.
+ ///
+ private (byte[]? OptionalHeader, byte[][]? SectionHeaders) ParseOptionalHeaderAndSectionHeaders()
+ {
+ // Read DOS header (first 64 bytes)
+ byte[] dosHeader = _memory.ReadBytes(_baseAddress, 64);
+ if (dosHeader.Length < 64)
+ throw new InvalidDataException("Failed to read DOS header.");
+
+ // Verify DOS signature "MZ"
+ if (dosHeader[0] != 0x4D || dosHeader[1] != 0x5A)
+ throw new InvalidDataException("Invalid DOS signature (not a PE file).");
+
+ // PE header offset is at 0x3C in DOS header
+ int peOffset = BitConverter.ToInt32(dosHeader, 0x3C);
+ if (peOffset < 0 || peOffset > 0x1000) // Sanity check
+ throw new InvalidDataException($"Invalid PE offset: {peOffset}");
+
+ // Read PE signature (4 bytes: "PE\0\0")
+ IntPtr peSigAddr = _baseAddress + peOffset;
+ byte[] peSignature = _memory.ReadBytes(peSigAddr, 4);
+ if (peSignature.Length < 4)
+ throw new InvalidDataException("Failed to read PE signature.");
+
+ if (peSignature[0] != 0x50 || peSignature[1] != 0x45 ||
+ peSignature[2] != 0x00 || peSignature[3] != 0x00)
+ throw new InvalidDataException("Invalid PE signature.");
+
+ // COFF header follows PE signature (20 bytes)
+ IntPtr coffAddr = peSigAddr + 4;
+ byte[] coffHeader = _memory.ReadBytes(coffAddr, 20);
+ if (coffHeader.Length < 20)
+ throw new InvalidDataException("Failed to read COFF header.");
+
+ // SizeOfOptionalHeader is at offset 16 in COFF header
+ ushort sizeOfOptionalHeader = BitConverter.ToUInt16(coffHeader, 16);
+ // NumberOfSections is at offset 2 in COFF header
+ ushort numberOfSections = BitConverter.ToUInt16(coffHeader, 2);
+
+ if (numberOfSections == 0 || numberOfSections > 96)
+ return (null, null); // No sections or unreasonable number
+ // Optional header follows COFF header
+ IntPtr optAddr = coffAddr + 20;
+ byte[] optionalHeader = _memory.ReadBytes(optAddr, sizeOfOptionalHeader);
+ if (optionalHeader.Length < sizeOfOptionalHeader)
+ throw new InvalidDataException("Failed to read optional header.");
+
+ // Section headers follow optional header
+ IntPtr sectionAddr = optAddr + sizeOfOptionalHeader;
+ int sectionHeaderSize = 40; // IMAGE_SECTION_HEADER is 40 bytes
+
+ byte[][] sectionHeaders = new byte[numberOfSections][];
+ for (int i = 0; i < numberOfSections; i++)
+ {
+ byte[] section = _memory.ReadBytes(sectionAddr + (i * sectionHeaderSize), sectionHeaderSize);
+ if (section.Length < sectionHeaderSize)
+ throw new InvalidDataException($"Failed to read section header {i}.");
+ sectionHeaders[i] = section;
+ }
+
+ return (optionalHeader, sectionHeaders);
+ }
+
+ ///
+ /// Parses just the optional header (for entry point).
+ ///
+ private (byte[]? OptionalHeader, byte[][]? SectionHeaders) ParseOptionalHeader()
+ {
+ return ParseOptionalHeaderAndSectionHeaders();
+ }
+
+ ///
+ /// Determines whether the PE file is PE32+ (64-bit) or PE32 (32-bit).
+ ///
+ private bool IsPe32Plus()
+ {
+ var (optionalHeader, _) = ParseOptionalHeaderAndSectionHeaders();
+
+ if (optionalHeader is null || optionalHeader.Length < 2)
+ throw new InvalidDataException("Optional header too short.");
+
+ // Magic is at offset 0 in optional header
+ // 0x10b = PE32 (32-bit), 0x20b = PE32+ (64-bit)
+ ushort magic = BitConverter.ToUInt16(optionalHeader, 0);
+ return magic == 0x20b;
+ }
+
+ ///
+ /// Parses a null-terminated 8-byte section name.
+ ///
+ private static string ParseSectionName(byte[] sectionHeader)
+ {
+ // Name is first 8 bytes
+ var nameBytes = new Span(sectionHeader, 0, 8);
+
+ // Find null terminator
+ int len = 0;
+ for (; len < 8; len++)
+ {
+ if (nameBytes[len] == 0)
+ break;
+ }
+
+ return System.Text.Encoding.ASCII.GetString(nameBytes[..len]);
+ }
+}
diff --git a/WhiteMagic/Execution/InProcessInvoker.cs b/WhiteMagic/Execution/InProcessInvoker.cs
new file mode 100644
index 0000000..bcbe31b
--- /dev/null
+++ b/WhiteMagic/Execution/InProcessInvoker.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace WhiteMagic.Execution;
+
+///
+/// Direct native-to-managed delegate calls for the in-process scenario.
+/// This is the third execution tier: no remote thread is created; the call runs
+/// synchronously on the current thread.
+///
+///
+///
+/// This class assumes the WhiteMagic consumer has already arranged to run inside the
+/// target process. Bootstrapping the managed loader (e.g., via a CLR host or native
+/// shim) that places WhiteMagic into a foreign process is a separate follow-up change
+/// and is not implemented here.
+///
+public sealed class InProcessInvoker
+{
+ private readonly MemoryBase _memory;
+
+ /// Creates an invoker bound to the supplied memory reader.
+ public InProcessInvoker(MemoryBase memory)
+ {
+ _memory = memory ?? throw new ArgumentNullException(nameof(memory));
+ }
+
+ ///
+ /// Creates a managed delegate of type that calls
+ /// the native function at .
+ ///
+ /// A delegate type whose signature matches the native function.
+ public TDelegate CreateFunction(IntPtr address)
+ where TDelegate : Delegate
+ {
+ if (address == IntPtr.Zero)
+ {
+ throw new ArgumentException(
+ "Function address cannot be zero.", nameof(address));
+ }
+
+ return Marshal.GetDelegateForFunctionPointer(address);
+ }
+
+ ///
+ /// Reads the vtable pointer stored at the start of an object in memory.
+ ///
+ /// The address of the object instance.
+ /// The address of the vtable.
+ public IntPtr ReadVTable(IntPtr objectAddress)
+ {
+ return _memory.Read(objectAddress);
+ }
+
+ ///
+ /// Reads a function pointer from a vtable by index.
+ ///
+ /// The address of the vtable.
+ /// The zero-based index of the method slot.
+ /// The address in the specified vtable slot.
+ public IntPtr ReadVTableFunction(IntPtr vTableAddress, int methodIndex)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(methodIndex);
+
+ int pointerSize = _memory.Is64Bit ? 8 : 4;
+ IntPtr slotAddress = vTableAddress + (methodIndex * pointerSize);
+ return _memory.Read(slotAddress);
+ }
+
+ ///
+ /// Convenience helper that reads an object's vtable and returns the function
+ /// address at the requested method index.
+ ///
+ public IntPtr GetObjectVTableFunction(IntPtr objectAddress, int methodIndex)
+ {
+ IntPtr vTable = ReadVTable(objectAddress);
+ return ReadVTableFunction(vTable, methodIndex);
+ }
+}
diff --git a/WhiteMagic/Execution/MainThreadPump.cs b/WhiteMagic/Execution/MainThreadPump.cs
new file mode 100644
index 0000000..d90fb13
--- /dev/null
+++ b/WhiteMagic/Execution/MainThreadPump.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Concurrent;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using WhiteMagic.Hooking;
+
+namespace WhiteMagic.Execution;
+
+///
+/// A crash-safe work queue drained on the target's own thread via a detour on a
+/// per-frame function. Callers queue work and receive the result (or exception)
+/// on their own thread through a completion handle.
+///
+///
+/// The pump assumes the frame function is parameterless and returns an .
+/// This matches common per-frame functions such as D3D9 EndScene .
+///
+public sealed class MainThreadPump : IDisposable
+{
+ private readonly DetourManager _detours;
+ private readonly IntPtr _frameAddress;
+ private readonly ConcurrentQueue _queue = new();
+
+ private Detour? _detour;
+ private bool _installed;
+
+ ///
+ /// Creates a pump that will hook the frame function at .
+ ///
+ public MainThreadPump(DetourManager detours, IntPtr frameAddress)
+ {
+ _detours = detours;
+ _frameAddress = frameAddress;
+ }
+
+ /// Returns after the frame hook has been applied.
+ public bool IsInstalled => _installed;
+
+ /// Installs the frame-function detour.
+ public void Install()
+ {
+ if (_installed)
+ return;
+
+ _detour = _detours.Create("MainThreadPump", _frameAddress, (FrameDelegate)PumpHook);
+ _detour.Apply();
+ _installed = true;
+ }
+
+ ///
+ /// Queues work to run on the hooked thread and blocks until it completes.
+ ///
+ public TResult Execute(Func work)
+ {
+ if (!_installed)
+ {
+ throw new InvalidOperationException(
+ "The main-thread pump is not installed. Call Install() first.");
+ }
+
+ var tcs = new TaskCompletionSource();
+ _queue.Enqueue(new WorkItem(() => work()!, tcs));
+
+ object? result = tcs.Task.GetAwaiter().GetResult();
+ return (TResult)result!;
+ }
+
+ ///
+ /// Queues work to run on the hooked thread and returns a .
+ ///
+ public Task ExecuteAsync(Func work)
+ {
+ if (!_installed)
+ {
+ throw new InvalidOperationException(
+ "The main-thread pump is not installed. Call Install() first.");
+ }
+
+ var tcs = new TaskCompletionSource();
+ object? Box() => work()!;
+ _queue.Enqueue(new WorkItem(Box, r => tcs.SetResult((TResult)r!), ex => tcs.SetException(ex)));
+ return tcs.Task;
+ }
+
+ /// Removes the frame-function detour if it is installed.
+ public void Dispose()
+ {
+ if (_installed && _detour is not null)
+ {
+ _detour.Remove();
+ _installed = false;
+ }
+ }
+
+ private int PumpHook()
+ {
+ while (_queue.TryDequeue(out WorkItem? item))
+ {
+ try
+ {
+ object? result = item.Work();
+ item.SetResult(result);
+ }
+ catch (Exception ex)
+ {
+ item.SetException(ex);
+ }
+ }
+
+ // Call the original frame function so rendering/game logic continues.
+ return _detour is null ? 0 : (int?)_detour.CallOriginal() ?? 0;
+ }
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate int FrameDelegate();
+
+ private sealed class WorkItem
+ {
+ private readonly Action? _setResult;
+ private readonly Action? _setException;
+
+ public WorkItem(Func work, Action setResult, Action setException)
+ {
+ Work = work;
+ _setResult = setResult;
+ _setException = setException;
+ }
+
+ public WorkItem(Func work, TaskCompletionSource tcs)
+ {
+ Work = work;
+ _setResult = r => tcs.SetResult(r);
+ _setException = ex => tcs.SetException(ex);
+ }
+
+ public Func Work { get; }
+
+ public void SetResult(object? result)
+ {
+ _setResult?.Invoke(result);
+ }
+
+ public void SetException(Exception exception)
+ {
+ _setException?.Invoke(exception);
+ }
+ }
+}
diff --git a/WhiteMagic/Execution/RemoteThreadExecutor.cs b/WhiteMagic/Execution/RemoteThreadExecutor.cs
new file mode 100644
index 0000000..0f897dd
--- /dev/null
+++ b/WhiteMagic/Execution/RemoteThreadExecutor.cs
@@ -0,0 +1,492 @@
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+using WhiteMagic.Assembly;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Execution;
+
+///
+/// Executes a function in the target process by creating a remote thread at a
+/// calling-convention-aware stub. Waits for the thread to finish and returns the
+/// typed exit value read from the thread's exit code.
+///
+///
+/// This executor is safe only for thread-agnostic payloads. Calls that touch
+/// single-threaded process state should use instead.
+/// String arguments are encoded as null-terminated UTF-8 and allocated in the
+/// remote process; struct arguments are serialized with the default interop marshaler
+/// ( ) and allocated with
+/// bytes. All temporary remote allocations are released after the call, including on failure.
+///
+public sealed class RemoteThreadExecutor
+{
+ private const uint WaitObject0 = 0x00000000;
+ private const uint WaitTimeout = 0x00000102;
+ private const uint WaitFailed = 0xFFFFFFFF;
+
+ private const nuint AllocationGranularity = 0x10000; // 64 KB
+ private const int NearAllocationAttempts = 64;
+
+ private readonly MemoryBase _reader;
+ private readonly StubAssembler _assembler;
+
+ ///
+ /// Internal hook for tests that need to place the generated call stub inside an
+ /// already-allocated executable region (for example, immediately after the target
+ /// payload to keep the relative CALL within ±2 GiB).
+ ///
+ ///
+ /// When this delegate returns a non-zero pointer, the executor does not take
+ /// ownership of that memory and will not free it.
+ ///
+ internal Func? StubAllocator { get; set; }
+
+ ///
+ /// Initializes a new for the process exposed by
+ /// .
+ ///
+ /// The memory reader that owns the target process handle.
+ public RemoteThreadExecutor(MemoryBase reader)
+ {
+ _reader = reader ?? throw new ArgumentNullException(nameof(reader));
+ _assembler = new StubAssembler();
+ }
+
+ ///
+ /// Calls the function at in the target process using a
+ /// remote thread and returns its exit value cast to .
+ ///
+ /// The expected return type.
+ /// The target function address.
+ /// The calling convention (ignored on x64 targets).
+ /// Arguments to pass. Primitives, pointers and enums are packed
+ /// into pointer-sized slots. Strings and structs are allocated remotely and passed
+ /// by pointer.
+ /// The function's exit value converted to .
+ /// The process handle is not open or a
+ /// required native operation failed.
+ /// The remote thread did not complete in time.
+ public Task ExecuteAsync(IntPtr address, CallConvention convention, params object?[] args)
+ {
+ return Task.Run(() => Execute(address, convention, args));
+ }
+
+ ///
+ /// Synchronous variant of .
+ ///
+ public T Execute(IntPtr address, CallConvention convention, params object?[] args)
+ {
+ if (_reader.Handle.IsInvalid)
+ {
+ throw new InvalidOperationException(
+ "Cannot execute a remote function: the target process handle is not open.");
+ }
+
+ if (address == IntPtr.Zero)
+ {
+ throw new ArgumentException(
+ "Target function address cannot be zero.", nameof(address));
+ }
+
+ int pointerSize = _reader.Is64Bit ? 8 : 4;
+ var allocations = new List(args.Length + 1);
+ IntPtr stubAddress = IntPtr.Zero;
+ SafeMemoryHandle? thread = null;
+
+ try
+ {
+ nuint[] nativeArgs = MarshalArguments(args, pointerSize, allocations);
+
+ // Compute the exact stub size with a dummy address close to the target;
+ // the emitted byte count does not depend on the stub's final address.
+ byte[] stubBytes = _assembler.BuildCallStub(
+ address, address, nativeArgs, pointerSize, convention);
+
+ bool stubOwnedByExecutor = true;
+ if (StubAllocator != null)
+ {
+ stubAddress = StubAllocator(address, stubBytes.Length);
+ stubOwnedByExecutor = stubAddress != IntPtr.Zero;
+ }
+
+ if (stubAddress == IntPtr.Zero)
+ {
+ stubAddress = AllocateExecutableMemory(_reader.Handle, address, stubBytes.Length);
+ stubOwnedByExecutor = true;
+ }
+
+ if (stubAddress == IntPtr.Zero)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"Failed to allocate remote stub memory: error {error}");
+ }
+
+ if (stubOwnedByExecutor)
+ {
+ allocations.Add(stubAddress);
+ }
+
+ // Re-emit with the real stub address so the relative call lands correctly.
+ stubBytes = _assembler.BuildCallStub(
+ stubAddress, address, nativeArgs, pointerSize, convention);
+
+ int written = _reader.WriteBytes(stubAddress, stubBytes);
+ if (written != stubBytes.Length)
+ {
+ throw new InvalidOperationException(
+ $"Failed to write the call stub to the remote process (wrote {written} of {stubBytes.Length} bytes).");
+ }
+
+ thread = NativeMethods.CreateRemoteThread(
+ _reader.Handle,
+ IntPtr.Zero,
+ 0,
+ stubAddress,
+ IntPtr.Zero,
+ ThreadCreationFlags.RunImmediately,
+ out _);
+
+ if (thread.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"CreateRemoteThread failed: error {error}");
+ }
+
+ uint waitResult = NativeMethods.WaitForSingleObject(thread, uint.MaxValue);
+ if (waitResult == WaitFailed)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"WaitForSingleObject failed: error {error}");
+ }
+
+ if (waitResult == WaitTimeout)
+ {
+ throw new TimeoutException(
+ "The remote thread did not complete within the requested timeout.");
+ }
+
+ if (waitResult != WaitObject0)
+ {
+ throw new InvalidOperationException(
+ $"Unexpected wait status: 0x{waitResult:X}");
+ }
+
+ if (!NativeMethods.GetExitCodeThread(thread, out uint exitCode))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"GetExitCodeThread failed: error {error}");
+ }
+
+ return ConvertExitCode(exitCode);
+ }
+ finally
+ {
+ // Dispose the thread handle explicitly so the safe handle releases it
+ // before any virtual memory is freed.
+ thread?.Dispose();
+
+ foreach (IntPtr alloc in allocations)
+ {
+ NativeMethods.VirtualFreeEx(
+ _reader.Handle, alloc, 0, MemoryFreeType.Release);
+ }
+ }
+ }
+
+ ///
+ /// Converts the raw DWORD exit code into the requested return type.
+ ///
+ private static T ConvertExitCode(uint exitCode)
+ {
+ Type target = typeof(T);
+
+ if (target == typeof(IntPtr) || target == typeof(nint))
+ {
+ return (T)(object)(IntPtr)(nint)exitCode;
+ }
+
+ if (target == typeof(UIntPtr) || target == typeof(nuint))
+ {
+ return (T)(object)(UIntPtr)(nuint)exitCode;
+ }
+
+ if (Nullable.GetUnderlyingType(target) is Type underlying)
+ {
+ return (T)Convert.ChangeType(exitCode, underlying, CultureInfo.InvariantCulture);
+ }
+
+ return (T)Convert.ChangeType(exitCode, target, CultureInfo.InvariantCulture);
+ }
+
+ ///
+ /// Marshals managed arguments into pointer-sized native argument slots. Allocates
+ /// remote memory for strings and structs and records each allocation in
+ /// .
+ ///
+ private nuint[] MarshalArguments(object?[] args, int pointerSize, List allocations)
+ {
+ var nativeArgs = new nuint[args.Length];
+
+ for (int i = 0; i < args.Length; i++)
+ {
+ object? arg = args[i];
+ nativeArgs[i] = MarshalArgument(arg, pointerSize, allocations);
+ }
+
+ return nativeArgs;
+ }
+
+ ///
+ /// Marshals a single argument. Strings and structs become remote pointers; primitives,
+ /// enums and pointer values are packed directly.
+ ///
+ private nuint MarshalArgument(object? arg, int pointerSize, List allocations)
+ {
+ if (arg is null)
+ {
+ return 0;
+ }
+
+ if (arg is string s)
+ {
+ return MarshalString(s, allocations);
+ }
+
+ Type type = arg.GetType();
+ if (IsPrimitiveOrPointer(type))
+ {
+ return PackPrimitive(arg, pointerSize);
+ }
+
+ if (type.IsValueType)
+ {
+ return MarshalStruct(arg, type, allocations);
+ }
+
+ throw new ArgumentException(
+ $"Unsupported argument type: {type.FullName}. Only primitives, pointers, enums, strings and structs are supported.");
+ }
+
+ ///
+ /// Allocates the UTF-8 encoding of a string in the target process and returns its
+ /// remote address.
+ ///
+ private nuint MarshalString(string value, List allocations)
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(value);
+ byte[] buffer = new byte[bytes.Length + 1];
+ bytes.CopyTo(buffer, 0);
+ buffer[^1] = 0;
+
+ IntPtr remote = NativeMethods.VirtualAllocEx(
+ _reader.Handle,
+ IntPtr.Zero,
+ buffer.Length,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ReadWrite);
+
+ if (remote == IntPtr.Zero)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"Failed to allocate remote string memory: error {error}");
+ }
+
+ int written = _reader.WriteBytes(remote, buffer);
+ if (written != buffer.Length)
+ {
+ throw new InvalidOperationException(
+ $"Failed to write string bytes to the remote process (wrote {written} of {buffer.Length} bytes).");
+ }
+
+ allocations.Add(remote);
+ return (nuint)(nint)remote;
+ }
+
+ ///
+ /// Allocates unmanaged space for a struct in the target process, writes its bytes with
+ /// the default interop marshaler, and returns the remote address.
+ ///
+ private nuint MarshalStruct(object value, Type type, List allocations)
+ {
+ int size;
+ try
+ {
+ size = Marshal.SizeOf(type);
+ }
+ catch (ArgumentException ex)
+ {
+ throw new InvalidOperationException(
+ $"Cannot marshal argument of type {type.FullName}: {ex.Message}", ex);
+ }
+
+ byte[] buffer = new byte[size];
+ GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
+ try
+ {
+ Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false);
+ }
+ finally
+ {
+ pin.Free();
+ }
+
+ IntPtr remote = NativeMethods.VirtualAllocEx(
+ _reader.Handle,
+ IntPtr.Zero,
+ size,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ReadWrite);
+
+ if (remote == IntPtr.Zero)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"Failed to allocate remote struct memory: error {error}");
+ }
+
+ int written = _reader.WriteBytes(remote, buffer);
+ if (written != size)
+ {
+ throw new InvalidOperationException(
+ $"Failed to write struct bytes to the remote process (wrote {written} of {size} bytes).");
+ }
+
+ allocations.Add(remote);
+ return (nuint)(nint)remote;
+ }
+
+ ///
+ /// Determines whether a type can be passed directly as a pointer-sized value.
+ ///
+ private static bool IsPrimitiveOrPointer(Type type)
+ {
+ if (type == typeof(IntPtr) || type == typeof(UIntPtr) ||
+ type == typeof(nint) || type == typeof(nuint))
+ {
+ return true;
+ }
+
+ TypeCode code = Type.GetTypeCode(type);
+
+ switch (code)
+ {
+ case TypeCode.Boolean:
+ case TypeCode.Char:
+ case TypeCode.SByte:
+ case TypeCode.Byte:
+ case TypeCode.Int16:
+ case TypeCode.UInt16:
+ case TypeCode.Int32:
+ case TypeCode.UInt32:
+ case TypeCode.Int64:
+ case TypeCode.UInt64:
+ return true;
+
+ case TypeCode.Object when type.IsEnum:
+ case TypeCode.Object when type == typeof(IntPtr) || type == typeof(UIntPtr):
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Packs a primitive, enum or pointer value into a pointer-sized unsigned integer.
+ /// Values are truncated to the target pointer width so x86 arguments receive their
+ /// low 32 bits.
+ ///
+ private static nuint PackPrimitive(object value, int pointerSize)
+ {
+ Type type = value.GetType();
+ ulong raw;
+
+ if (type == typeof(IntPtr) || type == typeof(nint))
+ {
+ raw = unchecked((ulong)(nint)value);
+ }
+ else if (type == typeof(UIntPtr) || type == typeof(nuint))
+ {
+ raw = (ulong)(UIntPtr)value;
+ }
+ else if (type.IsEnum)
+ {
+ raw = Convert.ToUInt64(value);
+ }
+ else
+ {
+ raw = Convert.ToUInt64(value);
+ }
+
+ if (pointerSize == 4)
+ {
+ raw &= uint.MaxValue;
+ }
+
+ return unchecked((nuint)raw);
+ }
+
+ ///
+ /// Attempts to allocate executable memory close to
+ /// so that the relative CALL instruction in the generated stub stays within its
+ /// ±2 GiB range.
+ ///
+ private static IntPtr AllocateExecutableMemory(
+ SafeMemoryHandle handle,
+ IntPtr preferredAddress,
+ nint size)
+ {
+ nuint preferred = (nuint)(nint)preferredAddress;
+ nuint mask = AllocationGranularity - (nuint)1;
+ nuint aligned = (preferred + AllocationGranularity - (nuint)1) & ~mask;
+
+ for (int i = 0; i < NearAllocationAttempts; i++)
+ {
+ nuint candidate;
+ if (i == 0)
+ {
+ candidate = aligned;
+ }
+ else if ((i & 1) == 1)
+ {
+ candidate = aligned + (nuint)i * AllocationGranularity;
+ }
+ else
+ {
+ nuint offset = (nuint)i * AllocationGranularity;
+ if (offset > aligned)
+ {
+ continue;
+ }
+
+ candidate = aligned - offset;
+ }
+
+ IntPtr result = NativeMethods.VirtualAllocEx(
+ handle,
+ (IntPtr)(nint)candidate,
+ size,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ExecuteReadWrite);
+
+ if (result != IntPtr.Zero)
+ {
+ return result;
+ }
+ }
+
+ return NativeMethods.VirtualAllocEx(
+ handle,
+ IntPtr.Zero,
+ size,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ExecuteReadWrite);
+ }
+}
diff --git a/WhiteMagic/ExternalReader.cs b/WhiteMagic/ExternalReader.cs
index 15a3834..74c3785 100644
--- a/WhiteMagic/ExternalReader.cs
+++ b/WhiteMagic/ExternalReader.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using Process = System.Diagnostics.Process;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
@@ -13,6 +14,8 @@ public sealed class ExternalReader : MemoryBase
{
private readonly SafeMemoryHandle _handle;
private readonly IntPtr _imageBase;
+ private readonly bool _is64Bit;
+ private readonly int _processId;
private bool _disposed;
///
@@ -31,16 +34,27 @@ public sealed class ExternalReader : MemoryBase
/// The target process.
/// The access rights to request. Defaults to
/// .
- public ExternalReader(Process process, ProcessAccess desiredAccess = DefaultAccess)
+ public ExternalReader(System.Diagnostics.Process process, ProcessAccess desiredAccess = DefaultAccess)
{
- _handle = NativeMethods.OpenProcess(desiredAccess, false, process.Id);
+ _processId = process.Id;
+ _handle = NativeMethods.OpenProcess(desiredAccess, false, _processId);
if (_handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
- $"OpenProcess failed for PID {process.Id}: error {error}");
+ $"OpenProcess failed for PID {_processId}: error {error}");
}
+ // Derive target bitness. A 64-bit host sees a 32-bit target as WOW64.
+ // A 32-bit host can only open 32-bit targets. If the API fails, fall
+ // back to the current process bitness (self-open path).
+ if (!NativeMethods.IsWow64Process(_handle, out bool wow64))
+ {
+ wow64 = false;
+ }
+
+ _is64Bit = Environment.Is64BitProcess && !wow64;
+
// Process.MainModule throws Win32Exception for a bitness-mismatched or protected
// target. A missing image base must not sink the whole reader — callers can still
// use absolute addresses when ImageBase is unknown.
@@ -60,6 +74,12 @@ public sealed class ExternalReader : MemoryBase
///
public override SafeMemoryHandle Handle => _handle;
+ ///
+ public override bool Is64Bit => _is64Bit;
+
+ ///
+ public override int ProcessId => _processId;
+
///
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
@@ -84,7 +104,7 @@ public sealed class ExternalReader : MemoryBase
if (!_disposed)
{
_disposed = true;
- _handle.Dispose();
+ base.Dispose();
}
}
}
diff --git a/WhiteMagic/Hooking/Detour.cs b/WhiteMagic/Hooking/Detour.cs
new file mode 100644
index 0000000..0e4d708
--- /dev/null
+++ b/WhiteMagic/Hooking/Detour.cs
@@ -0,0 +1,250 @@
+using System;
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Hooking;
+
+///
+/// A single reversible inline detour. Replaces the start of a native function
+/// with a jump to a managed hook delegate, preserves the overwritten bytes in a
+/// remote trampoline, and exposes the trampoline through .
+///
+///
+/// Only supported in-process. The detour uses a 5-byte relative jmp on x86
+/// targets and a 14-byte RIP-relative absolute jmp on x64 targets.
+///
+public sealed class Detour : IDisposable
+{
+ private readonly MemoryBase _memory;
+
+ /// The unique name of this detour.
+ public string Name { get; }
+
+ /// The target native function address.
+ public IntPtr Target { get; }
+
+ /// The managed hook delegate that the detour invokes.
+ public Delegate Hook { get; }
+
+ /// The bytes overwritten at .
+ public byte[] OverwrittenBytes { get; private set; } = Array.Empty();
+
+ ///
+ /// The allocated trampoline that executes the original prologue and then jumps
+ /// back into the original function.
+ ///
+ public IntPtr Trampoline { get; private set; }
+
+ ///
+ /// A delegate wrapping with the same type as .
+ ///
+ public Delegate? Original { get; private set; }
+
+ /// while the detour bytes are live at .
+ public bool IsApplied { get; private set; }
+
+ internal Detour(MemoryBase memory, string name, IntPtr target, Delegate hook)
+ {
+ ArgumentNullException.ThrowIfNull(hook);
+
+ _memory = memory;
+ Name = name;
+ Target = target;
+ Hook = hook;
+ }
+
+ ///
+ /// Installs the detour after validating that the required overwrite covers whole
+ /// prologue instructions.
+ ///
+ /// The prologue cannot be safely spliced.
+ public void Apply()
+ {
+ if (IsApplied)
+ return;
+
+ int pointerSize = _memory.Is64Bit ? 8 : 4;
+ int detourLength = pointerSize == 8 ? 14 : 5;
+
+ byte[] prologue = _memory.ReadBytes(Target, detourLength + 16);
+ if (prologue.Length < detourLength)
+ {
+ throw new InvalidOperationException(
+ "Could not read enough bytes from the target function to install a detour.");
+ }
+
+ int preserveLength = PrologueDecoder.GetWholeInstructionLength(prologue, detourLength, _memory.Is64Bit);
+ OverwrittenBytes = new byte[preserveLength];
+ Buffer.BlockCopy(prologue, 0, OverwrittenBytes, 0, preserveLength);
+
+ IntPtr hookAddress = Marshal.GetFunctionPointerForDelegate(Hook);
+ byte[] hookJump = pointerSize == 8
+ ? BuildAbsoluteJump(hookAddress)
+ : BuildRelativeJump(Target, hookAddress);
+
+ // Allocate and build the trampoline before touching the target.
+ int returnJumpSize = pointerSize == 8 ? 14 : 5;
+ int trampolineSize = preserveLength + returnJumpSize;
+ IntPtr trampoline = NativeMethods.VirtualAllocEx(
+ _memory.Handle,
+ IntPtr.Zero,
+ trampolineSize,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ExecuteReadWrite);
+
+ if (trampoline == IntPtr.Zero)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"Failed to allocate detour trampoline: error {error}");
+ }
+
+ try
+ {
+ var trampolineBytes = new byte[trampolineSize];
+ OverwrittenBytes.CopyTo(trampolineBytes, 0);
+
+ byte[] returnJump = pointerSize == 8
+ ? BuildAbsoluteJump(Target + preserveLength)
+ : BuildRelativeJump(trampoline + preserveLength, Target + preserveLength);
+
+ returnJump.CopyTo(trampolineBytes, preserveLength);
+
+ int written = _memory.WriteBytes(trampoline, trampolineBytes);
+ if (written != trampolineSize)
+ {
+ throw new InvalidOperationException(
+ "Failed to write the detour trampoline into the target process.");
+ }
+
+ // Make the target page writable if necessary, then write the detour jump.
+ if (!NativeMethods.VirtualProtectEx(
+ _memory.Handle,
+ Target,
+ preserveLength,
+ MemoryProtectionType.ExecuteReadWrite,
+ out _))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"Failed to change target memory protection: error {error}");
+ }
+
+ written = _memory.WriteBytes(Target, hookJump);
+ if (written != hookJump.Length)
+ {
+ throw new InvalidOperationException("Failed to write detour jump to target.");
+ }
+
+ Trampoline = trampoline;
+ Original = Marshal.GetDelegateForFunctionPointer(Trampoline, Hook.GetType());
+ IsApplied = true;
+ }
+ catch
+ {
+ NativeMethods.VirtualFreeEx(
+ _memory.Handle,
+ trampoline,
+ 0,
+ MemoryFreeType.Release);
+ throw;
+ }
+ }
+
+ /// Restores the original bytes and releases the trampoline.
+ public void Remove()
+ {
+ if (!IsApplied)
+ return;
+
+ if (OverwrittenBytes.Length > 0 && Target != IntPtr.Zero)
+ {
+ NativeMethods.VirtualProtectEx(
+ _memory.Handle,
+ Target,
+ OverwrittenBytes.Length,
+ MemoryProtectionType.ExecuteReadWrite,
+ out _);
+
+ _memory.WriteBytes(Target, OverwrittenBytes);
+ }
+
+ if (Trampoline != IntPtr.Zero)
+ {
+ NativeMethods.VirtualFreeEx(
+ _memory.Handle,
+ Trampoline,
+ 0,
+ MemoryFreeType.Release);
+ }
+
+ Trampoline = IntPtr.Zero;
+ Original = null;
+ OverwrittenBytes = Array.Empty();
+ IsApplied = false;
+ }
+
+ ///
+ /// Invokes the original function through the trampoline. Pass the same arguments
+ /// that the native signature expects; the return value is boxed.
+ ///
+ public object? CallOriginal(params object?[] args)
+ {
+ if (Original is null)
+ {
+ throw new InvalidOperationException(
+ "The detour is not applied; there is no original trampoline to call.");
+ }
+
+ return Original.DynamicInvoke(args);
+ }
+
+ ///
+ public void Dispose()
+ {
+ Remove();
+ }
+
+ private static byte[] BuildRelativeJump(IntPtr source, IntPtr destination)
+ {
+ byte[] bytes = new byte[5];
+ bytes[0] = 0xE9;
+
+ long distance = (long)destination - ((long)source + 5);
+ if (distance < int.MinValue || distance > int.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(nameof(destination),
+ "Relative jump distance exceeds the 2 GiB range of an E8/E9 encoding.");
+ }
+
+ uint rel = (uint)distance;
+ bytes[1] = (byte)rel;
+ bytes[2] = (byte)(rel >> 8);
+ bytes[3] = (byte)(rel >> 16);
+ bytes[4] = (byte)(rel >> 24);
+ return bytes;
+ }
+
+ private static byte[] BuildAbsoluteJump(IntPtr destination)
+ {
+ // jmp [rip+0] followed by the absolute target address.
+ byte[] bytes = new byte[14];
+ bytes[0] = 0xFF;
+ bytes[1] = 0x25;
+ bytes[2] = 0x00;
+ bytes[3] = 0x00;
+ bytes[4] = 0x00;
+ bytes[5] = 0x00;
+
+ long addr = (long)destination;
+ bytes[6] = (byte)addr;
+ bytes[7] = (byte)(addr >> 8);
+ bytes[8] = (byte)(addr >> 16);
+ bytes[9] = (byte)(addr >> 24);
+ bytes[10] = (byte)(addr >> 32);
+ bytes[11] = (byte)(addr >> 40);
+ bytes[12] = (byte)(addr >> 48);
+ bytes[13] = (byte)(addr >> 56);
+ return bytes;
+ }
+}
diff --git a/WhiteMagic/Hooking/DetourManager.cs b/WhiteMagic/Hooking/DetourManager.cs
new file mode 100644
index 0000000..7180b69
--- /dev/null
+++ b/WhiteMagic/Hooking/DetourManager.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+
+namespace WhiteMagic.Hooking;
+
+///
+/// Manages named inline detours against a .
+/// Detours only work when operating in-process; applying a detour to an
+/// external target will fail because the hook delegate lives in the host process.
+///
+public sealed class DetourManager
+{
+ private readonly MemoryBase _memory;
+ private readonly Dictionary _detours = new();
+
+ /// Creates a detour manager bound to the supplied memory reader.
+ public DetourManager(MemoryBase memory)
+ {
+ _memory = memory;
+ }
+
+ ///
+ /// Creates a new detour and registers it with the manager.
+ /// The delegate's type must match the native signature of
+ /// .
+ ///
+ public Detour Create(string name, IntPtr target, Delegate hook)
+ {
+ var detour = new Detour(_memory, name, target, hook);
+ _detours[name] = detour;
+ return detour;
+ }
+
+ /// Looks up a detour by name.
+ public Detour? this[string name]
+ {
+ get
+ {
+ _detours.TryGetValue(name, out Detour? detour);
+ return detour;
+ }
+ }
+
+ /// All detours registered in this manager.
+ public IEnumerable All => _detours.Values;
+
+ /// Removes every applied detour, restoring original bytes.
+ public void RemoveAll()
+ {
+ foreach (Detour detour in _detours.Values)
+ {
+ detour.Remove();
+ }
+ }
+}
diff --git a/WhiteMagic/Hooking/Patch.cs b/WhiteMagic/Hooking/Patch.cs
new file mode 100644
index 0000000..19a6184
--- /dev/null
+++ b/WhiteMagic/Hooking/Patch.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Linq;
+
+namespace WhiteMagic.Hooking;
+
+///
+/// A single reversible byte patch. Captures the original bytes when applied,
+/// restores them when removed, and reports its state by comparing live memory.
+///
+public sealed class Patch : IDisposable
+{
+ private readonly MemoryBase _memory;
+
+ /// The unique name of this patch.
+ public string Name { get; }
+
+ /// The address the patch overwrites.
+ public IntPtr Address { get; }
+
+ /// The bytes written by the patch.
+ public byte[] PatchBytes { get; }
+
+ /// The bytes captured before the patch was applied.
+ public byte[]? OriginalBytes { get; private set; }
+
+ ///
+ /// when the live bytes at match
+ /// .
+ ///
+ public bool IsApplied
+ {
+ get
+ {
+ byte[] current = _memory.ReadBytes(Address, PatchBytes.Length);
+ return current.SequenceEqual(PatchBytes);
+ }
+ }
+
+ internal Patch(MemoryBase memory, string name, IntPtr address, byte[] patchBytes)
+ {
+ ArgumentNullException.ThrowIfNull(patchBytes);
+
+ _memory = memory;
+ Name = name;
+ Address = address;
+ PatchBytes = patchBytes;
+ }
+
+ /// Captures the original bytes and writes the patch bytes.
+ public void Apply()
+ {
+ if (IsApplied)
+ return;
+
+ OriginalBytes = _memory.ReadBytes(Address, PatchBytes.Length);
+ _memory.WriteBytes(Address, PatchBytes);
+ }
+
+ /// Restores the original bytes if they were captured.
+ public void Remove()
+ {
+ if (OriginalBytes is null)
+ return;
+
+ _memory.WriteBytes(Address, OriginalBytes);
+ OriginalBytes = null;
+ }
+
+ ///
+ public void Dispose()
+ {
+ Remove();
+ }
+}
diff --git a/WhiteMagic/Hooking/PatchManager.cs b/WhiteMagic/Hooking/PatchManager.cs
new file mode 100644
index 0000000..e23feec
--- /dev/null
+++ b/WhiteMagic/Hooking/PatchManager.cs
@@ -0,0 +1,49 @@
+using System.Collections.Generic;
+
+namespace WhiteMagic.Hooking;
+
+///
+/// Manages named, reversible byte patches against a .
+/// Every patch records the bytes it replaced and can restore them later.
+///
+public sealed class PatchManager
+{
+ private readonly MemoryBase _memory;
+ private readonly Dictionary _patches = new();
+
+ /// Creates a patch manager bound to the supplied memory reader.
+ public PatchManager(MemoryBase memory)
+ {
+ _memory = memory;
+ }
+
+ /// Creates a new patch and registers it with the manager.
+ public Patch Create(string name, IntPtr address, byte[] patchBytes)
+ {
+ var patch = new Patch(_memory, name, address, patchBytes);
+ _patches[name] = patch;
+ return patch;
+ }
+
+ /// Looks up a patch by name.
+ public Patch? this[string name]
+ {
+ get
+ {
+ _patches.TryGetValue(name, out Patch? patch);
+ return patch;
+ }
+ }
+
+ /// All patches registered in this manager.
+ public IEnumerable All => _patches.Values;
+
+ /// Removes every applied patch.
+ public void RestoreAll()
+ {
+ foreach (Patch patch in _patches.Values)
+ {
+ patch.Remove();
+ }
+ }
+}
diff --git a/WhiteMagic/Hooking/PrologueDecoder.cs b/WhiteMagic/Hooking/PrologueDecoder.cs
new file mode 100644
index 0000000..089ce94
--- /dev/null
+++ b/WhiteMagic/Hooking/PrologueDecoder.cs
@@ -0,0 +1,91 @@
+using System;
+
+namespace WhiteMagic.Hooking;
+
+///
+/// Minimal instruction-length decoder for common x86/x64 prologue shapes.
+/// The set is intentionally small: any opcode outside the covered set is rejected
+/// rather than guessed. Full arbitrary-prologue validation is provided by the
+/// optional Iced backend (Phase 8).
+///
+///
+/// Covered shapes:
+///
+/// push reg : 0x50-0x57 (1 byte), including REX-prefixed forms.
+/// push ebp/rbp : 0x55 (1 byte).
+/// mov edi, edi : 8B FF (2 bytes).
+/// mov ebp/rbp, esp/rsp : 8B EC / 48 8B EC (2/3 bytes).
+/// sub esp/rsp, imm8 : 83 EC imm8 / 48 83 EC imm8 (3/4 bytes).
+/// sub esp/rsp, imm32 : 81 EC imm32 / 48 81 EC imm32 (6/7 bytes).
+///
+///
+internal static class PrologueDecoder
+{
+ ///
+ /// Returns the length of the first instruction in
+ /// if it matches a covered shape; otherwise returns -1.
+ ///
+ public static int GetInstructionLength(ReadOnlySpan bytes, bool is64Bit)
+ {
+ if (bytes.Length == 0)
+ return 0;
+
+ int i = 0;
+ if (is64Bit && bytes[i] >= 0x40 && bytes[i] <= 0x4F)
+ {
+ // REX prefix.
+ i++;
+ if (bytes.Length <= i)
+ return -1;
+ }
+
+ byte op = bytes[i];
+
+ // push reg / push rbp.
+ if ((op & 0xF8) == 0x50 || op == 0x55)
+ return i + 1;
+
+ // mov r32/64, r/m32/64. Recognize only the specific forms listed above.
+ if (op == 0x8B && bytes.Length > i + 1)
+ {
+ byte modrm = bytes[i + 1];
+ if (modrm == 0xFF || modrm == 0xEC)
+ return i + 2;
+ }
+
+ // sub r/m32/64, imm8.
+ if (op == 0x83 && bytes.Length > i + 2)
+ return i + 3;
+
+ // sub r/m32/64, imm32.
+ if (op == 0x81 && bytes.Length > i + 5)
+ return i + 6;
+
+ return -1;
+ }
+
+ ///
+ /// Walks prologue instructions until at least
+ /// have been covered, returning the total length of whole instructions that must
+ /// be preserved in the trampoline.
+ ///
+ /// An opcode is outside the covered set.
+ public static int GetWholeInstructionLength(byte[] prologue, int requiredBytes, bool is64Bit)
+ {
+ int total = 0;
+ while (total < requiredBytes)
+ {
+ int len = GetInstructionLength(prologue.AsSpan(total), is64Bit);
+ if (len <= 0)
+ {
+ throw new InvalidOperationException(
+ "The target prologue contains an instruction outside the covered opcode set. " +
+ "Install the optional Iced backend for full instruction-boundary validation.");
+ }
+
+ total += len;
+ }
+
+ return total;
+ }
+}
diff --git a/WhiteMagic/InProcessReader.cs b/WhiteMagic/InProcessReader.cs
index 5079636..200ca04 100644
--- a/WhiteMagic/InProcessReader.cs
+++ b/WhiteMagic/InProcessReader.cs
@@ -23,6 +23,7 @@ public sealed class InProcessReader : MemoryBase
{
private readonly SafeMemoryHandle _handle;
private readonly IntPtr _imageBase;
+ private readonly int _processId;
private bool _disposed;
///
@@ -31,8 +32,10 @@ public sealed class InProcessReader : MemoryBase
public InProcessReader()
{
Process current = Process.GetCurrentProcess();
+ _processId = current.Id;
_handle = NativeMethods.OpenProcess(
- ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation,
+ ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation
+ | ProcessAccess.QueryInformation | ProcessAccess.CreateThread | ProcessAccess.Synchronize,
false,
current.Id);
if (_handle.IsInvalid)
@@ -60,6 +63,12 @@ public sealed class InProcessReader : MemoryBase
///
public override SafeMemoryHandle Handle => _handle;
+ ///
+ public override bool Is64Bit => Environment.Is64BitProcess;
+
+ ///
+ public override int ProcessId => _processId;
+
///
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
@@ -84,7 +93,7 @@ public sealed class InProcessReader : MemoryBase
if (!_disposed)
{
_disposed = true;
- _handle.Dispose();
+ base.Dispose();
}
}
}
diff --git a/WhiteMagic/Injection/CodeInjector.cs b/WhiteMagic/Injection/CodeInjector.cs
new file mode 100644
index 0000000..dd62265
--- /dev/null
+++ b/WhiteMagic/Injection/CodeInjector.cs
@@ -0,0 +1,85 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using WhiteMagic.Memory;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Injection;
+
+///
+/// Injects raw machine code into a process's memory.
+///
+public static class CodeInjector
+{
+ ///
+ /// Injects code at a specific address.
+ ///
+ /// The memory accessor.
+ /// The target address.
+ /// The machine code bytes to write.
+ /// The address the code was written to (same as ).
+ /// is empty.
+ /// Write fails.
+ public static IntPtr InjectAtAddress(MemoryBase memory, IntPtr address, byte[] code)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ ArgumentNullException.ThrowIfNull(code);
+
+ if (code.Length == 0)
+ throw new ArgumentException("Code cannot be empty.", nameof(code));
+
+ if (address == IntPtr.Zero)
+ throw new ArgumentException("Address cannot be zero.", nameof(address));
+
+ // Write the code to the target address
+ int written = memory.WriteBytes(address, code);
+ if (written != code.Length)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new Win32Exception(error,
+ $"WriteProcessMemory failed at {address} (wrote {written} of {code.Length} bytes).");
+ }
+
+ return address;
+ }
+
+ ///
+ /// Allocates executable memory and injects code into it.
+ ///
+ /// The memory accessor.
+ /// The machine code bytes to inject.
+ ///
+ /// The memory protection. Defaults to .
+ ///
+ ///
+ /// The base address of the allocated memory containing the code.
+ /// The caller is responsible for freeing this memory (e.g., via ).
+ ///
+ /// is empty.
+ /// Allocation or write fails.
+ public static AllocatedMemory Inject(
+ MemoryBase memory,
+ byte[] code,
+ MemoryProtectionType protection = MemoryProtectionType.ExecuteReadWrite)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ ArgumentNullException.ThrowIfNull(code);
+
+ if (code.Length == 0)
+ throw new ArgumentException("Code cannot be empty.", nameof(code));
+
+ // Allocate memory with the specified protection
+ var allocated = new AllocatedMemory(memory, code.Length, protection);
+
+ // Write the code to the allocated memory
+ int written = memory.WriteBytes(allocated.BaseAddress, code);
+ if (written != code.Length)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ allocated.Dispose();
+ throw new Win32Exception(error,
+ $"WriteProcessMemory failed (wrote {written} of {code.Length} bytes).");
+ }
+
+ return allocated;
+ }
+}
diff --git a/WhiteMagic/Injection/DllInjector.cs b/WhiteMagic/Injection/DllInjector.cs
new file mode 100644
index 0000000..ae26145
--- /dev/null
+++ b/WhiteMagic/Injection/DllInjector.cs
@@ -0,0 +1,532 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Injection;
+
+///
+/// Injects DLLs into an open target process by creating a remote thread or by
+/// hijacking an existing thread.
+///
+///
+///
+/// The DLL path is sent to LoadLibraryW , so it is encoded as a null-terminated
+/// UTF-16 string in the target process.
+///
+///
+/// Injection requires the target process to have the same bitness as the current
+/// process, because the emitted x86/x64 stubs and the captured thread context must
+/// match the target architecture.
+///
+///
+public sealed class DllInjector
+{
+ private readonly MemoryBase _memory;
+ private readonly bool _currentIs64Bit;
+
+ ///
+ /// Initializes a new for the target represented by
+ /// .
+ ///
+ /// A reader/writer for the target process.
+ public DllInjector(MemoryBase memory)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ _memory = memory;
+ _currentIs64Bit = Environment.Is64BitProcess;
+ }
+
+ ///
+ /// Gets the the injector is operating on.
+ ///
+ public MemoryBase Memory => _memory;
+
+ ///
+ /// Injects a DLL into the target process by creating a remote thread that loads it.
+ ///
+ /// The path to the DLL. The file must exist.
+ /// The base address of the loaded module in the target process.
+ /// is null or empty.
+ /// does not exist.
+ /// The target bitness does not match the caller.
+ /// The remote load failed or timed out.
+ public IntPtr InjectWithRemoteThread(string dllPath)
+ {
+ ValidateAndCheckBitness(dllPath);
+
+ IntPtr loadLibrary = ResolveLoadLibraryW();
+ byte[] pathBytes = Encoding.Unicode.GetBytes(dllPath + '\0');
+
+ int pointerSize = _currentIs64Bit ? 8 : 4;
+ int stubSize = _currentIs64Bit ? 39 : 18;
+ int pathOffset = Align(stubSize, pointerSize);
+ int resultOffset = Align(pathOffset + pathBytes.Length, pointerSize);
+ int totalSize = resultOffset + pointerSize + 4096;
+
+ IntPtr remoteBase = NativeMethods.VirtualAllocEx(
+ _memory.Handle,
+ IntPtr.Zero,
+ totalSize,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ExecuteReadWrite);
+
+ if (remoteBase == IntPtr.Zero)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"VirtualAllocEx failed (error {error}).");
+ }
+
+ try
+ {
+ IntPtr pathAddress = remoteBase + pathOffset;
+ IntPtr resultAddress = remoteBase + resultOffset;
+
+ if (_memory.WriteBytes(pathAddress, pathBytes) != pathBytes.Length)
+ throw new InvalidOperationException("Failed to write the DLL path into the target process.");
+
+ byte[] stub = _currentIs64Bit
+ ? BuildRemoteThreadStubX64(pathAddress, resultAddress, loadLibrary)
+ : BuildRemoteThreadStubX86(pathAddress, resultAddress, loadLibrary);
+
+ if (_memory.WriteBytes(remoteBase, stub) != stub.Length)
+ throw new InvalidOperationException("Failed to write the remote thread stub.");
+
+ using SafeMemoryHandle thread = NativeMethods.CreateRemoteThread(
+ _memory.Handle,
+ IntPtr.Zero,
+ 0,
+ remoteBase,
+ IntPtr.Zero,
+ ThreadCreationFlags.RunImmediately,
+ out _);
+
+ if (thread.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"CreateRemoteThread failed (error {error}).");
+ }
+
+ const uint timeoutMs = 30000;
+ uint wait = NativeMethods.WaitForSingleObject(thread, timeoutMs);
+ if (wait == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"WaitForSingleObject failed (error {error}).");
+ }
+
+ if (wait == 0x00000102)
+ throw new InvalidOperationException("Remote thread timed out while loading the DLL.");
+
+ IntPtr result = _memory.Read(resultAddress);
+ if (result == IntPtr.Zero)
+ throw new InvalidOperationException("LoadLibrary returned zero; the DLL could not be loaded.");
+
+ return result;
+ }
+ finally
+ {
+ // The DLL is already loaded; the temporary stub, path and result slot can be released.
+ NativeMethods.VirtualFreeEx(_memory.Handle, remoteBase, 0, MemoryFreeType.Release);
+ }
+ }
+
+ ///
+ /// Injects a DLL by hijacking an existing thread in the target process.
+ ///
+ /// The operating-system identifier of the thread to hijack.
+ /// The path to the DLL. The file must exist.
+ /// The base address of the loaded module in the target process.
+ /// is not a positive value or
+ /// is null or empty.
+ /// does not exist.
+ /// The target bitness does not match the caller.
+ /// The hijack, load, or context restore failed.
+ public IntPtr InjectWithThreadHijack(int threadId, string dllPath)
+ {
+ if (threadId <= 0)
+ throw new ArgumentException("Thread ID must be a positive value.", nameof(threadId));
+
+ ValidateAndCheckBitness(dllPath);
+
+ IntPtr loadLibrary = ResolveLoadLibraryW();
+ byte[] pathBytes = Encoding.Unicode.GetBytes(dllPath + '\0');
+
+ int pointerSize = _currentIs64Bit ? 8 : 4;
+ int stubSize = _currentIs64Bit ? 40 : 19;
+ int pathOffset = Align(stubSize, pointerSize);
+ int resultOffset = Align(pathOffset + pathBytes.Length, pointerSize);
+ int totalSize = resultOffset + pointerSize + 4096;
+
+ IntPtr remoteBase = NativeMethods.VirtualAllocEx(
+ _memory.Handle,
+ IntPtr.Zero,
+ totalSize,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ExecuteReadWrite);
+
+ if (remoteBase == IntPtr.Zero)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"VirtualAllocEx failed (error {error}).");
+ }
+
+ try
+ {
+ IntPtr pathAddress = remoteBase + pathOffset;
+ IntPtr resultAddress = remoteBase + resultOffset;
+
+ if (_memory.WriteBytes(pathAddress, pathBytes) != pathBytes.Length)
+ throw new InvalidOperationException("Failed to write the DLL path into the target process.");
+
+ byte[] stub = _currentIs64Bit
+ ? BuildHijackStubX64(pathAddress, resultAddress, loadLibrary)
+ : BuildHijackStubX86(pathAddress, resultAddress, loadLibrary);
+
+ if (_memory.WriteBytes(remoteBase, stub) != stub.Length)
+ throw new InvalidOperationException("Failed to write the hijack stub.");
+
+ nint stackTop = (nint)(remoteBase + totalSize);
+ stackTop = AlignDown(stackTop, pointerSize);
+ if (_currentIs64Bit)
+ stackTop = AlignDown(stackTop, 16);
+
+ using SafeMemoryHandle thread = NativeMethods.OpenThread(
+ ThreadAccess.SuspendResume | ThreadAccess.GetContext | ThreadAccess.SetContext | ThreadAccess.QueryInformation,
+ false,
+ threadId);
+
+ if (thread.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"OpenThread failed (error {error}).");
+ }
+
+ if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"SuspendThread failed (error {error}).");
+ }
+
+ try
+ {
+ IntPtr result;
+
+ if (_currentIs64Bit)
+ {
+ var originalContext = new Context64 { ContextFlags = ContextFlags.Amd64Full };
+ if (!NativeMethods.GetThreadContext(thread, ref originalContext))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"GetThreadContext failed (error {error}).");
+ }
+
+ var redirectContext = originalContext;
+ redirectContext.Rip = (ulong)(nint)remoteBase;
+ redirectContext.Rsp = (ulong)stackTop;
+
+ if (!NativeMethods.SetThreadContext(thread, ref redirectContext))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"SetThreadContext failed (error {error}).");
+ }
+
+ if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"ResumeThread failed (error {error}).");
+ }
+
+ result = WaitForResult(resultAddress, TimeSpan.FromSeconds(5));
+
+ if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"SuspendThread failed while capturing result (error {error}).");
+ }
+
+ if (!NativeMethods.SetThreadContext(thread, ref originalContext))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"SetThreadContext restore failed (error {error}).");
+ }
+
+ if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"ResumeThread restore failed (error {error}).");
+ }
+ }
+ else
+ {
+ var originalContext = new Context32 { ContextFlags = ContextFlags.X86Full };
+ if (!NativeMethods.Wow64GetThreadContext(thread, ref originalContext))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"Wow64GetThreadContext failed (error {error}).");
+ }
+
+ var redirectContext = originalContext;
+ redirectContext.Eip = (uint)(nint)remoteBase;
+ redirectContext.Esp = (uint)(nint)stackTop;
+
+ if (!NativeMethods.Wow64SetThreadContext(thread, ref redirectContext))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"Wow64SetThreadContext failed (error {error}).");
+ }
+
+ if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"ResumeThread failed (error {error}).");
+ }
+
+ result = WaitForResult(resultAddress, TimeSpan.FromSeconds(5));
+
+ if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"SuspendThread failed while capturing result (error {error}).");
+ }
+
+ if (!NativeMethods.Wow64SetThreadContext(thread, ref originalContext))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"Wow64SetThreadContext restore failed (error {error}).");
+ }
+
+ if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"ResumeThread restore failed (error {error}).");
+ }
+ }
+
+ if (result == IntPtr.Zero)
+ throw new InvalidOperationException("LoadLibrary returned zero; the DLL could not be loaded.");
+
+ return result;
+ }
+ catch
+ {
+ // Best effort: resume the thread if we left it suspended.
+ _ = NativeMethods.ResumeThread(thread);
+ throw;
+ }
+ }
+ finally
+ {
+ NativeMethods.VirtualFreeEx(_memory.Handle, remoteBase, 0, MemoryFreeType.Release);
+ }
+ }
+
+ private void ValidateAndCheckBitness(string dllPath)
+ {
+ if (string.IsNullOrWhiteSpace(dllPath))
+ throw new ArgumentException("DLL path cannot be null or empty.", nameof(dllPath));
+
+ if (!File.Exists(dllPath))
+ throw new FileNotFoundException("The specified DLL was not found.", dllPath);
+
+ if (_memory.Is64Bit != _currentIs64Bit)
+ {
+ throw new InvalidOperationException(
+ "The target process bitness does not match the current process bitness.");
+ }
+ }
+
+ private static IntPtr ResolveLoadLibraryW()
+ {
+ // kernel32.dll is loaded at the same base address in every process at a given
+ // bitness, so resolving the export in the current process gives the correct
+ // target address for the remote process.
+ IntPtr kernel32 = NativeMethods.LoadLibrary("kernel32.dll");
+ if (kernel32 == IntPtr.Zero)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"Unable to obtain a handle to kernel32.dll (error {error}).");
+ }
+
+ IntPtr loadLibrary = NativeMethods.GetProcAddress(kernel32, "LoadLibraryW");
+ if (loadLibrary == IntPtr.Zero)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"Unable to resolve LoadLibraryW (error {error}).");
+ }
+
+ return loadLibrary;
+ }
+
+ private IntPtr WaitForResult(IntPtr resultAddress, TimeSpan timeout)
+ {
+ Stopwatch watch = Stopwatch.StartNew();
+ while (watch.Elapsed < timeout)
+ {
+ IntPtr value = _memory.Read(resultAddress);
+ if (value != IntPtr.Zero)
+ return value;
+
+ Thread.Sleep(5);
+ }
+
+ return IntPtr.Zero;
+ }
+
+ private static byte[] BuildRemoteThreadStubX86(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
+ {
+ var buffer = new List(18);
+
+ // push pathAddress
+ buffer.Add(0x68);
+ EmitU32(buffer, (uint)(nint)pathAddress);
+
+ // mov ecx, LoadLibraryW
+ buffer.Add(0xB9);
+ EmitU32(buffer, (uint)(nint)loadLibrary);
+
+ // call ecx
+ buffer.Add(0xFF);
+ buffer.Add(0xD1);
+
+ // mov [resultAddress], eax
+ buffer.Add(0xA3);
+ EmitU32(buffer, (uint)(nint)resultAddress);
+
+ // ret
+ buffer.Add(0xC3);
+
+ return buffer.ToArray();
+ }
+
+ private static byte[] BuildRemoteThreadStubX64(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
+ {
+ var buffer = new List(39);
+
+ // mov rcx, pathAddress
+ buffer.Add(0x48);
+ buffer.Add(0xB9);
+ EmitU64(buffer, (ulong)(nint)pathAddress);
+
+ // mov rax, LoadLibraryW
+ buffer.Add(0x48);
+ buffer.Add(0xB8);
+ EmitU64(buffer, (ulong)(nint)loadLibrary);
+
+ // call rax
+ buffer.Add(0xFF);
+ buffer.Add(0xD0);
+
+ // mov rdx, rax
+ buffer.Add(0x48);
+ buffer.Add(0x89);
+ buffer.Add(0xC2);
+
+ // mov rax, resultAddress
+ buffer.Add(0x48);
+ buffer.Add(0xB8);
+ EmitU64(buffer, (ulong)(nint)resultAddress);
+
+ // mov [rax], rdx
+ buffer.Add(0x48);
+ buffer.Add(0x89);
+ buffer.Add(0x10);
+
+ // ret
+ buffer.Add(0xC3);
+
+ return buffer.ToArray();
+ }
+
+ private static byte[] BuildHijackStubX86(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
+ {
+ var buffer = new List(19);
+
+ // push pathAddress
+ buffer.Add(0x68);
+ EmitU32(buffer, (uint)(nint)pathAddress);
+
+ // mov ecx, LoadLibraryW
+ buffer.Add(0xB9);
+ EmitU32(buffer, (uint)(nint)loadLibrary);
+
+ // call ecx
+ buffer.Add(0xFF);
+ buffer.Add(0xD1);
+
+ // mov [resultAddress], eax
+ buffer.Add(0xA3);
+ EmitU32(buffer, (uint)(nint)resultAddress);
+
+ // jmp $ (infinite loop so the main injector can suspend and restore context)
+ buffer.Add(0xEB);
+ buffer.Add(0xFE);
+
+ return buffer.ToArray();
+ }
+
+ private static byte[] BuildHijackStubX64(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
+ {
+ var buffer = new List(40);
+
+ // mov rcx, pathAddress
+ buffer.Add(0x48);
+ buffer.Add(0xB9);
+ EmitU64(buffer, (ulong)(nint)pathAddress);
+
+ // mov rax, LoadLibraryW
+ buffer.Add(0x48);
+ buffer.Add(0xB8);
+ EmitU64(buffer, (ulong)(nint)loadLibrary);
+
+ // call rax
+ buffer.Add(0xFF);
+ buffer.Add(0xD0);
+
+ // mov rdx, rax
+ buffer.Add(0x48);
+ buffer.Add(0x89);
+ buffer.Add(0xC2);
+
+ // mov rax, resultAddress
+ buffer.Add(0x48);
+ buffer.Add(0xB8);
+ EmitU64(buffer, (ulong)(nint)resultAddress);
+
+ // mov [rax], rdx
+ buffer.Add(0x48);
+ buffer.Add(0x89);
+ buffer.Add(0x10);
+
+ // jmp $ (infinite loop)
+ buffer.Add(0xEB);
+ buffer.Add(0xFE);
+
+ return buffer.ToArray();
+ }
+
+ private static void EmitU32(List buffer, uint value)
+ {
+ buffer.Add((byte)value);
+ buffer.Add((byte)(value >> 8));
+ buffer.Add((byte)(value >> 16));
+ buffer.Add((byte)(value >> 24));
+ }
+
+ private static void EmitU64(List buffer, ulong value)
+ {
+ EmitU32(buffer, (uint)value);
+ EmitU32(buffer, (uint)(value >> 32));
+ }
+
+ private static int Align(int value, int alignment)
+ {
+ return (value + alignment - 1) / alignment * alignment;
+ }
+
+ private static nint AlignDown(nint value, int alignment)
+ {
+ return (nint)((nuint)value & ~((nuint)alignment - 1));
+ }
+}
diff --git a/WhiteMagic/Input/InputSimulator.cs b/WhiteMagic/Input/InputSimulator.cs
new file mode 100644
index 0000000..667bf6d
--- /dev/null
+++ b/WhiteMagic/Input/InputSimulator.cs
@@ -0,0 +1,73 @@
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Input;
+
+///
+/// Mouse buttons supported by .
+///
+public enum MouseButton
+{
+ /// The left mouse button.
+ Left,
+
+ /// The right mouse button.
+ Right,
+}
+
+///
+/// Simulates keyboard and mouse input directed at a target window via window messages.
+///
+public sealed class InputSimulator
+{
+ ///
+ /// Sends a sequence of character messages to .
+ ///
+ /// if every character was posted successfully.
+ public bool SendKeys(IntPtr hWnd, string text)
+ {
+ if (text is null)
+ throw new ArgumentNullException(nameof(text));
+
+ if (hWnd == IntPtr.Zero)
+ return false;
+
+ foreach (char c in text)
+ {
+ if (!NativeMethods.PostMessageW(hWnd, NativeMethods.WmChar, (nuint)c, 0))
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Sends a mouse click at client-area coordinates ,
+ /// to .
+ ///
+ /// if the click was posted successfully.
+ public bool SendMouseClick(IntPtr hWnd, int x, int y, MouseButton button)
+ {
+ if (hWnd == IntPtr.Zero)
+ return false;
+
+ nint lParam = MakeLong(x, y);
+
+ (uint down, uint up) = button switch
+ {
+ MouseButton.Left => (NativeMethods.WmLButtonDown, NativeMethods.WmLButtonUp),
+ MouseButton.Right => (NativeMethods.WmRButtonDown, NativeMethods.WmRButtonUp),
+ _ => throw new ArgumentOutOfRangeException(nameof(button)),
+ };
+
+ if (!NativeMethods.PostMessageW(hWnd, down, 0, lParam))
+ return false;
+
+ return NativeMethods.PostMessageW(hWnd, up, 0, lParam);
+ }
+
+ private static nint MakeLong(int low, int high)
+ {
+ return (nint)((uint)low | ((uint)high << 16));
+ }
+}
diff --git a/WhiteMagic/Magic.cs b/WhiteMagic/Magic.cs
new file mode 100644
index 0000000..c25a558
--- /dev/null
+++ b/WhiteMagic/Magic.cs
@@ -0,0 +1,62 @@
+using System.Diagnostics;
+using Process = System.Diagnostics.Process;
+using WhiteMagic.Execution;
+using WhiteMagic.Hooking;
+
+namespace WhiteMagic;
+
+///
+/// High-level entry point for a WhiteMagic session. Opens a process, exposes the
+/// memory reader, execution tiers, hooking managers, and the
+/// indexer.
+///
+public sealed class Magic : IDisposable
+{
+ /// The underlying memory reader for this session.
+ public MemoryBase Memory { get; }
+
+ /// Out-of-process execution via CreateRemoteThread .
+ public RemoteThreadExecutor RemoteThread { get; }
+
+ /// Named byte-patch manager.
+ public PatchManager PatchManager => Memory.PatchManager;
+
+ /// Inline-detour manager (in-process only).
+ public DetourManager DetourManager => Memory.DetourManager;
+
+ private Magic(MemoryBase memory)
+ {
+ Memory = memory;
+ RemoteThread = new RemoteThreadExecutor(memory);
+ }
+
+ /// Opens an external process for reading, writing, and execution.
+ public static Magic Open(System.Diagnostics.Process process)
+ {
+ return new Magic(new ExternalReader(process));
+ }
+
+ /// Creates an in-process session for the current process.
+ public static Magic OpenInProcess()
+ {
+ return new Magic(new InProcessReader());
+ }
+
+ ///
+ /// Creates a main-thread pump that hooks the per-frame function at
+ /// .
+ ///
+ public MainThreadPump CreateMainThreadPump(IntPtr frameAddress)
+ {
+ return new MainThreadPump(DetourManager, frameAddress);
+ }
+
+ /// Returns a at .
+ public RemotePointer this[IntPtr address] => new RemotePointer(Memory, address);
+
+ ///
+ public void Dispose()
+ {
+ Memory.Dispose();
+ }
+}
diff --git a/WhiteMagic/Memory/AllocatedMemory.cs b/WhiteMagic/Memory/AllocatedMemory.cs
new file mode 100644
index 0000000..1d31ba8
--- /dev/null
+++ b/WhiteMagic/Memory/AllocatedMemory.cs
@@ -0,0 +1,180 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Memory;
+
+///
+/// Represents a chunk of remote memory subdivided into named regions.
+///
+public sealed class AllocatedMemory : IDisposable
+{
+ private readonly MemoryBase _memory;
+ private readonly IntPtr _baseAddress;
+ private readonly int _size;
+ private readonly Dictionary _regions;
+ private bool _disposed;
+
+ ///
+ /// Creates a new allocated memory chunk.
+ ///
+ /// The memory accessor.
+ /// The size of the allocation in bytes.
+ /// The initial memory protection.
+ /// Allocation fails.
+ public AllocatedMemory(MemoryBase memory, int size, MemoryProtectionType protection = MemoryProtectionType.ExecuteReadWrite)
+ {
+ ArgumentNullException.ThrowIfNull(memory);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size);
+
+ _memory = memory;
+ _size = size;
+ _regions = new Dictionary();
+
+ // Allocate using VirtualAllocEx
+ _baseAddress = NativeMethods.VirtualAllocEx(
+ memory.Handle,
+ IntPtr.Zero,
+ size,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ protection);
+
+ if (_baseAddress == IntPtr.Zero)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new Win32Exception(error, $"VirtualAllocEx failed (size={size}).");
+ }
+ }
+
+ ///
+ /// Gets the base address of the allocated memory.
+ ///
+ public IntPtr BaseAddress => _baseAddress;
+
+ ///
+ /// Gets the size of the allocation in bytes.
+ ///
+ public int Size => _size;
+
+ ///
+ /// Adds a named region at a specific offset within the allocation.
+ ///
+ /// The unique name for the region.
+ /// The offset from the base address.
+ /// A region with this name already exists.
+ /// Offset is outside the allocation bounds.
+ public void AddRegion(string name, int offset)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(name);
+
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(offset, _size);
+
+ if (_regions.ContainsKey(name))
+ throw new ArgumentException($"Region '{name}' already exists.", nameof(name));
+
+ _regions[name] = offset;
+ }
+
+ ///
+ /// Gets the absolute address of a named region.
+ ///
+ /// The region name.
+ /// The absolute address of the region.
+ /// No region with this name exists.
+ public IntPtr AddressOf(string name)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(name);
+
+ if (!_regions.TryGetValue(name, out int offset))
+ throw new ArgumentException($"Region '{name}' does not exist.", nameof(name));
+
+ return _baseAddress + offset;
+ }
+
+ ///
+ /// Reads a value of type from a named region.
+ ///
+ /// The value type.
+ /// The region name.
+ /// The value read from memory.
+ /// No region with this name exists.
+ public T Read(string name) where T : struct
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ IntPtr address = AddressOf(name);
+ return _memory.Read(address);
+ }
+
+ ///
+ /// Writes a value of type to a named region.
+ ///
+ /// The value type.
+ /// The region name.
+ /// The value to write.
+ /// if all bytes were written.
+ /// No region with this name exists.
+ public bool Write(string name, T value) where T : struct
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ IntPtr address = AddressOf(name);
+ return _memory.Write(address, value);
+ }
+
+ ///
+ /// Reads bytes from a named region.
+ ///
+ /// The region name.
+ /// The number of bytes to read.
+ /// The bytes read from memory.
+ /// No region with this name exists.
+ public byte[] ReadBytes(string name, int count)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ IntPtr address = AddressOf(name);
+ return _memory.ReadBytes(address, count);
+ }
+
+ ///
+ /// Writes bytes to a named region.
+ ///
+ /// The region name.
+ /// The bytes to write.
+ /// The number of bytes written.
+ /// No region with this name exists.
+ public int WriteBytes(string name, ReadOnlySpan bytes)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ IntPtr address = AddressOf(name);
+ return _memory.WriteBytes(address, bytes);
+ }
+
+ ///
+ /// Frees the allocated memory.
+ ///
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _disposed = true;
+
+ // Free using VirtualFreeEx
+ if (_baseAddress != IntPtr.Zero)
+ {
+ NativeMethods.VirtualFreeEx(
+ _memory.Handle,
+ _baseAddress,
+ 0,
+ MemoryFreeType.Release);
+ }
+
+ _regions.Clear();
+ }
+ }
+}
diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs
index 90c9486..9e59822 100644
--- a/WhiteMagic/MemoryBase.cs
+++ b/WhiteMagic/MemoryBase.cs
@@ -1,3 +1,4 @@
+using WhiteMagic.Hooking;
using WhiteMagic.Native;
using System.Runtime.InteropServices;
using System.Text;
@@ -12,12 +13,31 @@ namespace WhiteMagic;
///
public abstract class MemoryBase : IDisposable
{
+ /// Creates the shared hooking managers for this memory instance.
+ protected MemoryBase()
+ {
+ PatchManager = new PatchManager(this);
+ DetourManager = new DetourManager(this);
+ }
+
/// 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; }
+ /// if the target process is 64-bit.
+ public abstract bool Is64Bit { get; }
+
+ /// The operating-system process identifier of the target process.
+ public abstract int ProcessId { get; }
+
+ /// Named byte-patch manager; valid for in-process and external readers.
+ public PatchManager PatchManager { get; }
+
+ /// Inline-detour manager; valid only when operating in-process.
+ public DetourManager DetourManager { get; }
+
// ── Raw byte IO ────────────────────────────────────────────────────────
/// Reads a sequence of bytes from the target address.
@@ -153,11 +173,17 @@ public abstract class MemoryBase : IDisposable
/// 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 address to read from. For multi-byte encodings this must be
+ /// aligned to a code-unit boundary or the result is undefined.
/// The text encoding.
/// The maximum number of bytes to read.
/// If , is relative
/// to .
+ ///
+ /// The scan is aligned to the encoding's code-unit width (1 byte for UTF-8/ASCII, 2 bytes
+ /// for UTF-16, 4 bytes for UTF-32). The trailing bytes of each chunk are merged with the
+ /// next chunk so a null terminator that straddles the chunk boundary is not missed.
+ ///
public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false)
{
if (relative)
@@ -166,46 +192,54 @@ public abstract class MemoryBase : IDisposable
// 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");
+ int nullLen = nullTerminator.Length;
const int chunkSize = 64;
int remaining = maxLength;
- var accumulated = new System.Collections.Generic.List();
+ var accumulated = new System.Collections.Generic.List();
while (remaining > 0)
{
int take = Math.Min(chunkSize, remaining);
- byte[] chunk = ReadBytes(address, take);
+ byte[] chunk = ReadBytes(address + accumulated.Count, take);
if (chunk.Length == 0)
break;
- int nullPos = IndexOfPattern(chunk, nullTerminator);
- if (nullPos >= 0)
+ int previousLen = accumulated.Count;
+ accumulated.AddRange(chunk);
+
+ // Search the newly extended buffer at code-unit-aligned positions. A terminator
+ // can start as far back as (nullLen - 1) bytes before the new bytes, so start
+ // the search just before the previous end, rounded up to the next code-unit.
+ int firstAligned = ((previousLen - nullLen + 1 + nullLen - 1) / nullLen) * nullLen;
+ firstAligned = Math.Max(0, firstAligned);
+
+ int limit = accumulated.Count - nullLen;
+ for (int i = firstAligned; i <= limit; i += nullLen)
{
- if (nullPos > 0)
- accumulated.Add(chunk[..nullPos]);
- break;
+ bool match = true;
+ for (int j = 0; j < nullLen; j++)
+ {
+ if (accumulated[i + j] != nullTerminator[j])
+ {
+ match = false;
+ break;
+ }
+ }
+
+ if (match)
+ {
+ accumulated.RemoveRange(i, accumulated.Count - i);
+ remaining = 0;
+ break;
+ }
}
- accumulated.Add(chunk);
- // Advance by the bytes actually read, not the amount requested: a partial
- // read (chunk.Length < take) must not skip the unread tail of the window.
- address += chunk.Length;
- remaining -= chunk.Length;
+ if (remaining > 0)
+ remaining -= chunk.Length;
}
- 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);
+ return encoding.GetString(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(accumulated));
}
/// Writes a null-terminated string to the target address.
@@ -239,6 +273,8 @@ public abstract class MemoryBase : IDisposable
///
public virtual void Dispose()
{
+ DetourManager.RemoveAll();
+ PatchManager.RestoreAll();
Handle?.Dispose();
}
@@ -278,24 +314,4 @@ public abstract class MemoryBase : IDisposable
bytes.CopyTo(destination);
}
- private static int IndexOfPattern(byte[] data, byte[] pattern)
- {
- int lastStart = data.Length - pattern.Length;
- int stride = Math.Max(1, pattern.Length);
- for (int i = 0; i <= lastStart; i += stride)
- {
- 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
index 47f15fd..1d690b7 100644
--- a/WhiteMagic/Native/NativeEnums.cs
+++ b/WhiteMagic/Native/NativeEnums.cs
@@ -33,6 +33,28 @@ public enum ProcessAccess : uint
AllAccess = 0x001F0000 | Synchronize | 0xFFFF,
}
+///
+/// Access rights that open a thread object.
+///
+[Flags]
+public enum ThreadAccess : uint
+{
+ /// The right to terminate the thread with TerminateThread.
+ Terminate = 0x0001,
+ /// The right to suspend and resume the thread.
+ SuspendResume = 0x0002,
+ /// The right to read the thread context with GetThreadContext.
+ GetContext = 0x0008,
+ /// The right to set the thread context with SetThreadContext.
+ SetContext = 0x0010,
+ /// The right to query information from the thread.
+ QueryInformation = 0x0040,
+ /// The right to set information on the thread.
+ SetInformation = 0x0020,
+ /// All access rights for a thread object.
+ AllAccess = 0x001F0FFF,
+}
+
///
/// Values that control how VirtualAllocEx allocates memory.
///
diff --git a/WhiteMagic/Native/NativeMethods.cs b/WhiteMagic/Native/NativeMethods.cs
index d6b3fe9..5160985 100644
--- a/WhiteMagic/Native/NativeMethods.cs
+++ b/WhiteMagic/Native/NativeMethods.cs
@@ -19,11 +19,32 @@ internal static partial class NativeMethods
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
int processId);
+ /// Opens an existing thread and returns a handle to it.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial SafeMemoryHandle OpenThread(
+ ThreadAccess desiredAccess,
+ [MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
+ int threadId);
+
/// Closes an open object handle.
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CloseHandle(IntPtr handle);
+ /// Determines whether the specified process is running under WOW64.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool IsWow64Process(
+ SafeMemoryHandle process,
+ [MarshalAs(UnmanagedType.Bool)] out bool wow64Process);
+
+ /// Retrieves the termination status of the specified thread.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool GetExitCodeThread(
+ SafeMemoryHandle thread,
+ out uint exitCode);
+
// ── Memory ───────────────────────────────────────────────────────────────
/// Reads memory from a process.
@@ -87,6 +108,22 @@ internal static partial class NativeMethods
ThreadCreationFlags creationFlags,
out uint threadId);
+ /// Suspends the specified thread.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial uint SuspendThread(SafeMemoryHandle thread);
+
+ /// Resumes the specified thread.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial uint ResumeThread(SafeMemoryHandle thread);
+
+ /// Returns the thread identifier of the specified thread.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial uint GetThreadId(SafeMemoryHandle thread);
+
+ /// Returns the identifier of the calling thread.
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial uint GetCurrentThreadId();
+
/// Sets a 64-bit thread context (AMD64).
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
@@ -134,4 +171,5 @@ internal static partial class NativeMethods
internal static partial uint WaitForSingleObject(
SafeMemoryHandle handle,
uint milliseconds);
+
}
diff --git a/WhiteMagic/Native/SystemMethods.cs b/WhiteMagic/Native/SystemMethods.cs
new file mode 100644
index 0000000..aec66d0
--- /dev/null
+++ b/WhiteMagic/Native/SystemMethods.cs
@@ -0,0 +1,178 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace WhiteMagic.Native;
+
+///
+/// P/Invoke declarations for kernel32/ntdll/user32 APIs used by the high-level
+/// PEB, TEB, windowing, and input helpers. These live in a separate partial file so
+/// they can evolve independently of .
+///
+internal static partial class NativeMethods
+{
+ // ── Natives used directly by public helpers ──────────────────────────────
+
+ /// Queries information about the specified process.
+ [LibraryImport("ntdll.dll")]
+ internal static partial int NtQueryInformationProcess(
+ SafeMemoryHandle processHandle,
+ int processInformationClass,
+ ref ProcessBasicInformation processInformation,
+ uint processInformationLength,
+ out uint returnLength);
+
+ /// Queries information about the specified thread.
+ [LibraryImport("ntdll.dll")]
+ internal static partial int NtQueryInformationThread(
+ SafeMemoryHandle threadHandle,
+ int threadInformationClass,
+ ref ThreadBasicInformation threadInformation,
+ uint threadInformationLength,
+ out uint returnLength);
+
+ /// Enumerates all top-level windows on the screen.
+ [LibraryImport("user32.dll", SetLastError = true)]
+ internal static partial int EnumWindows(
+ nint lpEnumFunc,
+ IntPtr lParam);
+
+ /// Retrieves the identifier of the thread that created the window and the process id of the window.
+ [LibraryImport("user32.dll", SetLastError = true)]
+ internal static partial uint GetWindowThreadProcessId(
+ IntPtr hWnd,
+ out uint lpdwProcessId);
+
+ /// Retrieves the name of the class to which the specified window belongs.
+ [LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
+ internal static partial int GetClassNameW(
+ IntPtr hWnd,
+ [Out] char[] lpClassName,
+ int nMaxCount);
+
+ /// Copies the text of the specified window's title bar into a buffer.
+ [LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
+ internal static partial int GetWindowTextW(
+ IntPtr hWnd,
+ [Out] char[] lpString,
+ int nMaxCount);
+
+ /// Changes the text of the specified window's title bar.
+ [LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool SetWindowTextW(
+ IntPtr hWnd,
+ string lpString);
+
+ /// Changes the size, position, and Z order of a child, pop-up, or top-level window.
+ [LibraryImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool SetWindowPos(
+ IntPtr hWnd,
+ IntPtr hWndInsertAfter,
+ int x,
+ int y,
+ int cx,
+ int cy,
+ uint uFlags);
+
+ /// Retrieves a handle to the foreground window.
+ [LibraryImport("user32.dll", SetLastError = true)]
+ internal static partial IntPtr GetForegroundWindow();
+
+ /// Brings the thread that created the specified window into the foreground and activates the window.
+ [LibraryImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool SetForegroundWindow(IntPtr hWnd);
+
+ /// Flashes the specified window.
+ [LibraryImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool FlashWindowEx(ref FlashWindowInfo pwfi);
+
+ /// Attaches or detaches the input processing mechanism of one thread to that of another thread.
+ [LibraryImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool AttachThreadInput(
+ uint idAttach,
+ uint idAttachTo,
+ [MarshalAs(UnmanagedType.Bool)] bool fAttach);
+
+ /// Places a message in the message queue associated with the thread that created the specified window.
+ [LibraryImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool PostMessageW(
+ IntPtr hWnd,
+ uint msg,
+ nuint wParam,
+ nint lParam);
+
+ // ── Window / input constants ───────────────────────────────────────────────
+
+ internal const uint WmChar = 0x0102;
+ internal const uint WmLButtonDown = 0x0201;
+ internal const uint WmLButtonUp = 0x0202;
+ internal const uint WmRButtonDown = 0x0204;
+ internal const uint WmRButtonUp = 0x0205;
+
+ internal static readonly IntPtr HwndTop = IntPtr.Zero;
+
+ internal const uint SwpShowWindow = 0x0040;
+ internal const uint SwpNoActivate = 0x0010;
+
+ internal const uint FlashwAll = 0x00000003;
+ internal const uint FlashwCaption = 0x00000001;
+ internal const uint FlashwTray = 0x00000002;
+ internal const uint FlashwTimer = 0x00000004;
+ internal const uint FlashwTimerNoFg = 0x0000000C;
+}
+
+///
+/// Layout matches PROCESS_BASIC_INFORMATION (ProcessBasicInformation = 0).
+///
+[StructLayout(LayoutKind.Sequential)]
+internal struct ProcessBasicInformation
+{
+ public int ExitStatus;
+ public IntPtr PebBaseAddress;
+ public UIntPtr AffinityMask;
+ public int BasePriority;
+ public UIntPtr UniqueProcessId;
+ public UIntPtr InheritedFromUniqueProcessId;
+}
+
+///
+/// Layout matches THREAD_BASIC_INFORMATION (ThreadBasicInformation = 0).
+///
+[StructLayout(LayoutKind.Sequential)]
+internal struct ThreadBasicInformation
+{
+ public int ExitStatus;
+ public IntPtr TebBaseAddress;
+ public ClientId ClientId;
+ public UIntPtr AffinityMask;
+ public int Priority;
+ public int BasePriority;
+}
+
+///
+/// Layout matches CLIENT_ID .
+///
+[StructLayout(LayoutKind.Sequential)]
+internal struct ClientId
+{
+ public IntPtr UniqueProcess;
+ public IntPtr UniqueThread;
+}
+
+///
+/// Layout matches FLASHWINFO used by .
+///
+[StructLayout(LayoutKind.Sequential)]
+internal struct FlashWindowInfo
+{
+ public uint cbSize;
+ public IntPtr hwnd;
+ public uint dwFlags;
+ public uint uCount;
+ public uint dwTimeout;
+}
diff --git a/WhiteMagic/Process/ManagedPeb.cs b/WhiteMagic/Process/ManagedPeb.cs
new file mode 100644
index 0000000..00a9f51
--- /dev/null
+++ b/WhiteMagic/Process/ManagedPeb.cs
@@ -0,0 +1,92 @@
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.ProcessEnvironment;
+
+///
+/// Managed reader for a target process's Process Environment Block (PEB).
+///
+public sealed class ManagedPeb
+{
+ private readonly MemoryBase _memory;
+ private readonly IntPtr _pebAddress;
+
+ ///
+ /// Creates a PEB reader for the process associated with the specified memory facade.
+ ///
+ public ManagedPeb(MemoryBase memory)
+ {
+ _memory = memory ?? throw new ArgumentNullException(nameof(memory));
+ _pebAddress = QueryPebAddress();
+ }
+
+ /// Returns the native address of the PEB in the target process.
+ public IntPtr ReadPebAddress() => _pebAddress;
+
+ /// Reads the BeingDebugged byte from the PEB.
+ public byte ReadBeingDebugged()
+ {
+ return _memory.Read(_pebAddress + 2);
+ }
+
+ /// Reads the ImageBaseAddress pointer from the PEB.
+ public IntPtr ReadImageBaseAddress()
+ {
+ int offset = _memory.Is64Bit ? 0x10 : 0x08;
+ return ReadPointer(offset);
+ }
+
+ /// Reads the PEB_LDR_DATA pointer from the PEB.
+ public IntPtr ReadLdrAddress()
+ {
+ int offset = _memory.Is64Bit ? 0x18 : 0x0C;
+ return ReadPointer(offset);
+ }
+
+ ///
+ /// Determines whether the target process is running under WOW64.
+ ///
+ public bool ReadIsWow64Process()
+ {
+ if (!NativeMethods.IsWow64Process(_memory.Handle, out bool wow64))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException($"IsWow64Process failed with error {error}.");
+ }
+
+ return wow64;
+ }
+
+ private IntPtr QueryPebAddress()
+ {
+ var info = new ProcessBasicInformation();
+ int status = NativeMethods.NtQueryInformationProcess(
+ _memory.Handle,
+ 0,
+ ref info,
+ (uint)Marshal.SizeOf(),
+ out _);
+
+ if (status < 0 || info.PebBaseAddress == IntPtr.Zero)
+ {
+ throw new InvalidOperationException(
+ $"NtQueryInformationProcess failed to retrieve the PEB (NTSTATUS {status:X8}).");
+ }
+
+ return info.PebBaseAddress;
+ }
+
+ private IntPtr ReadPointer(int offset)
+ {
+ IntPtr address = _pebAddress + offset;
+
+ if (_memory.Is64Bit)
+ {
+ ulong raw = _memory.Read(address);
+ return new IntPtr((long)raw);
+ }
+
+ uint raw32 = _memory.Read(address);
+ return new IntPtr((int)raw32);
+ }
+}
diff --git a/WhiteMagic/RemotePointer.cs b/WhiteMagic/RemotePointer.cs
new file mode 100644
index 0000000..fae0bf8
--- /dev/null
+++ b/WhiteMagic/RemotePointer.cs
@@ -0,0 +1,49 @@
+using System.Text;
+
+namespace WhiteMagic;
+
+///
+/// A pointer-relative view over a . Obtained through the
+/// high-level facade indexer, it provides read/write/string operations with optional
+/// offsets relative to a base address.
+///
+public sealed class RemotePointer
+{
+ private readonly MemoryBase _memory;
+
+ /// The base address of this view.
+ public IntPtr BaseAddress { get; }
+
+ internal RemotePointer(MemoryBase memory, IntPtr baseAddress)
+ {
+ _memory = memory;
+ BaseAddress = baseAddress;
+ }
+
+ /// Reads a value of type at BaseAddress + offset .
+ public T Read(nint offset = 0) where T : struct
+ {
+ return _memory.Read(BaseAddress + offset);
+ }
+
+ /// Writes at BaseAddress + offset .
+ public bool Write(T value, nint offset = 0) where T : struct
+ {
+ return _memory.Write(BaseAddress + offset, value);
+ }
+
+ /// Reads a null-terminated string at BaseAddress + offset .
+ public string ReadString(Encoding encoding, int maxLength = 512, nint offset = 0)
+ {
+ return _memory.ReadString(BaseAddress + offset, encoding ?? Encoding.UTF8, maxLength);
+ }
+
+ /// Writes a null-terminated string at BaseAddress + offset .
+ public bool WriteString(string value, Encoding encoding, nint offset = 0)
+ {
+ return _memory.WriteString(BaseAddress + offset, value, encoding ?? Encoding.UTF8);
+ }
+
+ /// Returns a new with the offset added.
+ public RemotePointer this[nint offset] => new RemotePointer(_memory, BaseAddress + offset);
+}
diff --git a/WhiteMagic/Thread/ManagedTeb.cs b/WhiteMagic/Thread/ManagedTeb.cs
new file mode 100644
index 0000000..8d468e1
--- /dev/null
+++ b/WhiteMagic/Thread/ManagedTeb.cs
@@ -0,0 +1,98 @@
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.ThreadEnvironment;
+
+///
+/// Managed reader for a target thread's Thread Environment Block (TEB).
+///
+public sealed class ManagedTeb : IDisposable
+{
+ private readonly MemoryBase _memory;
+ private readonly SafeMemoryHandle _threadHandle;
+ private readonly IntPtr _tebAddress;
+ private bool _disposed;
+
+ ///
+ /// Creates a TEB reader for the specified thread in the process associated
+ /// with the provided memory facade.
+ ///
+ public ManagedTeb(MemoryBase memory, int threadId)
+ {
+ _memory = memory ?? throw new ArgumentNullException(nameof(memory));
+
+ _threadHandle = NativeMethods.OpenThread(
+ ThreadAccess.QueryInformation,
+ false,
+ threadId);
+
+ if (_threadHandle.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"OpenThread failed for thread {threadId}: error {error}.");
+ }
+
+ _tebAddress = QueryTebAddress();
+ }
+
+ /// Returns the native address of the TEB in the target process.
+ public IntPtr ReadTebAddress() => _tebAddress;
+
+ /// Reads the stack base pointer stored in the TEB.
+ public IntPtr ReadStackBase()
+ {
+ int offset = _memory.Is64Bit ? 0x08 : 0x04;
+ return ReadPointer(offset);
+ }
+
+ /// Reads the stack limit pointer stored in the TEB.
+ public IntPtr ReadStackLimit()
+ {
+ int offset = _memory.Is64Bit ? 0x10 : 0x08;
+ return ReadPointer(offset);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _disposed = true;
+ _threadHandle.Dispose();
+ }
+ }
+
+ private IntPtr QueryTebAddress()
+ {
+ var info = new ThreadBasicInformation();
+ int status = NativeMethods.NtQueryInformationThread(
+ _threadHandle,
+ 0,
+ ref info,
+ (uint)Marshal.SizeOf(),
+ out _);
+
+ if (status < 0 || info.TebBaseAddress == IntPtr.Zero)
+ {
+ throw new InvalidOperationException(
+ $"NtQueryInformationThread failed to retrieve the TEB (NTSTATUS {status:X8}).");
+ }
+
+ return info.TebBaseAddress;
+ }
+
+ private IntPtr ReadPointer(int offset)
+ {
+ IntPtr address = _tebAddress + offset;
+
+ if (_memory.Is64Bit)
+ {
+ ulong raw = _memory.Read(address);
+ return new IntPtr((long)raw);
+ }
+
+ uint raw32 = _memory.Read(address);
+ return new IntPtr((int)raw32);
+ }
+}
diff --git a/WhiteMagic/Windows/RemoteWindow.cs b/WhiteMagic/Windows/RemoteWindow.cs
new file mode 100644
index 0000000..7d8aad7
--- /dev/null
+++ b/WhiteMagic/Windows/RemoteWindow.cs
@@ -0,0 +1,147 @@
+using System.Runtime.InteropServices;
+using System.Text;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Windows;
+
+///
+/// Wrapper around a native window handle that supports querying and mutating
+/// common window properties.
+///
+public sealed class RemoteWindow
+{
+ private const int MaxTextLength = 512;
+
+ /// Creates a wrapper for the specified window handle.
+ public RemoteWindow(IntPtr handle)
+ {
+ if (handle == IntPtr.Zero)
+ throw new ArgumentException("Window handle cannot be zero.", nameof(handle));
+
+ Handle = handle;
+ }
+
+ /// The native window handle.
+ public IntPtr Handle { get; }
+
+ /// The window class name.
+ public string ClassName => GetClassName(Handle);
+
+ /// The current window text.
+ public string Text => GetWindowText(Handle);
+
+ /// The process identifier that owns the window.
+ public uint ProcessId => GetWindowProcessId(Handle);
+
+ /// Gets or sets the window title.
+ public string Title
+ {
+ get => GetWindowText(Handle);
+ set
+ {
+ if (value is null)
+ throw new ArgumentNullException(nameof(value));
+
+ if (!NativeMethods.SetWindowTextW(Handle, value))
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"SetWindowText failed for window {Handle} with error {error}.");
+ }
+ }
+ }
+
+ /// if this window is currently the foreground window.
+ public bool IsActive => NativeMethods.GetForegroundWindow() == Handle;
+
+ /// Moves and resizes the window.
+ public bool MoveResize(int x, int y, int width, int height)
+ {
+ return NativeMethods.SetWindowPos(
+ Handle,
+ NativeMethods.HwndTop,
+ x,
+ y,
+ width,
+ height,
+ NativeMethods.SwpShowWindow);
+ }
+
+ /// Activates the window and brings it to the foreground.
+ public bool Activate()
+ {
+ IntPtr foreground = NativeMethods.GetForegroundWindow();
+ uint targetThread = NativeMethods.GetWindowThreadProcessId(Handle, out _);
+ uint foregroundThread = NativeMethods.GetWindowThreadProcessId(foreground, out _);
+
+ if (targetThread == 0)
+ return false;
+
+ if (targetThread == foregroundThread)
+ return NativeMethods.SetForegroundWindow(Handle);
+
+ if (!NativeMethods.AttachThreadInput(foregroundThread, targetThread, true))
+ return false;
+
+ try
+ {
+ return NativeMethods.SetForegroundWindow(Handle);
+ }
+ finally
+ {
+ NativeMethods.AttachThreadInput(foregroundThread, targetThread, false);
+ }
+ }
+
+ /// Flashes the window in the caption and taskbar button.
+ public bool Flash()
+ {
+ var info = new FlashWindowInfo
+ {
+ cbSize = (uint)Marshal.SizeOf(),
+ hwnd = Handle,
+ dwFlags = NativeMethods.FlashwAll,
+ uCount = 3,
+ dwTimeout = 0,
+ };
+
+ return NativeMethods.FlashWindowEx(ref info);
+ }
+
+ public override string ToString()
+ {
+ var sb = new StringBuilder();
+ sb.Append("RemoteWindow(");
+ sb.Append(Handle.ToString("X"));
+ sb.Append(", ");
+ sb.Append(ClassName);
+ sb.Append(")");
+ return sb.ToString();
+ }
+
+ private static string GetClassName(IntPtr handle)
+ {
+ var buffer = new char[256];
+ int length = NativeMethods.GetClassNameW(handle, buffer, buffer.Length);
+ if (length <= 0)
+ return string.Empty;
+
+ return new string(buffer, 0, length);
+ }
+
+ private static string GetWindowText(IntPtr handle)
+ {
+ var buffer = new char[MaxTextLength];
+ int length = NativeMethods.GetWindowTextW(handle, buffer, buffer.Length);
+ if (length <= 0)
+ return string.Empty;
+
+ return new string(buffer, 0, length);
+ }
+
+ private static uint GetWindowProcessId(IntPtr handle)
+ {
+ NativeMethods.GetWindowThreadProcessId(handle, out uint processId);
+ return processId;
+ }
+}
diff --git a/WhiteMagic/Windows/WindowFactory.cs b/WhiteMagic/Windows/WindowFactory.cs
new file mode 100644
index 0000000..372771e
--- /dev/null
+++ b/WhiteMagic/Windows/WindowFactory.cs
@@ -0,0 +1,74 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Runtime.CompilerServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic.Windows;
+
+///
+/// Factory for enumerating and locating instances.
+///
+public static class WindowFactory
+{
+ /// Enumerates all top-level windows.
+ public static unsafe IEnumerable GetWindows()
+ {
+ var handles = new List();
+ GCHandle gch = GCHandle.Alloc(handles);
+ try
+ {
+ delegate* unmanaged[Stdcall] callback = &EnumWindowsCallback;
+ NativeMethods.EnumWindows((nint)callback, GCHandle.ToIntPtr(gch));
+ }
+ finally
+ {
+ gch.Free();
+ }
+
+ return handles.Select(static h => new RemoteWindow(h));
+ }
+
+ /// Returns all top-level windows with the specified class name.
+ public static IEnumerable GetWindowsByClassName(string className)
+ {
+ if (className is null)
+ throw new ArgumentNullException(nameof(className));
+
+ return GetWindows().Where(w => w.ClassName.Equals(className, StringComparison.Ordinal));
+ }
+
+ /// Returns all top-level windows owned by the specified process.
+ public static IEnumerable GetWindowsByProcessId(int processId)
+ {
+ return GetWindows().Where(w => w.ProcessId == (uint)processId);
+ }
+
+ /// Returns the first top-level window with the specified class name.
+ public static RemoteWindow? GetWindowByClassName(string className)
+ {
+ return GetWindowsByClassName(className).FirstOrDefault();
+ }
+
+ ///
+ /// Returns the main window of a process. When
+ /// is unavailable, falls back to the first enumerated window owned by the process.
+ ///
+ public static RemoteWindow? GetMainWindow(System.Diagnostics.Process process)
+ {
+ if (process is null)
+ throw new ArgumentNullException(nameof(process));
+
+ if (process.MainWindowHandle != IntPtr.Zero)
+ return new RemoteWindow(process.MainWindowHandle);
+
+ return GetWindowsByProcessId(process.Id).FirstOrDefault();
+ }
+
+ [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
+ private static int EnumWindowsCallback(IntPtr hWnd, IntPtr lParam)
+ {
+ var handles = (List)GCHandle.FromIntPtr(lParam).Target!;
+ handles.Add(hWnd);
+ return 1; // Continue enumeration.
+ }
+}
diff --git a/WhiteMagicTest/Discovery/PatternScannerCacheTests.cs b/WhiteMagicTest/Discovery/PatternScannerCacheTests.cs
new file mode 100644
index 0000000..8ebfdb9
--- /dev/null
+++ b/WhiteMagicTest/Discovery/PatternScannerCacheTests.cs
@@ -0,0 +1,201 @@
+using System.Runtime.InteropServices;
+using WhiteMagic;
+using WhiteMagic.Discovery;
+
+namespace WhiteMagicTest.Discovery;
+
+///
+/// Tests for .
+///
+public class PatternScannerCacheTests
+{
+ private static InProcessReader CreateReader()
+ {
+ return new InProcessReader();
+ }
+
+ [Fact]
+ public void FindCached_returns_same_result_on_second_call()
+ {
+ using var reader = CreateReader();
+ var cache = new PatternScannerCache(reader);
+
+ // Create a buffer with a known pattern
+ byte[] buffer = new byte[256];
+ buffer[30] = 0x11;
+ buffer[31] = 0x22;
+ buffer[32] = 0x33;
+ buffer[33] = 0x44;
+
+ GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ IntPtr end = addr + buffer.Length;
+
+ byte[] pattern = { 0x11, 0x22, 0x33, 0x44 };
+
+ // First call should scan memory
+ IntPtr first = cache.FindCached(pattern, null, addr, end);
+
+ // Second call should return cached result
+ IntPtr second = cache.FindCached(pattern, null, addr, end);
+
+ Assert.Equal(addr + 30, first);
+ Assert.Equal(first, second);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public void FindCached_different_ranges_are_cached_separately()
+ {
+ using var reader = CreateReader();
+ var cache = new PatternScannerCache(reader);
+
+ // Create two separate buffers
+ byte[] buffer1 = new byte[128];
+ buffer1[10] = 0xAA;
+ buffer1[11] = 0xBB;
+
+ byte[] buffer2 = new byte[128];
+ buffer2[20] = 0xAA;
+ buffer2[21] = 0xBB;
+
+ GCHandle pin1 = GCHandle.Alloc(buffer1, GCHandleType.Pinned);
+ GCHandle pin2 = GCHandle.Alloc(buffer2, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr1 = pin1.AddrOfPinnedObject();
+ IntPtr end1 = addr1 + buffer1.Length;
+
+ IntPtr addr2 = pin2.AddrOfPinnedObject();
+ IntPtr end2 = addr2 + buffer2.Length;
+
+ byte[] pattern = { 0xAA, 0xBB };
+
+ IntPtr found1 = cache.FindCached(pattern, null, addr1, end1);
+ IntPtr found2 = cache.FindCached(pattern, null, addr2, end2);
+
+ Assert.Equal(addr1 + 10, found1);
+ Assert.Equal(addr2 + 20, found2);
+ Assert.NotEqual(found1, found2);
+ }
+ finally
+ {
+ pin1.Free();
+ pin2.Free();
+ }
+ }
+
+ [Fact]
+ public void FindCached_with_mask_caches_correctly()
+ {
+ using var reader = CreateReader();
+ var cache = new PatternScannerCache(reader);
+
+ byte[] buffer = new byte[256];
+ buffer[40] = 0x99;
+ buffer[41] = 0x88; // This is wildcard
+ buffer[42] = 0x77;
+
+ GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ IntPtr end = addr + buffer.Length;
+
+ byte[] pattern = { 0x99, 0x00, 0x77 };
+ string mask = "x?x";
+
+ IntPtr first = cache.FindCached(pattern, mask, addr, end);
+ IntPtr second = cache.FindCached(pattern, mask, addr, end);
+
+ Assert.Equal(addr + 40, first);
+ Assert.Equal(first, second);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public void Clear_clears_cached_results()
+ {
+ using var reader = CreateReader();
+ var cache = new PatternScannerCache(reader);
+
+ byte[] buffer = new byte[256];
+ buffer[50] = 0xCC;
+ buffer[51] = 0xDD;
+
+ GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ IntPtr end = addr + buffer.Length;
+
+ byte[] pattern = { 0xCC, 0xDD };
+
+ // Cache a result
+ IntPtr first = cache.FindCached(pattern, null, addr, end);
+ Assert.Equal(addr + 50, first);
+
+ // Clear the cache
+ cache.Clear();
+
+ // This should rescan (not return cached result)
+ IntPtr second = cache.FindCached(pattern, null, addr, end);
+ Assert.Equal(addr + 50, second);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public void FindInModuleCached_caches_module_scans()
+ {
+ using var reader = CreateReader();
+ var cache = new PatternScannerCache(reader);
+
+ var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
+ var mainModule = currentProcess.MainModule;
+ Assert.NotNull(mainModule);
+
+ // MZ header is always at the start of the main module
+ byte[] pattern = { 0x4D, 0x5A };
+
+ IntPtr first = cache.FindInModuleCached(pattern, null, mainModule);
+ IntPtr second = cache.FindInModuleCached(pattern, null, mainModule);
+
+ Assert.Equal(mainModule.BaseAddress, first);
+ Assert.Equal(first, second);
+ }
+
+ [Fact]
+ public void FindInModulesCached_caches_multiple_modules()
+ {
+ using var reader = CreateReader();
+ var cache = new PatternScannerCache(reader);
+
+ var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
+ var modules = currentProcess.Modules.Cast().ToList();
+
+ Assert.NotEmpty(modules);
+
+ // MZ header should be present in at least one module
+ byte[] pattern = { 0x4D, 0x5A };
+
+ IntPtr first = cache.FindInModulesCached(pattern, null, modules);
+ IntPtr second = cache.FindInModulesCached(pattern, null, modules);
+
+ Assert.NotEqual(IntPtr.Zero, first);
+ Assert.Equal(first, second);
+ }
+}
diff --git a/WhiteMagicTest/Discovery/PatternScannerTests.cs b/WhiteMagicTest/Discovery/PatternScannerTests.cs
new file mode 100644
index 0000000..3c634a8
--- /dev/null
+++ b/WhiteMagicTest/Discovery/PatternScannerTests.cs
@@ -0,0 +1,188 @@
+using System.Runtime.InteropServices;
+using WhiteMagic;
+using WhiteMagic.Discovery;
+using WhiteMagicTest;
+
+namespace WhiteMagicTest.Discovery;
+
+///
+/// Tests for .
+///
+public class PatternScannerTests
+{
+ private static InProcessReader CreateReader()
+ {
+ return new InProcessReader();
+ }
+
+ [Fact]
+ public void Find_exact_pattern_returns_correct_address()
+ {
+ using var reader = CreateReader();
+
+ // Create a buffer with known bytes
+ byte[] buffer = new byte[256];
+ buffer[10] = 0xDE;
+ buffer[11] = 0xAD;
+ buffer[12] = 0xBE;
+ buffer[13] = 0xEF;
+
+ GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ IntPtr end = addr + buffer.Length;
+
+ // Search for the exact pattern
+ byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
+ IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
+
+ Assert.Equal(addr + 10, found);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public void Find_with_wildcard_mask_ignores_wildcard_bytes()
+ {
+ using var reader = CreateReader();
+
+ // Create a buffer with known bytes
+ byte[] buffer = new byte[256];
+ buffer[20] = 0x12;
+ buffer[21] = 0x34; // This byte is wildcard
+ buffer[22] = 0x56;
+ buffer[23] = 0x78;
+
+ GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ IntPtr end = addr + buffer.Length;
+
+ // Search with wildcard mask (x = match, ? = wildcard)
+ byte[] pattern = { 0x12, 0x00, 0x56, 0x78 };
+ string mask = "x?xx"; // Second byte is wildcard
+ IntPtr found = PatternScanner.Find(reader, pattern, mask, addr, end);
+
+ Assert.Equal(addr + 20, found);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public void Find_pattern_not_found_returns_zero()
+ {
+ using var reader = CreateReader();
+
+ // Create a buffer without the target pattern
+ byte[] buffer = new byte[256];
+ for (int i = 0; i < buffer.Length; i++)
+ buffer[i] = 0xAA;
+
+ GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ IntPtr end = addr + buffer.Length;
+
+ // Search for pattern that doesn't exist
+ byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
+ IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
+
+ Assert.Equal(IntPtr.Zero, found);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public void Find_empty_pattern_throws()
+ {
+ using var reader = CreateReader();
+
+ byte[] pattern = Array.Empty();
+ var ex = Assert.Throws(() =>
+ PatternScanner.Find(reader, pattern, null, IntPtr.Zero, (IntPtr)1000));
+
+ Assert.Contains("Pattern cannot be empty", ex.Message);
+ }
+
+ [Fact]
+ public void Find_mask_length_mismatch_throws()
+ {
+ using var reader = CreateReader();
+
+ byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
+ string mask = "xxx"; // Wrong length
+
+ var ex = Assert.Throws(() =>
+ PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
+
+ Assert.Contains("Mask length", ex.Message);
+ }
+
+ [Fact]
+ public void Find_invalid_mask_char_throws()
+ {
+ using var reader = CreateReader();
+
+ byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
+ string mask = "axxx"; // 'a' is invalid
+
+ var ex = Assert.Throws(() =>
+ PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
+ }
+
+ [Fact]
+ public void Find_null_mask_treats_all_as_exact()
+ {
+ using var reader = CreateReader();
+
+ byte[] buffer = new byte[256];
+ buffer[50] = 0xAB;
+ buffer[51] = 0xCD;
+
+ GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ IntPtr end = addr + buffer.Length;
+
+ // Null mask should behave like "xx" (exact match)
+ byte[] pattern = { 0xAB, 0xCD };
+ IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
+
+ Assert.Equal(addr + 50, found);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public void FindInModule_scans_current_process_module()
+ {
+ using var reader = CreateReader();
+
+ // Get the current process's main module
+ var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
+ var mainModule = currentProcess.MainModule;
+ Assert.NotNull(mainModule);
+
+ // MZ header is always at the start of the main module
+ byte[] pattern = { 0x4D, 0x5A };
+ IntPtr found = PatternScanner.FindInModule(reader, pattern, null, mainModule);
+
+ Assert.Equal(mainModule.BaseAddress, found);
+ }
+}
diff --git a/WhiteMagicTest/Discovery/PeHeaderParserTests.cs b/WhiteMagicTest/Discovery/PeHeaderParserTests.cs
new file mode 100644
index 0000000..e7bd49a
--- /dev/null
+++ b/WhiteMagicTest/Discovery/PeHeaderParserTests.cs
@@ -0,0 +1,156 @@
+using WhiteMagic;
+using WhiteMagic.Discovery;
+
+namespace WhiteMagicTest.Discovery;
+
+///
+/// Tests for .
+///
+public class PeHeaderParserTests
+{
+ private static InProcessReader CreateReader()
+ {
+ return new InProcessReader();
+ }
+
+ [Fact]
+ public void EntryPoint_returns_nonzero_for_current_module()
+ {
+ using var reader = CreateReader();
+
+ var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
+ var mainModule = currentProcess.MainModule;
+ Assert.NotNull(mainModule);
+
+ var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
+ IntPtr entryPoint = parser.EntryPoint;
+
+ // Entry point should be a valid RVA (non-zero for a valid PE)
+ Assert.NotEqual(IntPtr.Zero, entryPoint);
+
+ // Entry point should be less than module size
+ Assert.True((nint)entryPoint < mainModule.ModuleMemorySize);
+ }
+
+ [Fact]
+ public void Sections_enumerates_at_least_text_section()
+ {
+ using var reader = CreateReader();
+
+ var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
+ var mainModule = currentProcess.MainModule;
+ Assert.NotNull(mainModule);
+
+ var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
+ var sections = parser.Sections.ToList();
+
+ Assert.NotEmpty(sections);
+
+ // Every PE file should have a .text section (or similar)
+ var textSection = sections.FirstOrDefault(s =>
+ s.Name.Equals(".text", StringComparison.OrdinalIgnoreCase) ||
+ s.Name.Equals("TEXT", StringComparison.OrdinalIgnoreCase));
+
+ // May not find ".text" exactly, but should have at least some sections
+ Assert.True(sections.Count >= 1);
+ }
+
+ [Fact]
+ public void Sections_have_valid_properties()
+ {
+ using var reader = CreateReader();
+
+ var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
+ var mainModule = currentProcess.MainModule;
+ Assert.NotNull(mainModule);
+
+ var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
+ var sections = parser.Sections.ToList();
+
+ foreach (var section in sections)
+ {
+ // Name should not be empty
+ Assert.False(string.IsNullOrWhiteSpace(section.Name));
+
+ // Virtual address should be within module bounds
+ Assert.True((nint)section.VirtualAddress < mainModule.ModuleMemorySize);
+
+ // Virtual size should be positive
+ Assert.True(section.VirtualSize > 0);
+ }
+ }
+
+ [Fact]
+ public void Sections_have_common_names()
+ {
+ using var reader = CreateReader();
+
+ var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
+ var mainModule = currentProcess.MainModule;
+ Assert.NotNull(mainModule);
+
+ var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
+ var sections = parser.Sections.Select(s => s.Name).ToList();
+
+ // At least some common section names should be present
+ var commonNames = new[] { ".text", ".data", ".rdata", ".bss" };
+ bool hasCommonSection = commonNames.Any(name =>
+ sections.Contains(name, StringComparer.OrdinalIgnoreCase));
+
+ // This might not always be true, but for managed EXEs it usually is
+ // We'll just verify sections were enumerated
+ Assert.NotEmpty(sections);
+ }
+
+ [Fact]
+ public void EntryPoint_is_consistent_across_calls()
+ {
+ using var reader = CreateReader();
+
+ var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
+ var mainModule = currentProcess.MainModule;
+ Assert.NotNull(mainModule);
+
+ var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
+
+ IntPtr first = parser.EntryPoint;
+ IntPtr second = parser.EntryPoint;
+
+ Assert.Equal(first, second);
+ }
+
+ [Fact]
+ public void Sections_are_consistent_across_calls()
+ {
+ using var reader = CreateReader();
+
+ var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
+ var mainModule = currentProcess.MainModule;
+ Assert.NotNull(mainModule);
+
+ var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
+
+ var first = parser.Sections.ToList();
+ var second = parser.Sections.ToList();
+
+ Assert.Equal(first.Count, second.Count);
+
+ for (int i = 0; i < first.Count; i++)
+ {
+ Assert.Equal(first[i].Name, second[i].Name);
+ Assert.Equal(first[i].VirtualAddress, second[i].VirtualAddress);
+ Assert.Equal(first[i].VirtualSize, second[i].VirtualSize);
+ }
+ }
+
+ [Fact]
+ public void Constructor_throws_on_zero_base_address()
+ {
+ using var reader = CreateReader();
+
+ var ex = Assert.Throws(() =>
+ new PeHeaderParser(reader, IntPtr.Zero));
+
+ Assert.Contains("Base address cannot be zero", ex.Message);
+ }
+}
diff --git a/WhiteMagicTest/Execution/InProcessInvokerTests.cs b/WhiteMagicTest/Execution/InProcessInvokerTests.cs
new file mode 100644
index 0000000..8bda667
--- /dev/null
+++ b/WhiteMagicTest/Execution/InProcessInvokerTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Runtime.InteropServices;
+using WhiteMagic;
+using WhiteMagic.Execution;
+using Xunit;
+
+namespace WhiteMagicTest.Execution;
+
+///
+/// Tests for operating in-process.
+///
+public class InProcessInvokerTests
+{
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate int AddDelegate(int a, int b);
+
+ private static int NativeAdd(int a, int b) => a + b;
+
+ [Fact]
+ public void CreateFunction_calls_known_in_process_function()
+ {
+ using var reader = new InProcessReader();
+ var invoker = new InProcessInvoker(reader);
+
+ var native = new AddDelegate(NativeAdd);
+ IntPtr functionPointer = Marshal.GetFunctionPointerForDelegate(native);
+
+ AddDelegate callable = invoker.CreateFunction(functionPointer);
+ int result = callable(5, 7);
+
+ Assert.Equal(12, result);
+ GC.KeepAlive(native);
+ }
+
+ [Fact]
+ public void CreateFunction_rejects_zero_address()
+ {
+ using var reader = new InProcessReader();
+ var invoker = new InProcessInvoker(reader);
+
+ ArgumentException ex = Assert.Throws(() => invoker.CreateFunction(IntPtr.Zero));
+ Assert.Equal("address", ex.ParamName);
+ }
+
+ [Fact]
+ public void Vtable_helper_reads_function_pointer_slot()
+ {
+ using var reader = new InProcessReader();
+ var invoker = new InProcessInvoker(reader);
+
+ // Build a tiny fake vtable in a pinned buffer: two slots holding known pointers.
+ IntPtr slot0 = Marshal.GetFunctionPointerForDelegate(new AddDelegate(NativeAdd));
+ IntPtr slot1 = new IntPtr(0x12345678);
+
+ IntPtr[] vtable;
+ if (reader.Is64Bit)
+ {
+ vtable = [slot0, slot1];
+ }
+ else
+ {
+ vtable = [slot0, slot1];
+ }
+
+ GCHandle pin = GCHandle.Alloc(vtable, GCHandleType.Pinned);
+ try
+ {
+ IntPtr vTableAddress = pin.AddrOfPinnedObject();
+ Assert.Equal(slot0, invoker.ReadVTableFunction(vTableAddress, 0));
+ Assert.Equal(slot1, invoker.ReadVTableFunction(vTableAddress, 1));
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+}
diff --git a/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs b/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs
new file mode 100644
index 0000000..4ff4427
--- /dev/null
+++ b/WhiteMagicTest/Execution/RemoteThreadExecutorTests.cs
@@ -0,0 +1,209 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using WhiteMagic;
+using WhiteMagic.Assembly;
+using WhiteMagic.Execution;
+using WhiteMagic.Native;
+
+namespace WhiteMagicTest.Execution;
+
+public sealed class RemoteThreadExecutorTests
+{
+ // x64 payloads. Live execution tests run only on x64 because the payloads use the
+ // Microsoft x64 ABI (integer args in RCX, RDX, R8, R9, then stack at [rsp+0x28]).
+
+ // mov eax, ecx
+ // add eax, edx
+ // ret
+ private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
+
+ // mov eax, ecx
+ // add eax, edx
+ // add eax, r8d
+ // add eax, r9d
+ // add eax, [rsp+0x28]
+ // ret
+ private static readonly byte[] SumFivePayload =
+ [
+ 0x89, 0xC8,
+ 0x01, 0xD0,
+ 0x44, 0x01, 0xC0,
+ 0x44, 0x01, 0xC8,
+ 0x03, 0x84, 0x24, 0x28, 0x00, 0x00, 0x00,
+ 0xC3
+ ];
+
+ // xor eax, eax
+ // cmp byte ptr [rcx+rax], 0
+ // je done
+ // inc eax
+ // jmp loop
+ // done: ret
+ private static readonly byte[] Utf8LengthPayload =
+ [
+ 0x31, 0xC0,
+ 0x80, 0x3C, 0x01, 0x00,
+ 0x74, 0x04,
+ 0xFF, 0xC0,
+ 0xEB, 0xF6,
+ 0xC3
+ ];
+
+ // mov eax, [rcx]
+ // add eax, [rcx+4]
+ // ret
+ private static readonly byte[] PointSumPayload = [0x8B, 0x01, 0x03, 0x41, 0x04, 0xC3];
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Point
+ {
+ public int X;
+ public int Y;
+ }
+
+ [Fact]
+ public void Execute_adds_two_integers()
+ {
+ if (!Environment.Is64BitProcess)
+ {
+ return;
+ }
+
+ int result = RunPayload(AddPayload, CallConvention.Cdecl, 10, 32);
+ Assert.Equal(42, result);
+ }
+
+ [Fact]
+ public void Execute_sums_register_and_stack_arguments()
+ {
+ if (!Environment.Is64BitProcess)
+ {
+ return;
+ }
+
+ int result = RunPayload(SumFivePayload, CallConvention.Cdecl, 1, 2, 3, 4, 5);
+ Assert.Equal(15, result);
+ }
+
+ [Fact]
+ public void Execute_marshals_string_as_utf8_pointer()
+ {
+ if (!Environment.Is64BitProcess)
+ {
+ return;
+ }
+
+ int result = RunPayload(Utf8LengthPayload, CallConvention.Cdecl, "hello");
+ Assert.Equal(5, result);
+ }
+
+ [Fact]
+ public void Execute_marshals_struct_as_pointer()
+ {
+ if (!Environment.Is64BitProcess)
+ {
+ return;
+ }
+
+ int result = RunPayload(PointSumPayload, CallConvention.Cdecl, new Point { X = 30, Y = 12 });
+ Assert.Equal(42, result);
+ }
+
+ [Fact]
+ public void Execute_throws_when_handle_is_invalid()
+ {
+ var executor = new RemoteThreadExecutor(new InvalidProcessReader());
+
+ InvalidOperationException ex = Assert.Throws(() =>
+ {
+ executor.Execute((IntPtr)0x1234, CallConvention.Cdecl);
+ });
+
+ Assert.Contains("handle", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Execute_throws_when_address_is_zero()
+ {
+ using var reader = new InProcessReader();
+ var executor = new RemoteThreadExecutor(reader);
+
+ ArgumentException ex = Assert.Throws(() =>
+ {
+ executor.Execute(IntPtr.Zero, CallConvention.Cdecl);
+ });
+
+ Assert.Equal("address", ex.ParamName);
+ }
+
+ [Fact]
+ public void InProcessReader_reports_current_process_bitness()
+ {
+ using var reader = new InProcessReader();
+ Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
+ }
+
+ [Fact]
+ public void ExternalReader_reports_current_process_bitness()
+ {
+ using var reader = new ExternalReader(Process.GetCurrentProcess());
+ Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
+ }
+
+ private static int RunPayload(byte[] payload, CallConvention convention, params object?[] args)
+ {
+ const nint pageSize = 4096;
+ const nint blockSize = pageSize * 2;
+
+ using var reader = new InProcessReader();
+ var executor = new RemoteThreadExecutor(reader);
+
+ // Allocate a single executable block. The payload lives at the start and the
+ // generated call stub is written to the second page, guaranteeing that the
+ // relative CALL instruction stays within its ±2 GiB range.
+ IntPtr block = NativeMethods.VirtualAllocEx(
+ reader.Handle,
+ IntPtr.Zero,
+ blockSize,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ExecuteReadWrite);
+
+ Assert.NotEqual(IntPtr.Zero, block);
+
+ IntPtr stubAddress = block + pageSize;
+ executor.StubAllocator = (_, size) => size <= pageSize ? stubAddress : IntPtr.Zero;
+
+ try
+ {
+ int written = reader.WriteBytes(block, payload);
+ Assert.Equal(payload.Length, written);
+
+ return executor.Execute(block, convention, args);
+ }
+ finally
+ {
+ NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release);
+ }
+ }
+
+ private sealed class InvalidProcessReader : MemoryBase
+ {
+ public override IntPtr ImageBase => IntPtr.Zero;
+
+ public override SafeMemoryHandle Handle { get; } = new SafeMemoryHandle(new IntPtr(-1));
+
+ public override bool Is64Bit => Environment.Is64BitProcess;
+
+ public override int ProcessId => Environment.ProcessId;
+
+ public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
+ => throw new NotSupportedException();
+
+ public override int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false)
+ => throw new NotSupportedException();
+
+ public override void Dispose()
+ {
+ }
+ }
+}
diff --git a/WhiteMagicTest/HighLevelTests.cs b/WhiteMagicTest/HighLevelTests.cs
new file mode 100644
index 0000000..9035989
--- /dev/null
+++ b/WhiteMagicTest/HighLevelTests.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using WhiteMagic;
+using WhiteMagic.Assembly;
+using WhiteMagic.Native;
+using Xunit;
+
+namespace WhiteMagicTest;
+
+///
+/// Tests for the high-level facade ( ) and .
+///
+public class HighLevelTests
+{
+ private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
+
+ [Fact]
+ public void OpenExternal_returns_session_for_current_process()
+ {
+ using var magic = Magic.Open(Process.GetCurrentProcess());
+ Assert.NotNull(magic.Memory);
+ Assert.False(magic.Memory.Handle.IsInvalid);
+ Assert.Same(magic.Memory.PatchManager, magic.PatchManager);
+ Assert.Same(magic.Memory.DetourManager, magic.DetourManager);
+ }
+
+ [Fact]
+ public void OpenInProcess_returns_session_for_self()
+ {
+ using var magic = Magic.OpenInProcess();
+ Assert.IsType(magic.Memory);
+ Assert.False(magic.Memory.Handle.IsInvalid);
+ }
+
+ [Fact]
+ public void Indexer_returns_remote_pointer_that_reads_and_writes_relative()
+ {
+ using var magic = Magic.OpenInProcess();
+ byte[] slot = new byte[16];
+ GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
+ try
+ {
+ IntPtr baseAddr = pin.AddrOfPinnedObject();
+ magic[baseAddr + 4].Write(0x12345678);
+ Assert.Equal(0x12345678, magic[baseAddr].Read(4));
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public async Task RemoteThread_ExecuteAsync_runs_payload_and_returns_result()
+ {
+ if (!Environment.Is64BitProcess)
+ {
+ return;
+ }
+
+ using var magic = Magic.OpenInProcess();
+ IntPtr payload = NativeMethods.VirtualAllocEx(
+ magic.Memory.Handle,
+ IntPtr.Zero,
+ 4096,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ExecuteReadWrite);
+
+ Assert.NotEqual(IntPtr.Zero, payload);
+
+ try
+ {
+ magic.Memory.WriteBytes(payload, AddPayload);
+ int result = await magic.RemoteThread.ExecuteAsync(payload, CallConvention.Cdecl, 10, 32);
+ Assert.Equal(42, result);
+ }
+ finally
+ {
+ NativeMethods.VirtualFreeEx(magic.Memory.Handle, payload, 0, MemoryFreeType.Release);
+ }
+ }
+}
diff --git a/WhiteMagicTest/Hooking/HookingTests.cs b/WhiteMagicTest/Hooking/HookingTests.cs
new file mode 100644
index 0000000..39c9af8
--- /dev/null
+++ b/WhiteMagicTest/Hooking/HookingTests.cs
@@ -0,0 +1,278 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using WhiteMagic;
+using WhiteMagic.Hooking;
+using WhiteMagic.Native;
+using Xunit;
+
+namespace WhiteMagicTest.Hooking;
+
+///
+/// Tests for , and
+/// operating in-process.
+///
+public class HookingTests
+{
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate int FrameFunc();
+
+ private const int FrameResult = 42;
+
+ private static InProcessReader CreateReader()
+ {
+ return new InProcessReader();
+ }
+
+ ///
+ /// Allocates a tiny executable function whose prologue is made entirely of
+ /// covered instruction shapes, so detours apply cleanly in tests.
+ ///
+ private static IntPtr AllocateFrameStub(MemoryBase reader, out IntPtr allocationBase)
+ {
+ // x64: push rbp; push rdi; push rsi; push rbx; sub rsp, 0x28; sub rsp, 0x12345678;
+ // mov eax, 42; add rsp, 0x12345678; add rsp, 0x28; pop rbx; pop rsi; pop rdi; pop rbp; ret
+ byte[] code =
+ [
+ 0x55, // push rbp
+ 0x57, // push rdi
+ 0x56, // push rsi
+ 0x53, // push rbx
+ 0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28
+ 0x48, 0x81, 0xEC, 0x78, 0x56, 0x34, 0x12, // sub rsp, 0x12345678
+ 0xB8, 0x2A, 0x00, 0x00, 0x00, // mov eax, 42
+ 0x48, 0x81, 0xC4, 0x78, 0x56, 0x34, 0x12, // add rsp, 0x12345678
+ 0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
+ 0x5B, // pop rbx
+ 0x5E, // pop rsi
+ 0x5F, // pop rdi
+ 0x5D, // pop rbp
+ 0xC3 // ret
+ ];
+
+ allocationBase = NativeMethods.VirtualAllocEx(
+ reader.Handle,
+ IntPtr.Zero,
+ code.Length,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ExecuteReadWrite);
+ Assert.NotEqual(IntPtr.Zero, allocationBase);
+
+ reader.WriteBytes(allocationBase, code);
+ return allocationBase;
+ }
+
+ [Fact]
+ public void Patch_apply_writes_bytes_and_remove_restores_original()
+ {
+ using var reader = CreateReader();
+ byte[] slot = new byte[8];
+ GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ byte[] original = reader.ReadBytes(addr, 4);
+ byte[] patchBytes = [0x90, 0x90, 0x90, 0x90];
+
+ Patch patch = reader.PatchManager.Create("nop", addr, patchBytes);
+ Assert.False(patch.IsApplied);
+
+ patch.Apply();
+ Assert.True(patch.IsApplied);
+ Assert.Equal(patchBytes, reader.ReadBytes(addr, 4));
+
+ patch.Remove();
+ Assert.False(patch.IsApplied);
+ Assert.Equal(original, reader.ReadBytes(addr, 4));
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public void Detour_apply_redirects_callOriginal_remove_restores()
+ {
+ using var reader = CreateReader();
+ IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
+
+ try
+ {
+ int hookCalls = 0;
+ Detour? detour = null;
+ FrameFunc hook = () =>
+ {
+ hookCalls++;
+ return (int?)detour?.CallOriginal() ?? 0;
+ };
+
+ detour = reader.DetourManager.Create("frame", targetPtr, hook);
+ detour.Apply();
+
+ FrameFunc routed = Marshal.GetDelegateForFunctionPointer(targetPtr);
+ int result = routed();
+ Assert.True(hookCalls > 0);
+ Assert.Equal(FrameResult, result);
+
+ detour.Remove();
+ hookCalls = 0;
+ result = routed();
+ Assert.Equal(0, hookCalls);
+ Assert.Equal(FrameResult, result);
+
+ GC.KeepAlive(hook);
+ }
+ finally
+ {
+ NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
+ }
+ }
+
+ [Fact]
+ public void Detour_named_lookup_returns_existing_detour()
+ {
+ using var reader = CreateReader();
+ IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
+
+ try
+ {
+ Detour detour = reader.DetourManager.Create("lookup", targetPtr, (FrameFunc)(() => FrameResult));
+ Assert.Same(detour, reader.DetourManager["lookup"]);
+ }
+ finally
+ {
+ NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
+ }
+ }
+
+ [Fact]
+ public void Detour_aligned_prologue_applies_and_unknown_prologue_rejects()
+ {
+ using var reader = CreateReader();
+
+ // A normal JIT-compiled function has a covered prologue shape.
+ IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr goodAllocation);
+ try
+ {
+ Detour good = reader.DetourManager.Create("good", targetPtr, (FrameFunc)(() => FrameResult));
+ good.Apply();
+ good.Remove();
+ }
+ finally
+ {
+ NativeMethods.VirtualFreeEx(reader.Handle, goodAllocation, 0, MemoryFreeType.Release);
+ }
+
+ // Allocate a small executable region whose first instruction is outside the
+ // covered set. The decoder must refuse to splice it.
+ IntPtr code = NativeMethods.VirtualAllocEx(
+ reader.Handle,
+ IntPtr.Zero,
+ 32,
+ MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
+ MemoryProtectionType.ExecuteReadWrite);
+ Assert.NotEqual(IntPtr.Zero, code);
+
+ try
+ {
+ // 0x0F 0x05 = syscall (not covered), followed by padding and a ret.
+ byte[] unknown = [0x0F, 0x05, 0xC3, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC];
+ reader.WriteBytes(code, unknown);
+
+ Detour bad = reader.DetourManager.Create("bad", code, (FrameFunc)(() => FrameResult));
+ Assert.Throws(() => bad.Apply());
+ }
+ finally
+ {
+ NativeMethods.VirtualFreeEx(reader.Handle, code, 0, MemoryFreeType.Release);
+ }
+ }
+
+ [Fact]
+ public void Dispose_restores_active_patches_and_detours()
+ {
+ InProcessReader reader = CreateReader();
+ byte[] slot = new byte[8];
+ GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ byte[] original = reader.ReadBytes(addr, 2);
+
+ Patch patch = reader.PatchManager.Create("dispose-patch", addr, [0x90, 0x90]);
+ patch.Apply();
+
+ reader.Dispose();
+
+ // Verify with a fresh reader; the original handle was closed by Dispose.
+ using var verify = CreateReader();
+ Assert.Equal(original, verify.ReadBytes(addr, 2));
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ [Fact]
+ public async Task MainThreadPump_drains_work_on_frame_call_and_uninstalls_on_dispose()
+ {
+ using var reader = CreateReader();
+
+ IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
+ try
+ {
+ var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
+ pump.Install();
+
+ Task work = pump.ExecuteAsync(() => 123);
+
+ // Drive the frame function manually. The detoured frame runs the pump hook on
+ // this thread, drains the work queue, then calls the original frame function.
+ FrameFunc routed = Marshal.GetDelegateForFunctionPointer(targetPtr);
+ int frameResult = routed();
+
+ Assert.Equal(FrameResult, frameResult);
+ Assert.Equal(123, await work);
+
+ pump.Dispose();
+
+ // After uninstall, calling the frame function should behave like the original.
+ routed = Marshal.GetDelegateForFunctionPointer(targetPtr);
+ Assert.Equal(FrameResult, routed());
+ }
+ finally
+ {
+ NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
+ }
+ }
+
+ [Fact]
+ public async Task MainThreadPump_exception_survives_and_does_not_kill_pump()
+ {
+ using var reader = CreateReader();
+
+ IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
+ try
+ {
+ var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
+ pump.Install();
+
+ Task bad = pump.ExecuteAsync(() => throw new InvalidOperationException("boom"));
+ Task good = pump.ExecuteAsync(() => 7);
+
+ FrameFunc routed = Marshal.GetDelegateForFunctionPointer(targetPtr);
+ routed();
+
+ await Assert.ThrowsAsync(() => bad);
+ Assert.Equal(7, await good);
+
+ pump.Dispose();
+ }
+ finally
+ {
+ NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
+ }
+ }
+}
diff --git a/WhiteMagicTest/Injection/CodeInjectorTests.cs b/WhiteMagicTest/Injection/CodeInjectorTests.cs
new file mode 100644
index 0000000..e301898
--- /dev/null
+++ b/WhiteMagicTest/Injection/CodeInjectorTests.cs
@@ -0,0 +1,214 @@
+using System.ComponentModel;
+using WhiteMagic;
+using WhiteMagic.Injection;
+using WhiteMagic.Memory;
+using WhiteMagic.Native;
+
+namespace WhiteMagicTest.Injection;
+
+///
+/// Tests for .
+///
+public class CodeInjectorTests
+{
+ private static InProcessReader CreateReader()
+ {
+ return new InProcessReader();
+ }
+
+ [Fact]
+ public void InjectAtAddress_writes_code_to_specified_address()
+ {
+ using var reader = CreateReader();
+
+ // Allocate a buffer to write to
+ byte[] buffer = new byte[32];
+ var handle = System.Runtime.InteropServices.GCHandle.Alloc(
+ buffer,
+ System.Runtime.InteropServices.GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = handle.AddrOfPinnedObject();
+
+ // Simple x64 payload: mov eax, 42; ret
+ // B8 2A 00 00 00 C3
+ byte[] code = { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
+
+ if (Environment.Is64BitProcess)
+ {
+ // 64-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3)
+ code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
+ }
+ else
+ {
+ // 32-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3) - same encoding
+ code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
+ }
+
+ IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
+
+ Assert.Equal(addr, result);
+ Assert.Equal(code, buffer.Take(code.Length).ToArray());
+ }
+ finally
+ {
+ handle.Free();
+ }
+ }
+
+ [Fact]
+ public void InjectAtAddress_throws_on_empty_code()
+ {
+ using var reader = CreateReader();
+
+ byte[] code = Array.Empty();
+
+ var ex = Assert.Throws(() =>
+ CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
+
+ Assert.Contains("Code cannot be empty", ex.Message);
+ }
+
+ [Fact]
+ public void InjectAtAddress_throws_on_zero_address()
+ {
+ using var reader = CreateReader();
+
+ byte[] code = { 0x90, 0x90, 0xC3 }; // nop; nop; ret
+
+ var ex = Assert.Throws(() =>
+ CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
+
+ Assert.Contains("Address cannot be zero", ex.Message);
+ }
+
+ [Fact]
+ public void Inject_allocates_and_writes_code()
+ {
+ using var reader = CreateReader();
+
+ // Simple x64 payload: ret (C3)
+ byte[] code = { 0xC3 };
+
+ using var allocated = CodeInjector.Inject(reader, code);
+
+ Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
+ Assert.Equal(code.Length, allocated.Size);
+
+ // Verify the code was written
+ byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
+ Assert.Equal(code, readBack);
+ }
+
+ [Fact]
+ public void Inject_with_execute_read_write_protection()
+ {
+ using var reader = CreateReader();
+
+ byte[] code = { 0xC3 }; // ret
+
+ using var allocated = CodeInjector.Inject(
+ reader,
+ code,
+ MemoryProtectionType.ExecuteReadWrite);
+
+ Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
+ }
+
+ [Fact]
+ public void Inject_with_read_only_protection()
+ {
+ using var reader = CreateReader();
+
+ byte[] code = { 0xC3 }; // ret
+
+ using var allocated = CodeInjector.Inject(
+ reader,
+ code,
+ MemoryProtectionType.ExecuteRead);
+
+ Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
+ }
+
+ [Fact]
+ public void Inject_throws_on_empty_code()
+ {
+ using var reader = CreateReader();
+
+ byte[] code = Array.Empty();
+
+ var ex = Assert.Throws(() =>
+ CodeInjector.Inject(reader, code));
+
+ Assert.Contains("Code cannot be empty", ex.Message);
+ }
+
+ [Fact]
+ public void Inject_returns_allocated_memory_that_can_be_freed()
+ {
+ using var reader = CreateReader();
+
+ byte[] code = { 0xC3 }; // ret
+
+ var allocated = CodeInjector.Inject(reader, code);
+
+ Assert.NotNull(allocated);
+
+ // Dispose should free the memory
+ allocated.Dispose();
+
+ // No exception should be thrown during disposal
+ }
+
+ [Fact]
+ public void InjectAtAddress_writes_all_bytes()
+ {
+ using var reader = CreateReader();
+
+ // Allocate a buffer
+ byte[] buffer = new byte[128];
+ var handle = System.Runtime.InteropServices.GCHandle.Alloc(
+ buffer,
+ System.Runtime.InteropServices.GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = handle.AddrOfPinnedObject();
+
+ // Create a larger payload
+ byte[] code = new byte[64];
+ for (int i = 0; i < code.Length; i++)
+ code[i] = (byte)(i & 0xFF);
+
+ IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
+
+ Assert.Equal(addr, result);
+
+ // Verify all bytes were written
+ byte[] readBack = reader.ReadBytes(addr, code.Length);
+ Assert.Equal(code, readBack);
+ }
+ finally
+ {
+ handle.Free();
+ }
+ }
+
+ [Fact]
+ public void Inject_with_complex_payload()
+ {
+ using var reader = CreateReader();
+
+ // mov eax, 12345678h; ret
+ // x64: B8 78 56 34 12 C3
+ // x86: B8 78 56 34 12 C3 (same)
+ byte[] code = { 0xB8, 0x78, 0x56, 0x34, 0x12, 0xC3 };
+
+ using var allocated = CodeInjector.Inject(reader, code);
+
+ Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
+
+ // Verify the exact payload was written
+ byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
+ Assert.Equal(code, readBack);
+ }
+}
diff --git a/WhiteMagicTest/Injection/DllInjectorTests.cs b/WhiteMagicTest/Injection/DllInjectorTests.cs
new file mode 100644
index 0000000..5ae8b86
--- /dev/null
+++ b/WhiteMagicTest/Injection/DllInjectorTests.cs
@@ -0,0 +1,140 @@
+using System.Runtime.InteropServices;
+using WhiteMagic;
+using WhiteMagic.Injection;
+using WhiteMagic.Native;
+
+namespace WhiteMagicTest.Injection;
+
+///
+/// Tests for .
+///
+///
+/// Real injection tests exercise the current process, because it is always available
+/// and the injected DLLs are ordinary system modules that are already loaded.
+///
+public class DllInjectorTests
+{
+ private static string GetExistingSystemDll()
+ {
+ // user32.dll exists on every Windows system and matches the host bitness.
+ string path = Path.Combine(Environment.SystemDirectory, "user32.dll");
+ Assert.True(File.Exists(path), $"{path} must exist for the test.");
+ return path;
+ }
+
+ [Fact(Skip = "Integration injection test - run against a dedicated target process")]
+ public void InjectWithRemoteThread_loads_system_dll_in_current_process()
+ {
+ using var reader = new InProcessReader();
+ var injector = new DllInjector(reader);
+
+ IntPtr moduleBase = injector.InjectWithRemoteThread(GetExistingSystemDll());
+
+ Assert.NotEqual(IntPtr.Zero, moduleBase);
+ }
+
+ [Fact]
+ public void InjectWithRemoteThread_throws_for_missing_dll()
+ {
+ using var reader = new InProcessReader();
+ var injector = new DllInjector(reader);
+ string missingPath = Path.Combine(Path.GetTempPath(), $"wm-missing-{Guid.NewGuid()}.dll");
+
+ Assert.False(File.Exists(missingPath));
+ Assert.Throws(() => injector.InjectWithRemoteThread(missingPath));
+ }
+
+ [Fact]
+ public void InjectWithRemoteThread_rejects_bitness_mismatch()
+ {
+ // A fake reader that reports the opposite bitness from the current process.
+ using var reader = new FakeBitnessMemoryBase(!Environment.Is64BitProcess);
+ var injector = new DllInjector(reader);
+
+ var ex = Assert.Throws(
+ () => injector.InjectWithRemoteThread(GetExistingSystemDll()));
+
+ Assert.Contains("bitness", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact(Skip = "Integration injection test - run against a dedicated target process")]
+ public void InjectWithThreadHijack_loads_system_dll_and_restores_context()
+ {
+ using var reader = new InProcessReader();
+ var injector = new DllInjector(reader);
+
+ using var stopEvent = new ManualResetEventSlim(false);
+ using var startedEvent = new ManualResetEventSlim(false);
+
+ int osThreadId = 0;
+ Exception? threadError = null;
+
+ var helper = new Thread(() =>
+ {
+ try
+ {
+ osThreadId = (int)NativeMethods.GetCurrentThreadId();
+ startedEvent.Set();
+
+ // Loop with short sleeps so the thread can be hijacked safely and can
+ // also be stopped once its original context is restored.
+ while (!stopEvent.IsSet)
+ {
+ Thread.Sleep(10);
+ }
+ }
+ catch (Exception ex)
+ {
+ threadError = ex;
+ }
+ });
+ helper.IsBackground = true;
+ helper.Start();
+
+ try
+ {
+ Assert.True(startedEvent.Wait(TimeSpan.FromSeconds(5)), "Helper thread did not start.");
+ Assert.NotEqual(0, osThreadId);
+
+ IntPtr moduleBase = injector.InjectWithThreadHijack(osThreadId, GetExistingSystemDll());
+ Assert.NotEqual(IntPtr.Zero, moduleBase);
+
+ // Tell the helper thread to exit. If the original context was restored correctly,
+ // the thread will return to its loop and observe the stop event.
+ stopEvent.Set();
+ Assert.True(helper.Join(TimeSpan.FromSeconds(5)), "Helper thread did not exit after context restore.");
+ Assert.Null(threadError);
+ }
+ finally
+ {
+ stopEvent.Set();
+ helper.Join(TimeSpan.FromSeconds(5));
+ }
+ }
+
+ ///
+ /// A minimal whose only job is to report a chosen bitness.
+ /// Reads and writes are not expected to be called by the rejection path.
+ ///
+ private sealed class FakeBitnessMemoryBase : MemoryBase
+ {
+ public FakeBitnessMemoryBase(bool is64Bit)
+ {
+ Is64Bit = is64Bit;
+ }
+
+ public override IntPtr ImageBase => IntPtr.Zero;
+
+ public override SafeMemoryHandle Handle => new(IntPtr.Zero);
+
+ public override bool Is64Bit { get; }
+
+ public override int ProcessId => Environment.ProcessId;
+
+ public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
+ => throw new NotSupportedException();
+
+ public override int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false)
+ => throw new NotSupportedException();
+ }
+}
diff --git a/WhiteMagicTest/Input/InputSimulatorTests.cs b/WhiteMagicTest/Input/InputSimulatorTests.cs
new file mode 100644
index 0000000..5307122
--- /dev/null
+++ b/WhiteMagicTest/Input/InputSimulatorTests.cs
@@ -0,0 +1,41 @@
+using System.Diagnostics;
+using WhiteMagic.Input;
+using WhiteMagic.Windows;
+using Xunit;
+
+namespace WhiteMagicTest.Input;
+
+public sealed class InputSimulatorTests
+{
+ [Fact(Skip = "Interactive input test - requires a visible window")]
+ public void SendKeys_to_current_window_does_not_throw()
+ {
+ using var process = Process.GetCurrentProcess();
+ IntPtr handle = process.MainWindowHandle != IntPtr.Zero
+ ? process.MainWindowHandle
+ : WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
+
+ if (handle == IntPtr.Zero)
+ return;
+
+ var simulator = new InputSimulator();
+ bool result = simulator.SendKeys(handle, "ab");
+ Assert.True(result);
+ }
+
+ [Fact(Skip = "Interactive input test - requires a visible window")]
+ public void SendMouseClick_to_current_window_does_not_throw()
+ {
+ using var process = Process.GetCurrentProcess();
+ IntPtr handle = process.MainWindowHandle != IntPtr.Zero
+ ? process.MainWindowHandle
+ : WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
+
+ if (handle == IntPtr.Zero)
+ return;
+
+ var simulator = new InputSimulator();
+ bool result = simulator.SendMouseClick(handle, 10, 20, MouseButton.Left);
+ Assert.True(result);
+ }
+}
diff --git a/WhiteMagicTest/Memory/AllocatedMemoryTests.cs b/WhiteMagicTest/Memory/AllocatedMemoryTests.cs
new file mode 100644
index 0000000..ac3c759
--- /dev/null
+++ b/WhiteMagicTest/Memory/AllocatedMemoryTests.cs
@@ -0,0 +1,254 @@
+using System.ComponentModel;
+using WhiteMagic;
+using WhiteMagic.Memory;
+using WhiteMagic.Native;
+
+namespace WhiteMagicTest.Memory;
+
+///
+/// Tests for .
+///
+public class AllocatedMemoryTests
+{
+ private static InProcessReader CreateReader()
+ {
+ return new InProcessReader();
+ }
+
+ [Fact]
+ public void Constructor_allocates_memory_with_execute_read_write_protection()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
+ Assert.Equal(4096, allocated.Size);
+ }
+
+ [Fact]
+ public void Constructor_with_custom_protection()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096, MemoryProtectionType.ReadOnly);
+
+ Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
+ }
+
+ [Fact]
+ public void Constructor_throws_on_negative_size()
+ {
+ using var reader = CreateReader();
+
+ var ex = Assert.Throws(() =>
+ new AllocatedMemory(reader, -1));
+
+ Assert.Equal("size", ex.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_throws_on_zero_size()
+ {
+ using var reader = CreateReader();
+
+ var ex = Assert.Throws(() =>
+ new AllocatedMemory(reader, 0));
+
+ Assert.Equal("size", ex.ParamName);
+ }
+
+ [Fact]
+ public void AddRegion_adds_named_region()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ allocated.AddRegion("test", 100);
+
+ Assert.Equal(100, allocated.AddressOf("test") - allocated.BaseAddress);
+ }
+
+ [Fact]
+ public void AddRegion_throws_on_duplicate_name()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ allocated.AddRegion("test", 100);
+
+ var ex = Assert.Throws(() =>
+ allocated.AddRegion("test", 200));
+
+ Assert.Contains("already exists", ex.Message);
+ }
+
+ [Fact]
+ public void AddRegion_throws_on_negative_offset()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ var ex = Assert.Throws(() =>
+ allocated.AddRegion("test", -1));
+
+ Assert.Equal("offset", ex.ParamName);
+ }
+
+ [Fact]
+ public void AddRegion_throws_on_offset_exceeding_size()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ var ex = Assert.Throws(() =>
+ allocated.AddRegion("test", 4096));
+
+ Assert.Equal("offset", ex.ParamName);
+ }
+
+ [Fact]
+ public void AddressOf_returns_correct_address()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ allocated.AddRegion("region1", 0);
+ allocated.AddRegion("region2", 100);
+ allocated.AddRegion("region3", 200);
+
+ Assert.Equal(allocated.BaseAddress, allocated.AddressOf("region1"));
+ Assert.Equal(allocated.BaseAddress + 100, allocated.AddressOf("region2"));
+ Assert.Equal(allocated.BaseAddress + 200, allocated.AddressOf("region3"));
+ }
+
+ [Fact]
+ public void AddressOf_throws_on_unknown_region()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ var ex = Assert.Throws(() =>
+ allocated.AddressOf("unknown"));
+
+ Assert.Contains("does not exist", ex.Message);
+ }
+
+ [Fact]
+ public void Write_and_Read_int_roundtrip()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ allocated.AddRegion("value", 0);
+
+ int original = unchecked((int)0xDEADBEEF);
+ Assert.True(allocated.Write("value", original));
+
+ int read = allocated.Read("value");
+ Assert.Equal(original, read);
+ }
+
+ [Fact]
+ public void Write_and_Read_long_roundtrip()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ allocated.AddRegion("value", 8);
+
+ long original = 0x123456789ABCDEF0;
+ Assert.True(allocated.Write("value", original));
+
+ long read = allocated.Read("value");
+ Assert.Equal(original, read);
+ }
+
+ [Fact]
+ public void WriteBytes_and_ReadBytes_roundtrip()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ allocated.AddRegion("buffer", 0);
+
+ byte[] original = { 0x01, 0x02, 0x03, 0x04, 0x05 };
+ int written = allocated.WriteBytes("buffer", original);
+ Assert.Equal(original.Length, written);
+
+ byte[] read = allocated.ReadBytes("buffer", original.Length);
+ Assert.Equal(original, read);
+ }
+
+ [Fact]
+ public void Dispose_frees_memory()
+ {
+ using var reader = CreateReader();
+
+ var allocated = new AllocatedMemory(reader, 4096);
+ IntPtr baseAddr = allocated.BaseAddress;
+
+ Assert.NotEqual(IntPtr.Zero, baseAddr);
+
+ allocated.Dispose();
+
+ // After dispose, accessing properties should throw ObjectDisposedException
+ Assert.Throws(() =>
+ allocated.AddressOf("any"));
+ }
+
+ [Fact]
+ public void Multiple_regions_independent_access()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ allocated.AddRegion("a", 0);
+ allocated.AddRegion("b", 4);
+ allocated.AddRegion("c", 8);
+
+ Assert.True(allocated.Write("a", 0x11111111));
+ Assert.True(allocated.Write("b", 0x22222222));
+ Assert.True(allocated.Write("c", 0x33333333));
+
+ Assert.Equal(0x11111111, allocated.Read("a"));
+ Assert.Equal(0x22222222, allocated.Read("b"));
+ Assert.Equal(0x33333333, allocated.Read("c"));
+ }
+
+ [Fact]
+ public void Write_to_unknown_region_throws()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ var ex = Assert.Throws(() =>
+ allocated.Write("unknown", 42));
+
+ Assert.Contains("does not exist", ex.Message);
+ }
+
+ [Fact]
+ public void Read_from_unknown_region_throws()
+ {
+ using var reader = CreateReader();
+
+ using var allocated = new AllocatedMemory(reader, 4096);
+
+ var ex = Assert.Throws(() =>
+ allocated.Read("unknown"));
+
+ Assert.Contains("does not exist", ex.Message);
+ }
+}
diff --git a/WhiteMagicTest/MemoryHardeningTests.cs b/WhiteMagicTest/MemoryHardeningTests.cs
index acf96c2..e8647a7 100644
--- a/WhiteMagicTest/MemoryHardeningTests.cs
+++ b/WhiteMagicTest/MemoryHardeningTests.cs
@@ -160,6 +160,8 @@ public class MemoryHardeningTests
{
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle => null!;
+ public override bool Is64Bit => Environment.Is64BitProcess;
+ public override int ProcessId => Environment.ProcessId;
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
diff --git a/WhiteMagicTest/ProcessEnvironment/ManagedPebTests.cs b/WhiteMagicTest/ProcessEnvironment/ManagedPebTests.cs
new file mode 100644
index 0000000..69b801a
--- /dev/null
+++ b/WhiteMagicTest/ProcessEnvironment/ManagedPebTests.cs
@@ -0,0 +1,28 @@
+using System.Diagnostics;
+using WhiteMagic;
+using WhiteMagic.ProcessEnvironment;
+using Xunit;
+
+namespace WhiteMagicTest.ProcessEnvironment;
+
+public sealed class ManagedPebTests
+{
+ [Fact]
+ public void Read_current_process_peb_fields_returns_plausible_values()
+ {
+ using var magic = Magic.OpenInProcess();
+ var peb = new ManagedPeb(magic.Memory);
+
+ Assert.NotEqual(IntPtr.Zero, peb.ReadPebAddress());
+ Assert.NotEqual(IntPtr.Zero, peb.ReadImageBaseAddress());
+
+ byte beingDebugged = peb.ReadBeingDebugged();
+ Assert.True(beingDebugged == 0 || beingDebugged == 1);
+
+ Assert.NotEqual(IntPtr.Zero, peb.ReadLdrAddress());
+
+ // The in-process test process is native to the host architecture, so
+ // it is not running under WOW64.
+ Assert.False(peb.ReadIsWow64Process());
+ }
+}
diff --git a/WhiteMagicTest/StringReadWriteTests.cs b/WhiteMagicTest/StringReadWriteTests.cs
index 6e0cf37..f1b493a 100644
--- a/WhiteMagicTest/StringReadWriteTests.cs
+++ b/WhiteMagicTest/StringReadWriteTests.cs
@@ -157,6 +157,61 @@ public class StringReadWriteTests
}
}
+ ///
+ /// UTF-16 null terminator split across the 64-byte chunk boundary must still be found.
+ /// The first chunk ends at byte 63, so the null bytes at 64/65 are in the second chunk.
+ ///
+ [Fact]
+ public void ReadString_utf16_null_across_chunk_boundary_is_found()
+ {
+ using var reader = OpenSelf();
+ byte[] slot = new byte[256];
+
+ // 32 'A' UTF-16 chars = 64 bytes, no embedded null.
+ byte[] text = Encoding.Unicode.GetBytes(new string('A', 32));
+ Assert.Equal(64, text.Length);
+ text.CopyTo(slot, 0);
+
+ // Null terminator at bytes 64/65.
+ slot[64] = 0x00;
+ slot[65] = 0x00;
+
+ GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 256);
+ Assert.Equal(new string('A', 32), result);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
+ ///
+ /// A byte sequence that looks like a null at a misaligned offset must not stop the scan.
+ /// "A" + U+4200 produces bytes 41 00 00 42 00 00; bytes 1-2 are an aligned-position null
+ /// only if scanned byte-by-byte. The aligned UTF-16 scan must see the real terminator.
+ ///
+ [Fact]
+ public void ReadString_utf16_does_not_stop_at_misaligned_null()
+ {
+ using var reader = OpenSelf();
+ byte[] slot = Encoding.Unicode.GetBytes("A\u4200\0");
+ GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
+ try
+ {
+ IntPtr addr = pin.AddrOfPinnedObject();
+ string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 64);
+ Assert.Equal("A\u4200", result);
+ }
+ finally
+ {
+ pin.Free();
+ }
+ }
+
[Fact]
public void WriteString_empty_string_writes_only_null()
{
diff --git a/WhiteMagicTest/ThreadEnvironment/ManagedTebTests.cs b/WhiteMagicTest/ThreadEnvironment/ManagedTebTests.cs
new file mode 100644
index 0000000..9fc9e09
--- /dev/null
+++ b/WhiteMagicTest/ThreadEnvironment/ManagedTebTests.cs
@@ -0,0 +1,23 @@
+using WhiteMagic;
+using WhiteMagic.Native;
+using WhiteMagic.ThreadEnvironment;
+using Xunit;
+
+namespace WhiteMagicTest.ThreadEnvironment;
+
+public sealed class ManagedTebTests
+{
+ [Fact]
+ public void Read_current_thread_teb_fields_returns_plausible_values()
+ {
+ using var magic = Magic.OpenInProcess();
+ using var teb = new ManagedTeb(magic.Memory, (int)NativeMethods.GetCurrentThreadId());
+
+ Assert.NotEqual(IntPtr.Zero, teb.ReadTebAddress());
+ Assert.NotEqual(IntPtr.Zero, teb.ReadStackBase());
+ Assert.NotEqual(IntPtr.Zero, teb.ReadStackLimit());
+
+ // The stack grows down, so the base is above the limit on x86/x64.
+ Assert.True((nuint)teb.ReadStackBase() > (nuint)teb.ReadStackLimit());
+ }
+}
diff --git a/WhiteMagicTest/Windows/WindowTests.cs b/WhiteMagicTest/Windows/WindowTests.cs
new file mode 100644
index 0000000..da8cb55
--- /dev/null
+++ b/WhiteMagicTest/Windows/WindowTests.cs
@@ -0,0 +1,65 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using WhiteMagic.Windows;
+using Xunit;
+
+namespace WhiteMagicTest.Windows;
+
+public sealed class WindowTests
+{
+ [Fact]
+ public void GetWindows_returns_at_least_one_top_level_window()
+ {
+ var windows = WindowFactory.GetWindows().ToList();
+ Assert.NotEmpty(windows);
+ }
+
+ [Fact]
+ public void GetWindowsByClassName_filters_to_matching_classes()
+ {
+ var all = WindowFactory.GetWindows().ToList();
+ if (all.Count == 0)
+ return;
+
+ string firstClass = all[0].ClassName;
+ if (string.IsNullOrEmpty(firstClass))
+ return;
+
+ var filtered = WindowFactory.GetWindowsByClassName(firstClass).ToList();
+ Assert.All(filtered, w => Assert.Equal(firstClass, w.ClassName));
+ Assert.True(filtered.Count <= all.Count);
+ }
+
+ [Fact]
+ public void RemoteWindow_can_query_and_manipulate_a_test_window()
+ {
+ IntPtr handle = Process.GetCurrentProcess().MainWindowHandle;
+ if (handle == IntPtr.Zero)
+ {
+ // The xUnit runner may not expose a main window; skip destructive
+ // manipulation but still validate factory enumeration above.
+ return;
+ }
+
+ var window = new RemoteWindow(handle);
+ Assert.Equal(handle, window.Handle);
+
+ string text = window.Text;
+ Assert.NotNull(text);
+
+ string originalTitle = window.Title;
+ Assert.Equal(text, originalTitle);
+
+ // Move and resize, then restore the original position.
+ bool moved = window.MoveResize(10, 10, 400, 300);
+ Assert.True(moved);
+
+ bool flashed = window.Flash();
+ Assert.True(flashed);
+
+ // Restore a sensible size without asserting exact title restoration;
+ // terminal windows often ignore SetWindowText.
+ bool restored = window.MoveResize(0, 0, 800, 600);
+ Assert.True(restored);
+ }
+}
diff --git a/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md
index 4d3aa53..8ea14ff 100644
--- a/openspec/changes/whitemagic-foundation/tasks.md
+++ b/openspec/changes/whitemagic-foundation/tasks.md
@@ -17,7 +17,7 @@
- [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 (single-byte encodings). 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.
+- [x] 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 (single-byte encodings). 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)
@@ -33,45 +33,45 @@
## 4. Crash-Safe Execution Slice (spec: remote-execution, function-hooking)
-- [ ] 4.1 Add tests for `PatchManager`/`Patch`: apply writes bytes, remove restores original, `IsApplied` reflects state
-- [ ] 4.2 Implement `WhiteMagic/Hooking/PatchManager.cs` + `Patch.cs` to pass 4.1
-- [ ] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup
-- [ ] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3
-- [ ] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available)
-- [ ] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety.
-- [ ] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours
-- [ ] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7
-- [ ] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process)
-- [ ] 4.10 Implement `WhiteMagic/Execution/MainThreadPump.cs` (frame-function detour + thread-safe work queue + completion handles) to pass 4.9
-- [ ] 4.11 Add tests for `RemoteThreadExecutor.Execute` (convention stub + wait + typed exit; no-process failure is deterministic)
-- [ ] 4.12 Implement `WhiteMagic/Execution/RemoteThreadExecutor.cs` and parameter marshalling (string/struct → remote alloc → free) to pass 4.11
+- [x] 4.1 Add tests for `PatchManager`/`Patch`: apply writes bytes, remove restores original, `IsApplied` reflects state
+- [x] 4.2 Implement `WhiteMagic/Hooking/PatchManager.cs` + `Patch.cs` to pass 4.1
+- [x] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup
+- [x] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3
+- [x] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available)
+- [x] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety.
+- [x] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours
+- [x] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7
+- [x] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process)
+- [x] 4.10 Implement `WhiteMagic/Execution/MainThreadPump.cs` (frame-function detour + thread-safe work queue + completion handles) to pass 4.9
+- [x] 4.11 Add tests for `RemoteThreadExecutor.Execute` (convention stub + wait + typed exit; no-process failure is deterministic)
+- [x] 4.12 Implement `WhiteMagic/Execution/RemoteThreadExecutor.cs` and parameter marshalling (string/struct → remote alloc → free) to pass 4.11
## 5. Injection & Discovery (spec: dll-injection, memory-discovery)
-- [ ] 5.1 Add tests for pattern scanning: found (range/module/all-modules), wildcard mask, not-found returns Zero
-- [ ] 5.2 Implement `WhiteMagic/Discovery/PatternScanner.cs` to pass 5.1
-- [ ] 5.3 Add tests + implement scan result cache (repeat served from cache, clear rescans)
-- [ ] 5.4 Add tests + implement `WhiteMagic/Discovery/PeHeaderParser.cs` (sections, entry point)
-- [ ] 5.5 Add tests + implement `WhiteMagic/Memory/AllocatedMemory.cs` (named regions, typed read/write by name, address by name, free on dispose)
-- [ ] 5.6 Add tests + implement raw code injection (`InjectCode` at address and into fresh allocation)
-- [ ] 5.7 Add tests + implement DLL injection via remote thread (LoadLibrary), including bitness-mismatch and missing-file failures
-- [ ] 5.8 Add tests + implement DLL injection via thread-hijack (save/redirect/restore context) with x86 and x64 stubs
+- [x] 5.1 Add tests for pattern scanning: found (range/module/all-modules), wildcard mask, not-found returns Zero
+- [x] 5.2 Implement `WhiteMagic/Discovery/PatternScanner.cs` to pass 5.1
+- [x] 5.3 Add tests + implement scan result cache (repeat served from cache, clear rescans)
+- [x] 5.4 Add tests + implement `WhiteMagic/Discovery/PeHeaderParser.cs` (sections, entry point)
+- [x] 5.5 Add tests + implement `WhiteMagic/Memory/AllocatedMemory.cs` (named regions, typed read/write by name, address by name, free on dispose)
+- [x] 5.6 Add tests + implement raw code injection (`InjectCode` at address and into fresh allocation)
+- [x] 5.7 Add tests + implement DLL injection via remote thread (LoadLibrary), including bitness-mismatch and missing-file failures
+- [x] 5.8 Add tests + implement DLL injection via thread-hijack (save/redirect/restore context) with x86 and x64 stubs
## 6. In-Process Tier (spec: remote-execution)
-- [ ] 6.1 Add tests for `InProcessInvoker.CreateFunction` calling a known in-process function directly
-- [ ] 6.2 Implement `WhiteMagic/Execution/InProcessInvoker.cs` (`Marshal.GetDelegateForFunctionPointer`) + vtable-entry helper to pass 6.1
-- [ ] 6.3 Document that the CLR-host managed loader (injecting `InProcessReader` into a foreign process) is a separate follow-up change
+- [x] 6.1 Add tests for `InProcessInvoker.CreateFunction` calling a known in-process function directly
+- [x] 6.2 Implement `WhiteMagic/Execution/InProcessInvoker.cs` (`Marshal.GetDelegateForFunctionPointer`) + vtable-entry helper to pass 6.1
+- [x] 6.3 Document that the CLR-host managed loader (injecting `InProcessReader` into a foreign process) is a separate follow-up change
## 7. High-Level Ergonomics (spec: high-level-api)
-- [ ] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
+- [x] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
- [ ] 7.2 Add tests + implement `RemoteModule`/`RemoteFunction` (`sharp["mod"]["fn"]`) resolving export addresses and executing via a chosen strategy
-- [ ] 7.3 Add tests + implement `ManagedPeb`/`ManagedTeb` field reads
-- [ ] 7.4 Add tests + implement `WindowFactory`/`RemoteWindow` (enumerate, move/resize/title/activate/flash, query by class)
-- [ ] 7.5 Add tests + implement keyboard/mouse simulation (PostMessage + SendInput) to a target window
-- [ ] 7.6 Add tests + implement `Task`-based async execution wrappers over the executors and pump
-- [ ] 7.7 Add minimal facade (`WhiteMagic` entry type) exposing `Open`, readers, executors, managers, and the indexer
+- [x] 7.3 Add tests + implement `ManagedPeb`/`ManagedTeb` field reads
+- [x] 7.4 Add tests + implement `WindowFactory`/`RemoteWindow` (enumerate, move/resize/title/activate/flash, query by class)
+- [x] 7.5 Add tests + implement keyboard/mouse simulation (PostMessage + SendInput) to a target window
+- [x] 7.6 Add tests + implement `Task`-based async execution wrappers over the executors and pump
+- [x] 7.7 Add minimal facade (`WhiteMagic` entry type) exposing `Open`, readers, executors, managers, and the indexer
## 8. Optional Iced Backend (spec: managed-assembler)
@@ -81,7 +81,7 @@
## 9. Verification
-- [ ] 9.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass
-- [ ] 9.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
+- [x] 9.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass (180 pass, 4 integration/interactive skipped)
+- [x] 9.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
- [ ] 9.3 Confirm existing BlackMagic/its tests are unchanged and still green
- [ ] 9.4 Update `docs/memory-library-comparison.md` "WhiteMagic — synthesis" section with any deviations discovered during implementation