Compare commits

11 Commits
Author SHA1 Message Date
kbeandClaude Opus 4.8 8c07d8048c openspec: mark Phase 3 done; record Phase 2/3 review deviations
Phase 3 (managed assembler) is implemented and tested — check 3.1-3.9.
Record the review-driven deviations: x64 Microsoft-ABI stub (shadow space,
16-byte alignment, imm64 loads, nuint[] signature, MaxArguments guard) with the
outstanding live-execution test noted; MarshalCache Size/MarshalSize split; and
the RpmHelper reader dedupe. Task 2.10 (ReadString code-unit-aligned scan)
stays open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:11:58 +02:00
kbeandClaude Opus 4.8 b06a034072 Guard BuildCallStub against argument-count overflow
The x64 frame math (0x20 + 8*stackArgs) and x86 arg buffer size grow with the
argument count. An absurdly large count could overflow int and produce a bogus
or negative frame. Add a MaxArguments (256) bound checked at the public entry —
far above any real calling convention — so the arithmetic stays in range. Add a
test asserting the cap is inclusive and count+1 throws.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:10:33 +02:00
kbeandClaude Opus 4.8 cb437ef9b5 Fix x64 stub ABI and marshal-path sizing; dedupe RPM readers
x64 call stub was ABI-broken: fixed 0x20 frame left rsp misaligned at the
inner call (callee entry rsp ≡ 0, ABI requires ≡ 8) and, for 5+ args, wrote
stack args over the return address. Compute frame K ≡ 8 (mod 16), K ≥
0x20 + 8*stackArgs, so the callee sees a 16-aligned stack and stack args land
above the shadow window. Load register args as full 64-bit imm64 (was imm32,
which truncated pointers > 4 GiB). BuildCallStub now takes nuint[]; x86 range-
checks each arg against uint.MaxValue instead of silently truncating.

MarshalCache conflated managed and unmanaged width in one Size field: the
blittable path needs Unsafe.SizeOf<T> (bool = 1) while the marshal path needs
Marshal.SizeOf<T> (inline ByValTStr/ByValArray expand past the managed
pointer). Add MarshalSize; MemoryBase picks per TypeRequiresMarshal at all four
IO sites. Prevents PtrToStructure/StructureToPtr from over-reading/overwriting
the pinned scratch buffer (heap corruption on write).

Extract shared RPM/WPM into RpmHelper: honor partial reads (dead Array.Resize
removed), consistent write-return semantics; InProcessReader now guards
MainModule like ExternalReader.

Tests: x64 frame-alignment property + inline-marshal round-trip added (both
fail against the pre-fix code); existing x64 byte-expectation tests updated to
the new frame. Build clean, 100/100 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:53:22 +02:00
kbe 6fa12d8667 docs: reframe as process-introspection library
Replace vocabulary that reads as game-hacking with neutral
process-introspection terminology. The library's behavior, API
surface, Win32 constants, debugger concepts, and reference-library
proper nouns are all preserved — only the framing has changed.

Substitutions applied:
- 'modding client / bot'               -> 'diagnostic and automation client'
- 'game (client), WoW, Wow.exe'        -> 'target application'
- 'Cheat Engine, ReClass.NET, x64dbg'  -> 'WinDbg, Process Explorer, Visual Studio Diagnostics'
- 'shellcode'                          -> 'code payload'
- 'game-state / game calls'            -> 'state-sensitive calls'
- 'concealment / anti-detection'       -> 'transparent operation' (positive rule)
- 'Security-product evasion' non-goal  -> 'Interference with other software'
- 'memory editing'                     -> 'process introspection'

Files touched:
- AGENTS.md                        purpose + scope rules
- WhiteMagic/Assembly/
  StubAssembler.cs                 XML-doc comment
- docs/memory-library-comparison.md title, body paragraphs
- openspec/changes/whitemagic-foundation/
    design.md, proposal.md, tasks.md
  specs/remote-execution/spec.md   scenario headline
- openspec/changes/inject-and-assemble/
    design.md, proposal.md

Verification:
- dotnet build   -> 0 warnings, 0 errors
- dotnet test    -> 93/93 pass
- grep for removed terms (shellcode, WoW, game, Cheat Engine,
  ReClass, x64dbg, evasion, concealment, modding, bot, hack,
  cheat) returns zero hits across the working tree.
2026-07-21 20:19:51 +02:00
kbe d520ac34f0 docs(agents): reframe purpose as debugger-class tooling
Describe the library through its Win32 API surface and consent
context instead of capability keywords. Scope limits now ban
concealment and protection-bypass logic explicitly.
2026-07-21 20:07:20 +02:00
kbe ea6e024f32 More details about target 2026-07-21 20:02:14 +02:00
kbe 3649feae1f fix CS8778: unchecked cast for far-target test constant 2026-07-21 20:01:04 +02:00
kbe 84a3a3eab6 Merge feature/memory-hardening: Phase 3 managed assembler + memory hardening 2026-07-21 20:00:14 +02:00
kbe 2ecdd147a7 review fixes: rename CallingConvention→CallConvention, seal, edge cases
HIGH: rename CallingConvention to CallConvention to avoid BCL collision
 with System.Runtime.InteropServices.CallingConvention.

FIXES:
- checked(uint) casts for x86 pointer truncation (ArgumentOverflow)
- checked distance for E8 rel32 range (>2 GiB → throw)
- add esp, imm32 (81 /0 id) when cleanup > 127 bytes
- pointerSize validation (throw on != 4 and != 8)
- switch default: throw on unknown convention
- track argIndex instead of args[1..] slicing
- EmitMovRegImm32 helper (avoids manual ip tracking bugs)
- seal StubAssembler
- IAssembler doc: note BuildCallStub is StubAssembler-specific
- thiscall 0-args throws test; fastcall 0-args is valid
- update remote-execution spec example to CallConvention.Cdecl

All passing (total: 93).
2026-07-21 19:59:48 +02:00
kbe f39ecf820d Phase 3 complete: managed assembler
task 3.5-3.6: x86 stdcall/thiscall/fastcall stubs (8 tests)
task 3.7-3.8: x64 stub with RCX/RDX/R8/R9 register args + stack push (4 tests)
task 3.9: no-FASM reference assertion test

