diff --git a/.gitignore b/.gitignore index 88208ae..2eb4321 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ reference/ # Scratch *.tmp *.log + +# Test run artifacts +WhiteMagicTest/TestResults/ +**/TestResults/ diff --git a/WhiteMagic/Assembly/IcedAssembler.cs b/WhiteMagic/Assembly/IcedAssembler.cs new file mode 100644 index 0000000..cdacdb8 --- /dev/null +++ b/WhiteMagic/Assembly/IcedAssembler.cs @@ -0,0 +1,279 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using Iced.Intel; + +namespace WhiteMagic.Assembly; + +/// +/// Optional backend that assembles arbitrary x86/x64 mnemonic +/// text to machine code using the Iced library, and provides full instruction-boundary +/// decoding for detour prologue validation. +/// +/// +/// Iced ships a fluent code assembler (typed method calls) and a decoder, but no +/// text parser. This class bridges Intel-syntax text onto Iced's fluent +/// by reflection: each line's mnemonic selects the matching +/// method and its operands are bound to registers, immediates, or +/// labels. Register and immediate operands and label-relative branches are supported; +/// memory operands ([reg+disp]) are not — a caller needing those should emit bytes +/// directly. +/// This backend is entirely optional. Constructing it is the only thing that pulls +/// Iced into a behavioral path; the default never references it. +/// +public sealed class IcedAssembler : IAssembler +{ + private const int DefaultBitness = 64; + + private readonly int _bitness; + + // Lowercased register name -> boxed AssemblerRegisterNN value, built once from + // Iced's AssemblerRegisters. Enables binding a text operand like "esp" to a typed + // fluent-API register argument. + private static readonly Dictionary Registers = BuildRegisterMap(); + + /// Creates an assembler for the given bitness (32 or 64). + /// 32 for x86, 64 for x64. Defaults to 64. + public IcedAssembler(int bitness = DefaultBitness) + { + if (bitness != 32 && bitness != 64) + throw new ArgumentOutOfRangeException(nameof(bitness), "Bitness must be 32 or 64."); + + _bitness = bitness; + } + + /// + public byte[] Assemble(string assemblyText, ulong origin = 0) + { + ArgumentNullException.ThrowIfNull(assemblyText); + + var assembler = new Assembler(_bitness); + + List<(string Mnemonic, string[] Operands)> lines = Tokenize(assemblyText, out var labelNames); + + // Pre-create every label so a forward branch can reference it before its definition. + var labels = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string name in labelNames) + labels[name] = assembler.CreateLabel(name); + + foreach ((string mnemonic, string[] operands) in lines) + { + // A pure label definition (e.g. "loop:") marks the current position. + if (mnemonic.EndsWith(':')) + { + Label label = labels[mnemonic[..^1]]; + assembler.Label(ref label); + continue; + } + + EmitInstruction(assembler, mnemonic, operands, labels); + } + + var writer = new ByteListCodeWriter(); + assembler.Assemble(writer, origin); + return writer.Bytes.ToArray(); + } + + /// + /// Computes the number of whole prologue-instruction bytes that must be preserved for + /// a splice of bytes, decoding arbitrary instructions + /// (not just the common prologue shapes the built-in decoder covers). Matches the + /// PrologueLengthResolver delegate so it can be assigned to + /// . + /// + /// A prologue byte sequence does not decode + /// to a valid instruction. + public int GetPrologueLength(byte[] prologue, int requiredBytes, bool is64Bit) + { + ArgumentNullException.ThrowIfNull(prologue); + + var reader = new ByteArrayCodeReader(prologue); + var decoder = Decoder.Create(is64Bit ? 64 : 32, reader); + + int total = 0; + while (total < requiredBytes) + { + decoder.Decode(out Instruction instruction); + if (instruction.IsInvalid) + { + throw new InvalidOperationException( + "The target prologue contains a byte sequence that does not decode to a valid instruction."); + } + + total += instruction.Length; + } + + return total; + } + + private void EmitInstruction( + Assembler assembler, + string mnemonic, + string[] operandText, + Dictionary labels) + { + object?[] operands = new object?[operandText.Length]; + for (int i = 0; i < operandText.Length; i++) + operands[i] = ParseOperand(operandText[i], labels); + + // Find the fluent Assembler method whose name equals the mnemonic and whose + // parameters bind to the parsed operands. + foreach (MethodInfo method in typeof(Assembler).GetMethods(BindingFlags.Public | BindingFlags.Instance)) + { + if (!string.Equals(method.Name, mnemonic, StringComparison.OrdinalIgnoreCase)) + continue; + + ParameterInfo[] parameters = method.GetParameters(); + if (parameters.Length != operands.Length) + continue; + + if (TryBind(parameters, operands, out object?[]? boundArgs)) + { + method.Invoke(assembler, boundArgs); + return; + } + } + + throw new NotSupportedException( + $"Cannot assemble '{mnemonic}{(operandText.Length > 0 ? " " + string.Join(", ", operandText) : "")}': " + + "no matching Iced assembler overload for the given operands (registers, immediates and " + + "labels are supported; memory operands are not)."); + } + + private static bool TryBind(ParameterInfo[] parameters, object?[] operands, out object?[]? boundArgs) + { + var args = new object?[parameters.Length]; + for (int i = 0; i < parameters.Length; i++) + { + Type paramType = parameters[i].ParameterType; + object? operand = operands[i]; + + switch (operand) + { + case Immediate imm when IsNumeric(paramType): + args[i] = Convert.ChangeType(imm.Value, paramType, CultureInfo.InvariantCulture); + break; + + case not null when paramType.IsInstanceOfType(operand): + args[i] = operand; + break; + + default: + boundArgs = null; + return false; + } + } + + boundArgs = args; + return true; + } + + private static object ParseOperand(string text, Dictionary labels) + { + string token = text.Trim(); + + if (Registers.TryGetValue(token, out object? register)) + return register; + + if (labels.TryGetValue(token, out Label label)) + return label; + + if (TryParseImmediate(token, out long value)) + return new Immediate(value); + + throw new NotSupportedException( + $"Unrecognized operand '{token}' (expected a register, an immediate, or a label)."); + } + + private static bool TryParseImmediate(string token, out long value) + { + bool negative = token.StartsWith('-'); + string body = negative ? token[1..] : token; + + bool ok; + if (body.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + ok = long.TryParse(body[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value); + else + ok = long.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + + if (ok && negative) + value = -value; + + return ok; + } + + private static bool IsNumeric(Type type) => Type.GetTypeCode(type) is + TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 or + TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64; + + private static List<(string Mnemonic, string[] Operands)> Tokenize(string text, out List labelNames) + { + var result = new List<(string, string[])>(); + labelNames = new List(); + + foreach (string rawLine in text.Split('\n')) + { + string line = rawLine; + + int comment = line.IndexOf(';'); + if (comment >= 0) + line = line[..comment]; + + line = line.Trim(); + if (line.Length == 0) + continue; + + // A "name:" prefix is a label definition; keep any instruction that follows it + // on the same line as a separate entry. + int colon = line.IndexOf(':'); + if (colon >= 0) + { + string labelName = line[..colon].Trim(); + labelNames.Add(labelName); + result.Add((labelName + ":", Array.Empty())); + + line = line[(colon + 1)..].Trim(); + if (line.Length == 0) + continue; + } + + int space = line.IndexOfAny([' ', '\t']); + if (space < 0) + { + result.Add((line, Array.Empty())); + continue; + } + + string mnemonic = line[..space]; + string[] operands = line[(space + 1)..] + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + result.Add((mnemonic, operands)); + } + + return result; + } + + private static Dictionary BuildRegisterMap() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (FieldInfo field in typeof(AssemblerRegisters).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + object? value = field.GetValue(null); + if (value is not null) + map[field.Name] = value; + } + + return map; + } + + // A parsed immediate, distinguished from register/label operands so binding can widen + // or narrow it to whichever integer parameter type the chosen overload expects. + private readonly record struct Immediate(long Value); + + private sealed class ByteListCodeWriter : CodeWriter + { + public List Bytes { get; } = new(); + + public override void WriteByte(byte value) => Bytes.Add(value); + } +} diff --git a/WhiteMagic/Hooking/Detour.cs b/WhiteMagic/Hooking/Detour.cs index f9fcb43..bc8e0e3 100644 --- a/WhiteMagic/Hooking/Detour.cs +++ b/WhiteMagic/Hooking/Detour.cs @@ -16,6 +16,7 @@ namespace WhiteMagic.Hooking; public sealed class Detour : IDisposable { private readonly MemoryBase _memory; + private readonly PrologueLengthResolver _prologueLength; /// The unique name of this detour. public string Name { get; } @@ -43,14 +44,21 @@ public sealed class Detour : IDisposable /// while the detour bytes are live at . public bool IsApplied { get; private set; } - internal Detour(MemoryBase memory, string name, IntPtr target, Delegate hook) + internal Detour( + MemoryBase memory, + string name, + IntPtr target, + Delegate hook, + PrologueLengthResolver prologueLength) { ArgumentNullException.ThrowIfNull(hook); + ArgumentNullException.ThrowIfNull(prologueLength); _memory = memory; Name = name; Target = target; Hook = hook; + _prologueLength = prologueLength; } /// @@ -83,7 +91,7 @@ public sealed class Detour : IDisposable "Could not read enough bytes from the target function to install a detour."); } - int preserveLength = PrologueDecoder.GetWholeInstructionLength(prologue, detourLength, _memory.Is64Bit); + int preserveLength = _prologueLength(prologue, detourLength, _memory.Is64Bit); OverwrittenBytes = new byte[preserveLength]; Buffer.BlockCopy(prologue, 0, OverwrittenBytes, 0, preserveLength); diff --git a/WhiteMagic/Hooking/DetourManager.cs b/WhiteMagic/Hooking/DetourManager.cs index 7180b69..48e36db 100644 --- a/WhiteMagic/Hooking/DetourManager.cs +++ b/WhiteMagic/Hooking/DetourManager.cs @@ -19,6 +19,15 @@ public sealed class DetourManager _memory = memory; } + /// + /// Resolves how many whole prologue-instruction bytes a splice must preserve. Defaults + /// to the built-in , which covers only the common prologue + /// shapes and rejects anything else. Assign new IcedAssembler().GetPrologueLength + /// to validate arbitrary prologues via the optional Iced disassembler. + /// + public PrologueLengthResolver PrologueLengthResolver { get; set; } = + PrologueDecoder.GetWholeInstructionLength; + /// /// Creates a new detour and registers it with the manager. /// The delegate's type must match the native signature of @@ -26,7 +35,7 @@ public sealed class DetourManager /// public Detour Create(string name, IntPtr target, Delegate hook) { - var detour = new Detour(_memory, name, target, hook); + var detour = new Detour(_memory, name, target, hook, PrologueLengthResolver); _detours[name] = detour; return detour; } diff --git a/WhiteMagic/Hooking/PrologueDecoder.cs b/WhiteMagic/Hooking/PrologueDecoder.cs index 92fd661..e45bd99 100644 --- a/WhiteMagic/Hooking/PrologueDecoder.cs +++ b/WhiteMagic/Hooking/PrologueDecoder.cs @@ -2,6 +2,14 @@ using System; namespace WhiteMagic.Hooking; +/// +/// Resolves how many whole prologue-instruction bytes must be preserved to splice +/// bytes at a target. The built-in +/// satisfies this delegate, as +/// does IcedAssembler.GetPrologueLength for full instruction coverage. +/// +public delegate int PrologueLengthResolver(byte[] prologue, int requiredBytes, bool is64Bit); + /// /// Minimal instruction-length decoder for common x86/x64 prologue shapes. /// The set is intentionally small: any opcode outside the covered set is rejected diff --git a/WhiteMagic/WhiteMagic.csproj b/WhiteMagic/WhiteMagic.csproj index edf34ce..68c0d57 100644 --- a/WhiteMagic/WhiteMagic.csproj +++ b/WhiteMagic/WhiteMagic.csproj @@ -13,4 +13,13 @@ + + + + + diff --git a/WhiteMagicTest/Assembly/IcedAssemblerTests.cs b/WhiteMagicTest/Assembly/IcedAssemblerTests.cs new file mode 100644 index 0000000..612c195 --- /dev/null +++ b/WhiteMagicTest/Assembly/IcedAssemblerTests.cs @@ -0,0 +1,133 @@ +using System.Linq; +using Iced.Intel; +using WhiteMagic; +using WhiteMagic.Assembly; +using WhiteMagic.Hooking; + +namespace WhiteMagicTest.Assembly; + +/// +/// Tests for the optional backend (tasks 8.1–8.3): arbitrary +/// text assembly, origin-relative encoding, and full prologue instruction decoding. +/// +public class IcedAssemblerTests +{ + private static Instruction[] Disassemble(byte[] code, int bitness, ulong origin) + { + var decoder = Decoder.Create(bitness, new ByteArrayCodeReader(code)); + decoder.IP = origin; + + var result = new List(); + ulong end = origin + (ulong)code.Length; + while (decoder.IP < end) + result.Add(decoder.Decode()); + + return result.ToArray(); + } + + [Fact] + public void Assemble_emits_single_instruction() + { + var assembler = new IcedAssembler(64); + byte[] code = assembler.Assemble("ret"); + Assert.Equal(new byte[] { 0xC3 }, code); + } + + [Fact] + public void Assemble_emits_multiple_instructions_with_operands() + { + var assembler = new IcedAssembler(32); + + // The scenario from the managed-assembler spec. + byte[] code = assembler.Assemble("push 0\nadd esp, 4\nret"); + Assert.NotEmpty(code); + + Instruction[] instructions = Disassemble(code, 32, 0); + Assert.Equal(3, instructions.Length); + Assert.Equal(Mnemonic.Push, instructions[0].Mnemonic); + Assert.Equal(Mnemonic.Add, instructions[1].Mnemonic); + Assert.Equal(Register.ESP, instructions[1].Op0Register); + Assert.Equal(4UL, instructions[1].GetImmediate(1)); + Assert.Equal(Mnemonic.Ret, instructions[2].Mnemonic); + } + + [Fact] + public void Assemble_supports_comments_and_blank_lines() + { + var assembler = new IcedAssembler(64); + byte[] code = assembler.Assemble(" ; prologue\n\nnop ; a comment\nret\n"); + + Instruction[] instructions = Disassemble(code, 64, 0); + Assert.Equal(2, instructions.Length); + Assert.Equal(Mnemonic.Nop, instructions[0].Mnemonic); + Assert.Equal(Mnemonic.Ret, instructions[1].Mnemonic); + } + + [Fact] + public void Assemble_encodes_label_branch_relative_to_origin() + { + var assembler = new IcedAssembler(64); + const ulong origin = 0x1_4000_1000UL; + + // jmp forward over a nop to a label; the near-branch target must be resolved + // against the supplied origin, not zero. + byte[] code = assembler.Assemble("jmp done\nnop\ndone:\nret", origin); + + Instruction[] instructions = Disassemble(code, 64, origin); + Instruction jmp = instructions[0]; + Assert.Equal(Mnemonic.Jmp, jmp.Mnemonic); + + // Target = origin + len(jmp) + len(nop): the address of the 'done: ret'. + ulong expected = origin + (ulong)jmp.Length + 1; + Assert.Equal(expected, jmp.NearBranchTarget); + } + + [Fact] + public void Assemble_throws_on_unsupported_operand() + { + var assembler = new IcedAssembler(64); + Assert.Throws(() => assembler.Assemble("mov rax, [rbx]")); + } + + [Fact] + public void GetPrologueLength_decodes_prologue_the_builtin_decoder_rejects() + { + // 48 8B C1 = mov rax, rcx — a register-to-register mov the built-in PrologueDecoder + // does not cover (it only recognizes the 8B FF / 8B EC forms). + // Followed by push rbp; mov rbp,rsp; sub rsp,0x20; mov rax,rcx to exceed 14 bytes. + byte[] prologue = + [ + 0x48, 0x8B, 0xC1, // mov rax, rcx (3) + 0x55, // push rbp (1) + 0x48, 0x8B, 0xEC, // mov rbp, rsp (3) + 0x48, 0x83, 0xEC, 0x20, // sub rsp, 0x20 (4) + 0x48, 0x8B, 0xC1 // mov rax, rcx (3) -> total 14 + ]; + + // The built-in decoder refuses the very first instruction. + Assert.Throws(() => + PrologueDecoder.GetWholeInstructionLength(prologue, 14, is64Bit: true)); + + // The Iced backend decodes it and returns the whole-instruction length covering + // at least the 14 bytes a detour needs. + var iced = new IcedAssembler(); + int length = iced.GetPrologueLength(prologue, 14, is64Bit: true); + Assert.Equal(14, length); + } + + [Fact] + public void DetourManager_prologue_resolver_defaults_to_builtin_and_is_replaceable() + { + using var reader = new InProcessReader(); + var manager = new DetourManager(reader); + + // Default resolver is the built-in decoder. + Assert.Throws(() => + manager.PrologueLengthResolver(new byte[] { 0x48, 0x8B, 0xC1, 0x90, 0x90 }, 4, true)); + + // Swapping in the Iced resolver validates the same bytes. + manager.PrologueLengthResolver = new IcedAssembler().GetPrologueLength; + int length = manager.PrologueLengthResolver(new byte[] { 0x48, 0x8B, 0xC1, 0x90, 0x90 }, 4, true); + Assert.True(length >= 4); + } +} diff --git a/docs/memory-library-comparison.md b/docs/memory-library-comparison.md index ac6ed47..ff5c748 100644 --- a/docs/memory-library-comparison.md +++ b/docs/memory-library-comparison.md @@ -100,6 +100,7 @@ The design held, but building it surfaced corrections worth recording (each is d - **`MarshalCache` splits `Size` (managed, blittable) from `MarshalSize` (`Marshal.SizeOf`, marshal path)** — a single size mis-sized structs whose unmanaged width differs (a `bool` field is managed-1 / unmanaged-4; inline `ByValTStr`/`ByValArray` under-sized the marshal buffer and corrupted the heap on write). `MemoryBase` picks per `TypeRequiresMarshal` at every IO site. - **x64 call stub is fully MS-x64-ABI compliant** — 32-byte shadow space, 16-byte alignment at the inner `call`, full `imm64` register loads (no >4 GiB pointer truncation), stack args above the shadow window. Proven at runtime by a live SSE callee whose aligned `movaps` faults on any misalignment (task 3.8), not just by byte-level encoding tests. - **`RemoteModule`/`RemoteFunction` follow PE export forwarders** — `kernel32!HeapAlloc` → `NTDLL.RtlAllocateHeap` and similar resolve into the real target module; ordinal and API-set forwarders throw `NotSupportedException` rather than returning a wrong address (task 7.2). -- **Detour prologue safety ships partial** — the default `StubAssembler` length-decoder covers only the common x86/x64 prologue shapes and refuses any opcode outside that set; full arbitrary-prologue validation is gated on the optional Iced backend (tasks 4.6, 8.3). +- **Detour prologue safety is tiered** — the default `StubAssembler` length-decoder covers only the common x86/x64 prologue shapes and refuses any opcode outside that set (zero dependency); the optional `IcedAssembler.GetPrologueLength` decodes arbitrary prologues and is plugged in via `DetourManager.PrologueLengthResolver` when full validation is wanted (tasks 4.6, 8.3). +- **Iced has no text parser** — the design assumed arbitrary text assembly could be delegated to Iced, but Iced ships only a *fluent* code assembler and a decoder. `IcedAssembler.Assemble` bridges Intel-syntax text onto the fluent API by reflection (registers, immediates, labels; memory operands unsupported), rather than depending on a parser that does not exist (task 8.2). - **Injection bitness corrections** — the thread-hijack injector enforces matching host/target bitness, so the 32-bit path always runs from a 32-bit caller and uses native `GetThreadContext`/`SetThreadContext`; the WOW64 context APIs (for 64-bit callers inspecting WOW64 targets) never apply here and were removed. `ExternalReader` validates `QueryInformation`/`QueryLimitedInformation` access and surfaces `IsWow64Process` failures instead of silently assuming host bitness. - **Bounds and protection hardening** — `AllocatedMemory` range-checks typed IO against region size; `Patch` mirrors the detour's `VirtualProtectEx` dance; `MainThreadPump` guards the completion race on an already-completed `TaskCompletionSource`. diff --git a/openspec/changes/whitemagic-foundation/tasks.md b/openspec/changes/whitemagic-foundation/tasks.md index 76e6ab4..b4e23bb 100644 --- a/openspec/changes/whitemagic-foundation/tasks.md +++ b/openspec/changes/whitemagic-foundation/tasks.md @@ -38,7 +38,7 @@ - [x] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup - [x] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3 - [x] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available) -- [x] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety. +- [x] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety. **Resolved (8.3):** `DetourManager.PrologueLengthResolver` now accepts `IcedAssembler.GetPrologueLength` for full instruction-boundary validation of arbitrary prologues; the built-in decoder remains the zero-dependency default. - [x] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours - [x] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7 - [x] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process) @@ -75,9 +75,9 @@ ## 8. Optional Iced Backend (spec: managed-assembler) -- [ ] 8.1 Add `Iced` package reference behind an `IcedAssembler : IAssembler` in a way that keeps the default `StubAssembler` dependency-free -- [ ] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding -- [ ] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present +- [x] 8.1 Add `Iced` package reference behind an `IcedAssembler : IAssembler` in a way that keeps the default `StubAssembler` dependency-free. Iced 1.21.0 added to `WhiteMagic.csproj`; only constructing `IcedAssembler` pulls it into a behavioral path. `StubAssembler` never references it. +- [x] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding. **Deviation:** Iced ships a *fluent* code assembler and a decoder but **no text parser**, so `Assemble` bridges Intel-syntax text onto Iced's `Assembler` by reflection — the mnemonic selects the matching fluent method and operands bind to registers (reflected from `AssemblerRegisters`), immediates, or labels; origin-relative encoding via `Assembler.Assemble(writer, origin)`. Register/immediate/label operands and label-relative branches are supported; **memory operands (`[reg+disp]`) throw `NotSupportedException`** (a caller needing those emits bytes directly). Tests round-trip via Iced's decoder and assert origin-relative branch targets. +- [x] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present. `IcedAssembler.GetPrologueLength` decodes arbitrary instructions via Iced's `Decoder`; `DetourManager.PrologueLengthResolver` (a `PrologueLengthResolver` delegate) defaults to the built-in `PrologueDecoder` and is swappable to the Iced resolver, threaded into each `Detour`. Tests prove Iced resolves a prologue (`mov rax,rcx` = `48 8B C1`) the built-in decoder rejects. ## 9. Verification