// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using System; using System.Buffers.Binary; using System.Collections.Generic; using SharpEmu.HLE; using SharpEmu.Libs.Fiber; using Xunit; namespace SharpEmu.Libs.Tests.Fiber; /// /// Continuation contract for a job-system style fiber loop: two fibers that /// hand control back and forth many times from the same resume point. Each /// resume has to restore exactly the state its own suspension captured, so a /// stale or cross-fiber restore shows up here instead of as a null dereference /// inside guest code several switches later. /// [Collection(FiberStateCollection.Name)] public sealed class FiberSwitchLoopTests : IDisposable { private const ulong Base = 0x3_0000_0000UL; private const int RegionSize = 0x8000; private const ulong FiberA = Base + 0x0000; private const ulong FiberB = Base + 0x0100; private const ulong NameA = Base + 0x0300; private const ulong NameB = Base + 0x0340; private const ulong ArgSlotRoot = Base + 0x0380; private const ulong ArgSlotA = Base + 0x0388; private const ulong ArgSlotB = Base + 0x0390; private const ulong ContextA = Base + 0x1000; private const ulong ContextB = Base + 0x3000; private const ulong ContextSize = 0x1000; private const ulong EntryA = 0x4_0000_1000UL; private const ulong EntryB = 0x4_0000_2000UL; private const ulong ArgOnInitializeA = 0xA11AUL; private const ulong ArgOnInitializeB = 0xB22BUL; // The guest side of a job worker calls sceFiberSwitch from one place, so // every suspension of a given fiber reports the same return address and the // same resume stack. Only the payload registers differ between rounds. private const ulong ResumeRipA = 0x8_0084_1FD1UL; private const ulong ResumeRipB = 0x8_0084_1FD1UL; private const ulong ResumeRspA = 0x2_0858_FF10UL; private const ulong ResumeRspB = 0x2_085A_F3A0UL; private const ulong ReturnSlotA = ResumeRspA - 0x10UL; private const ulong ReturnSlotB = ResumeRspB - 0x10UL; private const ulong RootReturnRip = 0x8_0084_0000UL; private const ulong RootResumeRsp = 0x2_0850_0000UL; private const ulong RootReturnSlot = RootResumeRsp - 0x10UL; private const ulong ThreadHandle = 0x2E45_16AD_090UL; private const uint StateRun = 1; private const uint StateIdle = 2; private readonly IGuestThreadScheduler? _previousScheduler; private readonly ulong _previousThread; private readonly ulong _previousFiber; public FiberSwitchLoopTests() { FiberExports.ResetRuntimeState(); _previousScheduler = GuestThreadExecution.Scheduler; GuestThreadExecution.Scheduler = new ContextTransferScheduler(); _previousThread = GuestThreadExecution.EnterGuestThread(ThreadHandle); _previousFiber = GuestThreadExecution.EnterFiber(0); } public void Dispose() { GuestThreadExecution.EnterFiber(_previousFiber); GuestThreadExecution.RestoreGuestThread(_previousThread); GuestThreadExecution.Scheduler = _previousScheduler; FiberExports.ResetRuntimeState(); } [Fact] public void WarmSwitchLoop_BoundsAllocationAndPreservesResumeArguments() { var memory = new FakeCpuMemory(Base, RegionSize); var context = new CpuContext(memory, Generation.Gen5); InitializeFiber(memory, context, FiberA, NameA, EntryA, ArgOnInitializeA, ContextA); InitializeFiber(memory, context, FiberB, NameB, EntryB, ArgOnInitializeB, ContextB); GuestThreadExecution.EnterImportCallFrame(RootReturnRip, RootResumeRsp, RootReturnSlot); context[CpuRegister.Rdi] = FiberA; context[CpuRegister.Rdx] = ArgSlotRoot; Assert.Equal(0, FiberExports.FiberRun(context)); Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out _)); void SwitchOnce(int index) { var toB = (index & 1) == 0; GuestThreadExecution.EnterImportCallFrame(toB ? ResumeRipA : ResumeRipB, toB ? ResumeRspA : ResumeRspB, toB ? ReturnSlotA : ReturnSlotB); context[CpuRegister.Rdi] = toB ? FiberB : FiberA; context[CpuRegister.Rsi] = (ulong)index; context[CpuRegister.Rdx] = toB ? ArgSlotA : ArgSlotB; if (FiberExports.FiberSwitch(context) != 0 || !GuestThreadExecution.TryConsumeCurrentContextTransfer(out _)) throw new InvalidOperationException("Fiber switch failed."); } for (var i = 0; i < 2000; i++) SwitchOnce(i); const int count = 20000; var allocated = GC.GetAllocatedBytesForCurrentThread(); var start = System.Diagnostics.Stopwatch.GetTimestamp(); for (var i = 0; i < count; i++) SwitchOnce(i); var elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(start); allocated = GC.GetAllocatedBytesForCurrentThread() - allocated; Console.WriteLine($"Fiber switches: bytes/call={allocated / (double)count:F1}, us/call={elapsed.TotalMicroseconds / count:F3}"); // Previously 912 bytes per switch with tracing disabled. Keep room for // continuation storage, but do not format names or discarded traces. if (Environment.GetEnvironmentVariable("SHARPEMU_LOG_FIBER") != "1") Assert.True(allocated < count * 600L, $"Allocated {allocated} bytes for {count} switches."); Assert.Equal((ulong)(count - 1), ReadUInt64(memory, ArgSlotA)); Assert.Equal((ulong)(count - 2), ReadUInt64(memory, ArgSlotB)); Assert.Equal(FiberA, GuestThreadExecution.CurrentFiberAddress); } [Fact] public void RepeatedSwitch_RestoresEachFiberOwnResumePoint() { var memory = new FakeCpuMemory(Base, RegionSize); var context = new CpuContext(memory, Generation.Gen5); InitializeFiber(memory, context, FiberA, NameA, EntryA, ArgOnInitializeA, ContextA); InitializeFiber(memory, context, FiberB, NameB, EntryB, ArgOnInitializeB, ContextB); // The thread enters the fiber world through sceFiberRun. GuestThreadExecution.EnterImportCallFrame(RootReturnRip, RootResumeRsp, RootReturnSlot); context[CpuRegister.Rdi] = FiberA; context[CpuRegister.Rsi] = 0x1000UL; context[CpuRegister.Rdx] = ArgSlotRoot; Assert.Equal(0, FiberExports.FiberRun(context)); Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toA)); Assert.Equal(EntryA, toA.Rip); Assert.Equal(ArgOnInitializeA, toA.Rdi); Assert.Equal(0x1000UL, toA.Rsi); Assert.Equal(0UL, toA.Rax); Assert.Equal(FiberA, GuestThreadExecution.CurrentFiberAddress); Assert.Equal(StateRun, ReadUInt32(memory, FiberA + 4)); // Round 0 suspends A for the first time and starts B at its entry point. var sentinelsA = Sentinels(0xA0); Switch(memory, context, from: FiberA, to: FiberB, ResumeRipA, ResumeRspA, ReturnSlotA, ArgSlotA, argOnRun: 0xB000UL, sentinelsA); Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toB)); Assert.Equal(EntryB, toB.Rip); Assert.Equal(ArgOnInitializeB, toB.Rdi); Assert.Equal(0xB000UL, toB.Rsi); var expectedA = new FiberExpectation(ResumeRipA, ResumeRspA, ReturnSlotA, sentinelsA); FiberExpectation? expectedB = null; // Eight full round trips over the same pair of resume points. for (var round = 1; round <= 8; round++) { var sentinelsB = Sentinels((ulong)round << 32 | 0xB0); var argToA = 0xA000UL + (ulong)round; Switch(memory, context, from: FiberB, to: FiberA, ResumeRipB, ResumeRspB, ReturnSlotB, ArgSlotB, argToA, sentinelsB); expectedB = new FiberExpectation(ResumeRipB, ResumeRspB, ReturnSlotB, sentinelsB); Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var resumedA)); AssertResume(resumedA, expectedA, $"round {round}: fiber A"); Assert.Equal(argToA, ReadUInt64(memory, ArgSlotA)); Assert.Equal(StateRun, ReadUInt32(memory, FiberA + 4)); Assert.Equal(StateIdle, ReadUInt32(memory, FiberB + 4)); var nextSentinelsA = Sentinels((ulong)round << 32 | 0xA0); var argToB = 0xB000UL + (ulong)round; Switch(memory, context, from: FiberA, to: FiberB, ResumeRipA, ResumeRspA, ReturnSlotA, ArgSlotA, argToB, nextSentinelsA); expectedA = new FiberExpectation(ResumeRipA, ResumeRspA, ReturnSlotA, nextSentinelsA); Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var resumedB)); AssertResume(resumedB, expectedB, $"round {round}: fiber B"); Assert.Equal(argToB, ReadUInt64(memory, ArgSlotB)); Assert.Equal(StateRun, ReadUInt32(memory, FiberB + 4)); Assert.Equal(StateIdle, ReadUInt32(memory, FiberA + 4)); } } [Fact] public void RepeatedSwitch_KeepsResumeStackInsideTheOwningFiberContext() { var memory = new FakeCpuMemory(Base, RegionSize); var context = new CpuContext(memory, Generation.Gen5); InitializeFiber(memory, context, FiberA, NameA, EntryA, ArgOnInitializeA, ContextA); InitializeFiber(memory, context, FiberB, NameB, EntryB, ArgOnInitializeB, ContextB); GuestThreadExecution.EnterImportCallFrame(RootReturnRip, RootResumeRsp, RootReturnSlot); context[CpuRegister.Rdi] = FiberA; context[CpuRegister.Rsi] = 0; context[CpuRegister.Rdx] = ArgSlotRoot; Assert.Equal(0, FiberExports.FiberRun(context)); Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toA)); // A fresh fiber must start on its own context block, not on the stack of // the thread that ran it. Assert.InRange(toA.Rsp, ContextA, ContextA + ContextSize); Assert.InRange(toA.ReturnSlotAddress, ContextA, ContextA + ContextSize); Switch(memory, context, from: FiberA, to: FiberB, ResumeRipA, ResumeRspA, ReturnSlotA, ArgSlotA, argOnRun: 0, Sentinels(0xA0)); Assert.True(GuestThreadExecution.TryConsumeCurrentContextTransfer(out var toB)); Assert.InRange(toB.Rsp, ContextB, ContextB + ContextSize); Assert.InRange(toB.ReturnSlotAddress, ContextB, ContextB + ContextSize); Assert.NotEqual(toA.Rsp, toB.Rsp); } private static void Switch( FakeCpuMemory memory, CpuContext context, ulong from, ulong to, ulong resumeRip, ulong resumeRsp, ulong returnSlot, ulong argOnReturnAddress, ulong argOnRun, IReadOnlyDictionary sentinels) { Assert.Equal(from, GuestThreadExecution.CurrentFiberAddress); // The guest reaches sceFiberSwitch through the import trampoline, which // is what publishes the resume point for the calling fiber. GuestThreadExecution.EnterImportCallFrame(resumeRip, resumeRsp, returnSlot); foreach (var (register, value) in sentinels) { context[register] = value; } context[CpuRegister.Rdi] = to; context[CpuRegister.Rsi] = argOnRun; context[CpuRegister.Rdx] = argOnReturnAddress; Assert.Equal(0, FiberExports.FiberSwitch(context)); Assert.Equal(to, GuestThreadExecution.CurrentFiberAddress); _ = memory; } private static void AssertResume(GuestCpuContinuation actual, FiberExpectation? expected, string because) { Assert.NotNull(expected); Assert.Equal(expected.Rip, actual.Rip); Assert.Equal(expected.Rsp, actual.Rsp); Assert.Equal(expected.ReturnSlotAddress, actual.ReturnSlotAddress); // sceFiberSwitch returns SCE_OK to the fiber it resumes. Assert.Equal(0UL, actual.Rax); foreach (var (register, value) in expected.Sentinels) { var restored = register switch { CpuRegister.Rbx => actual.Rbx, CpuRegister.Rbp => actual.Rbp, CpuRegister.R12 => actual.R12, CpuRegister.R13 => actual.R13, CpuRegister.R14 => actual.R14, CpuRegister.R15 => actual.R15, _ => throw new InvalidOperationException($"Unexpected sentinel register {register}."), }; Assert.True(value == restored, $"{because}: {register} was 0x{restored:X16}, expected 0x{value:X16}."); } } private static IReadOnlyDictionary Sentinels(ulong seed) => new Dictionary { [CpuRegister.Rbx] = 0x1111_0000_0000_0000UL | seed, [CpuRegister.Rbp] = 0x2222_0000_0000_0000UL | seed, [CpuRegister.R12] = 0x3333_0000_0000_0000UL | seed, [CpuRegister.R13] = 0x4444_0000_0000_0000UL | seed, [CpuRegister.R14] = 0x5555_0000_0000_0000UL | seed, [CpuRegister.R15] = 0x6666_0000_0000_0000UL | seed, }; private static void InitializeFiber( FakeCpuMemory memory, CpuContext context, ulong fiber, ulong nameAddress, ulong entry, ulong argOnInitialize, ulong contextAddress) { memory.WriteCString(nameAddress, $"F{fiber:X}"); context[CpuRegister.Rdi] = fiber; context[CpuRegister.Rsi] = nameAddress; context[CpuRegister.Rdx] = entry; context[CpuRegister.Rcx] = argOnInitialize; context[CpuRegister.R8] = contextAddress; context[CpuRegister.R9] = ContextSize; Assert.Equal(0, FiberExports.FiberInitialize(context)); } private static uint ReadUInt32(FakeCpuMemory memory, ulong address) { Span buffer = stackalloc byte[sizeof(uint)]; Assert.True(memory.TryRead(address, buffer)); return BinaryPrimitives.ReadUInt32LittleEndian(buffer); } private static ulong ReadUInt64(FakeCpuMemory memory, ulong address) { Span buffer = stackalloc byte[sizeof(ulong)]; Assert.True(memory.TryRead(address, buffer)); return BinaryPrimitives.ReadUInt64LittleEndian(buffer); } private sealed record FiberExpectation( ulong Rip, ulong Rsp, ulong ReturnSlotAddress, IReadOnlyDictionary Sentinels); private sealed class ContextTransferScheduler : IGuestThreadScheduler { public bool SupportsGuestContextTransfer => true; public void RegisterGuestThreadContext(ulong threadHandle, CpuContext context) { } public bool TryStartThread(CpuContext creatorContext, GuestThreadStartRequest request, out string? error) => throw new NotSupportedException(); public bool TryJoinThread(CpuContext callerContext, ulong threadHandle, out ulong returnValue, out string? error) => throw new NotSupportedException(); public void Pump(CpuContext callerContext, string reason) { } public int WakeBlockedThreads(string wakeKey, int maxCount = int.MaxValue) => 0; public bool HasPendingGuestExceptionForCurrentThread() => false; public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority) => throw new NotSupportedException(); public bool TrySetGuestThreadAffinity(ulong guestThreadHandle, ulong affinityMask) => throw new NotSupportedException(); public IReadOnlyList SnapshotThreads() => Array.Empty(); public bool TryCallGuestFunction(CpuContext callerContext, ulong entryPoint, ulong arg0, ulong arg1, ulong stackAddress, ulong stackSize, string reason, out string? error) => throw new NotSupportedException(); public bool TryCallGuestFunction(CpuContext callerContext, ulong entryPoint, ulong arg0, ulong arg1, ulong arg2, ulong stackAddress, ulong stackSize, string reason, out ulong returnValue, out string? error) => throw new NotSupportedException(); public bool TryCallGuestFunction(CpuContext callerContext, ulong entryPoint, ulong arg0, ulong arg1, ulong arg2, ulong arg3, ulong stackAddress, ulong stackSize, string reason, out ulong returnValue, out string? error) => throw new NotSupportedException(); public bool TryCallGuestContinuation(CpuContext callerContext, GuestCpuContinuation continuation, string reason, out string? error) => throw new NotSupportedException(); public bool TryRaiseGuestException(CpuContext callerContext, ulong threadHandle, ulong handler, int exceptionType, out string? error) => throw new NotSupportedException(); } }