diff --git a/WhiteMagic/Assembly/StubAssembler.cs b/WhiteMagic/Assembly/StubAssembler.cs
index e470d19..5f9d228 100644
--- a/WhiteMagic/Assembly/StubAssembler.cs
+++ b/WhiteMagic/Assembly/StubAssembler.cs
@@ -170,56 +170,60 @@ public sealed class StubAssembler : IAssembler
///
/// 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.
+ /// above a 32-byte shadow space; 16-byte stack alignment at the inner call.
///
///
- /// The stub frame:
+ /// Frame derivation. The ABI requires the inner call site to
+ /// land with call-site rsp ≡ 0 (mod 16), so that call pushes 8 bytes and the
+ /// callee sees entry rsp ≡ 8 — the value an MSVC prologue (push rbp; sub rsp, 0x20)
+ /// expects, and the only value for which locals land 16-aligned (SSE-safe).
+ ///
+ /// - Stub entry: rsp ≡ 8 (mod 16).
+ /// - Need post-sub rsp ≡ 0 → sub operand K satisfies K ≡ 8 (mod 16).
+ /// - Frame must hold shadow space (0x20) + stack args (8 bytes each for args 4+).
+ /// Choose the smallest such K: K = frameBytes + ((8 − frameBytes) mod 16 + 16) mod 16.
+ /// For 0–5 args, K ∈ {0x28, 0x38}; pattern scales linearly.
+ ///
///
- /// 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
+ /// sub rsp, K ; K ≡ 8 (mod 16), K ≥ 0x20 + 8·stackArgs
+ /// mov rcx, arg0 ; REX.W + imm64 (10 bytes)
+ /// mov rdx, arg1 ; REX.W + imm64 (10 bytes)
+ /// mov r8, arg2 ; REX.WB+ imm64 (10 bytes, REX.R)
+ /// mov r9, arg3 ; REX.WB+ imm64 (10 bytes, REX.R)
+ /// mov rax, arg[N] ; REX.W + imm64 (10 bytes)
+ /// mov [rsp + 0x20 + 8*(N-4)], rax (5/8 bytes)
+ /// call target (rel32) ( 5 bytes)
+ /// add rsp, K ( 7 bytes)
+ /// ret ( 1 byte)
///
- /// 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, nuint[] args)
{
ulong current = stubAddr;
- // 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);
+ // Compute frame size K. K ≡ 8 (mod 16) so that the inner call sees
+ // post-sub rsp ≡ 0 and delivers target entry rsp ≡ 8 (mod 16).
+ int stackArgs = Math.Max(0, args.Length - 4);
+ int frameBytes = 0x20 + 8 * stackArgs;
+ int k = frameBytes + ((8 - (frameBytes % 16) + 16) % 16);
+
+ // sub rsp, imm32 (always imm32 form — constant 7 bytes regardless of K).
+ buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xEC);
+ EmitU32(buffer, (uint)k);
current += 7;
- // 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)
+ // 64-bit register loads for args 0..3. All encodings are exactly 10 bytes:
+ // REX.W (0x48) + 0xB9 + imm64 → mov rcx, imm64
+ // REX.W (0x48) + 0xBA + imm64 → mov rdx, imm64
+ // REX.WB(0x49) + 0xB8 + imm64 → mov r8, imm64 (REX.R for r8)
+ // REX.WB(0x49) + 0xB9 + imm64 → mov r9, imm64 (REX.R)
byte[][] regMoves =
[
- [0x48, 0xB9], // mov rcx, imm64
- [0x48, 0xBA], // mov rdx, imm64
- [0x49, 0xB8], // mov r8, imm64
- [0x49, 0xB9], // mov r9, imm64
+ [0x48, 0xB9],
+ [0x48, 0xBA],
+ [0x49, 0xB8],
+ [0x49, 0xB9],
];
int regCount = Math.Min(args.Length, 4);
@@ -232,10 +236,8 @@ public sealed class StubAssembler : IAssembler
current += (uint)(prefix.Length + 8);
}
- // 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)
+ // Stack args: written at [post-sub-rsp + 0x20 + 8*(i-4)], i.e. above the
+ // shadow window, where the inner call's callee expects them.
for (int i = 4; i < args.Length; i++)
{
int offset = 0x20 + (i - 4) * 8;
@@ -258,7 +260,7 @@ public sealed class StubAssembler : IAssembler
}
}
- // 4. call rel32
+ // call rel32
long distance = (long)target - (long)(current + 5);
if (distance is < int.MinValue or > int.MaxValue)
{
@@ -269,11 +271,10 @@ public sealed class StubAssembler : IAssembler
EmitU32(buffer, (uint)distance);
current += 5;
- // 5. add rsp, 0x20 ; tear down shadow space
+ // Tear down the frame symmetrically.
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xC4);
- EmitU32(buffer, 0x20);
+ EmitU32(buffer, (uint)k);
- // 6. ret
buffer.Add(0xC3);
}
diff --git a/WhiteMagic/MarshalCache.cs b/WhiteMagic/MarshalCache.cs
index bccbc6f..fe79211 100644
--- a/WhiteMagic/MarshalCache.cs
+++ b/WhiteMagic/MarshalCache.cs
@@ -5,25 +5,44 @@ using System.Runtime.InteropServices;
namespace WhiteMagic;
///
-/// Computes and caches the byte size and marshalling decision for type
-/// exactly once. and
+/// Caches the widths marshalling decisions for type
+/// once, at static-constructor time. and
/// branch on
-/// to decide between the blittable Span /
-/// path and the
-/// path.
+/// and pick the appropriate width from this cache.
///
/// The type to cache metadata for.
public static class MarshalCache
{
///
- /// 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.
+ /// The blittable (managed layout) width of . This is
+ /// what /
+ /// actually consume. Equals in the general case,
+ /// with fixed-width overrides for , , and
+ /// enums so the cache value matches the primitive layout width used by those
+ /// paths.
///
public static readonly int Size;
+ ///
+ /// The unmanaged (interop) width via . The marshal
+ /// path (/)
+ /// reads/writes this many bytes. Exceeds whenever a struct
+ /// carries inline unmanaged data that the marshaler expands — inline
+ /// ByValTStr/ByValArray buffers, bool fields (4 bytes per
+ /// default Win32 BOOL marshaling vs 1 byte managed), etc. For types that do
+ /// not go through the marshal path, this field is still populated but unused
+ /// by MemoryBase.
+ ///
+ ///
+ /// Marshal.SizeOf throws for some reference-containing shapes (e.g. a
+ /// bare ). When that happens, we fall back to
+ /// — the fallback path is unreachable from production code
+ /// because types with a reference field always have
+ /// true, so MemoryBase reads this field only
+ /// when it is known to be populated.
+ ///
+ public static readonly int MarshalSize;
+
///
/// when cannot be copied through
/// the blittable path
@@ -35,9 +54,9 @@ public static class MarshalCache
///
///
/// 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.
+ /// 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.
///
public static readonly bool TypeRequiresMarshal;
@@ -51,7 +70,6 @@ public static class MarshalCache
{
// 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;
}
else if (typeof(T).IsEnum)
@@ -60,10 +78,10 @@ public static class MarshalCache
}
else
{
- // 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).
+ // The blittable path goes through MemoryMarshal, which uses the CLR
+ // managed layout. Use Unsafe.SizeOf so Size agrees with that
+ // layout — Marshal.SizeOf disagrees when a struct contains a
+ // `bool` field (unmanaged 4 vs managed 1).
Size = Unsafe.SizeOf();
}
@@ -73,5 +91,17 @@ public static class MarshalCache
TypeRequiresMarshal =
hasMarshalAsField || RuntimeHelpers.IsReferenceOrContainsReferences();
+
+ // MarshalSize is only consulted when TypeRequiresMarshal is true; for the
+ // rare case where Marshal.SizeOf refuses a shape (ref-containing structs),
+ // fall back to the managed size so the field stays populated.
+ try
+ {
+ MarshalSize = Marshal.SizeOf();
+ }
+ catch (ArgumentException)
+ {
+ MarshalSize = Size;
+ }
}
}
diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs
index 7e52137..90c9486 100644
--- a/WhiteMagic/MemoryBase.cs
+++ b/WhiteMagic/MemoryBase.cs
@@ -37,7 +37,7 @@ public abstract class MemoryBase : IDisposable
if (isRelative)
address = GetAbsolute(address);
- int size = MarshalCache.Size;
+ int size = MarshalCache.TypeRequiresMarshal ? MarshalCache.MarshalSize : MarshalCache.Size;
byte[] raw = ReadBytes(address, size);
if (raw.Length < size)
@@ -56,7 +56,7 @@ public abstract class MemoryBase : IDisposable
if (isRelative)
address = GetAbsolute(address);
- int size = MarshalCache.Size;
+ int size = MarshalCache.TypeRequiresMarshal ? MarshalCache.MarshalSize : MarshalCache.Size;
byte[] raw;
if (MarshalCache.TypeRequiresMarshal)
@@ -81,7 +81,7 @@ public abstract class MemoryBase : IDisposable
if (isRelative)
address = GetAbsolute(address);
- int elementSize = MarshalCache.Size;
+ int elementSize = MarshalCache.TypeRequiresMarshal ? MarshalCache.MarshalSize : MarshalCache.Size;
long totalSize = (long)elementSize * count;
ArgumentOutOfRangeException.ThrowIfGreaterThan(totalSize, int.MaxValue, nameof(count));
@@ -127,7 +127,7 @@ public abstract class MemoryBase : IDisposable
if (values is null || values.Length == 0)
return true;
- int elementSize = MarshalCache.Size;
+ int elementSize = MarshalCache.TypeRequiresMarshal ? MarshalCache.MarshalSize : MarshalCache.Size;
long total = (long)elementSize * values.Length;
ArgumentOutOfRangeException.ThrowIfGreaterThan(total, int.MaxValue, nameof(values));
int totalSize = (int)total;
diff --git a/WhiteMagic/RpmHelper.cs b/WhiteMagic/RpmHelper.cs
new file mode 100644
index 0000000..3275278
--- /dev/null
+++ b/WhiteMagic/RpmHelper.cs
@@ -0,0 +1,61 @@
+using System.Runtime.InteropServices;
+using WhiteMagic.Native;
+
+namespace WhiteMagic;
+
+///
+/// Shared ReadProcessMemory / WriteProcessMemory wrappers used by both
+/// and . Kept in a single
+/// location to keep the two readers byte-for-byte consistent on partial-read handling,
+/// write-return semantics, and failure modes.
+///
+internal static class RpmHelper
+{
+ ///
+ /// Reads up to bytes from in
+ /// the process identified by . Returns:
+ ///
+ /// - An empty array if fails and
+ /// reports zero bytes read.
+ /// - A truncated array of exactly bytesRead bytes when the call returns
+ /// but the OS has placed a partial copy in the buffer
+ /// (for example, ERROR_PARTIAL_COPY).
+ /// - The full buffer on success.
+ ///
+ ///
+ public static byte[] ReadBytes(SafeMemoryHandle handle, IntPtr address, int count)
+ {
+ byte[] buffer = new byte[count];
+ bool ok = NativeMethods.ReadProcessMemory(handle, address, buffer, count, out nint bytesRead);
+
+ if (!ok && bytesRead == 0)
+ {
+ return [];
+ }
+
+ if ((int)bytesRead < count)
+ {
+ // Either a successful short read, or a failed-but-partial RPM. In both
+ // cases honor the bytes the OS actually produced rather than padding.
+ byte[] partial = new byte[(int)bytesRead];
+ Buffer.BlockCopy(buffer, 0, partial, 0, (int)bytesRead);
+ return partial;
+ }
+
+ return buffer;
+ }
+
+ ///
+ /// Writes to in the process
+ /// identified by . Returns the number of bytes actually
+ /// written, or 0 on total failure.
+ ///
+ public static int WriteBytes(SafeMemoryHandle handle, IntPtr address, ReadOnlySpan bytes)
+ {
+ if (!NativeMethods.WriteProcessMemory(handle, address, bytes, bytes.Length, out nint written))
+ {
+ return 0;
+ }
+ return (int)written;
+ }
+}
diff --git a/WhiteMagicTest/MarshalCacheTests.cs b/WhiteMagicTest/MarshalCacheTests.cs
index 263555a..4197e66 100644
--- a/WhiteMagicTest/MarshalCacheTests.cs
+++ b/WhiteMagicTest/MarshalCacheTests.cs
@@ -6,7 +6,7 @@ namespace WhiteMagicTest;
///
/// Tests for : blittable size, marshal-required flag,
-/// and computed-once behavior.
+/// the separate MarshalSize field, and computed-once behavior.
///
public class MarshalCacheTests
{
@@ -49,10 +49,10 @@ public class MarshalCacheTests
[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.
+ // Regression for the Marshal vs. Unsafe size disagreement on a struct
+ // whose only field is `bool`: Marshal reports 4 (Win32 BOOL default
+ // marshaling), but the blittable path (MemoryMarshal.Read) actually
+ // lays out a bool as 1 byte. MarshalCache.Size must match managed width.
Assert.Equal(Unsafe.SizeOf(), MarshalCache.Size);
Assert.Equal(1, MarshalCache.Size);
}
@@ -60,8 +60,9 @@ public class MarshalCacheTests
[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.
+ // 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);
}
@@ -72,7 +73,6 @@ public class MarshalCacheTests
Assert.False(MarshalCache.TypeRequiresMarshal);
Assert.False(MarshalCache.TypeRequiresMarshal);
Assert.False(MarshalCache.TypeRequiresMarshal);
- Assert.False(MarshalCache.TypeRequiresMarshal);
}
[Fact]
@@ -84,8 +84,35 @@ public class MarshalCacheTests
[Fact]
public void TypeRequiresMarshal_is_true_for_reference_containing_types()
{
- Assert.True(MarshalCache.TypeRequiresMarshal);
- Assert.True(MarshalCache.TypeRequiresMarshal);
+ Assert.True(MarshalCache.TypeRequiresMarshal);
+ }
+
+ [Fact]
+ public void Inline_struct_with_MarshalAs_has_separate_MarshalSize()
+ {
+ // Regression for the marshal-path size bug. A struct with an inline
+ // ByValTStr field has mismatched managed and unmanaged widths: the managed
+ // width is just the pointer reference (8 bytes); the marshal unroller
+ // expands it into a 16-WCHAR inline buffer (32 bytes). MarshalCache.Size
+ // must match what the blittable path uses; MarshalCache.MarshalSize must
+ // match what the marshal path uses.
+ Assert.True(MarshalCache.TypeRequiresMarshal);
+ int expectedManaged = Unsafe.SizeOf(); // 8 (ptr)
+ int expectedMarshal = Marshal.SizeOf(); // 32 (16 WCHAR)
+ Assert.Equal(expectedManaged, MarshalCache.Size);
+ Assert.Equal(expectedMarshal, MarshalCache.MarshalSize);
+ Assert.NotEqual(MarshalCache.Size,
+ MarshalCache.MarshalSize);
+ }
+
+ [Fact]
+ public void MarshalSize_equals_Size_for_blittable_types()
+ {
+ // No interop expansion is needed when the type is blittable; both widths
+ // coincide.
+ Assert.Equal(MarshalCache.Size, MarshalCache.MarshalSize);
+ Assert.Equal(MarshalCache.Size, MarshalCache.MarshalSize);
+ Assert.Equal(MarshalCache.Size, MarshalCache.MarshalSize);
}
[Fact]
@@ -128,8 +155,15 @@ public class MarshalCacheTests
public bool B;
}
- private class ClassWithInt
+ ///
+ /// A struct whose marshal layout carries an inline character buffer but
+ /// whose CLR managed layout is just a reference pointer. The canonical way
+ /// to exercise the marshal-vs-managed width split.
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ public struct InlineStrStruct
{
- public int Value = 0;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 16)]
+ public string Name;
}
}
diff --git a/WhiteMagicTest/MemoryBaseTests.cs b/WhiteMagicTest/MemoryBaseTests.cs
index afc177f..40de318 100644
--- a/WhiteMagicTest/MemoryBaseTests.cs
+++ b/WhiteMagicTest/MemoryBaseTests.cs
@@ -196,6 +196,43 @@ public class MemoryBaseTests
reader.Dispose();
}
+ // ── Marshal-path round-trip ───────────────────────────────────────────────
+ //
+ // The marshal path was previously sized using MarshalCache.Size (managed
+ // layout width). When a struct carries an inline marshal-expanded field
+ // (ByValTStr, ByValArray, etc.) this width is smaller than the actual
+ // read/write width — writing overflows the pinned buffer and reading
+ // under-fetches the remote bytes, producing silent heap corruption.
+ //
+ // The marshal path must use MarshalCache.MarshalSize (= Marshal.SizeOf)
+ // so the pinned buffer is large enough for PtrToStructure / StructureToPtr.
+
+ [Fact]
+ public void Read_struct_via_marshal_path_round_trips_inline_string()
+ {
+ using var reader = OpenSelf();
+
+ // A marshal-path struct carries a reference, so it cannot be pinned; the
+ // target must be an unmanaged buffer of the FULL marshal width. Pre-patch,
+ // Write sized its scratch buffer with MarshalCache.Size (managed pointer
+ // width, 8) and StructureToPtr overran it, while Read under-fetched the
+ // remote bytes — the string came back wrong. Post-patch both use
+ // MarshalSize (Marshal.SizeOf).
+ int size = Marshal.SizeOf();
+ IntPtr addr = Marshal.AllocHGlobal(size);
+ try
+ {
+ InlineStr original = new InlineStr { Name = "Hello, World!" };
+ Assert.True(reader.Write(addr, original));
+ InlineStr read = reader.Read(addr);
+ Assert.Equal("Hello, World!", read.Name);
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(addr);
+ }
+ }
+
// ── Graceful failure on invalid addresses ───────────────────────────────
[Fact]
@@ -243,3 +280,17 @@ public struct TestStruct : IEquatable
public override int GetHashCode() => HashCode.Combine(X, Y);
public override string ToString() => $"({X}, {Y})";
}
+
+///
+/// A struct whose managed layout is just a reference pointer (8 bytes) but whose
+/// unmanaged marshal layout carries an inline character buffer. Exercised by
+///
+/// to catch regressions where the marshal path uses the managed width instead
+/// of the marshal width.
+///
+[StructLayout(LayoutKind.Sequential)]
+public struct InlineStr
+{
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 16)]
+ public string Name;
+}
diff --git a/WhiteMagicTest/StubAssemblerTests.cs b/WhiteMagicTest/StubAssemblerTests.cs
index 242ec4c..ec13fbf 100644
--- a/WhiteMagicTest/StubAssemblerTests.cs
+++ b/WhiteMagicTest/StubAssemblerTests.cs
@@ -136,19 +136,19 @@ public class StubAssemblerTests
// ── 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 0..6 sub rsp, K (7 bytes — K = 32 + 8·stackArgs, rounded so K ≡ 8 mod 16)
// bytes 7..N mov r64, imm64 ... (10 bytes per reg move: 2-byte prefix + 8-byte imm)
// mov rax, imm64 / mov [rsp+0x20+8*(i-4)], rax for stack args (15 bytes each)
// E8 rel32 call target (5 bytes)
- // 48 81 C4 20 00... add rsp, 0x20 (7 bytes)
+ // 48 81 C4 K 00... add rsp, K (7 bytes)
// C3 ret (1 byte)
//
// Each `mov rNN, imm64` is 10 bytes regardless of the target register:
- // RCX REX.W 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)
+ // RCX REX.W+opcode B9 (0x48 0xB9)
+ // RDX REX.W+opcode BA (0x48 0xBA)
+ // R8 REX.WB+opcode B8 (0x49 0xB8, REX.R needed for r8)
+ // R9 REX.WB+opcode B9 (0x49 0xB9, REX.R needed for r9)
+ // RAX REX.W+opcode B8 (0x48 0xB8)
[Fact]
public void X64_0args_allocates_shadow_space_and_aligns()
@@ -158,15 +158,16 @@ public class StubAssemblerTests
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [], 8, CallConvention.Cdecl);
- // sub (7) + call (5) + add (7) + ret (1) = 20
+ // K = 0x20 + 0·8 = 0x20; round up to ≡ 8 mod 16 → K = 0x28.
+ // Frame = sub(7) + call(5) + add(7) + ret(1) = 20
Assert.Equal(20, stub.Length);
uint rel = (uint)(t - (a + 7 + 5)); // = t - a - 12
- Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x20
+ Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x28 (K ≡ 8 mod 16)
Assert.Equal(0xE8, stub[7]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 8));
- Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[12..19]); // add rsp, 0x20
+ Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[12..19]); // add rsp, 0x28
Assert.Equal(0xC3, stub[19]); // ret
}
@@ -178,12 +179,12 @@ public class StubAssemblerTests
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
+ // K = 0x28. Total = sub(7) + mov(10) + call(5) + add(7) + ret(1) = 30
Assert.Equal(30, stub.Length);
uint rel = (uint)(t - (a + 7 + 10 + 5)); // = t - a - 22
- // stub[0..6] = sub rsp, 0x20
- Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]);
+ // stub[0..6] = sub rsp, 0x28 (K ≡ 8 mod 16, 16-aligned call-site for SSE safety)
+ Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]);
// stub[7..16] = mov rcx, 0x00000000_AABBCCDD (zero-extended)
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
Assert.Equal(0xDD, stub[9]); Assert.Equal(0xCC, stub[10]);
@@ -193,8 +194,8 @@ public class StubAssemblerTests
// 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]);
+ // stub[22..28] = add rsp, 0x28
+ Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[22..29]);
Assert.Equal(0xC3, stub[29]); // ret
}
@@ -207,12 +208,12 @@ public class StubAssemblerTests
(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
+ // K = 0x28. Total = sub(7) + 4×mov(40) + call(5) + add(7) + ret(1) = 60
Assert.Equal(60, stub.Length);
uint rel = (uint)(t - (a + 7 + 40 + 5)); // = t - a - 52
- Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x20
+ Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x28
// mov rcx, 0x11111111 (48 B9 + 8 imm) at [7..16]
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
@@ -246,8 +247,8 @@ public class StubAssemblerTests
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]);
+ // add rsp, 0x28 at [52..58]
+ Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[52..59]);
// ret at [59]
Assert.Equal(0xC3, stub[59]);
}
@@ -261,11 +262,12 @@ public class StubAssemblerTests
(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)
+ // K = 0x20 + 1·8 = 0x28. Round-up rule: 0x28 % 16 = 8 → no extra padding.
+ // sub(7) + 4 reg moves (40) + stack arg (mov rax 10 + mov [rsp+0x20],rax 5 = 15)
// + call (5) + add (7) + ret (1) = 75
Assert.Equal(75, stub.Length);
- Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]);
+ Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x28 (K=0x20+8=0x28, 0x28 % 16 = 8 ✓)
// mov rcx, 1 at [7..16]
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
@@ -296,12 +298,75 @@ public class StubAssemblerTests
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]);
+ // add rsp, 0x28 at [67..73]
+ Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[67..74]);
// ret at [74]
Assert.Equal(0xC3, stub[74]);
}
+ [Fact]
+ public void X64_frame_alignment_property_for_arg_counts()
+ {
+ // ABI invariant: for every arg count the sub operand K must satisfy
+ // K ≡ 8 (mod 16), and the same K must appear in the matching 'add rsp, K'
+ // just before the ret. Violating this misaligns the inner call, which
+ // #GP-faults the next time an SSE-using callee executes movaps/movdqa.
+ var s = Create();
+ // Keep stub/target within E8 rel32 range (< 2 GiB) so the property check
+ // exercises the frame math, not the distance guard.
+ ulong a = 0x140000000, t = 0x140100000;
+
+ for (int argc = 0; argc <= 12; argc++)
+ {
+ nuint[] args = new nuint[argc];
+ for (int i = 0; i < argc; i++) args[i] = (nuint)(i + 1);
+
+ byte[] stub = s.BuildCallStub(
+ (IntPtr)(nint)a, (IntPtr)(nint)t, args, 8, CallConvention.Cdecl);
+
+ // sub rsp, imm32: 48 81 EC K0 K1 K2 K3
+ Assert.Equal(0x48, stub[0]);
+ Assert.Equal(0x81, stub[1]);
+ Assert.Equal(0xEC, stub[2]);
+ uint subK = BitConverter.ToUInt32(stub, 3);
+ Assert.True(subK % 16 == 8,
+ $"argc={argc}: sub K=0x{subK:X} must satisfy K % 16 == 8");
+
+ // add rsp, imm32 is 7 bytes immediately before the trailing C3
+ int last = stub.Length - 1;
+ Assert.Equal(0xC3, stub[last]);
+ int addIdx = last - 7;
+ Assert.Equal(0x48, stub[addIdx]);
+ Assert.Equal(0x81, stub[addIdx + 1]);
+ Assert.Equal(0xC4, stub[addIdx + 2]);
+ uint addK = BitConverter.ToUInt32(stub, addIdx + 3);
+ Assert.True(subK == addK,
+ $"argc={argc}: add K=0x{addK:X} must match sub K=0x{subK:X}");
+ }
+ }
+
+ [Fact]
+ public void X64_6args_frame_grows_to_0x38()
+ {
+ // Six args: frameBytes = 0x20 + 2·8 = 0x30. 0x30 % 16 = 0, so the
+ // pad-to-≡8 rule adds 8 more bytes → K = 0x38. Args 5 and 6 still live
+ // at [rsp+0x20] and [rsp+0x28]; the extra 8 bytes of padding at [rsp+0x30]
+ // are unused but necessary for alignment.
+ var s = Create();
+ ulong a = 0x100000000, t = 0x123456788;
+ nuint[] args = [(nuint)1, (nuint)2, (nuint)3, (nuint)4, (nuint)5, (nuint)6];
+ byte[] stub = s.BuildCallStub(
+ (IntPtr)(nint)a, (IntPtr)(nint)t, args, 8, CallConvention.Cdecl);
+
+ // sub rsp, 0x38 (7 bytes)
+ Assert.Equal([0x48, 0x81, 0xEC, 0x38, 0x00, 0x00, 0x00], stub[..7]);
+
+ // add rsp, 0x38 occupies the 7 bytes immediately before ret
+ int addIdx = stub.Length - 8;
+ Assert.Equal([0x48, 0x81, 0xC4, 0x38, 0x00, 0x00, 0x00], stub[addIdx..(addIdx + 7)]);
+ Assert.Equal(0xC3, stub[stub.Length - 1]);
+ }
+
[Fact]
public void X64_full_64bit_args_are_preserved_not_truncated()
{