14 KiB
Context
This repo contains four C# process-manipulation libraries studied in docs/memory-library-comparison.md:
- BlackMagic-old (
reference/Blackmagic-old/) — .NET FW 4.0, x86, FASM as a publicAsmproperty. - MemorySharp (
reference/MemorySharp/) — .NET FW, x86, FASM behindExecute<T>; rich high-level API (PEB/TEB, window, input, calling conventions) but unmaintained since 2016. - GreyMagic (
reference/GreyMagic/) — .NET FW, ~2016, x86; dual in/out-of-processMemoryBase,MarshalCache,DetourManager,PatchManager,CreateFunction<T>,PeHeaderParser. - BlackMagic (
reference/Blackmagic/, current) — .NET 8, x86 + x64, FASM removed, pattern scanning + cache, DLL injection (CreateThread + hijack), hand-assembled x86 stubs (SInject.csEmitU8/EmitU32byte emitter),CreateRemoteThread-basedExecute(BMThread.cs). No frame hook exists.
The consuming use case is an automation client targeting a legacy x86 desktop application. The dominant failure mode observed (FASM-MIGRATION.md) is target crashes when state-sensitive functions are invoked on a thread created by CreateRemoteThread, because the target's main thread has exclusive affinity for its scripting VM, render device, and object model.
WhiteMagic is a new, additive .NET 8 library that unifies the four. It reuses their ideas, not their assemblies. No project already depends on WhiteMagic, so there is no backward-compatibility constraint.
Goals / Non-Goals
Goals:
- Single modern (.NET 8, nullable,
Span<byte>,SafeHandle) library that is bitness-agnostic (x86 + x64). - A three-tier execution model whose default path for state-sensitive calls is crash-safe (runs on the target's own thread), while
CreateRemoteThreadremains available for thread-agnostic payloads. - Dual memory access: out-of-process (RPM/WPM) and in-process (RPM-on-self-handle, see D1 revision) behind one abstract
MemoryBase, withMarshalCache<T>for allocation-free typed IO. - Reversible function hooking (
DetourManager) and byte patching (PatchManager) with auto-restore on dispose. - Replace FASM with an
IAssemblerseam: hand-emitted convention stubs by default, optional Iced backend for arbitrary assembly. Zero native dependency in the default configuration. - Port DLL injection (CreateThread + thread-hijack, x86/x64) and pattern scanning + cache from current BlackMagic.
- Provide MemorySharp-grade ergonomics:
RemotePointerindexer,Module["fn"].Execute(...), PEB/TEB, window mutation, input simulation, async wrappers, helpers. - Test-first for all pure logic (assembler encodings, marshal cache, pattern matching, stub building, pump queue semantics).
Non-Goals:
- Modifying or replacing BlackMagic/MemorySharp/GreyMagic — WhiteMagic is additive.
- Shipping a full x86/x64 assembler ourselves — arbitrary assembly is delegated to Iced; only the fixed convention-stub shapes are hand-emitted.
- Managed-DLL injection bootstrapper (the CLR host that loads
InProcessReaderinto the target). WhiteMagic exposes the in-process API surface; wiring an actual managed loader is a follow-up change. - Application-specific offsets, scripting-engine hooks, or automation logic — those live in the consumer, not the library.
- Interference with other software's operation.
Decisions
D1: Layered architecture with an abstract MemoryBase (from GreyMagic)
MemoryBase defines abstract ReadBytes/WriteBytes/Read<T>/Write<T>, relative/absolute addressing, and hosts the PatchManager. Two concrete readers:
ExternalReader : MemoryBase—ReadProcessMemory/WriteProcessMemoryover aSafeMemoryHandle. Owns allocation, injection, and the remote-thread + main-thread executors.InProcessReader : MemoryBase— reads the current process viaReadProcessMemory/WriteProcessMemoryon a self-handle; owns theDetourManagerandInProcessInvoker. (Revised fromunsafedirect deref during Phase 2: .NET cannot catchAccessViolationException, so a bad deref kills the host with no soft-failure path; RPM-on-self fails soft. The in-process speed win moves to the delegate-call/detour paths, not the reader. Seespecs/memory-access.)
Why: GreyMagic proved this abstraction lets the same higher-level code (pattern scan, patch, high-level API) run in either mode. External is the primary path for an automation host; in-process becomes valuable once injected — not for faster reads (both readers use RPM/WPM, see the D1 revision) but for the delegate-call and detour paths it unlocks (InProcessInvoker, DetourManager).
Alternatives considered: single external-only class (current BlackMagic) — rejected: forecloses the in-process delegate path, which is the cleanest crash-free execution. MemorySharp's factory-per-concern model (Assembly, Threads, Windows factories) — adopted selectively for the high-level surface, but the read/write core stays on MemoryBase for GreyMagic-style polymorphism.
D2: Three-tier execution, crash-safe by default (the headline)
Execution is split by payload safety, not by convenience:
| Tier | Type | Use when | Mechanism |
|---|---|---|---|
| Remote thread | RemoteThreadExecutor |
payload is thread-agnostic (LoadLibrary, pure WinAPI, self-contained code payload) | CreateRemoteThread + convention stub, wait, exit code |
| Main-thread pump | MainThreadPump |
payload touches target state (default) | queue delegate → drained on target thread via per-frame hook |
| In-process | InProcessInvoker |
injected in-process | Marshal.GetDelegateForFunctionPointer, direct call |
MainThreadPump installs a detour on a caller-supplied per-frame function address (an EndScene resolver ships as a convenience helper) via DetourManager. Each frame the hook drains a thread-safe queue and runs pending work items synchronously in the target's context, returning results/exceptions to the requesting thread through a completion handle. This is net-new — no frame hook exists in current BlackMagic to port. It is built on GreyMagic-style detours (D5) as its one underlying primitive; the pump is the first consumer of DetourManager.
Why: CreateRemoteThread does not itself crash the target — calling single-thread-affinity target internals from a foreign thread does. Making the pump the default for target-state calls encodes that rule so callers cannot trip the crash by accident, while power users retain raw CreateRemoteThread for the payloads it is safe for.
Alternatives considered: (a) always CreateRemoteThread (MemorySharp/old-BM) — rejected: the documented crash source. (b) always in-process (GreyMagic) — rejected: requires a managed loader in the target and is not always available; external must work standalone. (c) thread-hijack for every call — rejected: high risk, one-shot, poor for repeated calls; kept only for injection.
D3: IAssembler seam replacing FASM
IAssembler { byte[] Assemble(string asm, ulong origin = 0); }
Two backends:
StubAssembler(default) — hand-emits the fixed calling-convention trampolines (cdecl/stdcall/thiscall/fastcall: push args, call, cleanup, ret) and the injection stubs, using anEmitU8/EmitU32/EmitU64byte emitter (the pattern already inSInject.cs). No parsing, deterministic, x64-capable, zero dependency.IcedAssembler(optional) — wraps Iced for arbitrary user-supplied mnemonics when a caller genuinely needs runtime text assembly.
Why: The comparison established that the only thing FASM did was runtime text→bytecode, needed solely for stubs and (rarely) arbitrary asm. Stubs are a small fixed set best hand-emitted; arbitrary asm is better served by a modern, managed, x64, MIT-licensed assembler (Iced) than by a native C++/CLI FASM DLL. Retiring FASM removes the x86-only mixed-mode constraint.
Alternatives considered: keep FASM/Fasm.NET — rejected: native dependency, x86-only, unmaintained. Hand-emit everything including arbitrary asm — rejected: reimplementing an assembler is out of scope; Iced already exists.
D4: MarshalCache<T> for typed IO (from GreyMagic)
A static class MarshalCache<T> computes and caches Marshal.SizeOf, TypeCode, TypeRequiresMarshal, and IsIntPtr once per type. Read<T>/Write<T> branch on the cached flags: blittable types round-trip through Span/MemoryMarshal; marshal-required types use Marshal.PtrToStructure.
Why: GreyMagic's signature perf win — avoids per-call reflection. Current BlackMagic's where unmanaged constraint is faster still for blittable types but cannot express marshalled structs; the cache gives both.
D5: Reversible hooking and patching with auto-restore (from GreyMagic)
DetourManager/Detour (inline E9 jmp over a prologue, CallOriginal, Apply/Remove) and PatchManager/Patch (named byte patch, Apply/Remove/IsApplied). Both register into a manager that restores all live modifications on MemoryBase.Dispose. Detours are in-process only (they require executing the hook delegate in the target); patches work in both modes.
Safety addition: before splicing a detour, StubAssembler/IcedAssembler disassembles the target prologue to confirm the overwrite lands on instruction boundaries, avoiding the mid-instruction-splice crash class. When only StubAssembler is present, a minimal length-disassembler covers the common prologue shapes; full validation requires the Iced backend.
Why: MemorySharp promised Hook/Patch "coming soon" and never shipped them; GreyMagic shipped both and they are the cleanest available. Auto-restore prevents leaving the target corrupted after a crash of the host.
D6: High-level surface as opt-in factories (from MemorySharp)
RemotePointer (indexer sharp[addr]), RemoteModule/RemoteFunction (sharp["user32"]["MessageBoxA"].Execute(...)), ManagedPeb/ManagedTeb, WindowFactory (move/resize/title/flash/activate), Keyboard/Mouse (PostMessage + SendInput), and async wrappers over the executors. These sit above MemoryBase and the execution tiers; none is required for core memory work.
Why: This is the ergonomic layer that made MemorySharp pleasant. It is pure P/Invoke over the core — no assembler, no FASM.
D7: New projects, additive, test-first
WhiteMagic/ (library, net8.0-windows, AllowUnsafeBlocks) and WhiteMagicTest/ (xUnit). Task 1.3 creates WhiteMagic.slnx (SDK 10's default solution format). The reference libraries live under reference/ (git-ignored) and are not part of this build. Pure-logic components (assembler encodings, marshal cache, pattern matcher, stub builders, pump queue) get tests before implementation, mirroring the existing inject-and-assemble change's TDD discipline. Live-process behavior (RPM/WPM, injection, detours) is validated in integration tests gated on an available target.
Risks / Trade-offs
- Scope is large → Deliver in vertical slices (see Migration Plan). The minimum crash-safe slice is
MemoryBase+ExternalReader+DetourManager+MainThreadPump; everything else layers on without reworking it. - In-process tier needs a managed loader not in scope → Ship
InProcessReader's API and delegate-call path now; mark the actual CLR-host injection as a follow-up. External + pump deliver crash-safety without it. - Detour prologue splicing can crash if misaligned → Validate instruction boundaries before writing (D5); require Iced backend for full validation; keep
Remove/auto-restore so a bad detour is recoverable. - Iced adds a NuGet dependency → Isolated behind
IAssembler; defaultStubAssemblerkeeps the library dependency-free. Only callers needing arbitrary asm opt in. MainThreadPumpadds per-frame overhead and a hook on a hot function → Keep the drained-per-frame work bounded; the hook is a thin queue check when idle. Document that a wedged work item stalls the frame.- x64 detours need larger/absolute jumps (14-byte
push/retor RIP-relative) →StubAssembleremits the correct form per bitness; covered by encoding tests. - Blittable vs marshalled ambiguity in
MarshalCache→ Explicit flags and tests per type category; document that[StructLayout]is required for non-blittable remote structs.
Migration Plan
WhiteMagic is additive; there is nothing to migrate off. Delivery is phased so each slice is independently useful and testable:
- Core —
MemoryBase,ExternalReader,SafeMemoryHandle,MarshalCache, typed/string/bytes IO. (Replaces nothing; standalone.) - Crash-safe execution slice —
StubAssembler,DetourManager,MainThreadPump,RemoteThreadExecutor. Proves state-sensitive calls without crashes end-to-end. This is the headline deliverable. - Injection & discovery — DLL injection (CreateThread + hijack, x86/x64), pattern scanning + cache,
PeHeaderParser, namedAllocatedMemory. - In-process tier —
InProcessReader,InProcessInvoker,CreateFunction<T>, vtable helpers (API surface; managed loader deferred). - High-level ergonomics —
RemotePointer,RemoteModule/RemoteFunction, PEB/TEB, window, input, async,PatchManagerpolish, helpers. - Optional Iced backend —
IcedAssembler, arbitrary-asm inject, full prologue validation.
Rollback: WhiteMagic is a separate assembly; removing its project reference reverts consumers with no effect on existing libraries.
Open Questions
- Frame-hook target:
default to D3D9Resolved:EndScene, or accept a caller-supplied per-frame function address?MainThreadPumptakes a caller-supplied frame-function address (application-agnostic, library stays offset-free per Non-Goals); anEndSceneresolver ships as a convenience helper only. Slice 2 depends on this — settled before slice 2 starts. - Managed in-process loader: which host mechanism (custom CLR host vs. a native shim that calls
CorBindToRuntimeEx/ICLRRuntimeHost)? Deferred to a follow-up change but affects theInProcessReaderseam shape. - Iced as default vs optional: keep hand-stubs default (zero dep) — confirmed — but should the library ship a
WhiteMagic.Icedcompanion package rather than an optional reference? Package boundary TBD. - Async model:
Task-based wrappers (MemorySharp) vs. exposing the pump's completion handles directly. Likely both: pump returns a handle, async wrappers adapt it toTask<T>.