From 30d5a9a5ecddd8e07726ac36f8b7fe5890fba64b Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Wed, 22 Jul 2026 08:56:31 +0200 Subject: [PATCH] Add optional Iced backend: arbitrary text assembly and full prologue validation Section 8 of the whitemagic-foundation change. - 8.1: reference Iced 1.21.0 behind IcedAssembler:IAssembler. The default StubAssembler path never touches Iced; only constructing IcedAssembler pulls it into a behavioral path. - 8.2: IcedAssembler.Assemble bridges Intel-syntax text onto Iced's fluent Assembler by reflection (Iced ships no text parser). Registers, immediates and labels are supported with origin-relative encoding; memory operands throw NotSupportedException. - 8.3: IcedAssembler.GetPrologueLength decodes arbitrary instructions via Iced's Decoder. DetourManager.PrologueLengthResolver (new delegate) defaults to the built-in PrologueDecoder and is swappable to the Iced resolver, threaded into each Detour. This lifts the "partial boundary safety" caveat on the hooking slice when Iced is opted in. Also gitignore test-run TestResults artifacts. Tests: 221 passing, 4 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + WhiteMagic/Assembly/IcedAssembler.cs | 279 ++++++++++++++++++ WhiteMagic/Hooking/Detour.cs | 12 +- WhiteMagic/Hooking/DetourManager.cs | 11 +- WhiteMagic/Hooking/PrologueDecoder.cs | 8 + WhiteMagic/WhiteMagic.csproj | 9 + WhiteMagicTest/Assembly/IcedAssemblerTests.cs | 133 +++++++++ docs/memory-library-comparison.md | 3 +- .../changes/whitemagic-foundation/tasks.md | 8 +- 9 files changed, 459 insertions(+), 8 deletions(-) create mode 100644 WhiteMagic/Assembly/IcedAssembler.cs create mode 100644 WhiteMagicTest/Assembly/IcedAssemblerTests.cs 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