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])); + } }