diff --git a/ps2xRecomp/include/ps2recomp/elf_parser.h b/ps2xRecomp/include/ps2recomp/elf_parser.h index c8cafe5ee..10f9e4ce8 100644 --- a/ps2xRecomp/include/ps2recomp/elf_parser.h +++ b/ps2xRecomp/include/ps2recomp/elf_parser.h @@ -1,6 +1,7 @@ #ifndef PS2RECOMP_ELF_PARSER_H #define PS2RECOMP_ELF_PARSER_H +#include #include #include #include diff --git a/ps2xRuntime/include/runtime/ps2_memory.h b/ps2xRuntime/include/runtime/ps2_memory.h index 30fc455bd..be45a68bf 100644 --- a/ps2xRuntime/include/runtime/ps2_memory.h +++ b/ps2xRuntime/include/runtime/ps2_memory.h @@ -343,6 +343,7 @@ class PS2Memory void processVIF1Data(const uint8_t *data, uint32_t sizeBytes); void processPendingTransfers(); std::vector consumeCompletedDmacCauses(); + uint32_t consumeVif1InterruptEdges(); int pollDmaRegisters(); @@ -420,6 +421,7 @@ class PS2Memory std::vector m_pendingVif1Transfers; std::mutex m_completedDmacMutex; std::vector m_completedDmacCauses; + std::atomic m_vif1InterruptEdges{0u}; // I-bit VIFcodes seen since last consume struct CodeRegion { diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h index 52fa84a45..b9e1dcd4e 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/Helpers/Support.h @@ -1,6 +1,11 @@ #include #include +namespace ps2_syscalls +{ + void raisePendingIntc(uint32_t cause); +} + namespace { constexpr uint32_t kCdSectorSize = 2048; @@ -1436,6 +1441,15 @@ namespace ps2_syscalls::dispatchDmacHandlersForCause(rdram, runtime, completedCause); } + // VIF1 INTC cause 5 is asserted by a VIFcode I bit, decoded during the + // processPendingTransfers() parse above. Raise only when such an edge was + // actually seen. Delivery stays deferred to the next drain: sce libdma + // registers the cause-5 handler after this kick returns. + if (mem.consumeVif1InterruptEdges() != 0u) + { + ps2_syscalls::raisePendingIntc(5u); + } + return 0; } diff --git a/ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.cpp b/ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.cpp index f7f1cf99a..39cdaf5d5 100644 --- a/ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.cpp +++ b/ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.cpp @@ -3,6 +3,8 @@ #include "ps2_log.h" #include "Stubs/GS.h" +#include + namespace ps2_syscalls { namespace interrupt_state @@ -24,10 +26,35 @@ namespace ps2_syscalls uint32_t g_enabled_dmac_mask = 0xFFFFFFFFu; uint64_t g_vsync_tick_counter = 0u; VSyncFlagRegistration g_vsync_registration{}; + + std::atomic g_pending_intc_causes{0u}; // bitmask, one pending bit per cause + std::atomic g_pending_intc_raise_ns[32] = {}; // steady_clock ns at last raise, per cause + // The raise-timestamp entries are atomic because raisePendingIntc (any + // thread) stores a fresh timestamp while the interrupt worker thread + // reads it in drainPendingIntc. } using namespace interrupt_state; + namespace + { + SteadyNowFn g_pendingIntcNowFn{}; // empty = real clock + + int64_t now_ns() + { + const auto tp = g_pendingIntcNowFn ? g_pendingIntcNowFn() + : std::chrono::steady_clock::now(); + return std::chrono::duration_cast( + tp.time_since_epoch()) + .count(); + } + } + + void setPendingIntcClockForTest(SteadyNowFn fn) + { + g_pendingIntcNowFn = std::move(fn); + } + static void writeGuestU32NoThrow(uint8_t *rdram, uint32_t addr, uint32_t value) { if (addr == 0u) @@ -96,11 +123,11 @@ namespace ps2_syscalls return (s_cachedStackTop != 0u) ? s_cachedStackTop : (PS2_RAM_SIZE - 0x10u); } - static void dispatchIntcHandlersForCause(uint8_t *rdram, PS2Runtime *runtime, uint32_t cause) + static int dispatchAndCountIntcHandlersForCause(uint8_t *rdram, PS2Runtime *runtime, uint32_t cause) { if (!rdram || !runtime) { - return; + return 0; } std::vector handlers; @@ -108,7 +135,7 @@ namespace ps2_syscalls std::lock_guard lock(g_irq_handler_mutex); if (cause < 32u && (g_enabled_intc_mask & (1u << cause)) == 0u) { - return; + return 0; } handlers.reserve(g_intcHandlers.size()); @@ -133,6 +160,7 @@ namespace ps2_syscalls { return a.order < b.order; }); } + int dispatchedCount = 0; for (const IrqHandlerInfo &info : handlers) { if (!runtime->hasFunction(info.handler)) @@ -157,6 +185,7 @@ namespace ps2_syscalls continue; } + ++dispatchedCount; try { R5900Context irqCtx{}; @@ -216,6 +245,96 @@ namespace ps2_syscalls } } } + + return dispatchedCount; + } + + void raisePendingIntc(uint32_t cause) + { + if (cause >= 32u || cause == kIntcVblankStart || cause == kIntcVblankEnd) + { + return; + } + + const uint32_t bit = 1u << cause; + g_pending_intc_raise_ns[cause].store(now_ns(), std::memory_order_relaxed); // every raise + const uint32_t prev = g_pending_intc_causes.fetch_or(bit, std::memory_order_acq_rel); + if ((prev & bit) == 0u) + { + PS2_IF_AGRESSIVE_LOGS({ + static std::atomic s_raiseLogCount{0u}; + const uint32_t logIndex = s_raiseLogCount.fetch_add(1u, std::memory_order_relaxed); + if (logIndex < 16u || (logIndex % 256u) == 0u) + { + RUNTIME_LOG("[INTC:raise] cause=" << cause); + } + }); + } + } + + void drainPendingIntc(uint8_t *rdram, PS2Runtime *runtime) + { + uint32_t pending = g_pending_intc_causes.load(std::memory_order_acquire); + while (pending != 0u) + { + const uint32_t cause = static_cast(std::countr_zero(pending)); + const uint32_t bit = 1u << cause; + pending &= ~bit; + + const int ran = dispatchAndCountIntcHandlersForCause(rdram, runtime, cause); + if (ran > 0) + { + // Level-triggered: concurrent same-cause raises collapse into one delivery (intentional). + g_pending_intc_causes.fetch_and(~bit, std::memory_order_acq_rel); + PS2_IF_AGRESSIVE_LOGS({ + static std::atomic s_deliverLogCount{0u}; + const uint32_t logIndex = s_deliverLogCount.fetch_add(1u, std::memory_order_relaxed); + if (logIndex < 16u || (logIndex % 256u) == 0u) + { + RUNTIME_LOG("[INTC:deliver] cause=" << cause << " handlers=" << ran); + } + }); + } + else + { + const int64_t age = now_ns() - g_pending_intc_raise_ns[cause].load(std::memory_order_relaxed); + if (age > kPendingIntcMaxAgeNs) + { + g_pending_intc_causes.fetch_and(~bit, std::memory_order_acq_rel); + PS2_IF_AGRESSIVE_LOGS({ + static std::atomic s_dropLogCount{0u}; + const uint32_t logIndex = s_dropLogCount.fetch_add(1u, std::memory_order_relaxed); + if (logIndex < 16u || (logIndex % 256u) == 0u) + { + RUNTIME_LOG("[INTC:drop] cause=" << cause << " aged out with no registered/enabled handler"); + } + }); + } + } + } + } + + void resetInterruptHandlerState() + { + { + std::lock_guard lock(g_irq_handler_mutex); + g_intcHandlers.clear(); + g_dmacHandlers.clear(); + g_nextIntcHandlerId = 1; + g_nextDmacHandlerId = 1; + g_intc_head_order = 0; + g_intc_tail_order = 1000; + g_dmac_head_order = 0; + g_dmac_tail_order = 1000; + g_enabled_intc_mask = 0xFFFFFFFFu; + g_enabled_dmac_mask = 0xFFFFFFFFu; + } + g_pending_intc_causes.store(0u, std::memory_order_release); + for (auto &ts : g_pending_intc_raise_ns) + { + ts.store(0, std::memory_order_relaxed); + } + setPendingIntcClockForTest(nullptr); // a leaked fake clock would leak into the next test } void dispatchDmacHandlersForCause(uint8_t *rdram, PS2Runtime *runtime, uint32_t cause) @@ -408,7 +527,7 @@ namespace ps2_syscalls PS2Runtime::DeferredGuestYieldScope deferYield(reschedulePending); const uint64_t tickValue = signalVSyncFlag(rdram, runtime); ps2_stubs::dispatchGsSyncVCallback(rdram, runtime, tickValue); - dispatchIntcHandlersForCause(rdram, runtime, kIntcVblankStart); + dispatchAndCountIntcHandlersForCause(rdram, runtime, kIntcVblankStart); handoffBaseline = runtime->guestExecutionHandoffEpochSnapshot(); } if (reschedulePending && !runtime->isStopRequested()) @@ -416,7 +535,8 @@ namespace ps2_syscalls runtime->waitForGuestExecutionHandoff(handoffBaseline); } std::this_thread::sleep_for(std::chrono::microseconds(500)); - dispatchIntcHandlersForCause(rdram, runtime, kIntcVblankEnd); + dispatchAndCountIntcHandlersForCause(rdram, runtime, kIntcVblankEnd); + drainPendingIntc(rdram, runtime); } } diff --git a/ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.h b/ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.h index eb9ba5f03..6695af19d 100644 --- a/ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.h +++ b/ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.h @@ -1,6 +1,10 @@ #pragma once +#include +#include #include +#include +#include #include "ps2_syscalls.h" namespace ps2_syscalls @@ -24,9 +28,18 @@ namespace ps2_syscalls extern uint32_t g_enabled_dmac_mask; extern uint64_t g_vsync_tick_counter; extern VSyncFlagRegistration g_vsync_registration; + extern std::atomic g_pending_intc_causes; + constexpr int64_t kPendingIntcMaxAgeNs = 2'000'000'000; // ~2 s wall-clock } void dispatchDmacHandlersForCause(uint8_t *rdram, PS2Runtime *runtime, uint32_t cause); + void raisePendingIntc(uint32_t cause); + void drainPendingIntc(uint8_t *rdram, PS2Runtime *runtime); + // Test-support: reset all INTC/DMAC handler bookkeeping to process-start + // defaults so regression tests are order-independent. + void resetInterruptHandlerState(); + using SteadyNowFn = std::function; + void setPendingIntcClockForTest(SteadyNowFn fn); // nullptr restores steady_clock::now void EnsureVSyncWorkerRunning(uint8_t *rdram, PS2Runtime *runtime); uint64_t GetCurrentVSyncTick(); void stopInterruptWorker(); diff --git a/ps2xRuntime/src/lib/ps2_memory.cpp b/ps2xRuntime/src/lib/ps2_memory.cpp index 3d593285a..8407025a9 100644 --- a/ps2xRuntime/src/lib/ps2_memory.cpp +++ b/ps2xRuntime/src/lib/ps2_memory.cpp @@ -297,6 +297,7 @@ bool PS2Memory::initialize(size_t ramSize) std::lock_guard lock(m_completedDmacMutex); m_completedDmacCauses.clear(); } + m_vif1InterruptEdges.store(0u, std::memory_order_relaxed); m_codeRegions.clear(); m_path3Masked = false; m_path3MaskedFifo.clear(); @@ -1665,6 +1666,11 @@ std::vector PS2Memory::consumeCompletedDmacCauses() return causes; } +uint32_t PS2Memory::consumeVif1InterruptEdges() +{ + return m_vif1InterruptEdges.exchange(0u, std::memory_order_relaxed); +} + void PS2Memory::flushMaskedPath3Packets(bool drainImmediately) { if (m_path3Masked || m_path3MaskedFifo.empty()) diff --git a/ps2xRuntime/src/lib/ps2_runtime.cpp b/ps2xRuntime/src/lib/ps2_runtime.cpp index b79e386a5..b8976e366 100644 --- a/ps2xRuntime/src/lib/ps2_runtime.cpp +++ b/ps2xRuntime/src/lib/ps2_runtime.cpp @@ -9,6 +9,7 @@ #include "Kernel/Stubs/Audio.h" #include "Kernel/Stubs/GS.h" #include "Kernel/Stubs/MPEG.h" +#include "Kernel/Syscalls/Interrupt.h" #include "ps2_host_backend.h" #include "ps2_iop_host.h" #include "ps2x/iop/iop_subsystem.h" @@ -1404,6 +1405,15 @@ void PS2Runtime::drainCompletedDmacHandlers(uint8_t *rdram) { ps2_syscalls::dispatchDmacHandlersForCause(rdram, this, cause); } + + // Catch-all for VIF1 I-bit edges parsed outside submitDmaSend's own + // consume (GS/Font/GIF-chain paths that call processVIF1Data within the + // frame) so they get delivered here instead of leaking into a later, + // unrelated submitDmaSend kick. + if (m_memory.consumeVif1InterruptEdges() != 0u) + { + ps2_syscalls::raisePendingIntc(5u); + } } void PS2Runtime::handleTrap(uint8_t *rdram, R5900Context *ctx) diff --git a/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp b/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp index c0af2a3ac..7bb6fd80c 100644 --- a/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp +++ b/ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp @@ -309,7 +309,11 @@ void PS2Memory::processVIF1Data(const uint8_t *data, uint32_t sizeBytes) vif1_regs.code = cmd; vif1_regs.num = num; if (irq) + { vif1_regs.stat |= (1u << 11); // INT + // Record the VIF1 INT edge for the deferred cause-5 raise. + m_vif1InterruptEdges.fetch_add(1u, std::memory_order_relaxed); + } if (opcode == VIF_NOP) { diff --git a/ps2xTest/src/ps2_runtime_interrupt_tests.cpp b/ps2xTest/src/ps2_runtime_interrupt_tests.cpp index b91b9d9bd..103f519d9 100644 --- a/ps2xTest/src/ps2_runtime_interrupt_tests.cpp +++ b/ps2xTest/src/ps2_runtime_interrupt_tests.cpp @@ -2,6 +2,7 @@ #include "ps2_runtime.h" #include "ps2_syscalls.h" #include "Stubs/DMA.h" +#include "Syscalls/Interrupt.h" #include "runtime/ps2_gs_gpu.h" #include @@ -52,6 +53,8 @@ namespace std::atomic g_dmacSendHits{0u}; std::atomic g_dmacSendLastCause{0u}; std::atomic g_dmacSendLastChcr{0u}; + std::atomic g_pendingIntcHits{0u}; + std::atomic g_pendingIntcLastCause{0xFFFFFFFFu}; void setRegU32(R5900Context &ctx, int reg, uint32_t value) { @@ -125,6 +128,9 @@ namespace { env.runtime.requestStop(); notifyRuntimeStop(); + // Defensive cleanup: reset the global INTC handler tables, enable masks, + // and pending latch so each test starts from a known INTC state. + resetInterruptHandlerState(); } void testIntcHandler(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) @@ -177,6 +183,18 @@ namespace ctx->pc = 0u; } + + void testPendingIntcHandler(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) + { + (void)rdram; + (void)runtime; + + const uint32_t cause = getRegU32(ctx, 4); + g_pendingIntcHits.fetch_add(1u, std::memory_order_relaxed); + g_pendingIntcLastCause.store(cause, std::memory_order_relaxed); + + ctx->pc = 0u; + } } void register_ps2_runtime_interrupt_tests() @@ -518,6 +536,123 @@ void register_ps2_runtime_interrupt_tests() cleanupRuntime(env); }); + tc.Run("I-bit VIF1 packet raises cause 5, delivered to a handler registered after the kick", [](TestCase &t) + { + notifyRuntimeStop(); + TestEnv env; + t.IsTrue(env.runtime.memory().initialize(), "runtime memory initialize should succeed"); + + constexpr uint32_t kHandlerAddr = 0x00ABD300u; + constexpr uint32_t kVif1Ch = 0x10009000u; + constexpr uint32_t kTag0 = 0x00028800u; + constexpr uint32_t kTag1 = kTag0 + 0x20u; + constexpr uint32_t kCause = 5u; + + uint8_t *rdram = env.runtime.memory().getRDRAM(); + writeDmaTag(rdram, kTag0, makeDmaTag(1u, 1u, 0u, false)); // CNT, qwc=1 + writeGuestU64(rdram, kTag0 + 0x10u, 0x0000000080000000ull); // I-bit VIF_NOP + writeGuestU64(rdram, kTag0 + 0x18u, 0u); + writeDmaTag(rdram, kTag1, makeDmaTag(0u, 7u, 0u, false)); // END + + stopInterruptWorker(); + interrupt_state::g_pending_intc_causes.store(0u); + g_pendingIntcHits.store(0u, std::memory_order_relaxed); + g_pendingIntcLastCause.store(0xFFFFFFFFu, std::memory_order_relaxed); + + // Kick before registering: sce libdma only registers the cause-5 + // handler after the kick returns -- the typical sce-libdma + // kick-before-register order. + R5900Context sendCtx{}; + setRegU32(sendCtx, 4, kVif1Ch); + setRegU32(sendCtx, 5, kTag0); + ps2_stubs::sceDmaSend(rdram, &sendCtx, &env.runtime); + + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), (1u << kCause), + "an I-bit VIFcode parsed by the kick should raise cause 5 before any handler is registered"); + + env.runtime.registerFunction(kHandlerAddr, &testPendingIntcHandler); + + R5900Context addCtx{}; + setRegU32(addCtx, 4, kCause); + setRegU32(addCtx, 5, kHandlerAddr); + setRegU32(addCtx, 6, 0u); + setRegU32(addCtx, 7, 0u); + t.IsTrue(callSyscall(0x10u, rdram, &addCtx, &env.runtime), "AddIntcHandler syscall should dispatch"); + t.IsTrue(getRegS32(addCtx, 2) > 0, "AddIntcHandler for cause 5 should return handler id"); + + R5900Context enableCtx{}; + setRegU32(enableCtx, 4, kCause); + t.IsTrue(callSyscall(0x14u, rdram, &enableCtx, &env.runtime), "EnableIntc syscall should dispatch"); + + // AddIntcHandler auto-starts the interrupt worker; stop it so this + // test thread is the only drainer. + stopInterruptWorker(); + + drainPendingIntc(rdram, &env.runtime); + + t.Equals(g_pendingIntcHits.load(std::memory_order_relaxed), 1u, "late-registered handler should run exactly once"); + t.Equals(g_pendingIntcLastCause.load(std::memory_order_relaxed), kCause, "handler should observe cause 5 in $a0"); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), 0u, + "pending bit 5 should be cleared after delivery"); + + cleanupRuntime(env); + }); + + tc.Run("VIF1 packet without an I bit does not raise cause 5", [](TestCase &t) + { + notifyRuntimeStop(); + TestEnv env; + t.IsTrue(env.runtime.memory().initialize(), "runtime memory initialize should succeed"); + + constexpr uint32_t kHandlerAddr = 0x00ABD380u; + constexpr uint32_t kVif1Ch = 0x10009000u; + constexpr uint32_t kTag0 = 0x00028A00u; + constexpr uint32_t kTag1 = kTag0 + 0x20u; + constexpr uint32_t kCause = 5u; + + uint8_t *rdram = env.runtime.memory().getRDRAM(); + writeDmaTag(rdram, kTag0, makeDmaTag(1u, 1u, 0u, false)); // CNT, qwc=1 + writeGuestU64(rdram, kTag0 + 0x10u, 0u); // plain VIF_NOP, no I bit + writeGuestU64(rdram, kTag0 + 0x18u, 0u); + writeDmaTag(rdram, kTag1, makeDmaTag(0u, 7u, 0u, false)); // END + + stopInterruptWorker(); + interrupt_state::g_pending_intc_causes.store(0u); + g_pendingIntcHits.store(0u, std::memory_order_relaxed); + g_pendingIntcLastCause.store(0xFFFFFFFFu, std::memory_order_relaxed); + + R5900Context sendCtx{}; + setRegU32(sendCtx, 4, kVif1Ch); + setRegU32(sendCtx, 5, kTag0); + ps2_stubs::sceDmaSend(rdram, &sendCtx, &env.runtime); + + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), 0u, + "an ordinary VIF1 packet with no I bit must not raise cause 5"); + + env.runtime.registerFunction(kHandlerAddr, &testPendingIntcHandler); + + R5900Context addCtx{}; + setRegU32(addCtx, 4, kCause); + setRegU32(addCtx, 5, kHandlerAddr); + setRegU32(addCtx, 6, 0u); + setRegU32(addCtx, 7, 0u); + t.IsTrue(callSyscall(0x10u, rdram, &addCtx, &env.runtime), "AddIntcHandler syscall should dispatch"); + t.IsTrue(getRegS32(addCtx, 2) > 0, "AddIntcHandler for cause 5 should return handler id"); + + R5900Context enableCtx{}; + setRegU32(enableCtx, 4, kCause); + t.IsTrue(callSyscall(0x14u, rdram, &enableCtx, &env.runtime), "EnableIntc syscall should dispatch"); + + stopInterruptWorker(); + + drainPendingIntc(rdram, &env.runtime); + + t.Equals(g_pendingIntcHits.load(std::memory_order_relaxed), 0u, + "a persistent cause-5 handler must not be invoked by an ordinary VIF1 packet with no I bit"); + + cleanupRuntime(env); + }); + tc.Run("MMIO VIF1 chain completion dispatches DMAC handler after CHCR store", [](TestCase &t) { notifyRuntimeStop(); @@ -887,5 +1022,201 @@ void register_ps2_runtime_interrupt_tests() cleanupRuntime(env); }); + + tc.Run("raisePendingIntc delivers to a registered handler on the next drain tick", [](TestCase &t) + { + notifyRuntimeStop(); + TestEnv env; + stopInterruptWorker(); + interrupt_state::g_pending_intc_causes.store(0u); + + constexpr uint32_t kCause = 5u; + constexpr uint32_t kHandlerAddr = 0x00ABE100u; + + g_pendingIntcHits.store(0u, std::memory_order_relaxed); + g_pendingIntcLastCause.store(0xFFFFFFFFu, std::memory_order_relaxed); + + env.runtime.registerFunction(kHandlerAddr, &testPendingIntcHandler); + + R5900Context addCtx{}; + setRegU32(addCtx, 4, kCause); + setRegU32(addCtx, 5, kHandlerAddr); + setRegU32(addCtx, 6, 0u); + setRegU32(addCtx, 7, 0u); + t.IsTrue(callSyscall(0x10u, env.rdram.data(), &addCtx, &env.runtime), "AddIntcHandler syscall should dispatch"); + t.IsTrue(getRegS32(addCtx, 2) > 0, "AddIntcHandler for cause 5 should return handler id"); + + R5900Context enableCtx{}; + setRegU32(enableCtx, 4, kCause); + t.IsTrue(callSyscall(0x14u, env.rdram.data(), &enableCtx, &env.runtime), "EnableIntc syscall should dispatch"); + + // AddIntcHandler auto-starts the interrupt worker thread; stop it so + // this test thread is the only drainer (avoids a double-dispatch race + // between the worker's own per-tick drain and the manual drain below). + stopInterruptWorker(); + + raisePendingIntc(kCause); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), (1u << kCause), + "pending bit 5 should be set after raise"); + + drainPendingIntc(env.rdram.data(), &env.runtime); + + t.Equals(g_pendingIntcHits.load(std::memory_order_relaxed), 1u, "handler should run exactly once"); + t.Equals(g_pendingIntcLastCause.load(std::memory_order_relaxed), kCause, + "handler should observe cause 5 in $a0"); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), 0u, + "pending bit 5 should be cleared after delivery"); + + cleanupRuntime(env); + }); + + tc.Run("re-raise at the age-out boundary is not dropped", [](TestCase &t) + { + notifyRuntimeStop(); + TestEnv env; + stopInterruptWorker(); + interrupt_state::g_pending_intc_causes.store(0u); + + // No cause-5 handler is registered in this test, and cleanupRuntime + // resets the process-global INTC handler table between tests, so the + // handler table starts empty here by construction (order-independent). + // Every drain therefore finds no registered+enabled handler for + // cause 5 and is a no-op until the wall-clock expiry fires. + constexpr uint32_t kCause = 5u; + + auto fakeNow = std::chrono::steady_clock::now(); + setPendingIntcClockForTest([&fakeNow]() { return fakeNow; }); + + raisePendingIntc(kCause); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), (1u << kCause), + "pending bit should be set after raise"); + + // Advance to just under the expiry window measured from the raise. + fakeNow += std::chrono::nanoseconds(interrupt_state::kPendingIntcMaxAgeNs - 1); + drainPendingIntc(env.rdram.data(), &env.runtime); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), (1u << kCause), + "pending bit should survive a drain just under the expiry window"); + + // Re-raise while still pending -- this refreshes the timestamp. + raisePendingIntc(kCause); + + // Advance again by just-under-the-window: measured from the + // ORIGINAL raise this is now well past expiry, but measured from + // the re-raise it is still just under. A comment-3 regression + // (only stamping the timestamp on the 0->1 edge) would drop the + // bit here. + fakeNow += std::chrono::nanoseconds(interrupt_state::kPendingIntcMaxAgeNs - 1); + drainPendingIntc(env.rdram.data(), &env.runtime); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), (1u << kCause), + "pending bit should survive when re-raised -- freshness is measured from the most recent raise"); + + // Advance past the window measured from the re-raise. + fakeNow += std::chrono::nanoseconds(interrupt_state::kPendingIntcMaxAgeNs + 1); + drainPendingIntc(env.rdram.data(), &env.runtime); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), 0u, + "pending bit should drop once the window has elapsed since the most recent raise"); + + cleanupRuntime(env); + }); + + tc.Run("a raise before worker startup cannot survive beyond the expiry window", [](TestCase &t) + { + notifyRuntimeStop(); + TestEnv env; + stopInterruptWorker(); + interrupt_state::g_pending_intc_causes.store(0u); + + constexpr uint32_t kCause = 5u; + + auto fakeNow = std::chrono::steady_clock::now(); + setPendingIntcClockForTest([&fakeNow]() { return fakeNow; }); + + // No worker started and no drain yet -- this models a cause raised + // synchronously before the game ever reaches a VSync/AddIntc path + // that would start the interrupt worker. + raisePendingIntc(kCause); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), (1u << kCause), + "pending bit should be set after raise"); + + fakeNow += std::chrono::nanoseconds(interrupt_state::kPendingIntcMaxAgeNs + 1); + + // The worker's first tick after a late start. + drainPendingIntc(env.rdram.data(), &env.runtime); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), 0u, + "a raise that predates worker startup by more than the expiry window must not survive " + "the worker's first drain"); + + cleanupRuntime(env); + }); + + tc.Run("pending cause persists across the raise-vs-registration race", [](TestCase &t) + { + notifyRuntimeStop(); + TestEnv env; + stopInterruptWorker(); + interrupt_state::g_pending_intc_causes.store(0u); + + constexpr uint32_t kCause = 5u; + constexpr uint32_t kHandlerAddr = 0x00ABE200u; + + g_pendingIntcHits.store(0u, std::memory_order_relaxed); + g_pendingIntcLastCause.store(0xFFFFFFFFu, std::memory_order_relaxed); + + raisePendingIntc(kCause); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), (1u << kCause), + "pending bit should be set after raise"); + + drainPendingIntc(env.rdram.data(), &env.runtime); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), (1u << kCause), + "pending bit should survive a drain while no handler is registered yet"); + + env.runtime.registerFunction(kHandlerAddr, &testPendingIntcHandler); + + R5900Context addCtx{}; + setRegU32(addCtx, 4, kCause); + setRegU32(addCtx, 5, kHandlerAddr); + setRegU32(addCtx, 6, 0u); + setRegU32(addCtx, 7, 0u); + t.IsTrue(callSyscall(0x10u, env.rdram.data(), &addCtx, &env.runtime), "AddIntcHandler syscall should dispatch"); + t.IsTrue(getRegS32(addCtx, 2) > 0, "AddIntcHandler for cause 5 should return handler id"); + + R5900Context enableCtx{}; + setRegU32(enableCtx, 4, kCause); + t.IsTrue(callSyscall(0x14u, env.rdram.data(), &enableCtx, &env.runtime), "EnableIntc syscall should dispatch"); + + // AddIntcHandler auto-starts the worker thread; it may legitimately + // deliver the still-pending cause on its own tick before we stop it. + // Either that path or the manual drain below is correct -- the + // level-triggered bit guarantees exactly one delivery either way. + stopInterruptWorker(); + + drainPendingIntc(env.rdram.data(), &env.runtime); + + t.Equals(g_pendingIntcHits.load(std::memory_order_relaxed), 1u, + "handler should run exactly once total, delivered by the worker or the manual drain"); + t.Equals(g_pendingIntcLastCause.load(std::memory_order_relaxed), kCause, + "delivered handler should observe cause 5 in $a0"); + t.Equals(interrupt_state::g_pending_intc_causes.load() & (1u << kCause), 0u, + "pending bit should be cleared after delivery"); + + cleanupRuntime(env); + }); + + tc.Run("vblank causes are excluded from the pending latch", [](TestCase &t) + { + notifyRuntimeStop(); + TestEnv env; + stopInterruptWorker(); + interrupt_state::g_pending_intc_causes.store(0u); + + raisePendingIntc(2u); // VBLANK start + raisePendingIntc(3u); // VBLANK end + raisePendingIntc(32u); // out of range + + t.Equals(interrupt_state::g_pending_intc_causes.load(), 0u, + "vblank causes and out-of-range causes must never set the pending latch"); + + cleanupRuntime(env); + }); }); }