Section 8 of the whitemagic-foundation change. - 8.1: reference Iced 1.21.0 behind IcedAssembler:IAssembler. The default StubAssembler path never touches Iced; only constructing IcedAssembler pulls it into a behavioral path. - 8.2: IcedAssembler.Assemble bridges Intel-syntax text onto Iced's fluent Assembler by reflection (Iced ships no text parser). Registers, immediates and labels are supported with origin-relative encoding; memory operands throw NotSupportedException. - 8.3: IcedAssembler.GetPrologueLength decodes arbitrary instructions via Iced's Decoder. DetourManager.PrologueLengthResolver (new delegate) defaults to the built-in PrologueDecoder and is swappable to the Iced resolver, threaded into each Detour. This lifts the "partial boundary safety" caveat on the hooking slice when Iced is opted in. Also gitignore test-run TestResults artifacts. Tests: 221 passing, 4 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
Process-Introspection Library Comparison — BlackMagic-old, MemorySharp, GreyMagic, BlackMagic
Date: 2026-07-21
Purpose: Compare four C# process-introspection libraries present in this repo and derive the design of a modern successor ("WhiteMagic"). The OpenSpec change whitemagic-foundation formalizes the design; this document is the supporting study.
The four subjects
| Library | Location | Era / Platform | Role in this study |
|---|---|---|---|
| BlackMagic-old | reference/Blackmagic-old/ |
.NET FW 4.0, x86 | The FASM "before" — initial commit, FASM as public API |
| MemorySharp | reference/MemorySharp/ (≡ lib/MemorySharp/, byte-identical) |
.NET FW, x86 | Contemporaneous peer that kept FASM behind a polished API |
| GreyMagic | reference/GreyMagic/ |
.NET FW, ~2016, x86 | Introduces the in-process execution model + detour/patch/marshal-cache |
| BlackMagic (current) | reference/Blackmagic/ |
.NET 8, x86 + x64 | The FASM "after" — modernized, FASM removed |
reference/MemorySharp/andlib/MemorySharp/are identical copies (diff -rqclean).
Where FASM lives (the through-line)
FASM (the Flat Assembler, via the ManagedFasm C++/CLI wrapper in reference/fasm/) exists in these libraries for exactly one job: turning assembly text into machine code at runtime, so a small call-stub / injection-stub can be synthesized when the target address, arguments, and calling convention are only known at call time.
- BlackMagic-old — FASM is a public, load-bearing dependency:
public ManagedFasm Asm { get; set; }sits directly on the facade (BMMain.cs:80), andSInject.csbuilds its DLL-redirect injection stub as mnemonic text at runtime. - MemorySharp — same dependency, wrapped: the assembler is internal (
Fasm32Assembler, created unconditionally byAssemblyFactory), driven by calling-convention formatters behindExecute<T>. - GreyMagic — FASM only on the external path (
ExternalProcessReader.Asm); the in-process path needs no assembler because it calls functions as delegates directly. - BlackMagic (current) — FASM removed entirely. Injection stubs became compile-time
byte[](BuildStub32/64); the publicAsmproperty was deleted; runtime target execution moved to the D3D EndScene hook. SeeFASM-MIGRATION.md.
Conclusion: the assembly subsystem is not inherent to process introspection — it is a consequence of choosing CreateRemoteThread + "support arbitrary calling conventions" as the execution contract. Change the execution primitive (as current BM did) and the need for a runtime assembler evaporates. A managed assembler (Iced) or hand-emitted stubs cover the residual need with no native dependency and full x64 support.
Feature matrix
| Axis | BM-old | MemorySharp | GreyMagic | BM (current) |
|---|---|---|---|---|
| Platform | FW 4.0, x86 | FW, x86 | FW, x86 | .NET 8, x86 + x64 |
| Handles | raw IntPtr |
SafeMemoryHandle |
SafeMemoryHandle |
SafeMemoryHandle, nullable |
| Addressing | uint |
IntPtr |
IntPtr |
IntPtr (64-bit-safe) |
| Process model | external | external | external + in-process | external (+ frame-hook) |
| Typed read/write | ReadInt etc. |
Read<T> + marshal |
Read<T> + MarshalCache |
Read<T> where unmanaged |
| Pattern scanning | ✅ | ❌ ("coming soon") | ❌ (has PE parser) | ✅ + cache |
| FASM / assembler | public Asm |
internal, behind Execute<T> |
Asm (external only) |
none |
| Remote fn call | raw Asm stubs |
Execute<T>(conv, args…) + async |
in-proc delegates | D3D frame-hook stub |
| Function hooking | — | — | Detour mgr (reversible, CallOriginal) |
Frame-hook only |
| Byte patching | — | ❌ ("coming soon") | Patch mgr (named, reversible) | ad hoc |
| DLL injection | CreateThread + hijack | LoadLibrary via CreateThread | (via Asm) |
CreateThread + hijack, x86/x64 |
| Named allocation | — | RemoteAllocation |
AllocatedMemory (by name) |
AllocateMemory |
| PE parsing | — | — | PeHeaderParser |
— |
| PEB / TEB | — | ✅ ManagedPeb/ManagedTeb |
— | — |
| Window mutation | — | ✅ (move/resize/title/flash) | — | — |
| Input simulation | — | ✅ (keyboard/mouse, no focus) | — | — |
| High-level ergonomics | simple facade | sharp[addr], module["fn"], Enum reads |
CreateFunction<T>, vtable helpers |
facade |
| Helpers | — | ApplicationFinder, HandleManipulator, Randomizer, Serialization, Singleton | MarshalCache, Utilities | — |
What each does best (the "take from each" summary)
- BlackMagic-old → the clean minimal
Open / Read / Write / FindPatternfacade; it is also the historical proof that FASM was once load-bearing and can be retired. - MemorySharp → high-level ergonomics: calling-convention
Execute<T>+ parameter marshalling + async,RemotePointerindexer, PEB/TEB, window + keyboard/mouse simulation, helper utilities. - GreyMagic → the engine: dual in/out-of-process
MemoryBase,MarshalCache<T>fast typed IO, reversibleDetourManager+PatchManager,CreateFunction<T>/vtable helpers, namedAllocatedMemory,PeHeaderParser. - BlackMagic (current) → the modern platform: .NET 8,
SafeMemoryHandle, nullable,Span<byte>, x64, pattern scanning + cache, rich DLL injection (CreateThread + thread-hijack, x86/x64 stubs), hand-assembled stubs (no FASM), per-frame hook (crash-safe execution), test coverage.
The crash-safety principle (why CreateRemoteThread needs care)
CreateRemoteThread does not crash the target — calling target internals from the wrong thread does. The target's main thread has exclusive affinity for its scripting VM, the render device, and the object model. A thread you spawn runs concurrently with it; the moment a payload touches that state (scripting-engine entry points, object traversal) it races the main thread → memory corruption → crash. This matches the "3 crashes in one session" recorded in FASM-MIGRATION.md.
The rule the successor must encode — split execution by payload safety:
CreateRemoteThreadis safe for self-contained, thread-agnostic payloads:LoadLibrary(DLL injection), pure WinAPI, code touching only memory you own.- State-sensitive calls must run on the target's own thread, reached by hooking a per-frame function (D3D
EndScene, or any frame function via a detour) and draining a work queue there each frame. - In-process (once a managed DLL is injected), call target functions directly as delegates — no thread crossing at all.
WhiteMagic — synthesis
A modern successor unifying the four. Full design in openspec/changes/whitemagic-foundation/.
WhiteMagic (facade — BM-old ergonomics)
├─ Core: SafeHandle, native P/Invoke, x64 [BM current]
├─ MemoryBase (abstract Read/Write + MarshalCache) [GreyMagic]
│ ├─ ExternalReader (RPM/WPM)
│ └─ InProcessReader (RPM/WPM on self-handle, injected)
├─ Discovery: PatternScanner(+cache), PeHeaderParser [BM current + GreyMagic]
├─ Allocation: AllocatedMemory (named chunks) [GreyMagic]
├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM]
├─ Execution (three tiers):
│ ├─ RemoteThreadExecutor (CreateRemoteThread — safe payloads) [MemorySharp Execute<T>, no FASM]
│ ├─ MainThreadPump (frame-hook work queue) [BM frame-hook + GreyMagic detour] ← crash-safe
│ └─ InProcessInvoker (CreateFunction<T> delegates) [GreyMagic]
├─ Hooking: DetourManager + PatchManager [GreyMagic]
├─ Injection: CreateThread + ThreadHijack (x86/x64) [BM current]
├─ HighLevel: RemotePointer, Module["fn"], PEB/TEB,
│ input sim, window, async, helpers [MemorySharp]
└─ Safety: disassemble-before-splice (Iced), auto-restore
all patches/detours on Dispose [new]
Net result: BM's modern, FASM-free, x64 core + GreyMagic's dual-mode / detour / patch / marshal-cache engine + MemorySharp's high-level ergonomics — with a three-tier execution model whose default for state-sensitive calls is the crash-safe main-thread pump, while CreateRemoteThread stays available for the payloads it is genuinely safe for.
Deviations discovered during implementation
The design held, but building it surfaced corrections worth recording (each is detailed against its task in openspec/changes/whitemagic-foundation/tasks.md):
InProcessReaderreads via RPM/WPM on a self-handle, notunsafedirect deref — .NET cannot catchAccessViolationException, so a bad direct deref kills the host with no soft-failure path. The in-process speed win moves to the delegate-call and detour paths, not the reader (design decision D1, revised mid-Phase 2).MarshalCache<T>splitsSize(managed, blittable) fromMarshalSize(Marshal.SizeOf, marshal path) — a single size mis-sized structs whose unmanaged width differs (aboolfield is managed-1 / unmanaged-4; inlineByValTStr/ByValArrayunder-sized the marshal buffer and corrupted the heap on write).MemoryBasepicks perTypeRequiresMarshalat every IO site.- x64 call stub is fully MS-x64-ABI compliant — 32-byte shadow space, 16-byte alignment at the inner
call, fullimm64register loads (no >4 GiB pointer truncation), stack args above the shadow window. Proven at runtime by a live SSE callee whose alignedmovapsfaults on any misalignment (task 3.8), not just by byte-level encoding tests. RemoteModule/RemoteFunctionfollow PE export forwarders —kernel32!HeapAlloc→NTDLL.RtlAllocateHeapand similar resolve into the real target module; ordinal and API-set forwarders throwNotSupportedExceptionrather than returning a wrong address (task 7.2).- Detour prologue safety is tiered — the default
StubAssemblerlength-decoder covers only the common x86/x64 prologue shapes and refuses any opcode outside that set (zero dependency); the optionalIcedAssembler.GetPrologueLengthdecodes arbitrary prologues and is plugged in viaDetourManager.PrologueLengthResolverwhen full validation is wanted (tasks 4.6, 8.3). - Iced has no text parser — the design assumed arbitrary text assembly could be delegated to Iced, but Iced ships only a fluent code assembler and a decoder.
IcedAssembler.Assemblebridges Intel-syntax text onto the fluent API by reflection (registers, immediates, labels; memory operands unsupported), rather than depending on a parser that does not exist (task 8.2). - Injection bitness corrections — the thread-hijack injector enforces matching host/target bitness, so the 32-bit path always runs from a 32-bit caller and uses native
GetThreadContext/SetThreadContext; the WOW64 context APIs (for 64-bit callers inspecting WOW64 targets) never apply here and were removed.ExternalReadervalidatesQueryInformation/QueryLimitedInformationaccess and surfacesIsWow64Processfailures instead of silently assuming host bitness. - Bounds and protection hardening —
AllocatedMemoryrange-checks typed IO against region size;Patchmirrors the detour'sVirtualProtectExdance;MainThreadPumpguards the completion race on an already-completedTaskCompletionSource.