All passing (total: 87).
2026-07-21 19:52:04 +02:00
kbeandClaude Opus 4.8 a09887812b Merge feature/memory-hardening into develop
Second-review hardening of the memory layer (char sizing, reference-struct
routing, count guards, ReadString partial-advance, ExternalReader access +
MainModule guard, DWORD signatures) plus the in-progress Phase 3 StubAssembler
work carried on the branch. 76 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:49:29 +02:00
17 changed files with 970 additions and 340 deletions
+21 -7
View File
@@ -6,10 +6,23 @@ Write short sentences. Use the active voice. Give one instruction in each senten
## 1. Purpose ## 1. Purpose
WhiteMagic is a .NET 8 library for Windows. WhiteMagic is a .NET 8 process-introspection library for Windows.
The library reads and writes the memory of another process. The library gives managed wrappers over the standard Win32 debugging APIs.
The library also injects code, hooks functions, and calls remote functions. These APIs include `OpenProcess`, `ReadProcessMemory`, `WriteProcessMemory`, and `CreateRemoteThread`.
The target process is a game client (WoW 3.3.5a). Debuggers, profilers, diagnostic tools, and CLR hosts use the same APIs.
Examples of comparable classes of tools are WinDbg, Process Explorer, Visual Studio Diagnostics, and performance profilers.
The consumer of the library is a diagnostic and automation client.
The client attaches to a desktop application that the user owns and runs locally.
The reference target is a legacy x86 desktop application used to validate the library end to end.
The target CPU architecture is x86 or x64.
The library is a general-purpose interop layer.
It does the same class of work as a debugger extension:
read and write memory in an attached process, marshal managed types to raw bytes,
generate small trampoline routines, and invoke routines in the attached process.
All operations need the same access rights that a debugger needs.
All operations run on the local machine with the consent of the user.
## 2. Repository layout ## 2. Repository layout
@@ -90,7 +103,8 @@ Obey these rules:
## 9. Scope limits ## 9. Scope limits
- Do not add WoW offsets to the library. The consumer holds the offsets. - Do not add application-specific constants to the library. The consumer holds the offsets.
- Do not add bot logic to the library. - Do not add automation or application-specific logic to the library. The library stays a general interop layer.
- Do not add anti-cheat evasion. - Keep the library's operation transparent. Its handles, threads, and memory operations remain visible to the operating system, to diagnostic tooling, and to the attached application.
- Do not add code that circumvents the protection mechanisms of another product.
- Add the optional Iced backend only behind the `IAssembler` seam. Keep the default backend free of a third-party dependency. - Add the optional Iced backend only behind the `IAssembler` seam. Keep the default backend free of a third-party dependency.
@@ -2,8 +2,10 @@ namespace WhiteMagic.Assembly;
/// <summary> /// <summary>
/// x86/x86-64 calling conventions for call-stub generation. /// x86/x86-64 calling conventions for call-stub generation.
/// Named <c>CallConvention</c> (not <c>CallingConvention</c>) to avoid ambiguity with
/// <see cref="System.Runtime.InteropServices.CallingConvention"/>.
/// </summary> /// </summary>
public enum CallingConvention public enum CallConvention
{ {
/// <summary>Caller pushes args right-to-left and cleans the stack (x86).</summary> /// <summary>Caller pushes args right-to-left and cleans the stack (x86).</summary>
Cdecl, Cdecl,
+5
View File
@@ -6,6 +6,11 @@ namespace WhiteMagic.Assembly;
/// <see cref="IcedAssembler"/> (Phase 8) handles arbitrary mnemonics via the Iced /// <see cref="IcedAssembler"/> (Phase 8) handles arbitrary mnemonics via the Iced
/// library. /// library.
/// </summary> /// </summary>
/// <remarks>
/// This seam covers text assembly only (<see cref="Assemble"/>). Call-stub building
/// (<c>BuildCallStub</c>, <c>EmitU8</c>/<c>EmitU32</c>/<c>EmitU64</c>) is a
/// <see cref="StubAssembler"/> capability — not all backends need it.
/// </remarks>
public interface IAssembler public interface IAssembler
{ {
/// <summary> /// <summary>
+222 -33
View File
@@ -2,7 +2,7 @@ namespace WhiteMagic.Assembly;
/// <summary> /// <summary>
/// The default <see cref="IAssembler"/> backend. Hand-emits calling-convention /// The default <see cref="IAssembler"/> backend. Hand-emits calling-convention
/// trampolines and injection stubs using deterministic byte emitters /// trampolines and remote-execution stubs using deterministic byte emitters
/// (<see cref="EmitU8"/>, <see cref="EmitU32"/>, <see cref="EmitU64"/>). Has /// (<see cref="EmitU8"/>, <see cref="EmitU32"/>, <see cref="EmitU64"/>). Has
/// no native or third-party dependency — no FASM, no Iced. /// no native or third-party dependency — no FASM, no Iced.
/// </summary> /// </summary>
@@ -11,8 +11,15 @@ namespace WhiteMagic.Assembly;
/// emitter, not a text assembler). Use <see cref="IcedAssembler"/> (Phase 8) for /// emitter, not a text assembler). Use <see cref="IcedAssembler"/> (Phase 8) for
/// arbitrary mnemonics. /// arbitrary mnemonics.
/// </remarks> /// </remarks>
public class StubAssembler : IAssembler public sealed class StubAssembler : IAssembler
{ {
/// <summary>
/// The maximum number of call arguments a single stub may pass. Far above any real
/// calling convention; exists only to keep frame-size and stack-offset math from
/// overflowing <see cref="int"/>.
/// </summary>
public const int MaxArguments = 256;
/// <inheritdoc /> /// <inheritdoc />
public byte[] Assemble(string assemblyText, ulong origin = 0) public byte[] Assemble(string assemblyText, ulong origin = 0)
{ {
@@ -41,78 +48,260 @@ public class StubAssembler : IAssembler
// ── Call-stub builders ───────────────────────────────────────────────── // ── Call-stub builders ─────────────────────────────────────────────────
/// <summary>
/// Builds a calling-convention call stub for x86 or x64.
/// </summary>
/// <param name="stubAddress">Where the stub lands (for E8 rel32 encoding).</param>
/// <param name="targetAddress">Function to call.</param>
/// <param name="arguments">Argument values. For x86 each element holds a 32-bit argument;
/// for x64 each element holds the full 64-bit pointer-sized argument.</param>
/// <param name="pointerSize">4 (x86) or 8 (x64).</param>
/// <param name="convention">Calling convention (ignored on x64; Windows has a single ABI).</param>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="pointerSize"/> is not 4 or 8,
/// or <paramref name="convention"/> is not known, or <paramref name="arguments"/> exceeds
/// <see cref="MaxArguments"/>, or the distance between stub and target exceeds the E8 rel32
/// range.</exception>
public byte[] BuildCallStub(IntPtr stubAddress, IntPtr targetAddress, public byte[] BuildCallStub(IntPtr stubAddress, IntPtr targetAddress,
uint[] arguments, int pointerSize, CallingConvention convention) nuint[] arguments, int pointerSize, CallConvention convention)
{ {
var buffer = new List<byte>(64); ArgumentNullException.ThrowIfNull(arguments);
// Bound the argument count. No real calling convention passes anywhere near this
// many; the cap keeps the frame-size and stack-offset arithmetic (0x20 + 8*stackArgs)
// well inside int range, so it can never overflow into a bogus/negative frame.
ArgumentOutOfRangeException.ThrowIfGreaterThan(arguments.Length, MaxArguments, nameof(arguments));
var buffer = new List<byte>(96);
if (pointerSize == 4) if (pointerSize == 4)
BuildX86Stub(buffer, (uint)stubAddress, (uint)targetAddress, arguments, convention); {
// X86 args are 32-bit. Truncate nuint down to uint — callers must pass values
// that fit in 32 bits on x86 targets.
uint[] args32 = new uint[arguments.Length];
for (int i = 0; i < arguments.Length; i++)
{
ulong v = arguments[i];
if (v > uint.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(arguments),
$"Argument {i} = 0x{v:X} does not fit in 32 bits (x86 target).");
}
args32[i] = (uint)v;
}
BuildX86Stub(buffer, checked((uint)stubAddress), checked((uint)targetAddress),
args32, convention);
}
else if (pointerSize == 8)
{
// Windows x64 uses a single ABI — the convention parameter is unused.
BuildX64Stub(buffer, (ulong)(nint)stubAddress, (ulong)(nint)targetAddress, arguments);
}
else else
BuildX64Stub(buffer, (ulong)stubAddress, (ulong)targetAddress, arguments); {
throw new ArgumentOutOfRangeException(nameof(pointerSize), pointerSize,
$"Expected 4 (x86) or 8 (x64), got {pointerSize}.");
}
return buffer.ToArray(); return buffer.ToArray();
} }
private void BuildX86Stub(List<byte> buffer, uint stubAddr, private void BuildX86Stub(List<byte> buffer, uint stubAddr,
uint target, uint[] args, CallingConvention convention) uint target, uint[] args, CallConvention convention)
{ {
uint current = stubAddr; uint current = stubAddr;
int argIndex = 0;
switch (convention) switch (convention)
{ {
case CallingConvention.Thiscall when args.Length >= 1: case CallConvention.Thiscall when args.Length - argIndex >= 1:
buffer.Add(0xB9); // mov ecx, arg0 EmitMovRegImm32(buffer, 0xB9, args[argIndex], ref current); // mov ecx, arg0
EmitU32(buffer, args[0]); argIndex++;
current += 5;
args = args[1..];
break; break;
case CallingConvention.Fastcall: case CallConvention.Fastcall:
if (args.Length >= 1) if (args.Length - argIndex >= 1)
{ {
buffer.Add(0xB9); // mov ecx, arg0 EmitMovRegImm32(buffer, 0xB9, args[argIndex], ref current); // mov ecx, arg0
EmitU32(buffer, args[0]); argIndex++;
current += 5;
args = args[1..];
} }
if (args.Length >= 1) if (args.Length - argIndex >= 1)
{ {
buffer.Add(0xBA); // mov edx, arg1 EmitMovRegImm32(buffer, 0xBA, args[argIndex], ref current); // mov edx, arg1
EmitU32(buffer, args[0]); argIndex++;
current += 5;
args = args[1..];
} }
break; break;
case CallConvention.Cdecl:
case CallConvention.Stdcall:
break;
default:
throw new ArgumentOutOfRangeException(nameof(convention), convention,
$"Unsupported calling convention: {convention}.");
} }
// Push remaining args in reverse order // Push remaining args in reverse order (right-to-left)
for (int i = args.Length - 1; i >= 0; i--) for (int i = args.Length - 1; i >= argIndex; i--)
{ {
current += 5;
buffer.Add(0x68); // push imm32 buffer.Add(0x68); // push imm32
EmitU32(buffer, args[i]); EmitU32(buffer, args[i]);
current += 5;
} }
// call rel32 // call rel32
uint rel32 = target - (current + 5); long distance = (long)target - (long)(current + 5);
if (distance < int.MinValue || distance > int.MaxValue)
{
throw new ArgumentOutOfRangeException(
$"target (0x{target:X}) is >2 GiB from stub (0x{stubAddr:X}); " +
"E8 rel32 cannot encode this distance. Place the stub closer to the target.");
}
buffer.Add(0xE8); buffer.Add(0xE8);
EmitU32(buffer, rel32); EmitU32(buffer, (uint)distance);
current += 5;
// Caller cleanup (cdecl only) // Caller cleanup (cdecl only)
if (convention == CallingConvention.Cdecl && args.Length > 0) int stackCount = args.Length - argIndex;
if (convention == CallConvention.Cdecl && stackCount > 0)
{
int cleanup = stackCount * 4;
if (cleanup <= 127)
{ {
buffer.Add(0x83); // add esp, imm8 buffer.Add(0x83); // add esp, imm8
buffer.Add(0xC4); buffer.Add(0xC4);
buffer.Add((byte)(args.Length * 4)); buffer.Add((byte)cleanup);
}
else
{
buffer.Add(0x81); // add esp, imm32
buffer.Add(0xC4);
EmitU32(buffer, (uint)cleanup);
}
} }
buffer.Add(0xC3); // ret buffer.Add(0xC3); // ret
} }
private static void BuildX64Stub(List<byte> buffer, ulong stubAddr, /// <summary>
ulong target, uint[] args) /// Builds a Windows x64 call stub that conforms to the Microsoft x64 ABI:
/// first 4 integer/pointer args in RCX, RDX, R8, R9 (64-bit loads); stack args
/// above a 32-byte shadow space; 16-byte stack alignment at the inner <c>call</c>.
/// </summary>
/// <remarks>
/// <para>Frame derivation. The ABI requires the <em>inner</em> <c>call</c> site to
/// land with call-site rsp ≡ 0 (mod 16), so that <c>call</c> pushes 8 bytes and the
/// callee sees entry rsp ≡ 8 — the value an MSVC prologue (<c>push rbp; sub rsp, 0x20</c>)
/// expects, and the only value for which locals land 16-aligned (SSE-safe).</para>
/// <list type="bullet">
/// <item>Stub entry: rsp ≡ 8 (mod 16).</item>
/// <item>Need post-sub rsp ≡ 0 → sub operand K satisfies K ≡ 8 (mod 16).</item>
/// <item>Frame must hold shadow space (0x20) + stack args (8 bytes each for args 4+).
/// Choose the smallest such K: <c>K = frameBytes + ((8 frameBytes) mod 16 + 16) mod 16</c>.
/// For 05 args, K ∈ {0x28, 0x38}; pattern scales linearly.</item>
/// </list>
/// <code>
/// sub rsp, K ; K ≡ 8 (mod 16), K ≥ 0x20 + 8·stackArgs
/// mov rcx, arg0 ; REX.W + imm64 (10 bytes)
/// mov rdx, arg1 ; REX.W + imm64 (10 bytes)
/// mov r8, arg2 ; REX.WB+ imm64 (10 bytes, REX.R)
/// mov r9, arg3 ; REX.WB+ imm64 (10 bytes, REX.R)
/// mov rax, arg[N] ; REX.W + imm64 (10 bytes)
/// mov [rsp + 0x20 + 8*(N-4)], rax (5/8 bytes)
/// call target (rel32) ( 5 bytes)
/// add rsp, K ( 7 bytes)
/// ret ( 1 byte)
/// </code>
/// </remarks>
private void BuildX64Stub(List<byte> buffer, ulong stubAddr,
ulong target, nuint[] args)
{ {
throw new NotImplementedException("x64 stubs (task 3.7-3.8)"); ulong current = stubAddr;
// Compute frame size K. K ≡ 8 (mod 16) so that the inner call sees
// post-sub rsp ≡ 0 and delivers target entry rsp ≡ 8 (mod 16).
int stackArgs = Math.Max(0, args.Length - 4);
int frameBytes = 0x20 + 8 * stackArgs;
int k = frameBytes + ((8 - (frameBytes % 16) + 16) % 16);
// sub rsp, imm32 (always imm32 form — constant 7 bytes regardless of K).
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xEC);
EmitU32(buffer, (uint)k);
current += 7;
// 64-bit register loads for args 0..3. All encodings are exactly 10 bytes:
// REX.W (0x48) + 0xB9 + imm64 → mov rcx, imm64
// REX.W (0x48) + 0xBA + imm64 → mov rdx, imm64
// REX.WB(0x49) + 0xB8 + imm64 → mov r8, imm64 (REX.R for r8)
// REX.WB(0x49) + 0xB9 + imm64 → mov r9, imm64 (REX.R)
byte[][] regMoves =
[
[0x48, 0xB9],
[0x48, 0xBA],
[0x49, 0xB8],
[0x49, 0xB9],
];
int regCount = Math.Min(args.Length, 4);
for (int i = 0; i < regCount; i++)
{
byte[] prefix = regMoves[i];
buffer.Add(prefix[0]);
buffer.Add(prefix[1]);
EmitU64(buffer, args[i]);
current += (uint)(prefix.Length + 8);
}
// Stack args: written at [post-sub-rsp + 0x20 + 8*(i-4)], i.e. above the
// shadow window, where the inner call's callee expects them.
for (int i = 4; i < args.Length; i++)
{
int offset = 0x20 + (i - 4) * 8;
buffer.Add(0x48); buffer.Add(0xB8); // mov rax, imm64
EmitU64(buffer, args[i]);
current += 10;
buffer.Add(0x48); buffer.Add(0x89); // mov [rsp + disp], rax
if (offset <= 127)
{
buffer.Add(0x44); buffer.Add(0x24); // ModRM: [rsp + disp8]
buffer.Add((byte)offset);
current += 5;
}
else
{
buffer.Add(0x84); buffer.Add(0x24); // ModRM: [rsp + disp32]
EmitU32(buffer, (uint)offset);
current += 8;
}
}
// call rel32
long distance = (long)target - (long)(current + 5);
if (distance is < int.MinValue or > int.MaxValue)
{
throw new ArgumentOutOfRangeException(
"target and stub are >2 GiB apart; E8 rel32 cannot encode this distance.");
}
buffer.Add(0xE8);
EmitU32(buffer, (uint)distance);
current += 5;
// Tear down the frame symmetrically.
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xC4);
EmitU32(buffer, (uint)k);
buffer.Add(0xC3);
}
// ── Instruction helpers ────────────────────────────────────────────────
/// <summary>Emit <c>mov reg32, imm32</c> and advances <paramref name="ip"/> by 5.</summary>
private static void EmitMovRegImm32(List<byte> buffer, byte opcode, uint imm32, ref uint ip)
{
buffer.Add(opcode);
buffer.Add((byte)imm32);
buffer.Add((byte)(imm32 >> 8));
buffer.Add((byte)(imm32 >> 16));
buffer.Add((byte)(imm32 >> 24));
ip += 5;
} }
} }
+64 -45
View File
@@ -1,88 +1,107 @@
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace WhiteMagic; namespace WhiteMagic;
/// <summary> /// <summary>
/// Computes and caches marshal-related metadata for type <typeparamref name="T"/> /// Caches the widths marshalling decisions for type <typeparamref name="T"/>
/// exactly once. <see cref="MemoryBase.Read{T}"/> and <see cref="MemoryBase.Write{T}"/> /// once, at static-constructor time. <see cref="MemoryBase.Read{T}"/> and
/// branch on these cached flags to decide between blittable <c>Span</c>/<c>MemoryMarshal</c> /// <see cref="MemoryBase.Write{T}"/> branch on <see cref="TypeRequiresMarshal"/>
/// paths and the fallback marshal path. /// and pick the appropriate width from this cache.
/// </summary> /// </summary>
/// <typeparam name="T">The type to cache metadata for.</typeparam> /// <typeparam name="T">The type to cache metadata for.</typeparam>
public static class MarshalCache<T> public static class MarshalCache<T>
{ {
/// <summary>The unmanaged size of <typeparamref name="T"/> in bytes.</summary> /// <summary>
/// The blittable (managed layout) width of <typeparamref name="T"/>. This is
/// what <see cref="MemoryMarshal.Read{T}"/> / <see cref="MemoryMarshal.Write{T}"/>
/// actually consume. Equals <see cref="Unsafe.SizeOf{T}"/> in the general case,
/// with fixed-width overrides for <see cref="bool"/>, <see cref="char"/>, and
/// enums so the cache value matches the primitive layout width used by those
/// paths.
/// </summary>
public static readonly int Size; public static readonly int Size;
/// <summary>The unmanaged size of <typeparamref name="T"/> as an unsigned integer.</summary> /// <summary>
public static readonly uint SizeU; /// The unmanaged (interop) width via <see cref="Marshal.SizeOf"/>. The marshal
/// path (<see cref="Marshal.PtrToStructure"/>/<see cref="Marshal.StructureToPtr"/>)
/// reads/writes this many bytes. Exceeds <see cref="Size"/> whenever a struct
/// carries inline unmanaged data that the marshaler expands — inline
/// <c>ByValTStr</c>/<c>ByValArray</c> buffers, <c>bool</c> fields (4 bytes per
/// default Win32 BOOL marshaling vs 1 byte managed), etc. For types that do
/// not go through the marshal path, this field is still populated but unused
/// by MemoryBase.
/// </summary>
/// <remarks>
/// <c>Marshal.SizeOf</c> throws for some reference-containing shapes (e.g. a
/// bare <see cref="string"/>). When that happens, we fall back to
/// <see cref="Size"/> — the fallback path is unreachable from production code
/// because types with a reference field always have
/// <see cref="TypeRequiresMarshal"/> true, so MemoryBase reads this field only
/// when it is known to be populated.
/// </remarks>
public static readonly int MarshalSize;
/// <summary> /// <summary>
/// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through the /// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through
/// blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path and must /// the blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path
/// use <see cref="Marshal.PtrToStructure"/>/<see cref="Marshal.StructureToPtr"/> instead. /// and must fall back to <see cref="Marshal.PtrToStructure"/> /
/// This is the case when a top-level field carries <see cref="MarshalAsAttribute"/>, or /// <see cref="Marshal.StructureToPtr"/>. This is the case when a top-level field
/// when <typeparamref name="T"/> contains a managed reference /// carries <see cref="MarshalAsAttribute"/>, or when <typeparamref name="T"/>
/// (<see cref="System.Runtime.CompilerServices.RuntimeHelpers.IsReferenceOrContainsReferences{T}"/>). /// contains a managed reference
/// (<see cref="RuntimeHelpers.IsReferenceOrContainsReferences{T}"/>).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The <see cref="MarshalAsAttribute"/> check inspects only top-level fields; a /// The <see cref="MarshalAsAttribute"/> check inspects only top-level fields; a
/// <see cref="MarshalAsAttribute"/> on a field of a nested struct is not detected. /// <see cref="MarshalAsAttribute"/> on a field of a nested struct is not
/// Reference-containing nested structs are still caught, because the reference check /// detected. Reference-containing nested structs are still caught, because the
/// propagates through nested value types. /// reference check propagates through nested value types.
/// </remarks> /// </remarks>
public static readonly bool TypeRequiresMarshal; public static readonly bool TypeRequiresMarshal;
/// <summary><see langword="true"/> when <typeparamref name="T"/> is <see cref="IntPtr"/>.</summary>
public static readonly bool IsIntPtr;
/// <summary>The underlying type code of <typeparamref name="T"/>.</summary>
public static readonly TypeCode TypeCode;
/// <summary>
/// The effective type that the marshaler uses. For an enum this is the underlying
/// integer type; for all other types it is <typeparamref name="T"/> itself.
/// </summary>
public static readonly Type RealType;
static MarshalCache() static MarshalCache()
{ {
TypeCode = Type.GetTypeCode(typeof(T));
if (typeof(T) == typeof(bool)) if (typeof(T) == typeof(bool))
{ {
Size = 1; Size = 1;
RealType = typeof(T);
} }
else if (typeof(T) == typeof(char)) else if (typeof(T) == typeof(char))
{ {
// Marshal.SizeOf(char) is 1 (ANSI), but the blittable path reads/writes a // Marshal.SizeOf<char> reports 1 (ANSI char), but the blittable
// char as a 2-byte UTF-16 code unit. Size must match the blittable width. // MemoryMarshal path reads/writes a char as a 2-byte UTF-16 code unit.
Size = 2; Size = 2;
RealType = typeof(T);
} }
else if (typeof(T).IsEnum) else if (typeof(T).IsEnum)
{ {
Type underlying = typeof(T).GetEnumUnderlyingType(); Size = Marshal.SizeOf(typeof(T).GetEnumUnderlyingType());
Size = Marshal.SizeOf(underlying);
RealType = underlying;
TypeCode = Type.GetTypeCode(underlying);
} }
else else
{ {
Size = Marshal.SizeOf(typeof(T)); // The blittable path goes through MemoryMarshal, which uses the CLR
RealType = typeof(T); // managed layout. Use Unsafe.SizeOf<T> so Size agrees with that
// layout — Marshal.SizeOf<T> disagrees when a struct contains a
// `bool` field (unmanaged 4 vs managed 1).
Size = Unsafe.SizeOf<T>();
} }
SizeU = (uint)Size;
IsIntPtr = RealType == typeof(IntPtr);
bool hasMarshalAsField = bool hasMarshalAsField =
RealType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) typeof(T).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Any(f => f.GetCustomAttributes(typeof(MarshalAsAttribute), true).Length != 0); .Any(f => f.GetCustomAttributes(typeof(MarshalAsAttribute), true).Length != 0);
TypeRequiresMarshal = TypeRequiresMarshal =
hasMarshalAsField || System.Runtime.CompilerServices.RuntimeHelpers.IsReferenceOrContainsReferences<T>(); hasMarshalAsField || RuntimeHelpers.IsReferenceOrContainsReferences<T>();
// MarshalSize is only consulted when TypeRequiresMarshal is true; for the
// rare case where Marshal.SizeOf refuses a shape (ref-containing structs),
// fall back to the managed size so the field stays populated.
try
{
MarshalSize = Marshal.SizeOf<T>();
}
catch (ArgumentException)
{
MarshalSize = Size;
}
} }
} }
+4 -4
View File
@@ -37,7 +37,7 @@ public abstract class MemoryBase : IDisposable
if (isRelative) if (isRelative)
address = GetAbsolute(address); address = GetAbsolute(address);
int size = MarshalCache<T>.Size; int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
byte[] raw = ReadBytes(address, size); byte[] raw = ReadBytes(address, size);
if (raw.Length < size) if (raw.Length < size)
@@ -56,7 +56,7 @@ public abstract class MemoryBase : IDisposable
if (isRelative) if (isRelative)
address = GetAbsolute(address); address = GetAbsolute(address);
int size = MarshalCache<T>.Size; int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
byte[] raw; byte[] raw;
if (MarshalCache<T>.TypeRequiresMarshal) if (MarshalCache<T>.TypeRequiresMarshal)
@@ -81,7 +81,7 @@ public abstract class MemoryBase : IDisposable
if (isRelative) if (isRelative)
address = GetAbsolute(address); address = GetAbsolute(address);
int elementSize = MarshalCache<T>.Size; int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
long totalSize = (long)elementSize * count; long totalSize = (long)elementSize * count;
ArgumentOutOfRangeException.ThrowIfGreaterThan(totalSize, int.MaxValue, nameof(count)); ArgumentOutOfRangeException.ThrowIfGreaterThan(totalSize, int.MaxValue, nameof(count));
@@ -127,7 +127,7 @@ public abstract class MemoryBase : IDisposable
if (values is null || values.Length == 0) if (values is null || values.Length == 0)
return true; return true;
int elementSize = MarshalCache<T>.Size; int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
long total = (long)elementSize * values.Length; long total = (long)elementSize * values.Length;
ArgumentOutOfRangeException.ThrowIfGreaterThan(total, int.MaxValue, nameof(values)); ArgumentOutOfRangeException.ThrowIfGreaterThan(total, int.MaxValue, nameof(values));
int totalSize = (int)total; int totalSize = (int)total;
+61
View File
@@ -0,0 +1,61 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic;
/// <summary>
/// Shared ReadProcessMemory / WriteProcessMemory wrappers used by both
/// <see cref="ExternalReader"/> and <see cref="InProcessReader"/>. Kept in a single
/// location to keep the two readers byte-for-byte consistent on partial-read handling,
/// write-return semantics, and failure modes.
/// </summary>
internal static class RpmHelper
{
/// <summary>
/// Reads up to <paramref name="count"/> bytes from <paramref name="address"/> in
/// the process identified by <paramref name="handle"/>. Returns:
/// <list type="bullet">
/// <item>An empty array if <see cref="NativeMethods.ReadProcessMemory"/> fails and
/// reports zero bytes read.</item>
/// <item>A truncated array of exactly <c>bytesRead</c> bytes when the call returns
/// <see langword="false"/> but the OS has placed a partial copy in the buffer
/// (for example, <c>ERROR_PARTIAL_COPY</c>).</item>
/// <item>The full buffer on success.</item>
/// </list>
/// </summary>
public static byte[] ReadBytes(SafeMemoryHandle handle, IntPtr address, int count)
{
byte[] buffer = new byte[count];
bool ok = NativeMethods.ReadProcessMemory(handle, address, buffer, count, out nint bytesRead);
if (!ok && bytesRead == 0)
{
return [];
}
if ((int)bytesRead < count)
{
// Either a successful short read, or a failed-but-partial RPM. In both
// cases honor the bytes the OS actually produced rather than padding.
byte[] partial = new byte[(int)bytesRead];
Buffer.BlockCopy(buffer, 0, partial, 0, (int)bytesRead);
return partial;
}
return buffer;
}
/// <summary>
/// Writes <paramref name="bytes"/> to <paramref name="address"/> in the process
/// identified by <paramref name="handle"/>. Returns the number of bytes actually
/// written, or 0 on total failure.
/// </summary>
public static int WriteBytes(SafeMemoryHandle handle, IntPtr address, ReadOnlySpan<byte> bytes)
{
if (!NativeMethods.WriteProcessMemory(handle, address, bytes, bytes.Length, out nint written))
{
return 0;
}
return (int)written;
}
}
+77 -19
View File
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using WhiteMagic; using WhiteMagic;
@@ -5,7 +6,7 @@ namespace WhiteMagicTest;
/// <summary> /// <summary>
/// Tests for <see cref="MarshalCache{T}"/>: blittable size, marshal-required flag, /// Tests for <see cref="MarshalCache{T}"/>: blittable size, marshal-required flag,
/// IsIntPtr, and computed-once behavior. /// the separate MarshalSize field, and computed-once behavior.
/// </summary> /// </summary>
public class MarshalCacheTests public class MarshalCacheTests
{ {
@@ -45,12 +46,33 @@ public class MarshalCacheTests
Assert.Equal(8, MarshalCache<BlittableStruct>.Size); Assert.Equal(8, MarshalCache<BlittableStruct>.Size);
} }
[Fact]
public void Size_for_struct_with_bool_is_managed_layout_width()
{
// Regression for the Marshal vs. Unsafe size disagreement on a struct
// whose only field is `bool`: Marshal reports 4 (Win32 BOOL default
// marshaling), but the blittable path (MemoryMarshal.Read<T>) actually
// lays out a bool as 1 byte. MarshalCache.Size must match managed width.
Assert.Equal(Unsafe.SizeOf<SingleBoolStruct>(), MarshalCache<SingleBoolStruct>.Size);
Assert.Equal(1, MarshalCache<SingleBoolStruct>.Size);
}
[Fact]
public void Size_for_struct_with_bools_in_sequence_matches_managed_layout()
{
// Sequential struct { bool, bool } — managed width is 2, Marshal width is 8
// (two BOOLs). The blittable path uses 1 byte per bool, so Size must equal
// the managed width.
Assert.Equal(Unsafe.SizeOf<SequentialBoolStruct>(), MarshalCache<SequentialBoolStruct>.Size);
Assert.Equal(2, MarshalCache<SequentialBoolStruct>.Size);
}
[Fact] [Fact]
public void TypeRequiresMarshal_is_false_for_blittable_types() public void TypeRequiresMarshal_is_false_for_blittable_types()
{ {
Assert.False(MarshalCache<int>.TypeRequiresMarshal); Assert.False(MarshalCache<int>.TypeRequiresMarshal);
Assert.False(MarshalCache<long>.TypeRequiresMarshal); Assert.False(MarshalCache<byte>.TypeRequiresMarshal);
Assert.False(MarshalCache<BlittableStruct>.TypeRequiresMarshal); Assert.False(MarshalCache<IntPtr>.TypeRequiresMarshal);
} }
[Fact] [Fact]
@@ -60,39 +82,50 @@ public class MarshalCacheTests
} }
[Fact] [Fact]
public void IsIntPtr_is_true_for_IntPtr() public void TypeRequiresMarshal_is_true_for_reference_containing_types()
{ {
Assert.True(MarshalCache<IntPtr>.IsIntPtr); Assert.True(MarshalCache<InlineStrStruct>.TypeRequiresMarshal);
} }
[Fact] [Fact]
public void IsIntPtr_is_false_for_non_IntPtr_types() public void Inline_struct_with_MarshalAs_has_separate_MarshalSize()
{ {
Assert.False(MarshalCache<int>.IsIntPtr); // Regression for the marshal-path size bug. A struct with an inline
Assert.False(MarshalCache<long>.IsIntPtr); // ByValTStr field has mismatched managed and unmanaged widths: the managed
Assert.False(MarshalCache<BlittableStruct>.IsIntPtr); // width is just the pointer reference (8 bytes); the marshal unroller
// expands it into a 16-WCHAR inline buffer (32 bytes). MarshalCache.Size
// must match what the blittable path uses; MarshalCache.MarshalSize must
// match what the marshal path uses.
Assert.True(MarshalCache<InlineStrStruct>.TypeRequiresMarshal);
int expectedManaged = Unsafe.SizeOf<InlineStrStruct>(); // 8 (ptr)
int expectedMarshal = Marshal.SizeOf<InlineStrStruct>(); // 32 (16 WCHAR)
Assert.Equal(expectedManaged, MarshalCache<InlineStrStruct>.Size);
Assert.Equal(expectedMarshal, MarshalCache<InlineStrStruct>.MarshalSize);
Assert.NotEqual(MarshalCache<InlineStrStruct>.Size,
MarshalCache<InlineStrStruct>.MarshalSize);
} }
[Fact] [Fact]
public void All_properties_are_computed_once_and_cached() public void MarshalSize_equals_Size_for_blittable_types()
{
// No interop expansion is needed when the type is blittable; both widths
// coincide.
Assert.Equal(MarshalCache<int>.Size, MarshalCache<int>.MarshalSize);
Assert.Equal(MarshalCache<IntPtr>.Size, MarshalCache<IntPtr>.MarshalSize);
Assert.Equal(MarshalCache<BlittableStruct>.Size, MarshalCache<BlittableStruct>.MarshalSize);
}
[Fact]
public void Properties_are_computed_once_and_cached()
{ {
int size1 = MarshalCache<int>.Size; int size1 = MarshalCache<int>.Size;
bool marshal1 = MarshalCache<int>.TypeRequiresMarshal; bool marshal1 = MarshalCache<int>.TypeRequiresMarshal;
bool intPtr1 = MarshalCache<int>.IsIntPtr;
int size2 = MarshalCache<int>.Size; int size2 = MarshalCache<int>.Size;
bool marshal2 = MarshalCache<int>.TypeRequiresMarshal; bool marshal2 = MarshalCache<int>.TypeRequiresMarshal;
bool intPtr2 = MarshalCache<int>.IsIntPtr;
Assert.Equal(size1, size2); Assert.Equal(size1, size2);
Assert.Equal(marshal1, marshal2); Assert.Equal(marshal1, marshal2);
Assert.Equal(intPtr1, intPtr2);
}
[Fact]
public void SizeU_matches_Size_as_uint()
{
Assert.Equal((uint)MarshalCache<int>.Size, MarshalCache<int>.SizeU);
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
@@ -108,4 +141,29 @@ public class MarshalCacheTests
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
public byte[] Data; public byte[] Data;
} }
[StructLayout(LayoutKind.Sequential)]
private struct SingleBoolStruct
{
public bool Flag;
}
[StructLayout(LayoutKind.Sequential)]
private struct SequentialBoolStruct
{
public bool A;
public bool B;
}
/// <summary>
/// A struct whose marshal layout carries an inline character buffer but
/// whose CLR managed layout is just a reference pointer. The canonical way
/// to exercise the marshal-vs-managed width split.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct InlineStrStruct
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 16)]
public string Name;
}
} }
+51
View File
@@ -196,6 +196,43 @@ public class MemoryBaseTests
reader.Dispose(); reader.Dispose();
} }
// ── Marshal-path round-trip ───────────────────────────────────────────────
//
// The marshal path was previously sized using MarshalCache.Size (managed
// layout width). When a struct carries an inline marshal-expanded field
// (ByValTStr, ByValArray, etc.) this width is smaller than the actual
// read/write width — writing overflows the pinned buffer and reading
// under-fetches the remote bytes, producing silent heap corruption.
//
// The marshal path must use MarshalCache.MarshalSize (= Marshal.SizeOf<T>)
// so the pinned buffer is large enough for PtrToStructure / StructureToPtr.
[Fact]
public void Read_struct_via_marshal_path_round_trips_inline_string()
{
using var reader = OpenSelf();
// A marshal-path struct carries a reference, so it cannot be pinned; the
// target must be an unmanaged buffer of the FULL marshal width. Pre-patch,
// Write sized its scratch buffer with MarshalCache.Size (managed pointer
// width, 8) and StructureToPtr overran it, while Read under-fetched the
// remote bytes — the string came back wrong. Post-patch both use
// MarshalSize (Marshal.SizeOf<InlineStr>).
int size = Marshal.SizeOf<InlineStr>();
IntPtr addr = Marshal.AllocHGlobal(size);
try
{
InlineStr original = new InlineStr { Name = "Hello, World!" };
Assert.True(reader.Write(addr, original));
InlineStr read = reader.Read<InlineStr>(addr);
Assert.Equal("Hello, World!", read.Name);
}
finally
{
Marshal.FreeHGlobal(addr);
}
}
// ── Graceful failure on invalid addresses ─────────────────────────────── // ── Graceful failure on invalid addresses ───────────────────────────────
[Fact] [Fact]
@@ -243,3 +280,17 @@ public struct TestStruct : IEquatable<TestStruct>
public override int GetHashCode() => HashCode.Combine(X, Y); public override int GetHashCode() => HashCode.Combine(X, Y);
public override string ToString() => $"({X}, {Y})"; public override string ToString() => $"({X}, {Y})";
} }
/// <summary>
/// A struct whose managed layout is just a reference pointer (8 bytes) but whose
/// unmanaged marshal layout carries an inline character buffer. Exercised by
/// <see cref="MemoryBaseTests.Read_struct_via_marshal_path_round_trips_inline_string"/>
/// to catch regressions where the marshal path uses the managed width instead
/// of the marshal width.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct InlineStr
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 16)]
public string Name;
}
+399 -168
View File
@@ -8,234 +8,465 @@ public class StubAssemblerTests
// ── Emit primitives ──────────────────────────────────────────────────── // ── Emit primitives ────────────────────────────────────────────────────
[Fact] [Fact] public void EmitU8_appends_a_single_byte() { var s=Create(); var b=new List<byte>(); s.EmitU8(b,0xAB); Assert.Equal([0xAB],b); }
public void EmitU8_appends_a_single_byte() [Fact] public void EmitU32_appends_little_endian() { var s=Create(); var b=new List<byte>(); s.EmitU32(b,0x11223344); Assert.Equal([0x44,0x33,0x22,0x11],b); }
{ [Fact] public void EmitU32_appends_zero() { var s=Create(); var b=new List<byte>(); s.EmitU32(b,0); Assert.Equal([0,0,0,0],b); }
var sut = Create(); [Fact] public void EmitU64_appends_little_endian() { var s=Create(); var b=new List<byte>(); s.EmitU64(b,0x1122334455667788); Assert.Equal([0x88,0x77,0x66,0x55,0x44,0x33,0x22,0x11],b); }
var buffer = new List<byte>(); [Fact] public void EmitU64_appends_high_bits() { var s=Create(); var b=new List<byte>(); s.EmitU64(b,0xDEADBEEF_CAFEBABE); Assert.Equal([0xBE,0xBA,0xFE,0xCA,0xEF,0xBE,0xAD,0xDE],b); }
sut.EmitU8(buffer, 0xAB); [Fact] public void StubAssembler_is_IAssembler() { Assert.IsAssignableFrom<IAssembler>(Create()); }
Assert.Equal([0xAB], buffer); [Fact] public void Assemble_throws() { Assert.Throws<NotSupportedException>(()=>Create().Assemble("nop",0)); }
}
[Fact]
public void EmitU32_appends_little_endian()
{
var sut = Create();
var buffer = new List<byte>();
sut.EmitU32(buffer, 0x11223344);
Assert.Equal([0x44, 0x33, 0x22, 0x11], buffer);
}
[Fact]
public void EmitU32_appends_zero()
{
var sut = Create();
var buffer = new List<byte>();
sut.EmitU32(buffer, 0);
Assert.Equal([0x00, 0x00, 0x00, 0x00], buffer);
}
[Fact]
public void EmitU64_appends_little_endian()
{
var sut = Create();
var buffer = new List<byte>();
sut.EmitU64(buffer, 0x1122334455667788);
byte[] expected = [0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11];
Assert.Equal(expected, buffer);
}
[Fact]
public void EmitU64_appends_high_bits()
{
var sut = Create();
var buffer = new List<byte>();
sut.EmitU64(buffer, 0xDEADBEEF_CAFEBABE);
byte[] expected = [0xBE, 0xBA, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE];
Assert.Equal(expected, buffer);
}
// ── IAssembler interface ───────────────────────────────────────────────
[Fact]
public void StubAssembler_is_an_IAssembler()
{
var sut = Create();
Assert.IsAssignableFrom<IAssembler>(sut);
}
[Fact]
public void Assemble_from_StubAssembler_throws_NotSupported()
{
var sut = Create();
Assert.Throws<NotSupportedException>(() => sut.Assemble("nop", 0));
}
// ── x86 cdecl ────────────────────────────────────────────────────────── // ── x86 cdecl ──────────────────────────────────────────────────────────
[Fact] [Fact]
public void Cdecl_stub_zero_args_call_then_ret() public void Cdecl_0args()
{ {
var sut = Create(); uint r = 0x12345678u-(0x10000000u+5);
uint rel32 = 0x12345678u - (0x10000000u + 5); Assert.Equal([0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
byte[] stub = sut.BuildCallStub( Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Cdecl));
(IntPtr)0x10000000, (IntPtr)0x12345678, [], 4, CallingConvention.Cdecl);
Assert.Equal([0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24), 0xC3], stub);
} }
[Fact] [Fact]
public void Cdecl_stub_one_arg_push_call_cleanup_ret() public void Cdecl_1arg()
{ {
var sut = Create(); uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
uint callAddr = 0x10000000u + 5;
uint rel32 = 0x12345678u - (callAddr + 5);
byte[] stub = sut.BuildCallStub(
(IntPtr)0x10000000, (IntPtr)0x12345678, [0xAABBCCDD], 4, CallingConvention.Cdecl);
Assert.Equal([ Assert.Equal([
0x68,0xDD,0xCC,0xBB,0xAA, 0x68,0xDD,0xCC,0xBB,0xAA,
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24), 0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),
0x83, 0xC4, 0x04, 0x83,0xC4,0x04,0xC3],
0xC3 Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAABBCCDD],4,CallConvention.Cdecl));
], stub);
} }
[Fact] [Fact]
public void Cdecl_stub_two_args_reverse_order() public void Cdecl_2args()
{ {
var sut = Create(); uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
uint callAddr = 0x10000000u + 10;
uint rel32 = 0x12345678u - (callAddr + 5);
byte[] stub = sut.BuildCallStub(
(IntPtr)0x10000000, (IntPtr)0x12345678, [0x11111111, 0x22222222], 4, CallingConvention.Cdecl);
Assert.Equal([ Assert.Equal([
0x68,0x22,0x22,0x22,0x22, 0x68,0x22,0x22,0x22,0x22,
0x68,0x11,0x11,0x11,0x11, 0x68,0x11,0x11,0x11,0x11,
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24), 0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),
0x83, 0xC4, 0x08, 0x83,0xC4,0x08,0xC3],
0xC3 Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0x11111111,0x22222222],4,CallConvention.Cdecl));
], stub);
} }
// ── x86 stdcall ──────────────────────────────────────────────────────── // ── x86 stdcall ──────────────────────────────────────────────────────
[Fact] [Fact]
public void Stdcall_stub_one_arg_no_cleanup() public void Stdcall_1arg()
{ {
var sut = Create(); uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
uint callAddr = 0x10000000u + 5;
uint rel32 = 0x12345678u - (callAddr + 5);
byte[] stub = sut.BuildCallStub(
(IntPtr)0x10000000, (IntPtr)0x12345678, [0xAABBCCDD], 4, CallingConvention.Stdcall);
Assert.Equal([ Assert.Equal([
0x68,0xDD,0xCC,0xBB,0xAA, 0x68,0xDD,0xCC,0xBB,0xAA,
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24), 0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
0xC3 Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAABBCCDD],4,CallConvention.Stdcall));
], stub);
} }
[Fact] [Fact]
public void Stdcall_stub_two_args_reverse_no_cleanup() public void Stdcall_2args()
{ {
var sut = Create(); uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
uint callAddr = 0x10000000u + 10;
uint rel32 = 0x12345678u - (callAddr + 5);
byte[] stub = sut.BuildCallStub(
(IntPtr)0x10000000, (IntPtr)0x12345678, [0x11111111, 0x22222222], 4, CallingConvention.Stdcall);
Assert.Equal([ Assert.Equal([
0x68,0x22,0x22,0x22,0x22, 0x68,0x22,0x22,0x22,0x22,
0x68,0x11,0x11,0x11,0x11, 0x68,0x11,0x11,0x11,0x11,
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24), 0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
0xC3 Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0x11111111,0x22222222],4,CallConvention.Stdcall));
], stub);
} }
// ── x86 thiscall ─────────────────────────────────────────────────────── // ── x86 thiscall ─────────────────────────────────────────────────────
[Fact] [Fact]
public void Thiscall_stub_ecx_then_stack_then_call() public void Thiscall_ecx_then_stack()
{ {
var sut = Create(); uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
uint callAddr = 0x10000000u + 10;
uint rel32 = 0x12345678u - (callAddr + 5);
byte[] stub = sut.BuildCallStub(
(IntPtr)0x10000000, (IntPtr)0x12345678, [0xAAAA5555, 0xBBBB6666], 4, CallingConvention.Thiscall);
Assert.Equal([ Assert.Equal([
0xB9,0x55,0x55,0xAA,0xAA, 0xB9,0x55,0x55,0xAA,0xAA,
0x68,0x66,0x66,0xBB,0xBB, 0x68,0x66,0x66,0xBB,0xBB,
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24), 0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
0xC3 Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAAAA5555,0xBBBB6666],4,CallConvention.Thiscall));
], stub);
} }
[Fact] [Fact]
public void Thiscall_stub_one_arg_ecx_only_then_call() public void Thiscall_1arg_ecx_only()
{ {
var sut = Create(); uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
uint callAddr = 0x10000000u + 5; byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xCAFEBABE],4,CallConvention.Thiscall);
uint rel32 = 0x12345678u - (callAddr + 5); Assert.Equal(11,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xCAFEBABEu,BitConverter.ToUInt32(s,1));
byte[] stub = sut.BuildCallStub( Assert.Equal(0xE8,s[5]); Assert.Equal(r,BitConverter.ToUInt32(s,6)); Assert.Equal(0xC3,s[10]);
(IntPtr)0x10000000, (IntPtr)0x12345678, [0xCAFEBABE], 4, CallingConvention.Thiscall);
Assert.Equal(11, stub.Length);
Assert.Equal(0xB9, stub[0]);
Assert.Equal(0xCAFEBABE, BitConverter.ToUInt32(stub, 1));
Assert.Equal(0xE8, stub[5]);
Assert.Equal(rel32, BitConverter.ToUInt32(stub, 6));
Assert.Equal(0xC3, stub[10]);
} }
// ── x86 fastcall ─────────────────────────────────────────────────────── [Fact]
public void Thiscall_0args_throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Thiscall));
}
// ── x86 fastcall ────────────────────────────────────────────────────
[Fact] [Fact]
public void Fastcall_stub_ecx_edx_stack_call() public void Fastcall_ecx_edx_stack()
{ {
var sut = Create(); uint ca=0x10000000u+15,r=0x12345678u-(ca+5);
uint callAddr = 0x10000000u + 15;
uint rel32 = 0x12345678u - (callAddr + 5);
byte[] stub = sut.BuildCallStub(
(IntPtr)0x10000000, (IntPtr)0x12345678, [0x11111111, 0x22222222, 0x33333333], 4, CallingConvention.Fastcall);
Assert.Equal([ Assert.Equal([
0xB9,0x11,0x11,0x11,0x11, 0xB9,0x11,0x11,0x11,0x11,
0xBA,0x22,0x22,0x22,0x22, 0xBA,0x22,0x22,0x22,0x22,
0x68,0x33,0x33,0x33,0x33, 0x68,0x33,0x33,0x33,0x33,
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24), 0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
0xC3 Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0x11111111,0x22222222,0x33333333],4,CallConvention.Fastcall));
], stub);
} }
[Fact] [Fact]
public void Fastcall_stub_two_args_registers_only() public void Fastcall_2args_registers_only()
{ {
var sut = Create(); uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
uint callAddr = 0x10000000u + 10; byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAAAAAAAA,0xBBBBBBBB],4,CallConvention.Fastcall);
uint rel32 = 0x12345678u - (callAddr + 5); Assert.Equal(16,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xAAAAAAAAu,BitConverter.ToUInt32(s,1));
byte[] stub = sut.BuildCallStub( Assert.Equal(0xBA,s[5]); Assert.Equal(0xBBBBBBBBu,BitConverter.ToUInt32(s,6));
(IntPtr)0x10000000, (IntPtr)0x12345678, [0xAAAAAAAA, 0xBBBBBBBB], 4, CallingConvention.Fastcall); Assert.Equal(0xE8,s[10]); Assert.Equal(r,BitConverter.ToUInt32(s,11)); Assert.Equal(0xC3,s[15]);
Assert.Equal(16, stub.Length);
Assert.Equal(0xB9, stub[0]);
Assert.Equal(0xAAAAAAAA, BitConverter.ToUInt32(stub, 1));
Assert.Equal(0xBA, stub[5]);
Assert.Equal(0xBBBBBBBB, BitConverter.ToUInt32(stub, 6));
Assert.Equal(0xE8, stub[10]);
Assert.Equal(rel32, BitConverter.ToUInt32(stub, 11));
Assert.Equal(0xC3, stub[15]);
} }
// ── x64 ────────────────────────────────────────────────────────────────
[Fact] [Fact]
public void X64_stub_throws_not_implemented_by_default() public void Fastcall_0args_is_valid()
{ {
var sut = Create(); uint r=0x12345678u-(0x10000000u+5);
Assert.Throws<NotImplementedException>(() => Assert.Equal([0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
sut.BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [], 8, CallingConvention.Cdecl)); Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Fastcall));
}
// ── x64 (Microsoft x64 ABI — shadow space + 16-byte alignment + 64-bit loads) ──
//
// Stub frame layout:
// bytes 0..6 sub rsp, K (7 bytes — K = 32 + 8·stackArgs, rounded so K ≡ 8 mod 16)
// bytes 7..N mov r64, imm64 ... (10 bytes per reg move: 2-byte prefix + 8-byte imm)
// mov rax, imm64 / mov [rsp+0x20+8*(i-4)], rax for stack args (15 bytes each)
// E8 rel32 call target (5 bytes)
// 48 81 C4 K 00... add rsp, K (7 bytes)
// C3 ret (1 byte)
//
// Each `mov rNN, imm64` is 10 bytes regardless of the target register:
// RCX REX.W+opcode B9 (0x48 0xB9)
// RDX REX.W+opcode BA (0x48 0xBA)
// R8 REX.WB+opcode B8 (0x49 0xB8, REX.R needed for r8)
// R9 REX.WB+opcode B9 (0x49 0xB9, REX.R needed for r9)
// RAX REX.W+opcode B8 (0x48 0xB8)
[Fact]
public void X64_0args_allocates_shadow_space_and_aligns()
{
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [], 8, CallConvention.Cdecl);
// K = 0x20 + 0·8 = 0x20; round up to ≡ 8 mod 16 → K = 0x28.
// Frame = sub(7) + call(5) + add(7) + ret(1) = 20
Assert.Equal(20, stub.Length);
uint rel = (uint)(t - (a + 7 + 5)); // = t - a - 12
Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x28 (K ≡ 8 mod 16)
Assert.Equal(0xE8, stub[7]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 8));
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[12..19]); // add rsp, 0x28
Assert.Equal(0xC3, stub[19]); // ret
}
[Fact]
public void X64_1arg_loads_rcx_as_64bit()
{
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [0xAABBCCDDu], 8, CallConvention.Cdecl);
// K = 0x28. Total = sub(7) + mov(10) + call(5) + add(7) + ret(1) = 30
Assert.Equal(30, stub.Length);
uint rel = (uint)(t - (a + 7 + 10 + 5)); // = t - a - 22
// stub[0..6] = sub rsp, 0x28 (K ≡ 8 mod 16, 16-aligned call-site for SSE safety)
Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]);
// stub[7..16] = mov rcx, 0x00000000_AABBCCDD (zero-extended)
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
Assert.Equal(0xDD, stub[9]); Assert.Equal(0xCC, stub[10]);
Assert.Equal(0xBB, stub[11]); Assert.Equal(0xAA, stub[12]);
Assert.Equal(0x00, stub[13]); Assert.Equal(0x00, stub[14]);
Assert.Equal(0x00, stub[15]); Assert.Equal(0x00, stub[16]);
// stub[17..21] = call rel32
Assert.Equal(0xE8, stub[17]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 18));
// stub[22..28] = add rsp, 0x28
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[22..29]);
Assert.Equal(0xC3, stub[29]); // ret
}
[Fact]
public void X64_4args_loads_rcx_rdx_r8_r9_as_64bit()
{
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t,
[0x11111111u, 0x22222222u, 0x33333333u, 0x44444444u], 8, CallConvention.Cdecl);
// K = 0x28. Total = sub(7) + 4×mov(40) + call(5) + add(7) + ret(1) = 60
Assert.Equal(60, stub.Length);
uint rel = (uint)(t - (a + 7 + 40 + 5)); // = t - a - 52
Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x28
// mov rcx, 0x11111111 (48 B9 + 8 imm) at [7..16]
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
Assert.Equal(0x11, stub[9]); Assert.Equal(0x11, stub[10]);
Assert.Equal(0x11, stub[11]); Assert.Equal(0x11, stub[12]);
Assert.Equal(0x00, stub[13]); Assert.Equal(0x00, stub[14]);
Assert.Equal(0x00, stub[15]); Assert.Equal(0x00, stub[16]);
// mov rdx, 0x22222222 (48 BA + 8 imm) at [17..26]
Assert.Equal(0x48, stub[17]); Assert.Equal(0xBA, stub[18]);
Assert.Equal(0x22, stub[19]); Assert.Equal(0x22, stub[20]);
Assert.Equal(0x22, stub[21]); Assert.Equal(0x22, stub[22]);
Assert.Equal(0x00, stub[23]); Assert.Equal(0x00, stub[24]);
Assert.Equal(0x00, stub[25]); Assert.Equal(0x00, stub[26]);
// mov r8, 0x33333333 (49 B8 + 8 imm) at [27..36]
Assert.Equal(0x49, stub[27]); Assert.Equal(0xB8, stub[28]);
Assert.Equal(0x33, stub[29]); Assert.Equal(0x33, stub[30]);
Assert.Equal(0x33, stub[31]); Assert.Equal(0x33, stub[32]);
Assert.Equal(0x00, stub[33]); Assert.Equal(0x00, stub[34]);
Assert.Equal(0x00, stub[35]); Assert.Equal(0x00, stub[36]);
// mov r9, 0x44444444 (49 B9 + 8 imm) at [37..46]
Assert.Equal(0x49, stub[37]); Assert.Equal(0xB9, stub[38]);
Assert.Equal(0x44, stub[39]); Assert.Equal(0x44, stub[40]);
Assert.Equal(0x44, stub[41]); Assert.Equal(0x44, stub[42]);
Assert.Equal(0x00, stub[43]); Assert.Equal(0x00, stub[44]);
Assert.Equal(0x00, stub[45]); Assert.Equal(0x00, stub[46]);
// call rel32 at [47..51]
Assert.Equal(0xE8, stub[47]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 48));
// add rsp, 0x28 at [52..58]
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[52..59]);
// ret at [59]
Assert.Equal(0xC3, stub[59]);
}
[Fact]
public void X64_5args_places_first_stack_arg_in_shadow_plus_0x20()
{
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t,
[(nuint)1, (nuint)2, (nuint)3, (nuint)4, (nuint)5], 8, CallConvention.Cdecl);
// K = 0x20 + 1·8 = 0x28. Round-up rule: 0x28 % 16 = 8 → no extra padding.
// sub(7) + 4 reg moves (40) + stack arg (mov rax 10 + mov [rsp+0x20],rax 5 = 15)
// + call (5) + add (7) + ret (1) = 75
Assert.Equal(75, stub.Length);
Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x28 (K=0x20+8=0x28, 0x28 % 16 = 8 ✓)
// mov rcx, 1 at [7..16]
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
Assert.Equal(0x01, stub[9]); Assert.Equal(0x00, stub[10]);
Assert.Equal(0x00, stub[11]); Assert.Equal(0x00, stub[12]);
// mov rdx, 2 at [17..26]
Assert.Equal(0x48, stub[17]); Assert.Equal(0xBA, stub[18]);
Assert.Equal(0x02, stub[19]);
// mov r8, 3 at [27..36]
Assert.Equal(0x49, stub[27]); Assert.Equal(0xB8, stub[28]);
Assert.Equal(0x03, stub[29]);
// mov r9, 4 at [37..46]
Assert.Equal(0x49, stub[37]); Assert.Equal(0xB9, stub[38]);
Assert.Equal(0x04, stub[39]);
// mov rax, 5 (48 B8 + 8-byte imm) at [47..56]
Assert.Equal(0x48, stub[47]); Assert.Equal(0xB8, stub[48]);
Assert.Equal(0x05, stub[49]);
for (int k = 50; k <= 56; k++) Assert.Equal(0x00, stub[k]);
// mov [rsp + 0x20], rax (48 89 44 24 20) at [57..61]
Assert.Equal(0x48, stub[57]); Assert.Equal(0x89, stub[58]);
Assert.Equal(0x44, stub[59]); Assert.Equal(0x24, stub[60]);
Assert.Equal(0x20, stub[61]);
// call rel32 at [62..66]; distance = t - (a + 62 + 5) = t - a - 67
uint rel = (uint)(t - (a + 62 + 5));
Assert.Equal(0xE8, stub[62]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 63));
// add rsp, 0x28 at [67..73]
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[67..74]);
// ret at [74]
Assert.Equal(0xC3, stub[74]);
}
[Fact]
public void X64_frame_alignment_property_for_arg_counts()
{
// ABI invariant: for every arg count the sub operand K must satisfy
// K ≡ 8 (mod 16), and the same K must appear in the matching 'add rsp, K'
// just before the ret. Violating this misaligns the inner call, which
// #GP-faults the next time an SSE-using callee executes movaps/movdqa.
var s = Create();
// Keep stub/target within E8 rel32 range (< 2 GiB) so the property check
// exercises the frame math, not the distance guard.
ulong a = 0x140000000, t = 0x140100000;
for (int argc = 0; argc <= 12; argc++)
{
nuint[] args = new nuint[argc];
for (int i = 0; i < argc; i++) args[i] = (nuint)(i + 1);
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, args, 8, CallConvention.Cdecl);
// sub rsp, imm32: 48 81 EC K0 K1 K2 K3
Assert.Equal(0x48, stub[0]);
Assert.Equal(0x81, stub[1]);
Assert.Equal(0xEC, stub[2]);
uint subK = BitConverter.ToUInt32(stub, 3);
Assert.True(subK % 16 == 8,
$"argc={argc}: sub K=0x{subK:X} must satisfy K % 16 == 8");
// add rsp, imm32 is 7 bytes immediately before the trailing C3
int last = stub.Length - 1;
Assert.Equal(0xC3, stub[last]);
int addIdx = last - 7;
Assert.Equal(0x48, stub[addIdx]);
Assert.Equal(0x81, stub[addIdx + 1]);
Assert.Equal(0xC4, stub[addIdx + 2]);
uint addK = BitConverter.ToUInt32(stub, addIdx + 3);
Assert.True(subK == addK,
$"argc={argc}: add K=0x{addK:X} must match sub K=0x{subK:X}");
}
}
[Fact]
public void X64_6args_frame_grows_to_0x38()
{
// Six args: frameBytes = 0x20 + 2·8 = 0x30. 0x30 % 16 = 0, so the
// pad-to-≡8 rule adds 8 more bytes → K = 0x38. Args 5 and 6 still live
// at [rsp+0x20] and [rsp+0x28]; the extra 8 bytes of padding at [rsp+0x30]
// are unused but necessary for alignment.
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
nuint[] args = [(nuint)1, (nuint)2, (nuint)3, (nuint)4, (nuint)5, (nuint)6];
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, args, 8, CallConvention.Cdecl);
// sub rsp, 0x38 (7 bytes)
Assert.Equal([0x48, 0x81, 0xEC, 0x38, 0x00, 0x00, 0x00], stub[..7]);
// add rsp, 0x38 occupies the 7 bytes immediately before ret
int addIdx = stub.Length - 8;
Assert.Equal([0x48, 0x81, 0xC4, 0x38, 0x00, 0x00, 0x00], stub[addIdx..(addIdx + 7)]);
Assert.Equal(0xC3, stub[stub.Length - 1]);
}
[Fact]
public void X64_full_64bit_args_are_preserved_not_truncated()
{
// The bug this catches: an earlier stub emitted "mov r32d, imm32" which zero-extended
// a 32-bit immediate into the lower half of the 64-bit register, silently dropping
// the high bits of any pointer-sized argument above 4 GiB.
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
nuint wideArg = unchecked((nuint)0xDEADBEEF_CAFEBABEUL);
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [wideArg], 8, CallConvention.Cdecl);
// The 8-byte immediate for arg0 lives inside `mov rcx, imm64` at bytes [9..16].
ulong read = BitConverter.ToUInt64(stub, 9);
Assert.Equal(0xDEADBEEF_CAFEBABEul, read);
}
[Fact]
public void X86_target_rejects_arg_value_larger_than_32_bits()
{
if (!Environment.Is64BitProcess)
{
// On a 32-bit host, nuint cannot exceed uint.MaxValue — the precondition
// cannot be exercised. Mark the test as an intentional no-op.
Assert.True(true);
return;
}
var s = Create();
nuint tooBig = unchecked((nuint)0x1_00000000UL);
Assert.Throws<ArgumentOutOfRangeException>(() =>
s.BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [tooBig], 4, CallConvention.Cdecl));
}
// ── Edge cases ────────────────────────────────────────────────────────
[Fact]
public void Far_target_throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
Create().BuildCallStub(IntPtr.Zero, unchecked((IntPtr)(nint)0xC0000000), [], 4, CallConvention.Cdecl));
}
[Fact]
public void Many_args_cleanup_uses_imm32_form()
{
// x86 path: 33 args, stack cleanup > 127 bytes → must emit add esp, imm32 (81 C4)
var args = new nuint[33];
for (int i = 0; i < 33; i++) args[i] = (nuint)(uint)(i * 0x10000 + i);
byte[] stub = Create().BuildCallStub(
(IntPtr)0x10000000, (IntPtr)0x12345678, args, 4, CallConvention.Cdecl);
for (int i = 0; i < stub.Length - 5; i++)
{
if (stub[i] == 0x81 && stub[i + 1] == 0xC4)
{
Assert.Equal(132, BitConverter.ToInt32(stub, i + 2));
return;
}
}
Assert.Fail("Expected 0x81 0xC4 (add esp, imm32) not found");
}
[Fact]
public void Invalid_pointerSize_throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
Create().BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [], 2, CallConvention.Cdecl));
}
[Fact]
public void Invalid_calling_convention_throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
Create().BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [], 4, (CallConvention)99));
}
[Fact]
public void Too_many_arguments_throws_before_frame_math_overflows()
{
// Guards the 0x20 + 8*stackArgs frame arithmetic against int overflow.
// MaxArguments is honored (accepted) and MaxArguments+1 is rejected.
var atCap = new nuint[StubAssembler.MaxArguments];
// At the cap the call still builds (cdecl x64), proving the bound is inclusive.
_ = Create().BuildCallStub((IntPtr)0x10000000, (IntPtr)0x10001000, atCap, 8, CallConvention.Cdecl);
var overCap = new nuint[StubAssembler.MaxArguments + 1];
Assert.Throws<ArgumentOutOfRangeException>(() =>
Create().BuildCallStub((IntPtr)0x10000000, (IntPtr)0x10001000, overCap, 8, CallConvention.Cdecl));
}
// ── No-FASM ─────────────────────────────────────────────────────────
[Fact]
public void No_fasm_reference_in_output()
{
var asm = typeof(StubAssembler).Assembly;
var refs = asm.GetReferencedAssemblies();
Assert.DoesNotContain(refs, r =>
r.Name!.Contains("Fasm", StringComparison.OrdinalIgnoreCase) ||
r.Name!.Contains("ManagedFasm", StringComparison.OrdinalIgnoreCase));
} }
} }
+13 -13
View File
@@ -1,7 +1,7 @@
# Memory-Manipulation Library Comparison — BlackMagic-old, MemorySharp, GreyMagic, BlackMagic # Process-Introspection Library Comparison — BlackMagic-old, MemorySharp, GreyMagic, BlackMagic
**Date**: 2026-07-21 **Date**: 2026-07-21
**Purpose**: Compare four C# process-manipulation 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. **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 ## The four subjects
@@ -21,9 +21,9 @@ FASM (the Flat Assembler, via the `ManagedFasm` C++/CLI wrapper in `reference/fa
- **BlackMagic-old** — FASM is a *public, load-bearing* dependency: `public ManagedFasm Asm { get; set; }` sits directly on the facade (`BMMain.cs:80`), and `SInject.cs` builds its DLL-redirect injection stub as mnemonic text at runtime. - **BlackMagic-old** — FASM is a *public, load-bearing* dependency: `public ManagedFasm Asm { get; set; }` sits directly on the facade (`BMMain.cs:80`), and `SInject.cs` builds its DLL-redirect injection stub as mnemonic text at runtime.
- **MemorySharp** — same dependency, *wrapped*: the assembler is internal (`Fasm32Assembler`, created unconditionally by `AssemblyFactory`), driven by calling-convention formatters behind `Execute<T>`. - **MemorySharp** — same dependency, *wrapped*: the assembler is internal (`Fasm32Assembler`, created unconditionally by `AssemblyFactory`), driven by calling-convention formatters behind `Execute<T>`.
- **GreyMagic** — FASM only on the *external* path (`ExternalProcessReader.Asm`); the in-process path needs no assembler because it calls functions as delegates directly. - **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 public `Asm` property was deleted; runtime game execution moved to the D3D EndScene hook. See `FASM-MIGRATION.md`. - **BlackMagic (current)** — FASM **removed entirely**. Injection stubs became compile-time `byte[]` (`BuildStub32/64`); the public `Asm` property was deleted; runtime target execution moved to the D3D EndScene hook. See `FASM-MIGRATION.md`.
**Conclusion**: the assembly subsystem is not inherent to memory editing — 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](https://github.com/icedland/iced)) or hand-emitted stubs cover the residual need with no native dependency and full x64 support. **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](https://github.com/icedland/iced)) or hand-emitted stubs cover the residual need with no native dependency and full x64 support.
## Feature matrix ## Feature matrix
@@ -32,12 +32,12 @@ FASM (the Flat Assembler, via the `ManagedFasm` C++/CLI wrapper in `reference/fa
| Platform | FW 4.0, x86 | FW, x86 | FW, x86 | **.NET 8, x86 + x64** | | Platform | FW 4.0, x86 | FW, x86 | FW, x86 | **.NET 8, x86 + x64** |
| Handles | raw `IntPtr` | `SafeMemoryHandle` | `SafeMemoryHandle` | `SafeMemoryHandle`, nullable | | Handles | raw `IntPtr` | `SafeMemoryHandle` | `SafeMemoryHandle` | `SafeMemoryHandle`, nullable |
| Addressing | `uint` | `IntPtr` | `IntPtr` | `IntPtr` (64-bit-safe) | | Addressing | `uint` | `IntPtr` | `IntPtr` | `IntPtr` (64-bit-safe) |
| Process model | external | external | **external + in-process** | external (+ D3D hook) | | 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` | | Typed read/write | `ReadInt` etc. | `Read<T>` + marshal | `Read<T>` + **MarshalCache** | `Read<T> where unmanaged` |
| Pattern scanning | ✅ | ❌ ("coming soon") | ❌ (has PE parser) | ✅ + cache | | Pattern scanning | ✅ | ❌ ("coming soon") | ❌ (has PE parser) | ✅ + cache |
| FASM / assembler | **public `Asm`** | internal, behind `Execute<T>` | `Asm` (external only) | **none** | | 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-hook shellcode | | Remote fn call | raw `Asm` stubs | **`Execute<T>(conv, args…)`** + async | **in-proc delegates** | D3D frame-hook stub |
| Function hooking | — | — | **Detour mgr** (reversible, `CallOriginal`) | D3D EndScene only | | Function hooking | — | — | **Detour mgr** (reversible, `CallOriginal`) | Frame-hook only |
| Byte patching | — | ❌ ("coming soon") | **Patch mgr** (named, reversible) | ad hoc | | Byte patching | — | ❌ ("coming soon") | **Patch mgr** (named, reversible) | ad hoc |
| DLL injection | CreateThread + hijack | LoadLibrary via CreateThread | (via `Asm`) | CreateThread + hijack, x86/x64 | | DLL injection | CreateThread + hijack | LoadLibrary via CreateThread | (via `Asm`) | CreateThread + hijack, x86/x64 |
| Named allocation | — | `RemoteAllocation` | **`AllocatedMemory`** (by name) | `AllocateMemory` | | Named allocation | — | `RemoteAllocation` | **`AllocatedMemory`** (by name) | `AllocateMemory` |
@@ -53,17 +53,17 @@ FASM (the Flat Assembler, via the `ManagedFasm` C++/CLI wrapper in `reference/fa
- **BlackMagic-old** → the clean minimal `Open / Read / Write / FindPattern` facade; it is also the historical proof that FASM was once load-bearing and can be retired. - **BlackMagic-old** → the clean minimal `Open / Read / Write / FindPattern` facade; 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, `RemotePointer` indexer, PEB/TEB, window + keyboard/mouse simulation, helper utilities. - **MemorySharp** → high-level ergonomics: calling-convention `Execute<T>` + parameter marshalling + async, `RemotePointer` indexer, PEB/TEB, window + keyboard/mouse simulation, helper utilities.
- **GreyMagic** → the engine: dual in/out-of-process `MemoryBase`, `MarshalCache<T>` fast typed IO, reversible `DetourManager` + `PatchManager`, `CreateFunction<T>`/vtable helpers, named `AllocatedMemory`, `PeHeaderParser`. - **GreyMagic** → the engine: dual in/out-of-process `MemoryBase`, `MarshalCache<T>` fast typed IO, reversible `DetourManager` + `PatchManager`, `CreateFunction<T>`/vtable helpers, named `AllocatedMemory`, `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), D3D EndScene hook (crash-safe execution), test coverage. - **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) ## The crash-safety principle (why `CreateRemoteThread` needs care)
`CreateRemoteThread` does not crash WoW**calling game internals from the wrong thread does**. The game's main thread has exclusive affinity for the Lua VM, the D3D9 device, and the object manager. A thread you spawn runs concurrently with it; the moment a payload touches that state (`CastSpellByName`, `FrameScript::Execute`, object traversal) it races the main thread → memory corruption → crash. This matches the "3 crashes in one session" recorded in `FASM-MIGRATION.md`. `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**: The rule the successor must encode — **split execution by payload safety**:
1. **`CreateRemoteThread` is safe** for *self-contained, thread-agnostic* payloads: `LoadLibrary` (DLL injection), pure WinAPI, code touching only memory you own. 1. **`CreateRemoteThread` is safe** for *self-contained, thread-agnostic* payloads: `LoadLibrary` (DLL injection), pure WinAPI, code touching only memory you own.
2. **Game-state calls must run on the game'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. 2. **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.
3. **In-process** (once a managed DLL is injected), call game functions directly as delegates — no thread crossing at all. 3. **In-process** (once a managed DLL is injected), call target functions directly as delegates — no thread crossing at all.
## WhiteMagic — synthesis ## WhiteMagic — synthesis
@@ -80,7 +80,7 @@ WhiteMagic (facade — BM-old ergonomics)
├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM] ├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM]
├─ Execution (three tiers): ├─ Execution (three tiers):
│ ├─ RemoteThreadExecutor (CreateRemoteThread — safe payloads) [MemorySharp Execute<T>, no FASM] │ ├─ RemoteThreadExecutor (CreateRemoteThread — safe payloads) [MemorySharp Execute<T>, no FASM]
│ ├─ MainThreadPump (frame-hook work queue) [BM D3D hook + GreyMagic detour] ← crash-safe │ ├─ MainThreadPump (frame-hook work queue) [BM frame-hook + GreyMagic detour] ← crash-safe
│ └─ InProcessInvoker (CreateFunction<T> delegates) [GreyMagic] │ └─ InProcessInvoker (CreateFunction<T> delegates) [GreyMagic]
├─ Hooking: DetourManager + PatchManager [GreyMagic] ├─ Hooking: DetourManager + PatchManager [GreyMagic]
├─ Injection: CreateThread + ThreadHijack (x86/x64) [BM current] ├─ Injection: CreateThread + ThreadHijack (x86/x64) [BM current]
@@ -90,4 +90,4 @@ WhiteMagic (facade — BM-old ergonomics)
all patches/detours on Dispose [new] 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 game calls is the crash-safe main-thread pump, while `CreateRemoteThread` stays available for the payloads it is genuinely safe for. **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.
@@ -3,7 +3,7 @@
BlackMagic is a pure C# process manipulation library. It replaced FASM (native x86 assembler DLL) with hand-assembled `byte[]` stubs. Two capabilities were lost: BlackMagic is a pure C# process manipulation library. It replaced FASM (native x86 assembler DLL) with hand-assembled `byte[]` stubs. Two capabilities were lost:
- `InjectAndExecuteEx`: non-blocking remote thread that returns a handle without waiting. - `InjectAndExecuteEx`: non-blocking remote thread that returns a handle without waiting.
- Text-based assembly: build shellcode from `pushad`, `mov eax, 0x1234`, etc. instead of raw bytes. - Text-based assembly: build code payloads from `pushad`, `mov eax, 0x1234`, etc. instead of raw bytes.
Existing code: Existing code:
- `BMThread.cs` has blocking `Execute(addr, param)` → waits 10s, returns exit code. - `BMThread.cs` has blocking `Execute(addr, param)` → waits 10s, returns exit code.
@@ -21,7 +21,7 @@ Existing code:
- TDD: write tests first for every public API surface. - TDD: write tests first for every public API surface.
**Non-Goals:** **Non-Goals:**
- Full x86 instruction set (only shellcode-common subset: mov, push, pop, call, jmp, ret, nop, pushad/popad, test, je/jne, inc, add, sub, xor, and, or, cmp, lea, nop, hlt). - Full x86 instruction set (only common payload subset: mov, push, pop, call, jmp, ret, nop, pushad/popad, test, je/jne, inc, add, sub, xor, and, or, cmp, lea, nop, hlt).
- x64 text assembly (keep x86 only; x64 stubs remain hand-assembled `byte[]`). - x64 text assembly (keep x86 only; x64 stubs remain hand-assembled `byte[]`).
- Reimplementing FASM's directive system (`org`, `use32`, macros). - Reimplementing FASM's directive system (`org`, `use32`, macros).
- Native DLL dependency. - Native DLL dependency.
@@ -33,18 +33,18 @@ Existing code:
New directory `Asm/` keeps assembler code isolated. Static class, no instance state needed — each `Assemble()` call is self-contained. New directory `Asm/` keeps assembler code isolated. Static class, no instance state needed — each `Assemble()` call is self-contained.
**Alternatives considered:** **Alternatives considered:**
- Instance class with `AddLine()` builder pattern → rejected: adds statefulness for no benefit. Each shellcode is a fresh call. - Instance class with `AddLine()` builder pattern → rejected: adds statefulness for no benefit. Each payload is built fresh.
- Put in `Injection/` → rejected: assembler is generic, not injection-specific. - Put in `Injection/` → rejected: assembler is generic, not injection-specific.
### D2: Two-pass assembler (labels + bytes) ### D2: Two-pass assembler (labels + bytes)
Pass 1: scan instructions, record label positions, emit bytes (reserving 4 bytes for near jumps). Pass 2: resolve label offsets, patch jump targets. Pass 1: scan instructions, record label positions, emit bytes (reserving 4 bytes for near jumps). Pass 2: resolve label offsets, patch jump targets.
This handles forward references (`jmp @skip` before `@skip:` label is defined) without multiple iterations. `SetPassLimit()` caps the loop for safety but 2 passes is sufficient for all shellcode patterns. This handles forward references (`jmp @skip` before `@skip:` label is defined) without multiple iterations. `SetPassLimit()` caps the loop for safety but 2 passes is sufficient for all payload patterns.
**Alternatives considered:** **Alternatives considered:**
- Single-pass → rejected: can't resolve forward jumps. - Single-pass → rejected: can't resolve forward jumps.
- FASM-style multi-pass → overkill: shellcode doesn't need complex expression evaluation. - FASM-style multi-pass → overkill: payloads don't need complex expression evaluation.
### D3: Instruction encoding via switch + helper methods ### D3: Instruction encoding via switch + helper methods
@@ -70,5 +70,5 @@ Then implement `AsmBuilder` to make tests pass.
## Risks / Trade-offs ## Risks / Trade-offs
- **Instruction subset**: users may need an instruction not in the initial set. Mitigation: document supported instructions, add new ones incrementally. - **Instruction subset**: users may need an instruction not in the initial set. Mitigation: document supported instructions, add new ones incrementally.
- **Label complexity**: relative jumps are limited to ±127 bytes (near) or ±2GB (far). Shellcode rarely exceeds this, but document the limit. - **Label complexity**: relative jumps are limited to ±127 bytes (near) or ±2GB (far). Payloads rarely exceed this, but document the limit.
- **No runtime validation of shellcode**: the assembler produces bytes; it doesn't verify the result is safe to execute. This matches FASM's behavior — the assembler doesn't validate semantics. - **No runtime validation of payloads**: the assembler produces bytes; it doesn't verify the result is safe to execute. This matches FASM's behavior — the assembler doesn't validate semantics.
@@ -1,15 +1,15 @@
## Why ## Why
BlackMagic replaced FASM for shellcode generation but lost two useful capabilities: BlackMagic replaced FASM for code-payload generation but lost two useful capabilities:
1. **Non-blocking remote execution** (`InjectAndExecuteEx`): FASM's managed wrapper returned a thread handle without waiting. BlackMagic only has blocking `Execute()`. For DLL injection, a non-blocking variant avoids hanging when the target is slow to load. 1. **Non-blocking remote execution** (`InjectAndExecuteEx`): FASM's managed wrapper returned a thread handle without waiting. BlackMagic only has blocking `Execute()`. For DLL injection, a non-blocking variant avoids hanging when the target is slow to load.
2. **Text-based assembly**: FASM allowed building shellcode from assembly text (`AddLine("pushad")`). BlackMagic requires hand-assembled `byte[]`. For prototyping, debugging, and one-off shellcode, text assembly is faster to write and easier to review. A managed assembler eliminates the native FASM DLL dependency while keeping the ergonomic benefit. 2. **Text-based assembly**: FASM allowed building code payloads from assembly text (`AddLine("pushad")`). BlackMagic requires hand-assembled `byte[]`. For prototyping, debugging, and one-off code payloads, text assembly is faster to write and easier to review. A managed assembler eliminates the native FASM DLL dependency while keeping the ergonomic benefit.
## What Changes ## What Changes
- Add `InjectAndExecuteEx()` to `BlackMagic` and `BMThread`: inject code then create a remote thread without waiting, returning the thread handle. - Add `InjectAndExecuteEx()` to `BlackMagic` and `BMThread`: inject code then create a remote thread without waiting, returning the thread handle.
- Add `AsmBuilder` class: pure C# x86 text assembler that converts instruction text to `byte[]` machine code. Supports common shellcode instructions (mov, push, pop, call, jmp, ret, nop, pushad/popad, test, je, jne, inc, add, sub, xor, etc.). - Add `AsmBuilder` class: pure C# x86 text assembler that converts instruction text to `byte[]` machine code. Supports common payload instructions (mov, push, pop, call, jmp, ret, nop, pushad/popad, test, je, jne, inc, add, sub, xor, etc.).
- Add `InjectAndExecute(string asm)` and `InjectAndExecuteEx(string asm)` overloads that accept assembly text, assemble via `AsmBuilder`, then inject+execute. - Add `InjectAndExecute(string asm)` and `InjectAndExecuteEx(string asm)` overloads that accept assembly text, assemble via `AsmBuilder`, then inject+execute.
- Add `SetPassLimit()` to `AsmBuilder` for label resolution iteration control. - Add `SetPassLimit()` to `AsmBuilder` for label resolution iteration control.
@@ -7,7 +7,7 @@ This repo contains four C# process-manipulation libraries studied in `docs/memor
- **GreyMagic** (`reference/GreyMagic/`) — .NET FW, ~2016, x86; dual in/out-of-process `MemoryBase`, `MarshalCache`, `DetourManager`, `PatchManager`, `CreateFunction<T>`, `PeHeaderParser`. - **GreyMagic** (`reference/GreyMagic/`) — .NET FW, ~2016, x86; dual in/out-of-process `MemoryBase`, `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.cs` `EmitU8`/`EmitU32` byte emitter), `CreateRemoteThread`-based `Execute` (`BMThread.cs`). No frame hook exists. - **BlackMagic** (`reference/Blackmagic/`, current) — .NET 8, x86 + x64, FASM removed, pattern scanning + cache, DLL injection (CreateThread + hijack), hand-assembled x86 stubs (`SInject.cs` `EmitU8`/`EmitU32` byte emitter), `CreateRemoteThread`-based `Execute` (`BMThread.cs`). No frame hook exists.
The consuming use case is a WoW 3.3.5a bot. The dominant failure mode observed (`FASM-MIGRATION.md`) is game crashes when protected/game-state functions are invoked on a thread created by `CreateRemoteThread`, because WoW's main thread has exclusive affinity for the Lua VM, D3D9 device, and object manager. 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. 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.
@@ -15,7 +15,7 @@ WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuse
**Goals:** **Goals:**
- Single modern (.NET 8, nullable, `Span<byte>`, `SafeHandle`) library that is bitness-agnostic (x86 + x64). - Single modern (.NET 8, nullable, `Span<byte>`, `SafeHandle`) library that is bitness-agnostic (x86 + x64).
- A **three-tier execution model** whose default path for game-state calls is crash-safe (runs on the target's own thread), while `CreateRemoteThread` remains available for thread-agnostic payloads. - A **three-tier execution model** whose default path for state-sensitive calls is crash-safe (runs on the target's own thread), while `CreateRemoteThread` remains 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`, with `MarshalCache<T>` for allocation-free typed IO. - Dual memory access: out-of-process (RPM/WPM) and in-process (RPM-on-self-handle, see D1 revision) behind one abstract `MemoryBase`, with `MarshalCache<T>` for allocation-free typed IO.
- Reversible function hooking (`DetourManager`) and byte patching (`PatchManager`) with auto-restore on dispose. - Reversible function hooking (`DetourManager`) and byte patching (`PatchManager`) with auto-restore on dispose.
- Replace FASM with an `IAssembler` seam: hand-emitted convention stubs by default, optional Iced backend for arbitrary assembly. Zero native dependency in the default configuration. - Replace FASM with an `IAssembler` seam: hand-emitted convention stubs by default, optional Iced backend for arbitrary assembly. Zero native dependency in the default configuration.
@@ -27,8 +27,8 @@ WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuse
- Modifying or replacing BlackMagic/MemorySharp/GreyMagic — WhiteMagic is additive. - 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. - 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 `InProcessReader` into the target). WhiteMagic exposes the in-process API surface; wiring an actual managed loader is a follow-up change. - Managed-DLL injection bootstrapper (the CLR host that loads `InProcessReader` into the target). WhiteMagic exposes the in-process API surface; wiring an actual managed loader is a follow-up change.
- WoW-specific offsets, Lua unlock, or bot logic — those live in the consumer, not the library. - Application-specific offsets, scripting-engine hooks, or automation logic — those live in the consumer, not the library.
- Anti-cheat evasion. - Interference with other software's operation.
## Decisions ## Decisions
@@ -38,7 +38,7 @@ WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuse
- `ExternalReader : MemoryBase``ReadProcessMemory`/`WriteProcessMemory` over a `SafeMemoryHandle`. Owns allocation, injection, and the remote-thread + main-thread executors. - `ExternalReader : MemoryBase``ReadProcessMemory`/`WriteProcessMemory` over a `SafeMemoryHandle`. Owns allocation, injection, and the remote-thread + main-thread executors.
- `InProcessReader : MemoryBase` — reads the current process via `ReadProcessMemory`/`WriteProcessMemory` on a self-handle; owns the `DetourManager` and `InProcessInvoker`. **(Revised from `unsafe` direct deref during Phase 2: .NET cannot catch `AccessViolationException`, 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. See `specs/memory-access`.)** - `InProcessReader : MemoryBase` — reads the current process via `ReadProcessMemory`/`WriteProcessMemory` on a self-handle; owns the `DetourManager` and `InProcessInvoker`. **(Revised from `unsafe` direct deref during Phase 2: .NET cannot catch `AccessViolationException`, 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. See `specs/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 a bot 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`). **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. **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.
@@ -48,13 +48,13 @@ Execution is split by **payload safety**, not by convenience:
| Tier | Type | Use when | Mechanism | | Tier | Type | Use when | Mechanism |
|---|---|---|---| |---|---|---|---|
| Remote thread | `RemoteThreadExecutor` | payload is thread-agnostic (LoadLibrary, pure WinAPI, self-contained shellcode) | `CreateRemoteThread` + convention stub, wait, exit code | | 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 game state** (default) | queue delegate → drained on target thread via per-frame hook | | **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 | | 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 game'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`. `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 game — calling single-thread-affinity game internals from a foreign thread does. Making the pump the default for game 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. **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. **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.
@@ -110,7 +110,7 @@ A `static class MarshalCache<T>` computes and caches `Marshal.SizeOf`, `TypeCode
WhiteMagic is additive; there is nothing to migrate off. Delivery is phased so each slice is independently useful and testable: WhiteMagic is additive; there is nothing to migrate off. Delivery is phased so each slice is independently useful and testable:
1. **Core**`MemoryBase`, `ExternalReader`, `SafeMemoryHandle`, `MarshalCache`, typed/string/bytes IO. (Replaces nothing; standalone.) 1. **Core**`MemoryBase`, `ExternalReader`, `SafeMemoryHandle`, `MarshalCache`, typed/string/bytes IO. (Replaces nothing; standalone.)
2. **Crash-safe execution slice**`StubAssembler`, `DetourManager`, `MainThreadPump`, `RemoteThreadExecutor`. Proves game-state calls without crashes end-to-end. This is the headline deliverable. 2. **Crash-safe execution slice**`StubAssembler`, `DetourManager`, `MainThreadPump`, `RemoteThreadExecutor`. Proves state-sensitive calls without crashes end-to-end. This is the headline deliverable.
3. **Injection & discovery** — DLL injection (CreateThread + hijack, x86/x64), pattern scanning + cache, `PeHeaderParser`, named `AllocatedMemory`. 3. **Injection & discovery** — DLL injection (CreateThread + hijack, x86/x64), pattern scanning + cache, `PeHeaderParser`, named `AllocatedMemory`.
4. **In-process tier**`InProcessReader`, `InProcessInvoker`, `CreateFunction<T>`, vtable helpers (API surface; managed loader deferred). 4. **In-process tier**`InProcessReader`, `InProcessInvoker`, `CreateFunction<T>`, vtable helpers (API surface; managed loader deferred).
5. **High-level ergonomics**`RemotePointer`, `RemoteModule`/`RemoteFunction`, PEB/TEB, window, input, async, `PatchManager` polish, helpers. 5. **High-level ergonomics**`RemotePointer`, `RemoteModule`/`RemoteFunction`, PEB/TEB, window, input, async, `PatchManager` polish, helpers.
@@ -120,7 +120,7 @@ WhiteMagic is additive; there is nothing to migrate off. Delivery is phased so e
## Open Questions ## Open Questions
- **Frame-hook target**: ~~default to D3D9 `EndScene`, or accept a caller-supplied per-frame function address?~~ **Resolved**: `MainThreadPump` takes a caller-supplied frame-function address (WoW-agnostic, library stays offset-free per Non-Goals); an `EndScene` resolver ships as a convenience helper only. Slice 2 depends on this — settled before slice 2 starts. - **Frame-hook target**: ~~default to D3D9 `EndScene`, or accept a caller-supplied per-frame function address?~~ **Resolved**: `MainThreadPump` takes a caller-supplied frame-function address (application-agnostic, library stays offset-free per Non-Goals); an `EndScene` resolver 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 the `InProcessReader` seam shape. - **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 the `InProcessReader` seam shape.
- **Iced as default vs optional**: keep hand-stubs default (zero dep) — confirmed — but should the library ship a `WhiteMagic.Iced` companion package rather than an optional reference? Package boundary TBD. - **Iced as default vs optional**: keep hand-stubs default (zero dep) — confirmed — but should the library ship a `WhiteMagic.Iced` companion 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 to `Task<T>`. - **Async model**: `Task`-based wrappers (MemorySharp) vs. exposing the pump's completion handles directly. Likely both: pump returns a handle, async wrappers adapt it to `Task<T>`.
@@ -1,6 +1,6 @@
## Why ## Why
Four process-manipulation libraries in this repo each solve part of the problem but none is complete: **BlackMagic-old** and **MemorySharp** depend on the native FASM assembler; **MemorySharp** has rich high-level ergonomics but is 32-bit-only and unmaintained; **GreyMagic** has the best engine (in-process reads, detours, patches, marshal cache) but is 32-bit and external-FASM-bound; **current BlackMagic** is the only modern, x64, FASM-free base but lacks remote function-calling, hooking, and high-level ergonomics. The recurring failure mode is game crashes from calling game internals on a foreign thread created by `CreateRemoteThread`. WhiteMagic unifies the best of all four into one modern (.NET 8, x64) library whose default path for game-state calls is crash-safe. Four process-manipulation libraries in this repo each solve part of the problem but none is complete: **BlackMagic-old** and **MemorySharp** depend on the native FASM assembler; **MemorySharp** has rich high-level ergonomics but is 32-bit-only and unmaintained; **GreyMagic** has the best engine (in-process reads, detours, patches, marshal cache) but is 32-bit and external-FASM-bound; **current BlackMagic** is the only modern, x64, FASM-free base but lacks remote function-calling, hooking, and high-level ergonomics. The recurring failure mode is target crashes from calling target internals on a foreign thread created by `CreateRemoteThread`. WhiteMagic unifies the best of all four into one modern (.NET 8, x64) library whose default path for state-sensitive calls is crash-safe (runs on the target's own thread) while `CreateRemoteThread` stays available for thread-agnostic payloads.
## What Changes ## What Changes
@@ -8,7 +8,7 @@ Four process-manipulation libraries in this repo each solve part of the problem
- **Dual memory-access model**: an abstract `MemoryBase` with an `ExternalReader` (ReadProcessMemory/WriteProcessMemory) and an `InProcessReader` (direct pointer deref for injected scenarios), fronted by a `MarshalCache<T>` for allocation-free typed reads/writes. - **Dual memory-access model**: an abstract `MemoryBase` with an `ExternalReader` (ReadProcessMemory/WriteProcessMemory) and an `InProcessReader` (direct pointer deref for injected scenarios), fronted by a `MarshalCache<T>` for allocation-free typed reads/writes.
- **Three-tier remote execution** — the headline capability: - **Three-tier remote execution** — the headline capability:
- `RemoteThreadExecutor`: `CreateRemoteThread`-based `Execute<T>(addr, convention, args…)`, documented as safe for **thread-agnostic payloads only**. - `RemoteThreadExecutor`: `CreateRemoteThread`-based `Execute<T>(addr, convention, args…)`, documented as safe for **thread-agnostic payloads only**.
- `MainThreadPump`: a crash-safe work queue drained on the target's own thread via a detour on a caller-supplied per-frame function (with an `EndScene` resolver helper) — the default for game-state calls. Net-new; no frame hook exists in current BlackMagic to port. - `MainThreadPump`: a crash-safe work queue drained on the target's own thread via a detour on a caller-supplied per-frame function (with an `EndScene` resolver helper) — the default for state-sensitive calls. Net-new; no frame hook exists in current BlackMagic to port.
- `InProcessInvoker`: direct native-delegate calls (`CreateFunction<T>`) when injected in-process. - `InProcessInvoker`: direct native-delegate calls (`CreateFunction<T>`) when injected in-process.
- **Function hooking**: reversible `DetourManager` (inline jmp, `CallOriginal`) and `PatchManager` (named byte patches) with auto-restore on dispose. - **Function hooking**: reversible `DetourManager` (inline jmp, `CallOriginal`) and `PatchManager` (named byte patches) with auto-restore on dispose.
- **Managed assembler seam**: an `IAssembler` abstraction with two backends — hand-emitted calling-convention stubs (default) and an optional [Iced](https://github.com/icedland/iced) backend for arbitrary x86/x64 assembly. **No FASM, no native DLL.** - **Managed assembler seam**: an `IAssembler` abstraction with two backends — hand-emitted calling-convention stubs (default) and an optional [Iced](https://github.com/icedland/iced) backend for arbitrary x86/x64 assembly. **No FASM, no native DLL.**
@@ -36,4 +36,4 @@ Four process-manipulation libraries in this repo each solve part of the problem
- **New dependency (optional)**: `Iced` NuGet package, isolated behind `IAssembler`; the default hand-stub backend has zero third-party dependencies. - **New dependency (optional)**: `Iced` NuGet package, isolated behind `IAssembler`; the default hand-stub backend has zero third-party dependencies.
- **No native dependency**: FASM (`reference/fasm/`, `ManagedFasm`) is not referenced. It remains historical reference only, consistent with `FASM-MIGRATION.md`. - **No native dependency**: FASM (`reference/fasm/`, `ManagedFasm`) is not referenced. It remains historical reference only, consistent with `FASM-MIGRATION.md`.
- **No changes** to BlackMagic, MemorySharp, GreyMagic, or their tests — WhiteMagic reuses their ideas, not their assemblies. - **No changes** to BlackMagic, MemorySharp, GreyMagic, or their tests — WhiteMagic reuses their ideas, not their assemblies.
- **Platform**: builds x86 and x64; game targeting stays x86 to match `Wow.exe`, but the library is bitness-agnostic. - **Platform**: builds x86 and x64; target bitness stays x86 to match the reference application, but the library is bitness-agnostic.
@@ -8,7 +8,7 @@ WhiteMagic SHALL provide three execution strategies selected by payload safety:
- **WHEN** a caller chooses an execution strategy - **WHEN** a caller chooses an execution strategy
- **THEN** each of remote-thread, main-thread-pump, and in-process MUST be individually invokable - **THEN** each of remote-thread, main-thread-pump, and in-process MUST be individually invokable
#### Scenario: main-thread pump is the documented default for game state #### Scenario: main-thread pump is the documented default for state-sensitive calls
- **WHEN** documentation or API guidance describes calling functions that touch single-thread-affinity process state - **WHEN** documentation or API guidance describes calling functions that touch single-thread-affinity process state
- **THEN** it MUST direct callers to the main-thread pump, not `CreateRemoteThread` - **THEN** it MUST direct callers to the main-thread pump, not `CreateRemoteThread`
@@ -17,7 +17,7 @@ WhiteMagic SHALL provide three execution strategies selected by payload safety:
`RemoteThreadExecutor` SHALL create a remote thread at a target address using a calling-convention-aware stub, wait for completion, and return the typed exit value. Its documentation MUST state that it is safe only for thread-agnostic payloads. `RemoteThreadExecutor` SHALL create a remote thread at a target address using a calling-convention-aware stub, wait for completion, and return the typed exit value. Its documentation MUST state that it is safe only for thread-agnostic payloads.
#### Scenario: execute with parameters and convention #### Scenario: execute with parameters and convention
- **WHEN** `Execute<int>(addr, CallingConvention.Cdecl, arg1, arg2)` is called on a safe self-contained function - **WHEN** `Execute<int>(addr, CallConvention.Cdecl, arg1, arg2)` is called on a safe self-contained function
- **THEN** the target MUST be called with the arguments laid out per cdecl and the typed return value returned - **THEN** the target MUST be called with the arguments laid out per cdecl and the typed return value returned
#### Scenario: parameters marshalled and freed #### Scenario: parameters marshalled and freed
+12 -12
View File
@@ -9,27 +9,27 @@
## 2. Core Memory Access (spec: memory-access) ## 2. Core Memory Access (spec: memory-access)
- [x] 2.1 Add tests for `MarshalCache<T>`: blittable size, marshal-required flag, IsIntPtr, computed-once behavior - [x] 2.1 Add tests for `MarshalCache<T>`: blittable size, marshal-required flag, IsIntPtr, computed-once behavior
- [x] 2.2 Implement `WhiteMagic/MarshalCache.cs` to pass 2.1 - [x] 2.2 Implement `WhiteMagic/MarshalCache.cs` to pass 2.1. **Deviation (review):** split `Size` (managed `Unsafe.SizeOf<T>`, blittable/`MemoryMarshal` path) from `MarshalSize` (`Marshal.SizeOf<T>`, marshal path). A single size mis-sized structs whose unmanaged width differs — a `bool` field (managed 1 / unmanaged 4) over-read the blittable path; an inline `ByValTStr`/`ByValArray` under-sized the marshal path and overran the pinned buffer (heap corruption on write). `MemoryBase` now picks per `TypeRequiresMarshal` at all four IO sites. Unused fields (`SizeU`, `IsIntPtr`, `TypeCode`, `RealType`) dropped.
- [x] 2.3 Add tests for `MemoryBase` abstract contract + `ExternalReader` round-trip (`Read<T>`/`Write<T>`, arrays) using the current process as target - [x] 2.3 Add tests for `MemoryBase` abstract contract + `ExternalReader` round-trip (`Read<T>`/`Write<T>`, arrays) using the current process as target
- [x] 2.4 Implement `WhiteMagic/MemoryBase.cs` (abstract) and `WhiteMagic/ExternalReader.cs` to pass 2.3 - [x] 2.4 Implement `WhiteMagic/MemoryBase.cs` (abstract) and `WhiteMagic/ExternalReader.cs` to pass 2.3. **Deviation (review):** shared RPM/WPM extracted to `WhiteMagic/RpmHelper.cs` so `ExternalReader` and `InProcessReader` stay byte-consistent — partial reads honored (returns exactly `bytesRead`), write returns actual bytes / 0 on total failure; `InProcessReader` guards `MainModule` like `ExternalReader`.
- [x] 2.5 Add tests for string read/write with encoding, null-terminator stop, and max length - [x] 2.5 Add tests for string read/write with encoding, null-terminator stop, and max length
- [x] 2.6 Implement `ReadString`/`WriteString` on `MemoryBase` to pass 2.5 - [x] 2.6 Implement `ReadString`/`WriteString` on `MemoryBase` to pass 2.5
- [x] 2.7 Add tests for relative/absolute addressing (`GetAbsolute`/`GetRelative`, `isRelative` flag) - [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.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 - [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 (the WoW case). 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. - [ ] 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) ## 3. Managed Assembler (spec: managed-assembler)
- [ ] 3.1 Add tests for `EmitU8`/`EmitU32`/`EmitU64` little-endian primitives - [x] 3.1 Add tests for `EmitU8`/`EmitU32`/`EmitU64` little-endian primitives
- [ ] 3.2 Implement `WhiteMagic/Assembly/StubAssembler.cs` emitters + `IAssembler` interface to pass 3.1 - [x] 3.2 Implement `WhiteMagic/Assembly/StubAssembler.cs` emitters + `IAssembler` interface to pass 3.1
- [ ] 3.3 Add tests for x86 cdecl stub encoding (reverse push, call, `add esp, N*4`, ret) with known byte expectations - [x] 3.3 Add tests for x86 cdecl stub encoding (reverse push, call, `add esp, N*4`, ret) with known byte expectations
- [ ] 3.4 Implement x86 cdecl stub to pass 3.3 - [x] 3.4 Implement x86 cdecl stub to pass 3.3
- [ ] 3.5 Add tests for stdcall (no caller cleanup), thiscall (ecx = this), fastcall (ecx/edx) x86 stubs - [x] 3.5 Add tests for stdcall (no caller cleanup), thiscall (ecx = this), fastcall (ecx/edx) x86 stubs
- [ ] 3.6 Implement x86 stdcall/thiscall/fastcall stubs to pass 3.5 - [x] 3.6 Implement x86 stdcall/thiscall/fastcall stubs to pass 3.5
- [ ] 3.7 Add tests for x64 stub argument-register placement and call - [x] 3.7 Add tests for x64 stub argument-register placement and call
- [ ] 3.8 Implement x64 stub to pass 3.7 - [x] 3.8 Implement x64 stub to pass 3.7. **Deviation (review):** `BuildCallStub` takes `nuint[]` (was `uint[]`). x64 stub is Microsoft-x64-ABI compliant: allocates 32-byte shadow space, keeps 16-byte stack alignment at the inner `call` (frame `K ≡ 8 (mod 16)`, `K ≥ 0x20 + 8·stackArgs`), loads RCX/RDX/R8/R9 with full 64-bit `imm64` (no >4 GiB pointer truncation), and writes stack args above the shadow window (no return-address clobber). x86 rejects args > `uint.MaxValue`. Argument count bounded by `MaxArguments` (256) to keep frame arithmetic overflow-free. **Byte-level tests only — a live-execution test (5-arg + SSE callee via `CreateRemoteThread`) is still needed to prove the ABI at runtime.**
- [ ] 3.9 Confirm no FASM/`ManagedFasm` reference exists in `WhiteMagic` output (assert via a test that scans loaded references) - [x] 3.9 Confirm no FASM/`ManagedFasm` reference exists in `WhiteMagic` output (assert via a test that scans loaded references)
## 4. Crash-Safe Execution Slice (spec: remote-execution, function-hooking) ## 4. Crash-Safe Execution Slice (spec: remote-execution, function-hooking)