Fix x64 stub ABI and marshal-path sizing; dedupe RPM readers

x64 call stub was ABI-broken: fixed 0x20 frame left rsp misaligned at the
inner call (callee entry rsp ≡ 0, ABI requires ≡ 8) and, for 5+ args, wrote
stack args over the return address. Compute frame K ≡ 8 (mod 16), K ≥
0x20 + 8*stackArgs, so the callee sees a 16-aligned stack and stack args land
above the shadow window. Load register args as full 64-bit imm64 (was imm32,
which truncated pointers > 4 GiB). BuildCallStub now takes nuint[]; x86 range-
checks each arg against uint.MaxValue instead of silently truncating.

MarshalCache conflated managed and unmanaged width in one Size field: the
blittable path needs Unsafe.SizeOf<T> (bool = 1) while the marshal path needs
Marshal.SizeOf<T> (inline ByValTStr/ByValArray expand past the managed
pointer). Add MarshalSize; MemoryBase picks per TypeRequiresMarshal at all four
IO sites. Prevents PtrToStructure/StructureToPtr from over-reading/overwriting
the pinned scratch buffer (heap corruption on write).

Extract shared RPM/WPM into RpmHelper: honor partial reads (dead Array.Resize
removed), consistent write-return semantics; InProcessReader now guards
MainModule like ExternalReader.

