Compare commits
11
Commits
98c53568d9
...
8c07d8048c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c07d8048c | ||
|
|
b06a034072 | ||
|
|
cb437ef9b5 | ||
|
|
6fa12d8667 | ||
|
|
d520ac34f0 | ||
|
|
ea6e024f32 | ||
|
|
3649feae1f | ||
|
|
84a3a3eab6 | ||
|
|
2ecdd147a7 | ||
|
|
f39ecf820d | ||
|
|
a09887812b |
@@ -6,10 +6,23 @@ Write short sentences. Use the active voice. Give one instruction in each senten
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
WhiteMagic is a .NET 8 library for Windows.
|
||||
The library reads and writes the memory of another process.
|
||||
The library also injects code, hooks functions, and calls remote functions.
|
||||
The target process is a game client (WoW 3.3.5a).
|
||||
WhiteMagic is a .NET 8 process-introspection library for Windows.
|
||||
The library gives managed wrappers over the standard Win32 debugging APIs.
|
||||
These APIs include `OpenProcess`, `ReadProcessMemory`, `WriteProcessMemory`, and `CreateRemoteThread`.
|
||||
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
|
||||
|
||||
@@ -90,7 +103,8 @@ Obey these rules:
|
||||
|
||||
## 9. Scope limits
|
||||
|
||||
- Do not add WoW offsets to the library. The consumer holds the offsets.
|
||||
- Do not add bot logic to the library.
|
||||
- Do not add anti-cheat evasion.
|
||||
- Do not add application-specific constants to the library. The consumer holds the offsets.
|
||||
- Do not add automation or application-specific logic to the library. The library stays a general interop layer.
|
||||
- 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.
|
||||
|
||||
@@ -2,8 +2,10 @@ namespace WhiteMagic.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public enum CallingConvention
|
||||
public enum CallConvention
|
||||
{
|
||||
/// <summary>Caller pushes args right-to-left and cleans the stack (x86).</summary>
|
||||
Cdecl,
|
||||
@@ -6,6 +6,11 @@ namespace WhiteMagic.Assembly;
|
||||
/// <see cref="IcedAssembler"/> (Phase 8) handles arbitrary mnemonics via the Iced
|
||||
/// library.
|
||||
/// </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
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace WhiteMagic.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// no native or third-party dependency — no FASM, no Iced.
|
||||
/// </summary>
|
||||
@@ -11,8 +11,15 @@ namespace WhiteMagic.Assembly;
|
||||
/// emitter, not a text assembler). Use <see cref="IcedAssembler"/> (Phase 8) for
|
||||
/// arbitrary mnemonics.
|
||||
/// </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 />
|
||||
public byte[] Assemble(string assemblyText, ulong origin = 0)
|
||||
{
|
||||
@@ -41,78 +48,260 @@ public class StubAssembler : IAssembler
|
||||
|
||||
// ── 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,
|
||||
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)
|
||||
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
|
||||
BuildX64Stub(buffer, (ulong)stubAddress, (ulong)targetAddress, arguments);
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pointerSize), pointerSize,
|
||||
$"Expected 4 (x86) or 8 (x64), got {pointerSize}.");
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private void BuildX86Stub(List<byte> buffer, uint stubAddr,
|
||||
uint target, uint[] args, CallingConvention convention)
|
||||
uint target, uint[] args, CallConvention convention)
|
||||
{
|
||||
uint current = stubAddr;
|
||||
int argIndex = 0;
|
||||
|
||||
switch (convention)
|
||||
{
|
||||
case CallingConvention.Thiscall when args.Length >= 1:
|
||||
buffer.Add(0xB9); // mov ecx, arg0
|
||||
EmitU32(buffer, args[0]);
|
||||
current += 5;
|
||||
args = args[1..];
|
||||
case CallConvention.Thiscall when args.Length - argIndex >= 1:
|
||||
EmitMovRegImm32(buffer, 0xB9, args[argIndex], ref current); // mov ecx, arg0
|
||||
argIndex++;
|
||||
break;
|
||||
|
||||
case CallingConvention.Fastcall:
|
||||
if (args.Length >= 1)
|
||||
case CallConvention.Fastcall:
|
||||
if (args.Length - argIndex >= 1)
|
||||
{
|
||||
buffer.Add(0xB9); // mov ecx, arg0
|
||||
EmitU32(buffer, args[0]);
|
||||
current += 5;
|
||||
args = args[1..];
|
||||
EmitMovRegImm32(buffer, 0xB9, args[argIndex], ref current); // mov ecx, arg0
|
||||
argIndex++;
|
||||
}
|
||||
if (args.Length >= 1)
|
||||
if (args.Length - argIndex >= 1)
|
||||
{
|
||||
buffer.Add(0xBA); // mov edx, arg1
|
||||
EmitU32(buffer, args[0]);
|
||||
current += 5;
|
||||
args = args[1..];
|
||||
EmitMovRegImm32(buffer, 0xBA, args[argIndex], ref current); // mov edx, arg1
|
||||
argIndex++;
|
||||
}
|
||||
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
|
||||
for (int i = args.Length - 1; i >= 0; i--)
|
||||
// Push remaining args in reverse order (right-to-left)
|
||||
for (int i = args.Length - 1; i >= argIndex; i--)
|
||||
{
|
||||
current += 5;
|
||||
buffer.Add(0x68); // push imm32
|
||||
EmitU32(buffer, args[i]);
|
||||
current += 5;
|
||||
}
|
||||
|
||||
// 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);
|
||||
EmitU32(buffer, rel32);
|
||||
EmitU32(buffer, (uint)distance);
|
||||
current += 5;
|
||||
|
||||
// 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(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
|
||||
}
|
||||
|
||||
private static void BuildX64Stub(List<byte> buffer, ulong stubAddr,
|
||||
ulong target, uint[] args)
|
||||
/// <summary>
|
||||
/// 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 0–5 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
@@ -1,88 +1,107 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// Computes and caches marshal-related metadata for type <typeparamref name="T"/>
|
||||
/// exactly once. <see cref="MemoryBase.Read{T}"/> and <see cref="MemoryBase.Write{T}"/>
|
||||
/// branch on these cached flags to decide between blittable <c>Span</c>/<c>MemoryMarshal</c>
|
||||
/// paths and the fallback marshal path.
|
||||
/// Caches the widths marshalling decisions for type <typeparamref name="T"/>
|
||||
/// once, at static-constructor time. <see cref="MemoryBase.Read{T}"/> and
|
||||
/// <see cref="MemoryBase.Write{T}"/> branch on <see cref="TypeRequiresMarshal"/>
|
||||
/// and pick the appropriate width from this cache.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to cache metadata for.</typeparam>
|
||||
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;
|
||||
|
||||
/// <summary>The unmanaged size of <typeparamref name="T"/> as an unsigned integer.</summary>
|
||||
public static readonly uint SizeU;
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through the
|
||||
/// blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path and must
|
||||
/// use <see cref="Marshal.PtrToStructure"/>/<see cref="Marshal.StructureToPtr"/> instead.
|
||||
/// This is the case when a top-level field carries <see cref="MarshalAsAttribute"/>, or
|
||||
/// when <typeparamref name="T"/> contains a managed reference
|
||||
/// (<see cref="System.Runtime.CompilerServices.RuntimeHelpers.IsReferenceOrContainsReferences{T}"/>).
|
||||
/// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through
|
||||
/// the blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path
|
||||
/// and must fall back to <see cref="Marshal.PtrToStructure"/> /
|
||||
/// <see cref="Marshal.StructureToPtr"/>. This is the case when a top-level field
|
||||
/// carries <see cref="MarshalAsAttribute"/>, or when <typeparamref name="T"/>
|
||||
/// contains a managed reference
|
||||
/// (<see cref="RuntimeHelpers.IsReferenceOrContainsReferences{T}"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="MarshalAsAttribute"/> check inspects only top-level fields; a
|
||||
/// <see cref="MarshalAsAttribute"/> on a field of a nested struct is not detected.
|
||||
/// Reference-containing nested structs are still caught, because the reference check
|
||||
/// propagates through nested value types.
|
||||
/// <see cref="MarshalAsAttribute"/> on a field of a nested struct is not
|
||||
/// detected. Reference-containing nested structs are still caught, because the
|
||||
/// reference check propagates through nested value types.
|
||||
/// </remarks>
|
||||
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()
|
||||
{
|
||||
TypeCode = Type.GetTypeCode(typeof(T));
|
||||
|
||||
if (typeof(T) == typeof(bool))
|
||||
{
|
||||
Size = 1;
|
||||
RealType = typeof(T);
|
||||
}
|
||||
else if (typeof(T) == typeof(char))
|
||||
{
|
||||
// Marshal.SizeOf(char) is 1 (ANSI), but the blittable path reads/writes a
|
||||
// char as a 2-byte UTF-16 code unit. Size must match the blittable width.
|
||||
// Marshal.SizeOf<char> reports 1 (ANSI char), but the blittable
|
||||
// MemoryMarshal path reads/writes a char as a 2-byte UTF-16 code unit.
|
||||
Size = 2;
|
||||
RealType = typeof(T);
|
||||
}
|
||||
else if (typeof(T).IsEnum)
|
||||
{
|
||||
Type underlying = typeof(T).GetEnumUnderlyingType();
|
||||
Size = Marshal.SizeOf(underlying);
|
||||
RealType = underlying;
|
||||
TypeCode = Type.GetTypeCode(underlying);
|
||||
Size = Marshal.SizeOf(typeof(T).GetEnumUnderlyingType());
|
||||
}
|
||||
else
|
||||
{
|
||||
Size = Marshal.SizeOf(typeof(T));
|
||||
RealType = typeof(T);
|
||||
// The blittable path goes through MemoryMarshal, which uses the CLR
|
||||
// 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 =
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public abstract class MemoryBase : IDisposable
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
int size = MarshalCache<T>.Size;
|
||||
int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
|
||||
byte[] raw = ReadBytes(address, size);
|
||||
|
||||
if (raw.Length < size)
|
||||
@@ -56,7 +56,7 @@ public abstract class MemoryBase : IDisposable
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
int size = MarshalCache<T>.Size;
|
||||
int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
|
||||
|
||||
byte[] raw;
|
||||
if (MarshalCache<T>.TypeRequiresMarshal)
|
||||
@@ -81,7 +81,7 @@ public abstract class MemoryBase : IDisposable
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
int elementSize = MarshalCache<T>.Size;
|
||||
int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
|
||||
long totalSize = (long)elementSize * count;
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(totalSize, int.MaxValue, nameof(count));
|
||||
|
||||
@@ -127,7 +127,7 @@ public abstract class MemoryBase : IDisposable
|
||||
if (values is null || values.Length == 0)
|
||||
return true;
|
||||
|
||||
int elementSize = MarshalCache<T>.Size;
|
||||
int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
|
||||
long total = (long)elementSize * values.Length;
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(total, int.MaxValue, nameof(values));
|
||||
int totalSize = (int)total;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
|
||||
@@ -5,7 +6,7 @@ namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public class MarshalCacheTests
|
||||
{
|
||||
@@ -45,12 +46,33 @@ public class MarshalCacheTests
|
||||
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]
|
||||
public void TypeRequiresMarshal_is_false_for_blittable_types()
|
||||
{
|
||||
Assert.False(MarshalCache<int>.TypeRequiresMarshal);
|
||||
Assert.False(MarshalCache<long>.TypeRequiresMarshal);
|
||||
Assert.False(MarshalCache<BlittableStruct>.TypeRequiresMarshal);
|
||||
Assert.False(MarshalCache<byte>.TypeRequiresMarshal);
|
||||
Assert.False(MarshalCache<IntPtr>.TypeRequiresMarshal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -60,39 +82,50 @@ public class MarshalCacheTests
|
||||
}
|
||||
|
||||
[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]
|
||||
public void IsIntPtr_is_false_for_non_IntPtr_types()
|
||||
public void Inline_struct_with_MarshalAs_has_separate_MarshalSize()
|
||||
{
|
||||
Assert.False(MarshalCache<int>.IsIntPtr);
|
||||
Assert.False(MarshalCache<long>.IsIntPtr);
|
||||
Assert.False(MarshalCache<BlittableStruct>.IsIntPtr);
|
||||
// Regression for the marshal-path size bug. A struct with an inline
|
||||
// ByValTStr field has mismatched managed and unmanaged widths: the managed
|
||||
// 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]
|
||||
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;
|
||||
bool marshal1 = MarshalCache<int>.TypeRequiresMarshal;
|
||||
bool intPtr1 = MarshalCache<int>.IsIntPtr;
|
||||
|
||||
int size2 = MarshalCache<int>.Size;
|
||||
bool marshal2 = MarshalCache<int>.TypeRequiresMarshal;
|
||||
bool intPtr2 = MarshalCache<int>.IsIntPtr;
|
||||
|
||||
Assert.Equal(size1, size2);
|
||||
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)]
|
||||
@@ -108,4 +141,29 @@ public class MarshalCacheTests
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,43 @@ public class MemoryBaseTests
|
||||
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 ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -243,3 +280,17 @@ public struct TestStruct : IEquatable<TestStruct>
|
||||
public override int GetHashCode() => HashCode.Combine(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;
|
||||
}
|
||||
|
||||
@@ -8,234 +8,465 @@ public class StubAssemblerTests
|
||||
|
||||
// ── Emit primitives ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EmitU8_appends_a_single_byte()
|
||||
{
|
||||
var sut = Create();
|
||||
var buffer = new List<byte>();
|
||||
sut.EmitU8(buffer, 0xAB);
|
||||
Assert.Equal([0xAB], buffer);
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
[Fact] public void EmitU8_appends_a_single_byte() { var s=Create(); var b=new List<byte>(); s.EmitU8(b,0xAB); Assert.Equal([0xAB],b); }
|
||||
[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); }
|
||||
[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); }
|
||||
[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); }
|
||||
[Fact] public void StubAssembler_is_IAssembler() { Assert.IsAssignableFrom<IAssembler>(Create()); }
|
||||
[Fact] public void Assemble_throws() { Assert.Throws<NotSupportedException>(()=>Create().Assemble("nop",0)); }
|
||||
|
||||
// ── x86 cdecl ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Cdecl_stub_zero_args_call_then_ret()
|
||||
public void Cdecl_0args()
|
||||
{
|
||||
var sut = Create();
|
||||
uint rel32 = 0x12345678u - (0x10000000u + 5);
|
||||
byte[] stub = sut.BuildCallStub(
|
||||
(IntPtr)0x10000000, (IntPtr)0x12345678, [], 4, CallingConvention.Cdecl);
|
||||
|
||||
Assert.Equal([0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24), 0xC3], stub);
|
||||
uint r = 0x12345678u-(0x10000000u+5);
|
||||
Assert.Equal([0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cdecl_stub_one_arg_push_call_cleanup_ret()
|
||||
public void Cdecl_1arg()
|
||||
{
|
||||
var sut = Create();
|
||||
uint callAddr = 0x10000000u + 5;
|
||||
uint rel32 = 0x12345678u - (callAddr + 5);
|
||||
byte[] stub = sut.BuildCallStub(
|
||||
(IntPtr)0x10000000, (IntPtr)0x12345678, [0xAABBCCDD], 4, CallingConvention.Cdecl);
|
||||
|
||||
uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0x68, 0xDD, 0xCC, 0xBB, 0xAA,
|
||||
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24),
|
||||
0x83, 0xC4, 0x04,
|
||||
0xC3
|
||||
], stub);
|
||||
0x68,0xDD,0xCC,0xBB,0xAA,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),
|
||||
0x83,0xC4,0x04,0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAABBCCDD],4,CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cdecl_stub_two_args_reverse_order()
|
||||
public void Cdecl_2args()
|
||||
{
|
||||
var sut = Create();
|
||||
uint callAddr = 0x10000000u + 10;
|
||||
uint rel32 = 0x12345678u - (callAddr + 5);
|
||||
byte[] stub = sut.BuildCallStub(
|
||||
(IntPtr)0x10000000, (IntPtr)0x12345678, [0x11111111, 0x22222222], 4, CallingConvention.Cdecl);
|
||||
|
||||
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0x68, 0x22, 0x22, 0x22, 0x22,
|
||||
0x68, 0x11, 0x11, 0x11, 0x11,
|
||||
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24),
|
||||
0x83, 0xC4, 0x08,
|
||||
0xC3
|
||||
], stub);
|
||||
0x68,0x22,0x22,0x22,0x22,
|
||||
0x68,0x11,0x11,0x11,0x11,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),
|
||||
0x83,0xC4,0x08,0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0x11111111,0x22222222],4,CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
// ── x86 stdcall ────────────────────────────────────────────────────────
|
||||
// ── x86 stdcall ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Stdcall_stub_one_arg_no_cleanup()
|
||||
public void Stdcall_1arg()
|
||||
{
|
||||
var sut = Create();
|
||||
uint callAddr = 0x10000000u + 5;
|
||||
uint rel32 = 0x12345678u - (callAddr + 5);
|
||||
byte[] stub = sut.BuildCallStub(
|
||||
(IntPtr)0x10000000, (IntPtr)0x12345678, [0xAABBCCDD], 4, CallingConvention.Stdcall);
|
||||
|
||||
uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0x68, 0xDD, 0xCC, 0xBB, 0xAA,
|
||||
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24),
|
||||
0xC3
|
||||
], stub);
|
||||
0x68,0xDD,0xCC,0xBB,0xAA,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAABBCCDD],4,CallConvention.Stdcall));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stdcall_stub_two_args_reverse_no_cleanup()
|
||||
public void Stdcall_2args()
|
||||
{
|
||||
var sut = Create();
|
||||
uint callAddr = 0x10000000u + 10;
|
||||
uint rel32 = 0x12345678u - (callAddr + 5);
|
||||
byte[] stub = sut.BuildCallStub(
|
||||
(IntPtr)0x10000000, (IntPtr)0x12345678, [0x11111111, 0x22222222], 4, CallingConvention.Stdcall);
|
||||
|
||||
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0x68, 0x22, 0x22, 0x22, 0x22,
|
||||
0x68, 0x11, 0x11, 0x11, 0x11,
|
||||
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24),
|
||||
0xC3
|
||||
], stub);
|
||||
0x68,0x22,0x22,0x22,0x22,
|
||||
0x68,0x11,0x11,0x11,0x11,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0x11111111,0x22222222],4,CallConvention.Stdcall));
|
||||
}
|
||||
|
||||
// ── x86 thiscall ───────────────────────────────────────────────────────
|
||||
// ── x86 thiscall ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Thiscall_stub_ecx_then_stack_then_call()
|
||||
public void Thiscall_ecx_then_stack()
|
||||
{
|
||||
var sut = Create();
|
||||
uint callAddr = 0x10000000u + 10;
|
||||
uint rel32 = 0x12345678u - (callAddr + 5);
|
||||
byte[] stub = sut.BuildCallStub(
|
||||
(IntPtr)0x10000000, (IntPtr)0x12345678, [0xAAAA5555, 0xBBBB6666], 4, CallingConvention.Thiscall);
|
||||
|
||||
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0xB9, 0x55, 0x55, 0xAA, 0xAA,
|
||||
0x68, 0x66, 0x66, 0xBB, 0xBB,
|
||||
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24),
|
||||
0xC3
|
||||
], stub);
|
||||
0xB9,0x55,0x55,0xAA,0xAA,
|
||||
0x68,0x66,0x66,0xBB,0xBB,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAAAA5555,0xBBBB6666],4,CallConvention.Thiscall));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Thiscall_stub_one_arg_ecx_only_then_call()
|
||||
public void Thiscall_1arg_ecx_only()
|
||||
{
|
||||
var sut = Create();
|
||||
uint callAddr = 0x10000000u + 5;
|
||||
uint rel32 = 0x12345678u - (callAddr + 5);
|
||||
byte[] stub = sut.BuildCallStub(
|
||||
(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]);
|
||||
uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
|
||||
byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xCAFEBABE],4,CallConvention.Thiscall);
|
||||
Assert.Equal(11,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xCAFEBABEu,BitConverter.ToUInt32(s,1));
|
||||
Assert.Equal(0xE8,s[5]); Assert.Equal(r,BitConverter.ToUInt32(s,6)); Assert.Equal(0xC3,s[10]);
|
||||
}
|
||||
|
||||
// ── x86 fastcall ───────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Thiscall_0args_throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Thiscall));
|
||||
}
|
||||
|
||||
// ── x86 fastcall ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Fastcall_stub_ecx_edx_stack_call()
|
||||
public void Fastcall_ecx_edx_stack()
|
||||
{
|
||||
var sut = Create();
|
||||
uint callAddr = 0x10000000u + 15;
|
||||
uint rel32 = 0x12345678u - (callAddr + 5);
|
||||
byte[] stub = sut.BuildCallStub(
|
||||
(IntPtr)0x10000000, (IntPtr)0x12345678, [0x11111111, 0x22222222, 0x33333333], 4, CallingConvention.Fastcall);
|
||||
|
||||
uint ca=0x10000000u+15,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0xB9, 0x11, 0x11, 0x11, 0x11,
|
||||
0xBA, 0x22, 0x22, 0x22, 0x22,
|
||||
0x68, 0x33, 0x33, 0x33, 0x33,
|
||||
0xE8, (byte)rel32, (byte)(rel32>>8), (byte)(rel32>>16), (byte)(rel32>>24),
|
||||
0xC3
|
||||
], stub);
|
||||
0xB9,0x11,0x11,0x11,0x11,
|
||||
0xBA,0x22,0x22,0x22,0x22,
|
||||
0x68,0x33,0x33,0x33,0x33,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0x11111111,0x22222222,0x33333333],4,CallConvention.Fastcall));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fastcall_stub_two_args_registers_only()
|
||||
public void Fastcall_2args_registers_only()
|
||||
{
|
||||
var sut = Create();
|
||||
uint callAddr = 0x10000000u + 10;
|
||||
uint rel32 = 0x12345678u - (callAddr + 5);
|
||||
byte[] stub = sut.BuildCallStub(
|
||||
(IntPtr)0x10000000, (IntPtr)0x12345678, [0xAAAAAAAA, 0xBBBBBBBB], 4, CallingConvention.Fastcall);
|
||||
|
||||
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]);
|
||||
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
|
||||
byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAAAAAAAA,0xBBBBBBBB],4,CallConvention.Fastcall);
|
||||
Assert.Equal(16,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xAAAAAAAAu,BitConverter.ToUInt32(s,1));
|
||||
Assert.Equal(0xBA,s[5]); Assert.Equal(0xBBBBBBBBu,BitConverter.ToUInt32(s,6));
|
||||
Assert.Equal(0xE8,s[10]); Assert.Equal(r,BitConverter.ToUInt32(s,11)); Assert.Equal(0xC3,s[15]);
|
||||
}
|
||||
|
||||
// ── x64 ────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Fastcall_0args_is_valid()
|
||||
{
|
||||
uint r=0x12345678u-(0x10000000u+5);
|
||||
Assert.Equal([0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
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_stub_throws_not_implemented_by_default()
|
||||
public void X64_0args_allocates_shadow_space_and_aligns()
|
||||
{
|
||||
var sut = Create();
|
||||
Assert.Throws<NotImplementedException>(() =>
|
||||
sut.BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [], 8, CallingConvention.Cdecl));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
**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
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -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** |
|
||||
| Handles | raw `IntPtr` | `SafeMemoryHandle` | `SafeMemoryHandle` | `SafeMemoryHandle`, nullable |
|
||||
| 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` |
|
||||
| Pattern scanning | ✅ | ❌ ("coming soon") | ❌ (has PE parser) | ✅ + cache |
|
||||
| FASM / assembler | **public `Asm`** | internal, behind `Execute<T>` | `Asm` (external only) | **none** |
|
||||
| Remote fn call | raw `Asm` stubs | **`Execute<T>(conv, args…)`** + async | **in-proc delegates** | D3D-hook shellcode |
|
||||
| Function hooking | — | — | **Detour mgr** (reversible, `CallOriginal`) | D3D EndScene only |
|
||||
| Remote fn call | raw `Asm` stubs | **`Execute<T>(conv, args…)`** + async | **in-proc delegates** | D3D frame-hook stub |
|
||||
| Function hooking | — | — | **Detour mgr** (reversible, `CallOriginal`) | Frame-hook only |
|
||||
| Byte patching | — | ❌ ("coming soon") | **Patch mgr** (named, reversible) | ad hoc |
|
||||
| DLL injection | CreateThread + hijack | LoadLibrary via CreateThread | (via `Asm`) | CreateThread + hijack, x86/x64 |
|
||||
| Named allocation | — | `RemoteAllocation` | **`AllocatedMemory`** (by name) | `AllocateMemory` |
|
||||
@@ -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.
|
||||
- **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`.
|
||||
- **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)
|
||||
|
||||
`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**:
|
||||
|
||||
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.
|
||||
3. **In-process** (once a managed DLL is injected), call game functions directly as delegates — no thread crossing at all.
|
||||
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 target functions directly as delegates — no thread crossing at all.
|
||||
|
||||
## WhiteMagic — synthesis
|
||||
|
||||
@@ -80,7 +80,7 @@ WhiteMagic (facade — BM-old ergonomics)
|
||||
├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM]
|
||||
├─ Execution (three tiers):
|
||||
│ ├─ RemoteThreadExecutor (CreateRemoteThread — safe payloads) [MemorySharp Execute<T>, no FASM]
|
||||
│ ├─ MainThreadPump (frame-hook work queue) [BM D3D hook + GreyMagic detour] ← crash-safe
|
||||
│ ├─ MainThreadPump (frame-hook work queue) [BM frame-hook + GreyMagic detour] ← crash-safe
|
||||
│ └─ InProcessInvoker (CreateFunction<T> delegates) [GreyMagic]
|
||||
├─ Hooking: DetourManager + PatchManager [GreyMagic]
|
||||
├─ Injection: CreateThread + ThreadHijack (x86/x64) [BM current]
|
||||
@@ -90,4 +90,4 @@ WhiteMagic (facade — BM-old ergonomics)
|
||||
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:
|
||||
|
||||
- `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:
|
||||
- `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.
|
||||
|
||||
**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[]`).
|
||||
- Reimplementing FASM's directive system (`org`, `use32`, macros).
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
|
||||
### 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.
|
||||
|
||||
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:**
|
||||
- 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
|
||||
|
||||
@@ -70,5 +70,5 @@ Then implement `AsmBuilder` to make tests pass.
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
- 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 `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`.
|
||||
- **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.
|
||||
|
||||
@@ -15,7 +15,7 @@ WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuse
|
||||
|
||||
**Goals:**
|
||||
- Single modern (.NET 8, nullable, `Span<byte>`, `SafeHandle`) library that is bitness-agnostic (x86 + x64).
|
||||
- A **three-tier execution model** whose default path for 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
- 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.
|
||||
- WoW-specific offsets, Lua unlock, or bot logic — those live in the consumer, not the library.
|
||||
- Anti-cheat evasion.
|
||||
- Application-specific offsets, scripting-engine hooks, or automation logic — those live in the consumer, not the library.
|
||||
- Interference with other software's operation.
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
|
||||
@@ -48,13 +48,13 @@ Execution is split by **payload safety**, not by convenience:
|
||||
|
||||
| Tier | Type | Use when | Mechanism |
|
||||
|---|---|---|---|
|
||||
| Remote thread | `RemoteThreadExecutor` | payload is thread-agnostic (LoadLibrary, pure WinAPI, self-contained shellcode) | `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 |
|
||||
| Remote thread | `RemoteThreadExecutor` | payload is thread-agnostic (LoadLibrary, pure WinAPI, self-contained code payload) | `CreateRemoteThread` + convention stub, wait, exit code |
|
||||
| **Main-thread pump** | `MainThreadPump` | **payload touches target state** (default) | queue delegate → drained on target thread via per-frame hook |
|
||||
| In-process | `InProcessInvoker` | injected in-process | `Marshal.GetDelegateForFunctionPointer`, direct call |
|
||||
|
||||
`MainThreadPump` installs a detour on a caller-supplied per-frame function address (an `EndScene` resolver ships as a convenience helper) via `DetourManager`. Each frame the hook drains a thread-safe queue and runs pending work items synchronously in the 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
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`.
|
||||
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.
|
||||
@@ -120,7 +120,7 @@ WhiteMagic is additive; there is nothing to migrate off. Delivery is phased so e
|
||||
|
||||
## 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.
|
||||
- **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>`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
- **Three-tier remote execution** — the headline capability:
|
||||
- `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.
|
||||
- **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.**
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
#### 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
|
||||
|
||||
#### Scenario: parameters marshalled and freed
|
||||
|
||||
@@ -9,27 +9,27 @@
|
||||
## 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.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.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.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.8 Implement addressing helpers to pass 2.7
|
||||
- [x] 2.9 Add tests + implementation for `InProcessReader` (RPM/WPM on a self-handle — see D1 deviation note; direct deref rejected because .NET cannot catch `AccessViolationException`); verify shared `MemoryBase` API works for both readers
|
||||
- [ ] 2.10 Follow-up (found in review): `ReadString` scans for the null terminator byte-by-byte, so for UTF-16/UTF-32 it can match a **misaligned** multi-byte null across a char boundary (e.g. `"A"`+U+4200 = `41 00 00 42` matches `{00,00}` at offset 1) and can miss a terminator split across the 64-byte chunk boundary. Harmless for ASCII/UTF-8 (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.1 Add tests for `EmitU8`/`EmitU32`/`EmitU64` little-endian primitives
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 3.6 Implement x86 stdcall/thiscall/fastcall stubs to pass 3.5
|
||||
- [ ] 3.7 Add tests for x64 stub argument-register placement and call
|
||||
- [ ] 3.8 Implement x64 stub to pass 3.7
|
||||
- [ ] 3.9 Confirm no FASM/`ManagedFasm` reference exists in `WhiteMagic` output (assert via a test that scans loaded references)
|
||||
- [x] 3.1 Add tests for `EmitU8`/`EmitU32`/`EmitU64` little-endian primitives
|
||||
- [x] 3.2 Implement `WhiteMagic/Assembly/StubAssembler.cs` emitters + `IAssembler` interface to pass 3.1
|
||||
- [x] 3.3 Add tests for x86 cdecl stub encoding (reverse push, call, `add esp, N*4`, ret) with known byte expectations
|
||||
- [x] 3.4 Implement x86 cdecl stub to pass 3.3
|
||||
- [x] 3.5 Add tests for stdcall (no caller cleanup), thiscall (ecx = this), fastcall (ecx/edx) x86 stubs
|
||||
- [x] 3.6 Implement x86 stdcall/thiscall/fastcall stubs to pass 3.5
|
||||
- [x] 3.7 Add tests for x64 stub argument-register placement and call
|
||||
- [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.**
|
||||
- [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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user