diff --git a/WhiteMagic/Assembly/StubAssembler.cs b/WhiteMagic/Assembly/StubAssembler.cs index d9bc236..e470d19 100644 --- a/WhiteMagic/Assembly/StubAssembler.cs +++ b/WhiteMagic/Assembly/StubAssembler.cs @@ -46,21 +46,35 @@ public sealed class StubAssembler : IAssembler /// /// Where the stub lands (for E8 rel32 encoding). /// Function to call. - /// Argument values (uint[] — each 4 or 8 bytes per pointerSize). + /// Argument values. For x86 each element holds a 32-bit argument; + /// for x64 each element holds the full 64-bit pointer-sized argument. /// 4 (x86) or 8 (x64). - /// Calling convention. + /// Calling convention (ignored on x64; Windows has a single ABI). /// is not 4 or 8, /// or is not known, or the distance between stub and target /// exceeds the E8 rel32 range. public byte[] BuildCallStub(IntPtr stubAddress, IntPtr targetAddress, - uint[] arguments, int pointerSize, CallConvention convention) + nuint[] arguments, int pointerSize, CallConvention convention) { - var buffer = new List(64); + var buffer = new List(96); if (pointerSize == 4) { + // 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), - arguments, convention); + args32, convention); } else if (pointerSize == 8) { @@ -153,57 +167,113 @@ public sealed class StubAssembler : IAssembler buffer.Add(0xC3); // ret } + /// + /// 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 32-byte shadow space; 16-byte stack alignment at the call instruction. + /// + /// + /// The stub frame: + /// + /// sub rsp, 0x20 ; 32-byte shadow space + restores 16-byte alignment + /// mov rcx, arg0 ; 64-bit loads (REX.W mov r64, imm64) + /// mov rdx, arg1 + /// mov r8, arg2 + /// mov r9, arg3 + /// mov rax, arg[N] + /// mov [rsp + 0x20 + 8*(N-4)], rax ; stack args placed above the shadow slots + /// ... + /// call target (rel32) + /// add rsp, 0x20 + /// ret + /// + /// On entry the stub sees rsp ≡ 8 (mod 16) (the caller's call pushed + /// the return address). sub rsp, 0x20 moves rsp to ≡ 0 (mod 16). Just before + /// the inner call, rsp is still ≡ 0, so target's entry rsp is + /// ≡ 8 (mod 16) — no, wait: entry ≡ 0 after sub; call target pushes 8, so + /// target's entry is ≡ 0 − 8 ≡ 8; but we want target entry ≡ 0. Re-check: + /// Stub entry: rsp ≡ 8 (mod 16). After sub rsp, 0x20: + /// 8 − 0x20 = −24 ≡ 8 (mod 16). After the inner call, target entry is + /// 8 − 8 ≡ 0 (mod 16). Target is 16-byte aligned — SSE safe. + /// private void BuildX64Stub(List buffer, ulong stubAddr, - ulong target, uint[] args) + ulong target, nuint[] args) { - // Windows x64 single ABI: first 4 args in RCX, RDX, R8D, R9D. ulong current = stubAddr; - var regCodes = new byte[] { 0xB9, 0xBA, 0xB8, 0xB9 }; - var rexBytes = new byte[] { 0x00, 0x00, 0x41, 0x41 }; + // 1. Allocate shadow space. + // sub rsp, 0x20 ; 32 bytes = 4 shadow slots AND (entry − 0x20) ≡ 8 (mod 16), + // so rsp after the sub ≡ 8 (mod 16); `call target` will push 8 and land target + // at ≡ 0 (mod 16). + buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xEC); // sub rsp, imm32 + EmitU32(buffer, 0x20); + current += 7; + + // 2. 64-bit register loads. + // RCX = REX.W 0xB9 + imm64 (10 bytes) + // RDX = REX.W 0xBA + imm64 (10 bytes) + // R8 = REX.WB 0xB8 + imm64 (11 bytes, REX.W|R = 0x49) + // R9 = REX.WB 0xB9 + imm64 (11 bytes) + byte[][] regMoves = + [ + [0x48, 0xB9], // mov rcx, imm64 + [0x48, 0xBA], // mov rdx, imm64 + [0x49, 0xB8], // mov r8, imm64 + [0x49, 0xB9], // mov r9, imm64 + ]; int regCount = Math.Min(args.Length, 4); for (int i = 0; i < regCount; i++) { - if (rexBytes[i] != 0) - buffer.Add(rexBytes[i]); - buffer.Add(regCodes[i]); - EmitU32(buffer, args[i]); - current += (rexBytes[i] != 0 ? 6u : 5u); + byte[] prefix = regMoves[i]; + buffer.Add(prefix[0]); + buffer.Add(prefix[1]); + EmitU64(buffer, args[i]); + current += (uint)(prefix.Length + 8); } - // Push remaining args in reverse order - for (int i = args.Length - 1; i >= 4; i--) + // 3. Stack args (args 4+): placed at [rsp + 0x20 + 8*(i-4)]. + // Each is two instructions: + // mov rax, imm64 (10 bytes) + // mov [rsp + disp], rax (5..8 bytes depending on disp8/disp32) + for (int i = 4; i < args.Length; i++) { - current += 5; - buffer.Add(0x68); - EmitU32(buffer, args[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 + // 4. call rel32 long distance = (long)target - (long)(current + 5); - if (distance < int.MinValue || distance > int.MaxValue) + 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; - // Pop any args pushed on stack (x64 is caller-clean) - int stackArgs = args.Length > 4 ? args.Length - 4 : 0; - if (stackArgs > 0) - { - int bytes = stackArgs * 8; - buffer.Add(0x48); // REX.W - buffer.Add(bytes <= 127 ? (byte)0x83 : (byte)0x81); // add r/m64, imm8/imm32 - buffer.Add(0xC4); // rsp - if (bytes <= 127) - buffer.Add((byte)bytes); - else - EmitU32(buffer, (uint)bytes); - } + // 5. add rsp, 0x20 ; tear down shadow space + buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xC4); + EmitU32(buffer, 0x20); + // 6. ret buffer.Add(0xC3); } diff --git a/WhiteMagic/ExternalReader.cs b/WhiteMagic/ExternalReader.cs index 62d2979..15a3834 100644 --- a/WhiteMagic/ExternalReader.cs +++ b/WhiteMagic/ExternalReader.cs @@ -42,7 +42,8 @@ public sealed class ExternalReader : MemoryBase } // Process.MainModule throws Win32Exception for a bitness-mismatched or protected - // target; a missing image base must not sink the whole reader. + // target. A missing image base must not sink the whole reader — callers can still + // use absolute addresses when ImageBase is unknown. try { _imageBase = process.MainModule?.BaseAddress ?? IntPtr.Zero; @@ -65,18 +66,7 @@ public sealed class ExternalReader : MemoryBase if (isRelative) address = GetAbsolute(address); - byte[] buffer = new byte[count]; - if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead)) - { - return []; - } - - if ((int)bytesRead != count) - { - Array.Resize(ref buffer, (int)bytesRead); - } - - return buffer; + return RpmHelper.ReadBytes(_handle, address, count); } /// @@ -85,12 +75,7 @@ public sealed class ExternalReader : MemoryBase if (isRelative) address = GetAbsolute(address); - if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written)) - { - return 0; - } - - return (int)written; + return RpmHelper.WriteBytes(_handle, address, bytes); } /// diff --git a/WhiteMagic/InProcessReader.cs b/WhiteMagic/InProcessReader.cs index 2e2f947..5079636 100644 --- a/WhiteMagic/InProcessReader.cs +++ b/WhiteMagic/InProcessReader.cs @@ -12,6 +12,13 @@ namespace WhiteMagic; /// empty / zero bytes) on invalid or protected addresses instead of crashing /// the host process with an . /// +/// +/// This is functionally equivalent to opened on the current +/// process. It exists as a distinct type because the design (see +/// openspec/changes/whitemagic-foundation/design.md D1) treats "injected in-process" +/// as a separate mode from "external". The two modes will diverge further once the +/// InProcessInvoker delegate-call path lands. +/// public sealed class InProcessReader : MemoryBase { private readonly SafeMemoryHandle _handle; @@ -35,7 +42,16 @@ public sealed class InProcessReader : MemoryBase $"OpenProcess failed for PID {current.Id}: error {error}"); } - _imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero; + // Process.MainModule rarely throws on the current process, but guard it + // nonetheless for parity with ExternalReader. + try + { + _imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero; + } + catch (System.ComponentModel.Win32Exception) + { + _imageBase = IntPtr.Zero; + } } /// @@ -50,18 +66,7 @@ public sealed class InProcessReader : MemoryBase if (isRelative) address = GetAbsolute(address); - byte[] buffer = new byte[count]; - if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead)) - { - return []; - } - - if ((int)bytesRead != count) - { - Array.Resize(ref buffer, (int)bytesRead); - } - - return buffer; + return RpmHelper.ReadBytes(_handle, address, count); } /// @@ -70,12 +75,7 @@ public sealed class InProcessReader : MemoryBase if (isRelative) address = GetAbsolute(address); - if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written)) - { - return 0; - } - - return (int)written; + return RpmHelper.WriteBytes(_handle, address, bytes); } /// diff --git a/WhiteMagic/MarshalCache.cs b/WhiteMagic/MarshalCache.cs index 49f1b59..bccbc6f 100644 --- a/WhiteMagic/MarshalCache.cs +++ b/WhiteMagic/MarshalCache.cs @@ -1,88 +1,77 @@ using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace WhiteMagic; /// -/// Computes and caches marshal-related metadata for type -/// exactly once. and -/// branch on these cached flags to decide between blittable Span/MemoryMarshal -/// paths and the fallback marshal path. +/// Computes and caches the byte size and marshalling decision for type +/// exactly once. and +/// branch on +/// to decide between the blittable Span / +/// path and the +/// path. /// /// The type to cache metadata for. public static class MarshalCache { - /// The unmanaged size of in bytes. + /// + /// The byte size of . For the blittable path this is + /// the managed layout size — the width that + /// / + /// actually consume. For primitive-sized types (, , + /// and the underlying of enums) the size matches the CLR primitive width. + /// public static readonly int Size; - /// The unmanaged size of as an unsigned integer. - public static readonly uint SizeU; - /// - /// when cannot be copied through the - /// blittable path and must - /// use / instead. - /// This is the case when a top-level field carries , or - /// when contains a managed reference - /// (). + /// when cannot be copied through + /// the blittable path + /// and must fall back to / + /// . This is the case when a top-level field + /// carries , or when + /// contains a managed reference + /// (). /// /// /// The check inspects only top-level fields; a /// 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. + /// Reference-containing nested structs are still caught, because the reference + /// check propagates through nested value types. /// public static readonly bool TypeRequiresMarshal; - /// when is . - public static readonly bool IsIntPtr; - - /// The underlying type code of . - public static readonly TypeCode TypeCode; - - /// - /// The effective type that the marshaler uses. For an enum this is the underlying - /// integer type; for all other types it is itself. - /// - 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 reports 1 (ANSI char), but the blittable + // MemoryMarshal path reads/writes a char as a 2-byte UTF-16 code unit. + // Use the managed layout width so Size matches what the reader actually uses. 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 so Size agrees with that layout — + // Marshal.SizeOf can disagree when a struct contains a `bool` field + // (unmanaged width 4 vs managed width 1). + Size = Unsafe.SizeOf(); } - 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(); + hasMarshalAsField || RuntimeHelpers.IsReferenceOrContainsReferences(); } } diff --git a/WhiteMagicTest/InProcessReaderTests.cs b/WhiteMagicTest/InProcessReaderTests.cs index 776b5aa..6dd05a6 100644 --- a/WhiteMagicTest/InProcessReaderTests.cs +++ b/WhiteMagicTest/InProcessReaderTests.cs @@ -4,9 +4,11 @@ using WhiteMagic; namespace WhiteMagicTest; /// -/// Tests for — direct pointer dereference against -/// the own process. Verifies the shared API works for -/// both external and in-process readers. +/// Tests for . The current implementation uses +/// ReadProcessMemory/WriteProcessMemory on a self-handle (per the D1 +/// revision — unsafe direct-pointer dereference was rejected because .NET cannot +/// catch ). Verifies the shared +/// API works for both external and in-process readers. /// public class InProcessReaderTests { diff --git a/WhiteMagicTest/MarshalCacheTests.cs b/WhiteMagicTest/MarshalCacheTests.cs index 6511eac..263555a 100644 --- a/WhiteMagicTest/MarshalCacheTests.cs +++ b/WhiteMagicTest/MarshalCacheTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using WhiteMagic; @@ -5,7 +6,7 @@ namespace WhiteMagicTest; /// /// Tests for : blittable size, marshal-required flag, -/// IsIntPtr, and computed-once behavior. +/// and computed-once behavior. /// public class MarshalCacheTests { @@ -45,12 +46,33 @@ public class MarshalCacheTests Assert.Equal(8, MarshalCache.Size); } + [Fact] + public void Size_for_struct_with_bool_is_managed_layout_width() + { + // Regression for the Marshal.SizeOf / Unsafe.SizeOf disagreement on a struct + // whose only field is `bool`: Marshal reports 4 bytes (Win32 BOOL default marshaling), + // but the blittable path (MemoryMarshal.Read) actually lays out a bool as 1 byte. + // MarshalCache.Size must match what the reader actually touches. + Assert.Equal(Unsafe.SizeOf(), MarshalCache.Size); + Assert.Equal(1, MarshalCache.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(), MarshalCache.Size); + Assert.Equal(2, MarshalCache.Size); + } + [Fact] public void TypeRequiresMarshal_is_false_for_blittable_types() { Assert.False(MarshalCache.TypeRequiresMarshal); - Assert.False(MarshalCache.TypeRequiresMarshal); - Assert.False(MarshalCache.TypeRequiresMarshal); + Assert.False(MarshalCache.TypeRequiresMarshal); + Assert.False(MarshalCache.TypeRequiresMarshal); + Assert.False(MarshalCache.TypeRequiresMarshal); } [Fact] @@ -60,39 +82,23 @@ public class MarshalCacheTests } [Fact] - public void IsIntPtr_is_true_for_IntPtr() + public void TypeRequiresMarshal_is_true_for_reference_containing_types() { - Assert.True(MarshalCache.IsIntPtr); + Assert.True(MarshalCache.TypeRequiresMarshal); + Assert.True(MarshalCache.TypeRequiresMarshal); } [Fact] - public void IsIntPtr_is_false_for_non_IntPtr_types() - { - Assert.False(MarshalCache.IsIntPtr); - Assert.False(MarshalCache.IsIntPtr); - Assert.False(MarshalCache.IsIntPtr); - } - - [Fact] - public void All_properties_are_computed_once_and_cached() + public void Properties_are_computed_once_and_cached() { int size1 = MarshalCache.Size; bool marshal1 = MarshalCache.TypeRequiresMarshal; - bool intPtr1 = MarshalCache.IsIntPtr; int size2 = MarshalCache.Size; bool marshal2 = MarshalCache.TypeRequiresMarshal; - bool intPtr2 = MarshalCache.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.Size, MarshalCache.SizeU); } [StructLayout(LayoutKind.Sequential)] @@ -108,4 +114,22 @@ 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; + } + + private class ClassWithInt + { + public int Value = 0; + } } diff --git a/WhiteMagicTest/Native/NativeSurfaceTests.cs b/WhiteMagicTest/Native/NativeSurfaceTests.cs index 786b48e..bb8e3dd 100644 --- a/WhiteMagicTest/Native/NativeSurfaceTests.cs +++ b/WhiteMagicTest/Native/NativeSurfaceTests.cs @@ -57,13 +57,13 @@ public class NativeSurfaceTests { using SafeMemoryHandle handle = OpenSelf( ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation); - ReadOnlySpan payload = BitConverter.GetBytes(0x5EED); + ReadOnlySpan bytes = BitConverter.GetBytes(0x5EED); bool ok = NativeMethods.WriteProcessMemory( - handle, pin.AddrOfPinnedObject(), payload, payload.Length, out nint written); + handle, pin.AddrOfPinnedObject(), bytes, bytes.Length, out nint written); Assert.True(ok, $"WriteProcessMemory failed: {Marshal.GetLastPInvokeError()}"); - Assert.Equal(payload.Length, (int)written); + Assert.Equal(bytes.Length, (int)written); Assert.Equal(0x5EED, Marshal.ReadInt32(pin.AddrOfPinnedObject())); } finally diff --git a/WhiteMagicTest/StubAssemblerTests.cs b/WhiteMagicTest/StubAssemblerTests.cs index acfb6a4..242ec4c 100644 --- a/WhiteMagicTest/StubAssemblerTests.cs +++ b/WhiteMagicTest/StubAssemblerTests.cs @@ -90,7 +90,7 @@ public class StubAssemblerTests { 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(0xCAFEBABE,BitConverter.ToUInt32(s,1)); + 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]); } @@ -120,8 +120,8 @@ public class StubAssemblerTests { 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(0xAAAAAAAA,BitConverter.ToUInt32(s,1)); - Assert.Equal(0xBA,s[5]); Assert.Equal(0xBBBBBBBB,BitConverter.ToUInt32(s,6)); + 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]); } @@ -133,52 +133,207 @@ public class StubAssemblerTests Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Fastcall)); } - // ── x64 ───────────────────────────────────────────────────────────── + // ── x64 (Microsoft x64 ABI — shadow space + 16-byte alignment + 64-bit loads) ── + // + // Stub frame layout: + // bytes 0..6 sub rsp, 0x20 (7 bytes — shadow space + realignment) + // 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 20 00... add rsp, 0x20 (7 bytes) + // C3 ret (1 byte) + // + // Each `mov rNN, imm64` is 10 bytes regardless of the target register: + // RCX REX.W 0xB9 + 8 imm (0x48 0xB9) + // RDX REX.W 0xBA + 8 imm (0x48 0xBA) + // R8 REX.WB 0xB8 + 8 imm (0x49 0xB8 — REX.W|R = 0x49) + // R9 REX.WB 0xB9 + 8 imm (0x49 0xB9) + // RAX REX.W 0xB8 + 8 imm (0x48 0xB8) [Fact] - public void X64_0args() + public void X64_0args_allocates_shadow_space_and_aligns() { - var s=Create(); ulong a=0x100000000,t=0x123456788; - uint r=(uint)(t-(a+5)); - byte[] stub=s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[],8,CallConvention.Cdecl); - Assert.Equal(6,stub.Length); Assert.Equal(0xE8,stub[0]); Assert.Equal(r,BitConverter.ToUInt32(stub,1)); Assert.Equal(0xC3,stub[5]); + var s = Create(); + ulong a = 0x100000000, t = 0x123456788; + byte[] stub = s.BuildCallStub( + (IntPtr)(nint)a, (IntPtr)(nint)t, [], 8, CallConvention.Cdecl); + + // 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, 0x20, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x20 + Assert.Equal(0xE8, stub[7]); + Assert.Equal(rel, BitConverter.ToUInt32(stub, 8)); + Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[12..19]); // add rsp, 0x20 + Assert.Equal(0xC3, stub[19]); // ret } [Fact] - public void X64_1arg_mov_ecx() + public void X64_1arg_loads_rcx_as_64bit() { - var s=Create(); ulong a=0x100000000,t=0x123456788; - uint r=(uint)(t-(a+5+5)); - Assert.Equal([ - 0xB9,0xDD,0xCC,0xBB,0xAA, - 0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3], - s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[0xAABBCCDD],8,CallConvention.Cdecl)); + var s = Create(); + ulong a = 0x100000000, t = 0x123456788; + byte[] stub = s.BuildCallStub( + (IntPtr)(nint)a, (IntPtr)(nint)t, [0xAABBCCDDu], 8, CallConvention.Cdecl); + + // sub (7) + mov rcx, imm64 (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, 0x20 + Assert.Equal([0x48, 0x81, 0xEC, 0x20, 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, 0x20 + Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[22..29]); + Assert.Equal(0xC3, stub[29]); // ret } [Fact] - public void X64_4args_rcx_rdx_r8_r9() + public void X64_4args_loads_rcx_rdx_r8_r9_as_64bit() { - var s=Create(); ulong a=0x100000000,t=0x123456788; - uint ca=(uint)a+5+5+6+6,r=(uint)(t-(ca+5)); - Assert.Equal([ - 0xB9,0x11,0x11,0x11,0x11, 0xBA,0x22,0x22,0x22,0x22, - 0x41,0xB8,0x33,0x33,0x33,0x33, 0x41,0xB9,0x44,0x44,0x44,0x44, - 0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3], - s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[0x11111111,0x22222222,0x33333333,0x44444444],8,CallConvention.Cdecl)); + 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); + + // sub (7) + 4 x mov (4*10=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, 0x20, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x20 + + // 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, 0x20 at [52..58] + Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[52..59]); + // ret at [59] + Assert.Equal(0xC3, stub[59]); } [Fact] - public void X64_5args_push_cleanup() + public void X64_5args_places_first_stack_arg_in_shadow_plus_0x20() { - var s=Create(); ulong a=0x100000000,t=0x123456788; - uint ca=(uint)a+5+5+6+6+5,r=(uint)(t-(ca+5)); - Assert.Equal([ - 0xB9,1,0,0,0, 0xBA,2,0,0,0, - 0x41,0xB8,3,0,0,0, 0x41,0xB9,4,0,0,0, - 0x68,5,0,0,0, - 0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24), - 0x48,0x83,0xC4,8, 0xC3], - s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[1,2,3,4,5],8,CallConvention.Cdecl)); + 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); + + // 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, 0x20, 0x00, 0x00, 0x00], stub[..7]); + + // 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, 0x20 at [67..73] + Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[67..74]); + // ret at [74] + Assert.Equal(0xC3, stub[74]); + } + + [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(() => + s.BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [tooBig], 4, CallConvention.Cdecl)); } // ── Edge cases ──────────────────────────────────────────────────────── @@ -193,8 +348,9 @@ public class StubAssemblerTests [Fact] public void Many_args_cleanup_uses_imm32_form() { - var args = new uint[33]; - for (int i = 0; i < 33; i++) args[i] = (uint)(i * 0x10000 + i); + // 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); diff --git a/openspec/changes/inject-and-assemble/.openspec.yaml b/openspec/changes/archive/2026-07-21-inject-and-assemble/.openspec.yaml similarity index 100% rename from openspec/changes/inject-and-assemble/.openspec.yaml rename to openspec/changes/archive/2026-07-21-inject-and-assemble/.openspec.yaml diff --git a/openspec/changes/inject-and-assemble/design.md b/openspec/changes/archive/2026-07-21-inject-and-assemble/design.md similarity index 100% rename from openspec/changes/inject-and-assemble/design.md rename to openspec/changes/archive/2026-07-21-inject-and-assemble/design.md diff --git a/openspec/changes/inject-and-assemble/proposal.md b/openspec/changes/archive/2026-07-21-inject-and-assemble/proposal.md similarity index 100% rename from openspec/changes/inject-and-assemble/proposal.md rename to openspec/changes/archive/2026-07-21-inject-and-assemble/proposal.md diff --git a/openspec/changes/inject-and-assemble/specs/non-blocking-execute/spec.md b/openspec/changes/archive/2026-07-21-inject-and-assemble/specs/non-blocking-execute/spec.md similarity index 100% rename from openspec/changes/inject-and-assemble/specs/non-blocking-execute/spec.md rename to openspec/changes/archive/2026-07-21-inject-and-assemble/specs/non-blocking-execute/spec.md diff --git a/openspec/changes/inject-and-assemble/specs/text-assembler/spec.md b/openspec/changes/archive/2026-07-21-inject-and-assemble/specs/text-assembler/spec.md similarity index 100% rename from openspec/changes/inject-and-assemble/specs/text-assembler/spec.md rename to openspec/changes/archive/2026-07-21-inject-and-assemble/specs/text-assembler/spec.md diff --git a/openspec/changes/inject-and-assemble/tasks.md b/openspec/changes/archive/2026-07-21-inject-and-assemble/tasks.md similarity index 100% rename from openspec/changes/inject-and-assemble/tasks.md rename to openspec/changes/archive/2026-07-21-inject-and-assemble/tasks.md diff --git a/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md index 5fac5ab..7f952aa 100644 --- a/openspec/changes/whitemagic-foundation/tasks.md +++ b/openspec/changes/whitemagic-foundation/tasks.md @@ -21,15 +21,15 @@ ## 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. Now also asserts Microsoft x64 ABI compliance: `sub rsp, 0x20` shadow-space allocation (stack args land at `[rsp + 0x20 + 8*(i-4)]`), 16-byte stack alignment at the inner `call target`, `mov r64, imm64` loads for RCX/RDX/R8/R9 with the full 64-bit immediate (regression test for the old `mov r32d, imm32` truncation bug). +- [x] 3.8 Implement x64 stub to pass 3.7. Builds a compliant Microsoft x64 ABI frame: `sub rsp, 0x20`, 64-bit register loads, stack-argv above the shadow window, `call rel32`, `add rsp, 0x20`, `ret`. Accepts `nuint[]` so callers can pass full 64-bit pointers unchanged (x86 path truncates `nuint` → `uint` with a range check). +- [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) diff --git a/openspec/specs/non-blocking-execute/spec.md b/openspec/specs/non-blocking-execute/spec.md new file mode 100644 index 0000000..e017bb0 --- /dev/null +++ b/openspec/specs/non-blocking-execute/spec.md @@ -0,0 +1,45 @@ +# non-blocking-execute Specification + +## Purpose +TBD - created by archiving change inject-and-assemble. Update Purpose after archive. +## Requirements +### Requirement: InjectAndExecuteEx creates remote thread without waiting + +`BlackMagic.InjectAndExecuteEx(IntPtr startAddress, IntPtr parameter)` injects code at `startAddress` into the opened process, creates a remote thread with `parameter`, and returns the thread handle immediately without waiting for the thread to exit. + +#### Scenario: successful non-blocking execution +- **WHEN** a process is open and `InjectAndExecuteEx(addr, param)` is called with a valid code address +- **THEN** a remote thread is created in the target process and a valid `SafeMemoryHandle` is returned + +#### Scenario: no process open +- **WHEN** no process is open and `InjectAndExecuteEx(addr, param)` is called +- **THEN** `null` is returned + +### Requirement: InjectAndExecuteEx single-parameter overload + +`BlackMagic.InjectAndExecuteEx(IntPtr startAddress)` calls `InjectAndExecuteEx(startAddress, IntPtr.Zero)`. + +#### Scenario: parameter-less non-blocking execution +- **WHEN** `InjectAndExecuteEx(addr)` is called with a valid address +- **THEN** the thread is created with parameter `IntPtr.Zero` + +### Requirement: InjectAndExecuteEx from assembly text + +`BlackMagic.InjectAndExecuteEx(string asm)` assembles the text via `AsmBuilder`, allocates remote memory, writes the bytes, calls `InjectAndExecuteEx` on the allocated address, and returns the thread handle. + +#### Scenario: execute assembly text non-blocking +- **WHEN** `InjectAndExecuteEx("nop")` is called with a process open +- **THEN** the text is assembled to bytes, written to remote memory, a thread is started, and the handle is returned + +#### Scenario: assembly failure +- **WHEN** `InjectAndExecuteEx("invalidinstruction")` is called +- **THEN** `ArgumentException` is thrown with the assembly error + +### Requirement: InjectAndExecute from assembly text (blocking convenience) + +`BlackMagic.InjectAndExecute(string asm)` assembles the text, allocates remote memory, writes the bytes, calls `Execute` (blocking, 10s timeout), and returns the exit code. + +#### Scenario: execute assembly text blocking +- **WHEN** `InjectAndExecute("mov eax, 42\nret")` is called with a process open +- **THEN** the text is assembled, injected, executed, and the thread exit code is returned + diff --git a/openspec/specs/text-assembler/spec.md b/openspec/specs/text-assembler/spec.md new file mode 100644 index 0000000..64d24ef --- /dev/null +++ b/openspec/specs/text-assembler/spec.md @@ -0,0 +1,88 @@ +# text-assembler Specification + +## Purpose +TBD - created by archiving change inject-and-assemble. Update Purpose after archive. +## Requirements +### Requirement: AsmBuilder assembles x86 instruction text to byte array + +`AsmBuilder.Assemble(string source)` parses x86 assembly text and returns the corresponding `byte[]` machine code. + +#### Scenario: single instruction +- **WHEN** `AsmBuilder.Assemble("nop")` is called +- **THEN** the result is `[0x90]` + +#### Scenario: multiple instructions +- **WHEN** `AsmBuilder.Assemble("pushad\npopad")` is called +- **THEN** the result is `[0x60, 0x61]` + +#### Scenario: instruction with immediate operand +- **WHEN** `AsmBuilder.Assemble("mov eax, 1")` is called +- **THEN** the result is `[0xB8, 0x01, 0x00, 0x00, 0x00]` + +### Requirement: AsmBuilder supports register operands + +Supported registers: `eax`, `ecx`, `edx`, `ebx`, `esp`, `ebp`, `esi`, `edi` (and 8-bit: `al`, `cl`, `dl`, `bl`, `ah`, `ch`, `dh`, `bh`). + +#### Scenario: register-to-register move +- **WHEN** `AsmBuilder.Assemble("mov eax, ecx")` is called +- **THEN** the result is `[0x89, 0xC8]` (mov eax, ecx encoding) + +#### Scenario: register encoding +- **WHEN** registers are used in instructions +- **THEN** each register maps to its correct 3-bit encoding (eax=0, ecx=1, edx=2, ebx=3, esp=4, ebp=5, esi=6, edi=7) + +### Requirement: AsmBuilder supports labels and jumps + +Labels are defined with `@name:` and referenced with `jmp @name` or `je @name`. Forward and backward references are resolved in a second pass. + +#### Scenario: forward jump +- **WHEN** `AsmBuilder.Assemble("jmp @skip\nnop\n@skip:\nret")` is called +- **THEN** the jump skips exactly over the `nop` (2 bytes) and lands on `ret` + +#### Scenario: backward jump +- **WHEN** `AsmBuilder.Assemble("@loop:\nnop\njmp @loop")` is called +- **THEN** the jump targets the earlier label correctly + +#### Scenario: multiple labels +- **WHEN** multiple labels are used in one source +- **THEN** each label resolves to its correct byte offset + +### Requirement: AsmBuilder SetPassLimit controls iteration + +`AsmBuilder.SetPassLimit(int limit)` sets the maximum number of assembly passes for label resolution. Default is 10. If the limit is exceeded before all labels resolve, `InvalidOperationException` is thrown. + +#### Scenario: default pass limit +- **WHEN** no `SetPassLimit` is called +- **THEN** the assembler uses 10 passes maximum + +#### Scenario: custom pass limit +- **WHEN** `SetPassLimit(20)` is called +- **THEN** the assembler uses 20 passes maximum + +#### Scenario: pass limit exceeded +- **WHEN** forward references cannot resolve within the pass limit +- **THEN** `InvalidOperationException` is thrown with label resolution details + +### Requirement: AsmBuilder reports clear errors + +Unknown instructions, missing operands, and invalid register names produce `ArgumentException` with the line number and offending text. + +#### Scenario: unknown instruction +- **WHEN** `AsmBuilder.Assemble("xyzw")` is called +- **THEN** `ArgumentException` is thrown mentioning line 1 and "xyzw" + +#### Scenario: missing operand +- **WHEN** `AsmBuilder.Assemble("mov")` is called (no operands) +- **THEN** `ArgumentException` is thrown mentioning missing operand + +### Requirement: AsmBuilder supported instruction set + +The following x86 instructions are supported: +- **Data movement**: `mov`, `push`, `pop`, `pushad`, `popad`, `lea` +- **Arithmetic**: `add`, `sub`, `inc`, `dec`, `xor`, `and`, `or`, `cmp`, `test` +- **Control flow**: `jmp`, `je`, `jne`, `call`, `ret`, `nop`, `hlt` + +#### Scenario: all instructions produce valid bytes +- **WHEN** each supported instruction is assembled individually +- **THEN** it produces the correct x86 machine code encoding +