Tests: x64 frame-alignment property + inline-marshal round-trip added (both
fail against the pre-fix code); existing x64 byte-expectation tests updated to
the new frame. Build clean, 100/100 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kbe
2026-07-21 21:53:22 +02:00
co-authored by Claude Opus 4.8
parent 6fa12d8667
commit cb437ef9b5
7 changed files with 618 additions and 137 deletions
+77 -19
View File
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using WhiteMagic;
@@ -5,7 +6,7 @@ namespace WhiteMagicTest;
/// <summary>
/// Tests for <see cref="MarshalCache{T}"/>: blittable size, marshal-required flag,
/// IsIntPtr, and computed-once behavior.
/// the separate MarshalSize field, and computed-once behavior.
/// </summary>
public class MarshalCacheTests
{
@@ -45,12 +46,33 @@ public class MarshalCacheTests
Assert.Equal(8, MarshalCache<BlittableStruct>.Size);
}
[Fact]
public void Size_for_struct_with_bool_is_managed_layout_width()
{
// 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<T>) actually
// lays out a bool as 1 byte. MarshalCache.Size must match managed width.
Assert.Equal(Unsafe.SizeOf<SingleBoolStruct>(), MarshalCache<SingleBoolStruct>.Size);
Assert.Equal(1, MarshalCache<SingleBoolStruct>.Size);
}
[Fact]
public void Size_for_struct_with_bools_in_sequence_matches_managed_layout()
{
// Sequential struct { bool, bool } — managed width is 2, Marshal width is 8
// (two BOOLs). The blittable path uses 1 byte per bool, so Size must equal
// the managed width.
Assert.Equal(Unsafe.SizeOf<SequentialBoolStruct>(), MarshalCache<SequentialBoolStruct>.Size);
Assert.Equal(2, MarshalCache<SequentialBoolStruct>.Size);
}
[Fact]
public void TypeRequiresMarshal_is_false_for_blittable_types()
{
Assert.False(MarshalCache<int>.TypeRequiresMarshal);
Assert.False(MarshalCache<long>.TypeRequiresMarshal);
Assert.False(MarshalCache<BlittableStruct>.TypeRequiresMarshal);
Assert.False(MarshalCache<byte>.TypeRequiresMarshal);
Assert.False(MarshalCache<IntPtr>.TypeRequiresMarshal);
}
[Fact]
@@ -60,39 +82,50 @@ public class MarshalCacheTests
}
[Fact]
public void IsIntPtr_is_true_for_IntPtr()
public void TypeRequiresMarshal_is_true_for_reference_containing_types()
{
Assert.True(MarshalCache<IntPtr>.IsIntPtr);
Assert.True(MarshalCache<InlineStrStruct>.TypeRequiresMarshal);
}
[Fact]
public void IsIntPtr_is_false_for_non_IntPtr_types()
public void Inline_struct_with_MarshalAs_has_separate_MarshalSize()
{
Assert.False(MarshalCache<int>.IsIntPtr);
Assert.False(MarshalCache<long>.IsIntPtr);
Assert.False(MarshalCache<BlittableStruct>.IsIntPtr);
// 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<InlineStrStruct>.TypeRequiresMarshal);
int expectedManaged = Unsafe.SizeOf<InlineStrStruct>(); // 8 (ptr)
int expectedMarshal = Marshal.SizeOf<InlineStrStruct>(); // 32 (16 WCHAR)
Assert.Equal(expectedManaged, MarshalCache<InlineStrStruct>.Size);
Assert.Equal(expectedMarshal, MarshalCache<InlineStrStruct>.MarshalSize);
Assert.NotEqual(MarshalCache<InlineStrStruct>.Size,
MarshalCache<InlineStrStruct>.MarshalSize);
}
[Fact]
public void All_properties_are_computed_once_and_cached()
public void MarshalSize_equals_Size_for_blittable_types()
{
// No interop expansion is needed when the type is blittable; both widths
// coincide.
Assert.Equal(MarshalCache<int>.Size, MarshalCache<int>.MarshalSize);
Assert.Equal(MarshalCache<IntPtr>.Size, MarshalCache<IntPtr>.MarshalSize);
Assert.Equal(MarshalCache<BlittableStruct>.Size, MarshalCache<BlittableStruct>.MarshalSize);
}
[Fact]
public void Properties_are_computed_once_and_cached()
{
int size1 = MarshalCache<int>.Size;
bool marshal1 = MarshalCache<int>.TypeRequiresMarshal;
bool intPtr1 = MarshalCache<int>.IsIntPtr;
int size2 = MarshalCache<int>.Size;
bool marshal2 = MarshalCache<int>.TypeRequiresMarshal;
bool intPtr2 = MarshalCache<int>.IsIntPtr;
Assert.Equal(size1, size2);
Assert.Equal(marshal1, marshal2);
Assert.Equal(intPtr1, intPtr2);
}
[Fact]
public void SizeU_matches_Size_as_uint()
{
Assert.Equal((uint)MarshalCache<int>.Size, MarshalCache<int>.SizeU);
}
[StructLayout(LayoutKind.Sequential)]
@@ -108,4 +141,29 @@ public class MarshalCacheTests
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
public byte[] Data;
}
[StructLayout(LayoutKind.Sequential)]
private struct SingleBoolStruct
{
public bool Flag;
}
[StructLayout(LayoutKind.Sequential)]
private struct SequentialBoolStruct
{
public bool A;
public bool B;
}
/// <summary>
/// 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.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct InlineStrStruct
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 16)]
public string Name;
}
}
+51
View File
@@ -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<T>)
// 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<InlineStr>).
int size = Marshal.SizeOf<InlineStr>();
IntPtr addr = Marshal.AllocHGlobal(size);
try
{
InlineStr original = new InlineStr { Name = "Hello, World!" };
Assert.True(reader.Write(addr, original));
InlineStr read = reader.Read<InlineStr>(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<TestStruct>
public override int GetHashCode() => HashCode.Combine(X, Y);
public override string ToString() => $"({X}, {Y})";
}
/// <summary>
/// A struct whose managed layout is just a reference pointer (8 bytes) but whose
/// unmanaged marshal layout carries an inline character buffer. Exercised by
/// <see cref="MemoryBaseTests.Read_struct_via_marshal_path_round_trips_inline_string"/>
/// to catch regressions where the marshal path uses the managed width instead
/// of the marshal width.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct InlineStr
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 16)]
public string Name;
}
+257 -36
View File
@@ -90,7 +90,7 @@ public class StubAssemblerTests
{
uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xCAFEBABE],4,CallConvention.Thiscall);
Assert.Equal(11,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xCAFEBABE,BitConverter.ToUInt32(s,1));
Assert.Equal(11,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xCAFEBABEu,BitConverter.ToUInt32(s,1));
Assert.Equal(0xE8,s[5]); Assert.Equal(r,BitConverter.ToUInt32(s,6)); Assert.Equal(0xC3,s[10]);
}
@@ -120,8 +120,8 @@ public class StubAssemblerTests
{
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAAAAAAAA,0xBBBBBBBB],4,CallConvention.Fastcall);
Assert.Equal(16,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xAAAAAAAA,BitConverter.ToUInt32(s,1));
Assert.Equal(0xBA,s[5]); Assert.Equal(0xBBBBBBBB,BitConverter.ToUInt32(s,6));
Assert.Equal(16,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xAAAAAAAAu,BitConverter.ToUInt32(s,1));
Assert.Equal(0xBA,s[5]); Assert.Equal(0xBBBBBBBBu,BitConverter.ToUInt32(s,6));
Assert.Equal(0xE8,s[10]); Assert.Equal(r,BitConverter.ToUInt32(s,11)); Assert.Equal(0xC3,s[15]);
}
@@ -133,52 +133,272 @@ public class StubAssemblerTests
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Fastcall));
}
// ── x64 ─────────────────────────────────────────────────────────────
// ── x64 (Microsoft x64 ABI — shadow space + 16-byte alignment + 64-bit loads) ──
//
// Stub frame layout:
// bytes 0..6 sub rsp, 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 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+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()
public void X64_0args_allocates_shadow_space_and_aligns()
{
var s=Create(); ulong a=0x100000000,t=0x123456788;
uint r=(uint)(t-(a+5));
byte[] stub=s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[],8,CallConvention.Cdecl);
Assert.Equal(6,stub.Length); Assert.Equal(0xE8,stub[0]); Assert.Equal(r,BitConverter.ToUInt32(stub,1)); Assert.Equal(0xC3,stub[5]);
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [], 8, CallConvention.Cdecl);
// 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, 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, 0x28, 0x00, 0x00, 0x00], stub[12..19]); // add rsp, 0x28
Assert.Equal(0xC3, stub[19]); // ret
}
[Fact]
public void X64_1arg_mov_ecx()
public void X64_1arg_loads_rcx_as_64bit()
{
var s=Create(); ulong a=0x100000000,t=0x123456788;
uint r=(uint)(t-(a+5+5));
Assert.Equal([
0xB9,0xDD,0xCC,0xBB,0xAA,
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[0xAABBCCDD],8,CallConvention.Cdecl));
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [0xAABBCCDDu], 8, CallConvention.Cdecl);
// 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, 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]);
Assert.Equal(0xBB, stub[11]); Assert.Equal(0xAA, stub[12]);
Assert.Equal(0x00, stub[13]); Assert.Equal(0x00, stub[14]);
Assert.Equal(0x00, stub[15]); Assert.Equal(0x00, stub[16]);
// stub[17..21] = call rel32
Assert.Equal(0xE8, stub[17]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 18));
// stub[22..28] = add rsp, 0x28
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[22..29]);
Assert.Equal(0xC3, stub[29]); // ret
}
[Fact]
public void X64_4args_rcx_rdx_r8_r9()
public void X64_4args_loads_rcx_rdx_r8_r9_as_64bit()
{
var s=Create(); ulong a=0x100000000,t=0x123456788;
uint ca=(uint)a+5+5+6+6,r=(uint)(t-(ca+5));
Assert.Equal([
0xB9,0x11,0x11,0x11,0x11, 0xBA,0x22,0x22,0x22,0x22,
0x41,0xB8,0x33,0x33,0x33,0x33, 0x41,0xB9,0x44,0x44,0x44,0x44,
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[0x11111111,0x22222222,0x33333333,0x44444444],8,CallConvention.Cdecl));
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t,
[0x11111111u, 0x22222222u, 0x33333333u, 0x44444444u], 8, CallConvention.Cdecl);
// 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, 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]);
Assert.Equal(0x11, stub[9]); Assert.Equal(0x11, stub[10]);
Assert.Equal(0x11, stub[11]); Assert.Equal(0x11, stub[12]);
Assert.Equal(0x00, stub[13]); Assert.Equal(0x00, stub[14]);
Assert.Equal(0x00, stub[15]); Assert.Equal(0x00, stub[16]);
// mov rdx, 0x22222222 (48 BA + 8 imm) at [17..26]
Assert.Equal(0x48, stub[17]); Assert.Equal(0xBA, stub[18]);
Assert.Equal(0x22, stub[19]); Assert.Equal(0x22, stub[20]);
Assert.Equal(0x22, stub[21]); Assert.Equal(0x22, stub[22]);
Assert.Equal(0x00, stub[23]); Assert.Equal(0x00, stub[24]);
Assert.Equal(0x00, stub[25]); Assert.Equal(0x00, stub[26]);
// mov r8, 0x33333333 (49 B8 + 8 imm) at [27..36]
Assert.Equal(0x49, stub[27]); Assert.Equal(0xB8, stub[28]);
Assert.Equal(0x33, stub[29]); Assert.Equal(0x33, stub[30]);
Assert.Equal(0x33, stub[31]); Assert.Equal(0x33, stub[32]);
Assert.Equal(0x00, stub[33]); Assert.Equal(0x00, stub[34]);
Assert.Equal(0x00, stub[35]); Assert.Equal(0x00, stub[36]);
// mov r9, 0x44444444 (49 B9 + 8 imm) at [37..46]
Assert.Equal(0x49, stub[37]); Assert.Equal(0xB9, stub[38]);
Assert.Equal(0x44, stub[39]); Assert.Equal(0x44, stub[40]);
Assert.Equal(0x44, stub[41]); Assert.Equal(0x44, stub[42]);
Assert.Equal(0x00, stub[43]); Assert.Equal(0x00, stub[44]);
Assert.Equal(0x00, stub[45]); Assert.Equal(0x00, stub[46]);
// call rel32 at [47..51]
Assert.Equal(0xE8, stub[47]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 48));
// add rsp, 0x28 at [52..58]
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[52..59]);
// ret at [59]
Assert.Equal(0xC3, stub[59]);
}
[Fact]
public void X64_5args_push_cleanup()
public void X64_5args_places_first_stack_arg_in_shadow_plus_0x20()
{
var s=Create(); ulong a=0x100000000,t=0x123456788;
uint ca=(uint)a+5+5+6+6+5,r=(uint)(t-(ca+5));
Assert.Equal([
0xB9,1,0,0,0, 0xBA,2,0,0,0,
0x41,0xB8,3,0,0,0, 0x41,0xB9,4,0,0,0,
0x68,5,0,0,0,
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),
0x48,0x83,0xC4,8, 0xC3],
s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[1,2,3,4,5],8,CallConvention.Cdecl));
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t,
[(nuint)1, (nuint)2, (nuint)3, (nuint)4, (nuint)5], 8, CallConvention.Cdecl);
// 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, 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]);
Assert.Equal(0x01, stub[9]); Assert.Equal(0x00, stub[10]);
Assert.Equal(0x00, stub[11]); Assert.Equal(0x00, stub[12]);
// mov rdx, 2 at [17..26]
Assert.Equal(0x48, stub[17]); Assert.Equal(0xBA, stub[18]);
Assert.Equal(0x02, stub[19]);
// mov r8, 3 at [27..36]
Assert.Equal(0x49, stub[27]); Assert.Equal(0xB8, stub[28]);
Assert.Equal(0x03, stub[29]);
// mov r9, 4 at [37..46]
Assert.Equal(0x49, stub[37]); Assert.Equal(0xB9, stub[38]);
Assert.Equal(0x04, stub[39]);
// mov rax, 5 (48 B8 + 8-byte imm) at [47..56]
Assert.Equal(0x48, stub[47]); Assert.Equal(0xB8, stub[48]);
Assert.Equal(0x05, stub[49]);
for (int k = 50; k <= 56; k++) Assert.Equal(0x00, stub[k]);
// mov [rsp + 0x20], rax (48 89 44 24 20) at [57..61]
Assert.Equal(0x48, stub[57]); Assert.Equal(0x89, stub[58]);
Assert.Equal(0x44, stub[59]); Assert.Equal(0x24, stub[60]);
Assert.Equal(0x20, stub[61]);
// call rel32 at [62..66]; distance = t - (a + 62 + 5) = t - a - 67
uint rel = (uint)(t - (a + 62 + 5));
Assert.Equal(0xE8, stub[62]);
Assert.Equal(rel, BitConverter.ToUInt32(stub, 63));
// add rsp, 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()
{
// The bug this catches: an earlier stub emitted "mov r32d, imm32" which zero-extended
// a 32-bit immediate into the lower half of the 64-bit register, silently dropping
// the high bits of any pointer-sized argument above 4 GiB.
var s = Create();
ulong a = 0x100000000, t = 0x123456788;
nuint wideArg = unchecked((nuint)0xDEADBEEF_CAFEBABEUL);
byte[] stub = s.BuildCallStub(
(IntPtr)(nint)a, (IntPtr)(nint)t, [wideArg], 8, CallConvention.Cdecl);
// The 8-byte immediate for arg0 lives inside `mov rcx, imm64` at bytes [9..16].
ulong read = BitConverter.ToUInt64(stub, 9);
Assert.Equal(0xDEADBEEF_CAFEBABEul, read);
}
[Fact]
public void X86_target_rejects_arg_value_larger_than_32_bits()
{
if (!Environment.Is64BitProcess)
{
// On a 32-bit host, nuint cannot exceed uint.MaxValue — the precondition
// cannot be exercised. Mark the test as an intentional no-op.
Assert.True(true);
return;
}
var s = Create();
nuint tooBig = unchecked((nuint)0x1_00000000UL);
Assert.Throws<ArgumentOutOfRangeException>(() =>
s.BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [tooBig], 4, CallConvention.Cdecl));
}
// ── Edge cases ────────────────────────────────────────────────────────
@@ -193,8 +413,9 @@ public class StubAssemblerTests
[Fact]
public void Many_args_cleanup_uses_imm32_form()
{
var args = new uint[33];
for (int i = 0; i < 33; i++) args[i] = (uint)(i * 0x10000 + i);
// x86 path: 33 args, stack cleanup > 127 bytes → must emit add esp, imm32 (81 C4)
var args = new nuint[33];
for (int i = 0; i < 33; i++) args[i] = (nuint)(uint)(i * 0x10000 + i);
byte[] stub = Create().BuildCallStub(
(IntPtr)0x10000000, (IntPtr)0x12345678, args, 4, CallConvention.Cdecl);