diff --git a/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs b/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs
new file mode 100644
index 00000000..24abfae3
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Memory/PhysicalVirtualMemoryTests.cs
@@ -0,0 +1,229 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Core.Memory;
+using SharpEmu.Core.Loader;
+using SharpEmu.HLE.Host;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Memory;
+
+// PhysicalVirtualMemory is the host-backed (identity-mapped) implementation.
+// Reserve-only regions (> 4 GiB, non-executable) defer commit until first
+// access; TryAllocateGuestMemory serves a first-fit free-list with coalescing.
+// These tests pin that behaviour through fake IHostMemory implementations.
+public sealed class PhysicalVirtualMemoryTests
+{
+ // 1. Lazy commit: a reserve-only region has its pages committed on demand
+ // when read; freshly committed pages read as zero.
+ [Fact]
+ public void LazyReadCommitsPageOnDemandAndReadsZero()
+ {
+ using var host = new LazyZeroedHostMemory();
+ using var memory = new PhysicalVirtualMemory(host);
+
+ // > 4 GiB, non-executable -> reserve-only with lazy commit.
+ var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
+ Assert.NotEqual(0UL, address);
+
+ // Discard the priming commits AllocateAt issues up front; we want to
+ // observe the on-demand commit triggered by the read itself.
+ host.CommitCalls.Clear();
+
+ var buffer = new byte[1];
+ Assert.True(memory.TryRead(address, buffer));
+ Assert.Equal(0, buffer[0]);
+
+ // The touched page (page-aligned to `address`) was committed on demand.
+ var page = address & ~0xFFFUL;
+ Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
+ }
+
+ // 2. Reserve-only region: GetPointer commits the page before returning it,
+ // so callers receive a valid (non-null) pointer. An unmapped address yields null.
+ [Fact]
+ public unsafe void GetPointerOnReserveOnlyRegionCommitsAndReturnsValidPointer()
+ {
+ using var host = new LazyZeroedHostMemory();
+ using var memory = new PhysicalVirtualMemory(host);
+
+ var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
+ host.CommitCalls.Clear();
+
+ var pointer = memory.GetPointer(address + 0x123);
+ Assert.NotEqual(0UL, (ulong)pointer);
+ Assert.Equal(address + 0x123, (ulong)pointer);
+
+ var page = (address + 0x123) & ~0xFFFUL;
+ Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
+ }
+
+ [Fact]
+ public unsafe void GetPointerOnUnmappedAddressReturnsNull()
+ {
+ using var host = new LazyZeroedHostMemory();
+ using var memory = new PhysicalVirtualMemory(host);
+
+ Assert.Equal(0UL, (ulong)memory.GetPointer(0x0001_0000));
+ }
+
+ // 3. Free-list reuse: a freed range is served back by first-fit allocation,
+ // preferring the lowest fitting free range over the larger trailing span.
+ [Fact]
+ public void FreedRangeIsReusedByFirstFitAllocation()
+ {
+ using var memory = new PhysicalVirtualMemory(new FakeHostMemory());
+
+ Assert.True(memory.TryAllocateGuestMemory(0x4000, 0x1000, out var first));
+ Assert.True(memory.TryAllocateGuestMemory(0x4000, 0x1000, out var second));
+ Assert.NotEqual(first, second);
+ Assert.True(memory.TryFreeGuestMemory(first));
+
+ // A smaller allocation must reuse first's freed slot (lowest fitting range),
+ // not the larger trailing free range.
+ Assert.True(memory.TryAllocateGuestMemory(0x2000, 0x1000, out var reused));
+ Assert.Equal(first, reused);
+ }
+
+ // 4. Coalescing: freeing the middle of three adjacent ranges merges both the
+ // left and right free neighbours in a single TryFreeGuestMemory call,
+ // restoring the full span for subsequent first-fit reuse.
+ [Fact]
+ public void FreeingMiddleRangeCoalescesBothNeighbours()
+ {
+ using var memory = new PhysicalVirtualMemory(new FakeHostMemory());
+
+ // Three adjacent 0x1000 allocations: offsets 0x1000, 0x2000, 0x3000.
+ Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var first));
+ Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var second));
+ Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var third));
+
+ // Free the outer ranges first, leaving two separate free ranges.
+ Assert.True(memory.TryFreeGuestMemory(first));
+ Assert.True(memory.TryFreeGuestMemory(third));
+
+ // Freeing the middle range must coalesce both neighbours at once.
+ Assert.True(memory.TryFreeGuestMemory(second));
+
+ // The whole arena is now one coalesced free range; a full-arena allocation
+ // reuses first's base address.
+ Assert.True(memory.TryAllocateGuestMemory(0x000F_F000, 0x1000, out var coalesced));
+ Assert.Equal(first, coalesced);
+ }
+
+ ///
+ /// Host memory backed by a single real, zero-initialised page. Reserve/Allocate
+ /// report the page-aligned buffer address so lazy-commit read paths can actually
+ /// dereference the returned pointer. Query always reports Reserved, so
+ /// EnsureRangeCommitted issues a Commit on first access.
+ ///
+ private sealed unsafe class LazyZeroedHostMemory : IHostMemory, IDisposable
+ {
+ private readonly void* _allocation;
+ private readonly ulong _address;
+ private bool _freed;
+
+ public LazyZeroedHostMemory()
+ {
+ _allocation = System.Runtime.InteropServices.NativeMemory.AllocZeroed(0x2000);
+ _address = ((ulong)_allocation + 0xFFF) & ~0xFFFUL;
+ }
+
+ public List<(ulong Address, ulong Size, HostPageProtection Protection)> CommitCalls { get; } = [];
+
+ public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
+
+ public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
+
+ public bool Commit(ulong address, ulong size, HostPageProtection protection)
+ {
+ CommitCalls.Add((address, size, protection));
+ return true;
+ }
+
+ public bool Free(ulong address)
+ {
+ // The real buffer is released in Dispose; keep Free a no-op so
+ // PhysicalVirtualMemory.Clear does not double-free it.
+ return true;
+ }
+
+ public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
+ {
+ rawOldProtection = 0;
+ return true;
+ }
+
+ public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
+ {
+ rawOldProtection = 0;
+ return true;
+ }
+
+ public bool Query(ulong address, out HostRegionInfo info)
+ {
+ var pageAddress = address & ~0xFFFUL;
+ info = new HostRegionInfo(
+ pageAddress,
+ pageAddress,
+ 0x1000,
+ HostRegionState.Reserved,
+ 0,
+ HostPageProtection.NoAccess,
+ 0,
+ 0);
+ return true;
+ }
+
+ public void FlushInstructionCache(ulong address, ulong size)
+ {
+ }
+
+ public void Dispose()
+ {
+ if (!_freed)
+ {
+ System.Runtime.InteropServices.NativeMemory.Free(_allocation);
+ _freed = true;
+ }
+ }
+ }
+
+ // Minimal host memory for free-list tests: Allocate honours the desired
+ // address (or a fallback), everything else succeeds as a no-op. The guest
+ // allocation arena never dereferences, so no real backing is required.
+ private sealed class FakeHostMemory : IHostMemory
+ {
+ public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>
+ desiredAddress != 0 ? desiredAddress : 0x00007000_0000_0000;
+
+ public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) =>
+ Allocate(desiredAddress, size, protection);
+
+ public bool Commit(ulong address, ulong size, HostPageProtection protection) => true;
+
+ public bool Free(ulong address) => true;
+
+ public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
+ {
+ rawOldProtection = 0;
+ return true;
+ }
+
+ public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
+ {
+ rawOldProtection = 0;
+ return true;
+ }
+
+ public bool Query(ulong address, out HostRegionInfo info)
+ {
+ info = default;
+ return false;
+ }
+
+ public void FlushInstructionCache(ulong address, ulong size)
+ {
+ }
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Memory/VirtualMemoryTests.cs b/tests/SharpEmu.Libs.Tests/Memory/VirtualMemoryTests.cs
index 204ddf2b..5c5a3c63 100644
--- a/tests/SharpEmu.Libs.Tests/Memory/VirtualMemoryTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Memory/VirtualMemoryTests.cs
@@ -84,4 +84,74 @@ public sealed class VirtualMemoryTests
Assert.False(memory.TryRead(0x3000, unchanged));
Assert.True(memory.TryWrite(0x3000, [9]));
}
+
+ [Fact]
+ public void TryReadAndTryWriteOnUnmappedAddressReturnFalse()
+ {
+ var memory = new VirtualMemory();
+ memory.Map(0x1000, 0x100, 0, [], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
+
+ // Addresses with no covering region fail for both reads and writes.
+ Span value = stackalloc byte[1];
+ Assert.False(memory.TryRead(0x2000, value));
+ Assert.False(memory.TryWrite(0x2000, value));
+
+ // With no regions mapped at all, every access fails.
+ var empty = new VirtualMemory();
+ Assert.False(empty.TryRead(0x1000, value));
+ Assert.False(empty.TryWrite(0x1000, value));
+ }
+
+ // Zero-length operations succeed on a mapped address and touch nothing, but
+ // still require a mapped address.
+ [Fact]
+ public void ZeroLengthOperationsOnMappedAddressReturnTrue()
+ {
+ var memory = new VirtualMemory();
+ memory.Map(0x1000, 0x100, 0, [0x01], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
+
+ Assert.True(memory.TryRead(0x1000, []));
+ Assert.True(memory.TryWrite(0x1000, []));
+
+ Span value = stackalloc byte[1];
+ Assert.True(memory.TryRead(0x1000, value));
+ Assert.Equal(0x01, value[0]);
+
+ Assert.False(memory.TryRead(0x2000, []));
+ Assert.False(memory.TryWrite(0x2000, []));
+ }
+
+ // A page-aligned region must be accessible at both offset 0 and the final byte.
+ [Fact]
+ public void MappingAtPageBoundarySupportsOffsetZeroAndLastByte()
+ {
+ const ulong pageSize = 0x1000;
+ var memory = new VirtualMemory();
+ memory.Map(pageSize, pageSize, 0, [], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
+
+ Assert.True(memory.TryWrite(pageSize, [0x5A]));
+ Assert.True(memory.TryWrite(pageSize + pageSize - 1, [0xA5]));
+
+ Span head = stackalloc byte[1];
+ Span tail = stackalloc byte[1];
+ Assert.True(memory.TryRead(pageSize, head));
+ Assert.True(memory.TryRead(pageSize + pageSize - 1, tail));
+ Assert.Equal(0x5A, head[0]);
+ Assert.Equal(0xA5, tail[0]);
+ }
+
+ // A read/write that starts in one region but extends into the unmapped gap
+ // between two non-adjacent regions must fail across the entire range.
+ [Fact]
+ public void ReadWriteAcrossGapBetweenNonAdjacentRegionsReturnsFalse()
+ {
+ const ulong pageSize = 0x1000;
+ var memory = new VirtualMemory();
+ const ProgramHeaderFlags protection = ProgramHeaderFlags.Read | ProgramHeaderFlags.Write;
+ memory.Map(pageSize, pageSize, 0, [], protection);
+ memory.Map(pageSize * 3, pageSize, 0, [], protection);
+
+ Assert.False(memory.TryRead(pageSize, new byte[(int)pageSize * 2]));
+ Assert.False(memory.TryWrite(pageSize, new byte[(int)pageSize * 2]));
+ }
}