// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using System.Buffers.Binary; using SharpEmu.HLE; using SharpEmu.Libs.Kernel; using SharpEmu.Libs.LibcStdio; using Xunit; namespace SharpEmu.Libs.Tests.LibcStdio; public sealed class LibcStdioExportsTests { private const ulong BaseAddress = 0x1_0000_0000; private const ulong PathAddress = BaseAddress + 0x100; private const ulong ModeAddress = BaseAddress + 0x200; private const ulong PositionAddress = BaseAddress + 0x300; private const ulong DataAddress = BaseAddress + 0x400; private const ulong TlsAddress = BaseAddress + 0x800; private const ulong ErrnoAddress = TlsAddress + 0x40; [Fact] public void Fopen_RejectsEmptyPath() { var context = CreateContext(out var memory); memory.WriteCString(PathAddress, string.Empty); memory.WriteCString(ModeAddress, "rb"); context[CpuRegister.Rdi] = PathAddress; context[CpuRegister.Rsi] = ModeAddress; var result = LibcStdioExports.Fopen(context); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result); Assert.Equal(0UL, context[CpuRegister.Rax]); Assert.Equal(2, ReadErrno(memory)); } [Fact] public void Fopen_RejectsInvalidMode() { var context = CreateContext(out var memory); memory.WriteCString(PathAddress, "/app0/file.bin"); memory.WriteCString(ModeAddress, "invalid"); context[CpuRegister.Rdi] = PathAddress; context[CpuRegister.Rsi] = ModeAddress; var result = LibcStdioExports.Fopen(context); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result); Assert.Equal(0UL, context[CpuRegister.Rax]); Assert.Equal(22, ReadErrno(memory)); } [Fact] public void Fopen_RejectsUnreadablePathPointer() { var context = CreateContext(out var memory); memory.WriteCString(ModeAddress, "rb"); context[CpuRegister.Rdi] = BaseAddress + 0x2000; context[CpuRegister.Rsi] = ModeAddress; var result = LibcStdioExports.Fopen(context); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result); Assert.Equal(0UL, context[CpuRegister.Rax]); Assert.Equal(14, ReadErrno(memory)); } [Fact] public void Fopen_UnresolvedGuestPathSetsEnoent() { var context = CreateContext(out var memory); memory.WriteCString(PathAddress, "/sharpemu-unregistered/file.bin"); memory.WriteCString(ModeAddress, "rb"); context[CpuRegister.Rdi] = PathAddress; context[CpuRegister.Rsi] = ModeAddress; var result = LibcStdioExports.Fopen(context); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result); Assert.Equal(0UL, context[CpuRegister.Rax]); Assert.Equal(2, ReadErrno(memory)); } [Fact] public void Freopen_RejectsEmptyPath() { var context = CreateContext(out var memory); memory.WriteCString(PathAddress, string.Empty); memory.WriteCString(ModeAddress, "rb"); context[CpuRegister.Rdi] = PathAddress; context[CpuRegister.Rsi] = ModeAddress; context[CpuRegister.Rdx] = 0x1234; var result = LibcStdioExports.Freopen(context); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result); Assert.Equal(0UL, context[CpuRegister.Rax]); Assert.Equal(2, ReadErrno(memory)); } [Fact] public void Fgetpos_WritesSdkFposLayoutAndFileOffset() { var fixture = OpenTemporaryFile("rb", new byte[] { 1, 2, 3, 4, 5 }); try { fixture.Context[CpuRegister.Rdi] = fixture.Handle; fixture.Context[CpuRegister.Rsi] = 0; fixture.Context[CpuRegister.Rdx] = 2; Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, LibcStdioExports.Fseek(fixture.Context)); fixture.Context[CpuRegister.Rdi] = fixture.Handle; fixture.Context[CpuRegister.Rsi] = PositionAddress; Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, LibcStdioExports.Fgetpos(fixture.Context)); Span position = stackalloc byte[24]; Assert.True(fixture.Memory.TryRead(PositionAddress, position)); Assert.Equal(5L, BinaryPrimitives.ReadInt64LittleEndian(position)); Assert.True(position[8..].SequenceEqual(new byte[16])); } finally { fixture.Dispose(); } } [Fact] public void Fsetpos_SeeksToSdkFposOffset() { var fixture = OpenTemporaryFile("rb", new byte[] { 10, 20, 30, 40 }); try { Span position = stackalloc byte[24]; position.Clear(); BinaryPrimitives.WriteInt64LittleEndian(position, 2); Assert.True(fixture.Memory.TryWrite(PositionAddress, position)); fixture.Context[CpuRegister.Rdi] = fixture.Handle; fixture.Context[CpuRegister.Rsi] = PositionAddress; Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, LibcStdioExports.Fsetpos(fixture.Context)); fixture.Context[CpuRegister.Rdi] = DataAddress; fixture.Context[CpuRegister.Rsi] = 1; fixture.Context[CpuRegister.Rdx] = 2; fixture.Context[CpuRegister.Rcx] = fixture.Handle; Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, LibcStdioExports.Fread(fixture.Context)); Span bytes = stackalloc byte[2]; Assert.True(fixture.Memory.TryRead(DataAddress, bytes)); Assert.True(bytes.SequenceEqual(new byte[] { 30, 40 })); } finally { fixture.Dispose(); } } [Fact] public void Fgetpos_RejectsUnwritablePosition() { var fixture = OpenTemporaryFile("rb", new byte[] { 1 }); try { fixture.Context[CpuRegister.Rdi] = fixture.Handle; fixture.Context[CpuRegister.Rsi] = 0x7FFF_FFFF_FFFF_F000; var result = LibcStdioExports.Fgetpos(fixture.Context); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result); Assert.Equal(unchecked((ulong)(int)-1), fixture.Context[CpuRegister.Rax]); Assert.Equal(14, ReadErrno(fixture.Memory)); } finally { fixture.Dispose(); } } [Fact] public void Setvbuf_NoBufferingFlushesEachWrite() { var fixture = OpenTemporaryFile("wb", Array.Empty()); try { fixture.Context[CpuRegister.Rdi] = fixture.Handle; fixture.Context[CpuRegister.Rsi] = 0; fixture.Context[CpuRegister.Rdx] = 2; fixture.Context[CpuRegister.Rcx] = 0; Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, LibcStdioExports.Setvbuf(fixture.Context)); fixture.Context[CpuRegister.Rdi] = 0x5A; fixture.Context[CpuRegister.Rsi] = fixture.Handle; Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, LibcStdioExports.Fputc(fixture.Context)); using var reader = new FileStream( fixture.HostPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); Assert.Equal(0x5A, reader.ReadByte()); } finally { fixture.Dispose(); } } [Fact] public void Setvbuf_RejectsUnknownMode() { var fixture = OpenTemporaryFile("rb", new byte[] { 1 }); try { fixture.Context[CpuRegister.Rdi] = fixture.Handle; fixture.Context[CpuRegister.Rdx] = 3; var result = LibcStdioExports.Setvbuf(fixture.Context); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result); Assert.Equal(unchecked((ulong)(int)-1), fixture.Context[CpuRegister.Rax]); Assert.Equal(22, ReadErrno(fixture.Memory)); } finally { fixture.Dispose(); } } [Fact] public void Fread_BlocksConcurrentSeekUntilTheReadCompletes() { var contents = Enumerable.Range(0, 64).Select(value => (byte)value).ToArray(); var fixture = OpenTemporaryFile("rb", contents); using var writeEntered = new ManualResetEventSlim(); using var releaseWrite = new ManualResetEventSlim(); StdioTestWorker? readWorker = null; StdioTestWorker? seekWorker = null; try { fixture.Memory.BeforeWrite = (address, _) => { if (address != DataAddress) { return; } writeEntered.Set(); Assert.True(releaseWrite.Wait(StdioTestWorker.Timeout), "The test did not release the blocked read."); }; var readContext = CreateContext(fixture.Memory); readContext[CpuRegister.Rdi] = DataAddress; readContext[CpuRegister.Rsi] = 1; readContext[CpuRegister.Rdx] = 32; readContext[CpuRegister.Rcx] = fixture.Handle; readWorker = new StdioTestWorker(() => LibcStdioExports.Fread(readContext)); Assert.True(writeEntered.Wait(StdioTestWorker.Timeout), "The read did not reach the guest-memory write."); var seekContext = CreateContext(fixture.Memory); seekContext[CpuRegister.Rdi] = fixture.Handle; seekContext[CpuRegister.Rsi] = 48; seekContext[CpuRegister.Rdx] = 0; seekWorker = new StdioTestWorker(() => LibcStdioExports.Fseek(seekContext)); seekWorker.AssertBlocked("Seek did not block behind the active read."); releaseWrite.Set(); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, readWorker.Result); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, seekWorker.Result); var actual = new byte[32]; Assert.True(fixture.Memory.TryRead(DataAddress, actual)); Assert.True(actual.SequenceEqual(contents.AsSpan(0, 32))); } finally { releaseWrite.Set(); StdioTestWorker.JoinAll(readWorker, seekWorker); fixture.Memory.BeforeWrite = null; fixture.Dispose(); } } [Fact] public void Fclose_WaitsForAnInFlightReadAndRejectsLaterReads() { var fixture = OpenTemporaryFile("rb", Enumerable.Range(0, 64).Select(value => (byte)value).ToArray()); using var writeEntered = new ManualResetEventSlim(); using var releaseWrite = new ManualResetEventSlim(); StdioTestWorker? readWorker = null; StdioTestWorker? closeWorker = null; try { fixture.Memory.BeforeWrite = (address, _) => { if (address != DataAddress) { return; } writeEntered.Set(); Assert.True(releaseWrite.Wait(StdioTestWorker.Timeout), "The test did not release the blocked read."); }; var readContext = CreateContext(fixture.Memory); readContext[CpuRegister.Rdi] = DataAddress; readContext[CpuRegister.Rsi] = 1; readContext[CpuRegister.Rdx] = 32; readContext[CpuRegister.Rcx] = fixture.Handle; readWorker = new StdioTestWorker(() => LibcStdioExports.Fread(readContext)); Assert.True(writeEntered.Wait(StdioTestWorker.Timeout), "The read did not reach the guest-memory write."); var closeContext = CreateContext(fixture.Memory); closeContext[CpuRegister.Rdi] = fixture.Handle; closeWorker = new StdioTestWorker(() => LibcStdioExports.Fclose(closeContext)); closeWorker.AssertBlocked("Close did not block behind the active read."); releaseWrite.Set(); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, readWorker.Result); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, closeWorker.Result); var lateReadContext = CreateContext(fixture.Memory); lateReadContext[CpuRegister.Rdi] = DataAddress; lateReadContext[CpuRegister.Rsi] = 1; lateReadContext[CpuRegister.Rdx] = 1; lateReadContext[CpuRegister.Rcx] = fixture.Handle; Assert.Equal( (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, LibcStdioExports.Fread(lateReadContext)); Assert.Equal(0UL, lateReadContext[CpuRegister.Rax]); } finally { releaseWrite.Set(); StdioTestWorker.JoinAll(readWorker, closeWorker); fixture.Memory.BeforeWrite = null; fixture.Dispose(); } } private sealed class StdioTestWorker { public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); private readonly Thread _thread; private readonly TaskCompletionSource _result = new(TaskCreationOptions.RunContinuationsAsynchronously); public int Result { get { // These operations run on dedicated threads. Join them directly so // thread-pool load cannot delay a continuation until its timer expires. Assert.True(_thread.Join(Timeout), "The file-operation worker did not complete."); return _result.Task.GetAwaiter().GetResult(); } } public StdioTestWorker(Func operation) { _thread = new Thread(() => { try { _result.SetResult(operation()); } catch (Exception exception) { _result.SetException(exception); } }) { IsBackground = true }; _thread.Start(); } public void AssertBlocked(string message) { // The worker has no test-side waits; blocking occurs inside the file operation. Assert.True(SpinWait.SpinUntil(() => _result.Task.IsCompleted || (_thread.ThreadState & ThreadState.WaitSleepJoin) != 0, Timeout), message); Assert.False(_result.Task.IsCompleted, message); } public static void JoinAll(params StdioTestWorker?[] workers) { var allStopped = true; foreach (var worker in workers) allStopped &= worker is null || worker._thread.Join(Timeout); Assert.True(allStopped, "A file-operation worker did not stop during cleanup."); } } private static CpuContext CreateContext(out FakeCpuMemory memory) { memory = new FakeCpuMemory(BaseAddress, 0x1000); return new CpuContext(memory, Generation.Gen5) { FsBase = TlsAddress, }; } private static CpuContext CreateContext(StdioCpuMemory memory) => new(memory, Generation.Gen5) { FsBase = TlsAddress, }; private static int ReadErrno(ICpuMemory memory) { Span value = stackalloc byte[sizeof(int)]; Assert.True(memory.TryRead(ErrnoAddress, value)); return BinaryPrimitives.ReadInt32LittleEndian(value); } private static TemporaryFileFixture OpenTemporaryFile(string mode, byte[] contents) { var mountPoint = $"/stdio-{Guid.NewGuid():N}"; var hostRoot = Path.Combine(Path.GetTempPath(), $"sharpemu-stdio-{Guid.NewGuid():N}"); Directory.CreateDirectory(hostRoot); var hostPath = Path.Combine(hostRoot, "data.bin"); File.WriteAllBytes(hostPath, contents); KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, hostRoot); var memory = new StdioCpuMemory(BaseAddress, 0x1000); var context = new CpuContext(memory, Generation.Gen5) { FsBase = TlsAddress, }; memory.WriteCString(PathAddress, $"{mountPoint}/data.bin"); memory.WriteCString(ModeAddress, mode); context[CpuRegister.Rdi] = PathAddress; context[CpuRegister.Rsi] = ModeAddress; var result = LibcStdioExports.Fopen(context); Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result); Assert.NotEqual(0UL, context[CpuRegister.Rax]); return new TemporaryFileFixture( context, memory, context[CpuRegister.Rax], mountPoint, hostRoot, hostPath); } private sealed class TemporaryFileFixture( CpuContext context, StdioCpuMemory memory, ulong handle, string mountPoint, string hostRoot, string hostPath) : IDisposable { public CpuContext Context { get; } = context; public StdioCpuMemory Memory { get; } = memory; public ulong Handle { get; } = handle; public string HostPath { get; } = hostPath; public void Dispose() { Context[CpuRegister.Rdi] = Handle; _ = LibcStdioExports.Fclose(Context); KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint); Directory.Delete(hostRoot, recursive: true); } } private sealed class StdioCpuMemory : ICpuMemory, IGuestAddressSpace { private readonly ulong _baseAddress; private readonly byte[] _storage; private readonly Dictionary _allocations = new(); public Action? BeforeWrite { get; set; } public StdioCpuMemory(ulong baseAddress, int size) { _baseAddress = baseAddress; _storage = new byte[size]; } public bool TryRead(ulong virtualAddress, Span destination) { if (!TryResolve(virtualAddress, destination.Length, out var storage, out var offset)) { return false; } storage.AsSpan(offset, destination.Length).CopyTo(destination); return true; } public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) { BeforeWrite?.Invoke(virtualAddress, source.Length); if (!TryResolve(virtualAddress, source.Length, out var storage, out var offset)) { return false; } source.CopyTo(storage.AsSpan(offset, source.Length)); return true; } public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address) => TryAllocateAtOrAbove(_baseAddress + (ulong)_storage.Length, size, false, alignment, out address); public bool TryFreeGuestMemory(ulong address) => _allocations.Remove(address); public ulong AllocateAt( ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true) { if (TryAllocateAtOrAbove(desiredAddress, size, executable, 0x1000, out var address)) { return address; } return 0; } public bool TryBackFixedRange(ulong address, ulong size, bool executable) => AllocateAt(address, size, executable, allowAlternative: false) == address; public bool TryAllocateAtOrAbove( ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress) { actualAddress = 0; if (size == 0 || size > int.MaxValue || alignment == 0 || (alignment & (alignment - 1)) != 0) { return false; } var minimum = _baseAddress + (ulong)_storage.Length; var candidate = Math.Max(desiredAddress, minimum); candidate = (candidate + alignment - 1) & ~(alignment - 1); while (_allocations.Any(pair => RangesOverlap(candidate, size, pair.Key, (ulong)pair.Value.Length))) { candidate += alignment; } _allocations[candidate] = new byte[(int)size]; actualAddress = candidate; return true; } public bool TryEnsureRangeCommitted(ulong address, ulong size) => TryResolve(address, checked((int)size), out _, out _); public bool TryProtect(ulong address, ulong size, GuestPageProtection protection) => TryEnsureRangeCommitted(address, size); public ulong WriteCString(ulong virtualAddress, string text) { var bytes = System.Text.Encoding.UTF8.GetBytes(text); Assert.True(TryWrite(virtualAddress, bytes)); Assert.True(TryWrite(virtualAddress + (ulong)bytes.Length, stackalloc byte[] { 0 })); return virtualAddress; } private bool TryResolve( ulong virtualAddress, int length, out byte[] storage, out int offset) { if (TryResolveInRegion(virtualAddress, length, _baseAddress, _storage, out offset)) { storage = _storage; return true; } foreach (var pair in _allocations) { if (TryResolveInRegion(virtualAddress, length, pair.Key, pair.Value, out offset)) { storage = pair.Value; return true; } } storage = Array.Empty(); offset = 0; return false; } private static bool TryResolveInRegion( ulong address, int length, ulong regionAddress, byte[] region, out int offset) { offset = 0; if (address < regionAddress) { return false; } var relative = address - regionAddress; if (relative + (ulong)length > (ulong)region.Length) { return false; } offset = (int)relative; return true; } private static bool RangesOverlap(ulong left, ulong leftSize, ulong right, ulong rightSize) => left < right + rightSize && right < left + leftSize; } }