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