From 13e6da9d9f5d769d17a532aabfec2c0bda112ad7 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:06:22 +0300 Subject: [PATCH 01/13] fix: save or load a state only at a check given its resume address --- CMakeLists.txt | 12 + PSXRECOMP_OVERLAY_FILES.txt | 2 + .../runtime/include/save_point.h | 26 + psxrecomp-overlay/runtime/src/interrupts.c | 1669 +++++++++++++++++ tests/save_point_test.cpp | 101 + tests/test_save_point_contract.py | 133 ++ 6 files changed, 1943 insertions(+) create mode 100644 psxrecomp-overlay/runtime/include/save_point.h create mode 100644 psxrecomp-overlay/runtime/src/interrupts.c create mode 100644 tests/save_point_test.cpp create mode 100644 tests/test_save_point_contract.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 1dfdd43..a477231 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -212,6 +212,10 @@ if(BUILD_TESTING) COMMAND "${Python3_EXECUTABLE}" -B "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_loaded_state_contract.py") + add_test(NAME disruptor_save_point_contract + COMMAND "${Python3_EXECUTABLE}" -B + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_save_point_contract.py") + add_test(NAME disruptor_cheats_contract COMMAND "${Python3_EXECUTABLE}" -B "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cheats_contract.py") @@ -589,6 +593,14 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) add_test(NAME disruptor_gpu_world_depth COMMAND disruptor-gpu-world-depth-test) + add_executable(disruptor-save-point-test + tests/save_point_test.cpp) + target_include_directories(disruptor-save-point-test PRIVATE + "${PSXRECOMP_ROOT}/runtime/include") + target_compile_features(disruptor-save-point-test PRIVATE cxx_std_20) + add_test(NAME disruptor_save_point + COMMAND disruptor-save-point-test) + add_executable(disruptor-presentation-yaw-math-test tests/presentation_yaw_math_test.cpp) target_compile_features(disruptor-presentation-yaw-math-test PRIVATE cxx_std_20) diff --git a/PSXRECOMP_OVERLAY_FILES.txt b/PSXRECOMP_OVERLAY_FILES.txt index f8d67f5..a0f2939 100644 --- a/PSXRECOMP_OVERLAY_FILES.txt +++ b/PSXRECOMP_OVERLAY_FILES.txt @@ -31,6 +31,7 @@ runtime/include/gpu_sw_renderer.h +runtime/include/iso_overlay.h runtime/include/mod_memory.h runtime/include/psx_cyc.h ++runtime/include/save_point.h runtime/include/overlay_dispatch_preamble.c.inc runtime/include/ws_aspect_cone_math.h runtime/src/boot_state.c @@ -40,6 +41,7 @@ runtime/src/gpu_gl_renderer.c +runtime/src/gpu_gl_temporal.c.inc runtime/src/gpu_render.c runtime/src/gte.cpp +runtime/src/interrupts.c runtime/src/iso_reader_c.cpp runtime/src/main.cpp runtime/src/memory.c diff --git a/psxrecomp-overlay/runtime/include/save_point.h b/psxrecomp-overlay/runtime/include/save_point.h new file mode 100644 index 0000000..0de4332 --- /dev/null +++ b/psxrecomp-overlay/runtime/include/save_point.h @@ -0,0 +1,26 @@ +/* When an interrupt check polls for a waiting save or load of a state. */ +#ifndef PSXRECOMP_SAVE_POINT_H +#define PSXRECOMP_SAVE_POINT_H + +#ifdef __cplusplus +extern "C" { +#endif + +int savestate_pending(void); + +/* 1 when this check must poll. A periodic poll that meets a request at a check with no address of its own is owed to the next that has one. */ +static inline int psx_save_poll_due(int periodic, int save_point, int *owed) { + if (!save_point) { + if (periodic) *owed = savestate_pending(); + return 0; + } + if (!periodic && !*owed) return 0; + *owed = 0; + return 1; +} + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/psxrecomp-overlay/runtime/src/interrupts.c b/psxrecomp-overlay/runtime/src/interrupts.c new file mode 100644 index 0000000..f105a56 --- /dev/null +++ b/psxrecomp-overlay/runtime/src/interrupts.c @@ -0,0 +1,1669 @@ +/* + * interrupts.c — v4 interrupt delivery for recompiled BIOS. + * + * Pure hardware simulation. No BIOS state, no HLE, no interpreter. + * + * Since recompiled code runs as native C (no per-instruction stepping), + * interrupt delivery happens at dispatch loop boundaries. The dispatch + * loop calls psx_check_interrupts() after each function returns. + * + * Vblank is fired on a dispatch-count schedule to approximate 60 Hz. + * + * ReturnFromException handling: + * On real hardware, ReturnFromException (B0:0x17 or SYSCALL(3)) + * restores the full register context from the TCB and jumps to the + * saved EPC. This effectively "longjmps" out of the exception + * handler, bypassing any remaining chain-walk code. + * + * In our recompiled model the exception handler runs as a nested + * psx_dispatch call. A normal function return would unwind only one + * frame, leaving the chain walker running with corrupted registers. + * We use setjmp/longjmp to model the real hardware behaviour: + * psx_check_interrupts sets a jump point before dispatching the + * handler, and psx_exception_longjmp() (called by the runtime's + * ReturnFromException implementation) longjmps back, unwinding the + * entire handler call tree in one step. + */ + +#include "interrupts.h" +#include "sio.h" +#include "timers.h" +#include "gpu.h" +#include "cdrom.h" +#include "dma.h" +#include "cpu_state.h" +#include "debug_server.h" +#include "event_ring.h" +#include "lockstep.h" +#include "psx_cycles.h" +#include "psx_scheduler.h" +#include "save_point.h" +#include +#include +#include +#include + +/* Event-timeline ring: execution-mode flag owned by dirty_ram_interp.c. The + * static BIOS exception handler is NOT interp code, so we clear it around the + * handler dispatch (see psx_check_interrupts). */ +extern int g_dirty_interp_active; +extern uint32_t g_dirty_safe_resume_pc; + +/* IRQ-delivery context ring (MMX6 VSync-vs-CD-DMA hunt; dumped via `irqctx_ring`). */ +#define IRQCTX_RING_CAP 4096u +typedef struct { + uint64_t seq, cycle; + uint32_t frame, istat, imask, sr, d44, cdrom_active, is_vblank; + int dma_depth; + /* Exception-EXIT half (Tomba menu IRQ-resume wedge): filled when this + * delivery returns to the interrupted code, so the ring shows for EVERY + * delivery which escape path fired and whether the interrupted GPRs were + * restored. take_pc/real_epc are the entry-side resume selection. */ + uint32_t take_pc; /* resume PC selected at entry (0 = boundary/sentinel) */ + uint32_t real_epc; /* g_exception_real_epc installed at entry */ + uint32_t exit_pc; /* cpu->pc at the restore decision */ + uint32_t exit_reason; /* g_exc_escape_reason at exit */ + uint32_t same_thread; /* same_thread_resume discriminator result */ + uint32_t restored; /* saved_gpr restore fired */ + uint32_t v1_exit; /* cpu->gpr[3] at exit before restore decision */ + uint32_t v1_saved; /* saved_gpr[3] (interrupted code's v1) */ + uint32_t ra_exit; /* cpu->gpr[31] at exit before restore decision */ + uint32_t ra_saved; /* saved_gpr[31] */ + uint32_t redirects; /* jmp_val==2 RestoreState redirects in this delivery */ + /* Ape memcard native<->interp resume-desync probe: the live sp and the + * dirty pump-site at IRQ ENTRY. The interrupted-thread TCB save captures + * exactly this (resume_pc=take_pc, sp=entry_sp); a pair where take_pc is a + * dirty return point but entry_sp belongs to a native callee's frame is the + * corrupt snapshot (0x801384AC paired with 0x801EFE80). */ + uint32_t entry_sp; /* cpu->gpr[29] at exception entry */ + uint32_t pump_site; /* g_cosim_dirty_pump_site at entry (which delivery path) */ +} IrqCtxEntry; +IrqCtxEntry g_irqctx_ring[IRQCTX_RING_CAP]; +uint64_t g_irqctx_seq = 0; +extern uint64_t psx_get_cycle_count(void); + +/* Record an interrupt-delivery decision into the event ring. GATE outcomes are + * edge-suppressed (they repeat every block while blocked); DELIVER is always + * recorded (once per interrupt); the not-pending (idle) case emits nothing but + * still updates the edge key so the next gate/deliver is captured. */ +static void irq_record_outcome(uint8_t kind, uint8_t detail, uint32_t aux) { + static uint16_t s_last = 0xFFFFu; + uint16_t key = ((uint16_t)kind << 8) | detail; + int repeat = (key == s_last); + s_last = key; + if (kind == EV_NONE) return; /* idle: update key only */ + /* IRQ_DELIVER: aux carries the architectural TAKE-PC (the resume PC), so the + * interrupt-take point can be compared across backends at the SAME guest + * cycle — native takes the IRQ at a basic-block boundary, the dirty-interp + * per-instruction. A take-PC that diverges at identical cycle is the + * take-point-granularity signature (PRINCIPLES "Control Flow Semantics": + * model the interrupt frame explicitly; the class fix is precise event + * slicing). The independent Beetle oracle records the same field. */ + if (kind == EV_IRQ_DELIVER) { event_ring_record_aux(EV_IRQ_DELIVER, detail, aux); return; } + if (!repeat) event_ring_record(kind, detail);/* GATE: edge only */ +} + +/* COP0 register indices */ +#define COP0_SR 12 +#define COP0_CAUSE 13 +#define COP0_EPC 14 + +/* I_STAT and I_MASK are owned by memory.c */ +extern uint32_t i_stat; +extern uint32_t i_mask; + +/* Device-event cycle ring (device_trace.c): every hardware IRQ-raise edge is + * recorded here with its guest cycle. */ +#include "device_trace.h" + +/* Central IRQ-raise choke point. All device sources call this to set their + * I_STAT bit so the device-event ring sees every raise from one place with the + * exact guest cycle. Pure addition over `i_stat |= (1<seq = s_cosim_irq_seq++; + e->cycle = psx_get_cycle_count(); + e->kind = kind; + e->cpu_pc = cpu ? cpu->pc : 0; + e->take_pc = take_pc; + e->dirty_resume_pc = dirty_resume_pc; + e->compiled_resume_pc = compiled_resume_pc; + e->epc = cpu ? cpu->cop0[COP0_EPC] : 0; + e->sr_before = sr_before; + e->sr_after = cpu ? cpu->cop0[COP0_SR] : 0; + e->cause = cpu ? cpu->cop0[COP0_CAUSE] : 0; + e->istat = i_stat; + e->imask = i_mask; + e->func = g_debug_current_func_addr; + e->block = cosim_last_block(); + e->native = overlay_loader_get_inprogress(); + { /* remaining cooldown cycles (0 = inactive) for the cosim ring display */ + uint64_t now = psx_get_cycle_count(); + e->cooldown = (post_exception_cooldown_until > now) + ? (int32_t)(post_exception_cooldown_until - now) : 0; + } + e->dirty_site = g_cosim_dirty_pump_site; +} + +void interrupts_cosim_irq_dump(char *out, int cap) +{ + if (!out || cap <= 0) return; + char *p = out; + size_t rem = (size_t)cap; + uint64_t total = s_cosim_irq_seq < COSIM_IRQ_RING_CAP ? s_cosim_irq_seq : COSIM_IRQ_RING_CAP; + uint64_t count = total; + if (count > 16u) count = 16u; + int w = snprintf(p, rem, "irqtrace count %llu", + (unsigned long long)count); + if (w < 0 || (size_t)w >= rem) { out[cap - 1] = 0; return; } + p += w; rem -= (size_t)w; + for (uint64_t i = 0; i < count && rem > 1; i++) { + uint64_t seq = s_cosim_irq_seq - count + i; + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + w = snprintf(p, rem, + " ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", + (unsigned long long)e->seq, + e->kind, + (unsigned long long)e->cycle, + e->cpu_pc, e->take_pc, e->dirty_resume_pc, + e->compiled_resume_pc, e->epc, e->sr_before, + e->sr_after, e->cause, e->istat, e->imask, + e->func, e->block, e->native, + e->cooldown, e->dirty_site); + if (w < 0 || (size_t)w >= rem) { break; } + p += w; rem -= (size_t)w; + } + w = snprintf(p, rem, " | deliveries"); + if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; } + uint64_t emitted = 0; + for (uint64_t scanned = 0; scanned < total && emitted < 16u && rem > 1; scanned++) { + uint64_t seq = s_cosim_irq_seq - 1u - scanned; + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + if (e->kind != 1u) continue; + w = snprintf(p, rem, + " ; seq %llu cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", + (unsigned long long)e->seq, + (unsigned long long)e->cycle, + e->cpu_pc, e->take_pc, e->dirty_resume_pc, + e->compiled_resume_pc, e->epc, e->sr_before, + e->sr_after, e->cause, e->istat, e->imask, + e->func, e->block, e->native, + e->cooldown, e->dirty_site); + if (w < 0 || (size_t)w >= rem) { break; } + p += w; rem -= (size_t)w; + emitted++; + } + uint64_t latest_delivery = UINT64_MAX; + for (uint64_t scanned = 0; scanned < total; scanned++) { + uint64_t seq = s_cosim_irq_seq - 1u - scanned; + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + if (e->kind == 1u) { latest_delivery = e->seq; break; } + } + if (latest_delivery != UINT64_MAX && rem > 1) { + w = snprintf(p, rem, " | pre_delivery"); + if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; } + uint64_t first = (latest_delivery >= 15u) ? (latest_delivery - 15u) : 0u; + for (uint64_t seq = first; seq <= latest_delivery && rem > 1; seq++) { + CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; + w = snprintf(p, rem, + " ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", + (unsigned long long)e->seq, + e->kind, + (unsigned long long)e->cycle, + e->cpu_pc, e->take_pc, e->dirty_resume_pc, + e->compiled_resume_pc, e->epc, e->sr_before, + e->sr_after, e->cause, e->istat, e->imask, + e->func, e->block, e->native, + e->cooldown, e->dirty_site); + if (w < 0 || (size_t)w >= rem) { break; } + p += w; rem -= (size_t)w; + } + } + if (rem >= 2) { + p[0] = '\n'; + p[1] = 0; + } else if (cap == 1) { + out[0] = 0; + } else { + out[cap - 2] = '\n'; + out[cap - 1] = 0; + } +} +#endif + +static void note_sio_progress_cycle(void) { + uint32_t cur_sio_seq = sio_get_seq(); + if (cur_sio_seq != last_sio_seq_seen) { + last_sio_seq_seen = cur_sio_seq; + last_sio_progress_cycle = psx_get_cycle_count(); + } +} + +static int should_defer_vblank_for_sio(void) { + if (!sio_card_protocol_active()) return 0; + uint64_t now = psx_get_cycle_count(); + uint64_t since_progress = now >= last_sio_progress_cycle + ? now - last_sio_progress_cycle + : 0; + return since_progress < VBLANK_DEFER_STALE_CYCLES; +} + +static void fire_vblank_edge(void) { + /* Subtract one VBlank period rather than reset to 0 so cycle overshoot + * carries forward. Prevents long-running blocks from rounding multiple + * VBlanks together. */ + cycles_since_vblank -= VBLANK_CYCLES; + dispatch_count = 0; + /* DEQUEUE: this VBlank fired. ENQUEUE: next VBlank scheduled one period out. */ + event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, + (uint32_t)psx_get_cycle_count()); + event_ring_record_aux(EV_ENQ, (uint8_t)SRC_VBLANK, + (uint32_t)(psx_get_cycle_count() + VBLANK_CYCLES)); + psx_irq_raise(IRQ_VBLANK, 0); + g_vblank_raise_count++; + event_ring_record(EV_ISTAT_RAISE, IRQ_VBLANK); + gpu_vblank_tick(); /* Toggle LCF (GPUSTAT bit 31) */ +#ifndef PSX_ENABLE_BLOCK_CYCLES + timers_tick(33868); /* ~1 NTSC frame worth of cycles */ + cdrom_tick(); /* Process pending CDROM responses */ +#endif +} + +void interrupts_service_scheduled_events(void) { + note_sio_progress_cycle(); + if (in_exception) return; + while (cycles_since_vblank >= VBLANK_CYCLES) { + if (should_defer_vblank_for_sio()) return; + fire_vblank_edge(); + } +} + +uint32_t interrupts_cycles_to_vblank(void) { + if (cycles_since_vblank >= VBLANK_CYCLES) return 0; + return VBLANK_CYCLES - cycles_since_vblank; +} + +void interrupts_advance_cycles(uint32_t cycles) { + cycles_since_vblank += cycles; + interrupts_service_scheduled_events(); +} + +/* Diagnostic: total times the exception handler was entered (in_exception + * transitioned 0->1). Diff this between two snapshots to measure handler + * dispatch rate. */ +static uint64_t exception_entries_total; + +/* Diagnostic: psx_check_interrupts called while already in_exception (the + * `return` path at line ~159). Real hardware can't double-fault into the + * same handler; on our model this should stay near zero — non-zero means + * something is calling psx_check_interrupts from inside the recompiled + * exception handler tree. */ +static uint64_t exception_reentry_blocks; +/* Nested-exception depth (0 = not in exception). in_exception stays the + * boolean the rest of the runtime reads; this tracks how deep the + * synchronous-handler recursion is so the nested-delivery path (hardware + * IEc-re-enabled semantics, see the gate below) can cap host-stack use. */ +static int exception_nest_depth; +/* Nested-delivery gate veto attribution (freeze_check). One counter per guard + * in the refinement gate below; first-true-wins ordering matches the gate. */ +uint64_t g_nestgate_depth, g_nestgate_rfepend, g_nestgate_escreason, g_nestgate_iec; + +/* After the exception handler returns, suppress the next interrupt delivery + * to give the interrupted code at least one block of execution — matching + * real hardware where at least one instruction runs after RFE before the + * pending interrupt can re-fire. Without this, unhandled interrupts cause + * a livelock: the handler runs, doesn't clear I_STAT, returns, and the + * very next psx_check_interrupts re-enters immediately. */ +/* IRQ raise/deliver/ack telemetry (Tomba 2 exception-reentry-storm diagnosis). + * Always-on, surfaced in the freeze heartbeat. Distinguishes: + * raise≈deliver≈ack≈1/frame → healthy + * deliver≫ack → handler runs but doesn't clear i_stat (re-delivered) + * raise≫1/frame → something keeps RE-RAISING the bit (cycle/condition bug) + * g_vblank_ack_count is incremented in memory.c at the I_STAT write that clears + * the VBLANK bit. The others are incremented here. */ +uint64_t g_vblank_raise_count = 0; /* bit0 set at the cycle-paced raise site */ +uint64_t g_vblank_deliver_count = 0; /* VBLANK delivered to the guest (exception taken) */ +uint64_t g_irq_deliver_count = 0; /* ANY hardware interrupt delivered */ +uint64_t g_cdrom_deliver_count = 0; /* CD IRQ (IRQ_CDROM) delivered to the guest — FMV dispatch probe */ +extern uint64_t g_vblank_ack_count; /* defined in memory.c */ + +/* Blocks of guaranteed main-code forward progress imposed after a CLAIMED + * non-SIO interrupt (DMA/VBLANK/timer/...). With cooldown 0 (the prior blanket + * policy, commit 6d2cb65) the block leader at the interrupted PC re-takes the + * exception before the block body executes, so under a fast-disc DMA flood the + * main code is pinned at one PC forever (reentry-storm freeze). A few blocks + * guarantee the interrupted block — and the field loop — advance between + * deliveries. SIO is exempt (card reads need immediate back-to-back IRQs). + * + * FAITHFULNESS (MMX5 cp-3259 fork, 2026-07-04): the cooldown window is counted + * in GUEST CYCLES, not psx_check_interrupts CALLS. A call is a backend-dependent + * unit — the compiled path checks at block edges, the dirty-RAM interp checks + * per-instruction, and compiled has extra check sites (e.g. at a `jr ra` return + * transition) the interp lacks. A per-call countdown therefore reached zero at a + * DIFFERENT guest cycle in each backend → IRQ delivered at a different cycle/EPC + * → the two backends forked (cosim first-divergence cp 3259 @ cyc ~213.5M). Guest + * cycles are advanced identically by both backends (shared cycle-cost model, ruler- + * validated), so a guest-cycle deadline makes the delivery decision backend- + * independent. Magnitudes below preserve the old behavior's breathing room: the + * claimed window ~= the old "8 blocks", the unclaimed ~= the old "500 blocks" + * (a compiled block edge ~= one psx_check_interrupts call ~= ~15 guest cycles). */ +#define POST_EXC_CLAIMED_COOLDOWN_CYCLES 128u /* claimed non-SIO: a few blocks */ +#define POST_EXC_UNCLAIMED_COOLDOWN_CYCLES 8192u /* unclaimed: generous boot window */ + +/* setjmp target for ReturnFromException during handler dispatch. + * + * IMPORTANT: longjmp(exception_jmpbuf, ...) MUST execute on the same + * Windows fiber that called setjmp; otherwise RSP is restored to that + * fiber's stack while the OS still tracks a different fiber as current, + * corrupting fiber state and eventually deadlocking SwitchToFiber. + * + * s_exception_owner_fiber records which fiber called setjmp. If a + * longjmp request originates on a different fiber, the caller must: + * (1) set s_pending_exception_longjmp = code + * (2) SwitchToFiber back to s_exception_owner_fiber + * That fiber's wrapped SwitchToFiber call will observe the flag on + * return and execute the longjmp on the correct stack. */ +jmp_buf exception_jmpbuf; /* non-static so traps.c can deferred-longjmp */ +/* Monotonic epoch of the exception setjmp frame: bumped each time the setjmp + * loop is (re)armed at exception entry. Lets an observer holding a frame on + * the C stack (the overlay shadow-diff, run_shadow_diff) decide whether a + * longjmp targets a frame BELOW it (armed before it — the unwind escapes its + * frame and skips its epilogue) or a frame armed after it (contained). The + * single global exception_jmpbuf always holds the latest-armed frame, so the + * current epoch identifies every longjmp's target. */ +uint64_t g_exc_setjmp_epoch = 0; +uint64_t psx_exception_setjmp_epoch(void) { return g_exc_setjmp_epoch; } +/* The fiber that owns the current exception setjmp. A longjmp must run on + * that same fiber/stack, so a non-owner defers by switching back to it + * first (see deferred_exception_longjmp). Used on all platforms now that + * the thread scheduler is fiber-based everywhere. */ +void* g_exception_owner_fiber = NULL; +int g_pending_exception_longjmp = 0; +extern int g_psx_dispatch_depth; + +/* Set by psx_check_interrupts_at while a compiled block-leader interrupt check + * is in progress. If the delivered handler later RFEs to the sentinel outside + * the synchronous host window, dirty_ram_dispatch can resume at this guest PC + * instead of treating the sentinel as pc=0 termination. */ +static uint32_t s_compiled_interrupt_resume_pc = 0; +/* Set by psx_check_interrupts_at for the one call it makes: in any other call the latch above is an earlier call's. */ +static int s_resume_pc_given = 0; +static int s_save_poll_owed = 0; +extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); +static uint32_t s_last_interrupt_check_pc = 0; +static uint64_t s_last_interrupt_check_cycle = UINT64_MAX; + +/* Deferred cooperative thread switch from nested exception delivery. + * + * A genuine in-exception ChangeThread (kind-30, escape site below) must be + * honored at the OUTERMOST dispatch boundary, never mid-nested-host-dispatch. + * Only at the outermost boundary is the outgoing thread's guest state fully + * materialized in CPUState (its true block PC is live, GPRs current); nested + * inside a call unit / dirty pump the PC lives only in the resume-PC latch, + * which desyncs from the live GPRs when the IRQ was taken deep inside a nested + * call (the async-poll callback), so switching there saves a poisoned context + * (latched PC ahead of the callback's registers) → the resumed thread dispatches + * a jumptable with a smeared index → misaligned DISPATCH FATAL. When a switch is + * detected nested, we record it here, let the outgoing thread resume + * transparently, and honor it at the next outermost block-leader boundary (top + * of psx_check_interrupts) where the thread can be re-saved cleanly. */ +static int s_defer_switch_pending = 0; +static uint32_t s_defer_switch_target = 0; /* TCB PCB[0] should name after the switch */ +static uint32_t s_defer_switch_from = 0; /* the interrupted thread to re-save cleanly */ + +/* A/B toggle: PSX_DEFER_SWITCH=0 forces the legacy immediate-switch behavior + * (longjmp the instant a mid-exception ChangeThread is detected, regardless of + * nesting) so the deferred-switch fix can be compared against the baseline + * without a rebuild. Default ON. Latched at first query. */ +static int defer_switch_enabled(void) { + static int s = -1; + if (s < 0) { const char *e = getenv("PSX_DEFER_SWITCH"); s = (e && e[0] == '0') ? 0 : 1; } + return s; +} + +static int same_guest_pc(uint32_t a, uint32_t b) { + return (((a ^ b) & 0x1FFFFFFFu) == 0); +} + +int psx_get_in_exception(void) { return in_exception; } + +/* Co-sim (COSIM_ORACLE.md): fold the GENUINE guest-timing interrupt statics into the + * state hash. Deliberately EXCLUDES total_checks / dispatch_count / post_exception_ + * cooldown / s_compiled_interrupt_resume_pc — those are counted in psx_check_interrupts + * CALLS or are backend-internal, so they legitimately differ between the compiled and + * interp backends by call frequency and would be a false first-divergence. The one + * quantity that is a real guest-cycle timing value is cycles_since_vblank (drives the + * VBLANK deadline); in_exception mirrors the architectural handler-active state. */ +uint64_t interrupts_cosim_hash(uint64_t h) { + const uint64_t P = 1099511628211ULL; + uint32_t v = cycles_since_vblank; + for (int i = 0; i < 4; i++) { h ^= (uint8_t)(v >> (i*8)); h *= P; } + uint64_t sp = last_sio_progress_cycle; + for (int i = 0; i < 8; i++) { h ^= (uint8_t)(sp >> (i*8)); h *= P; } + h ^= (uint8_t)in_exception; h *= P; + return h; +} +/* Co-sim field dump: expose the exact irqctl-timing fields so a divergence can be + * field-diffed (is it cycles_since_vblank = VBLANK-raise-timing, or in_exception, ...). */ +void interrupts_cosim_dump(uint32_t *csv, int *inexc) { + if (csv) *csv = cycles_since_vblank; + if (inexc) *inexc = in_exception; +} + +/* ===== Fix B: faithful exception-return escape state (see psx_runtime.h) ===== */ +int g_rfe_escape_pending = 0; +int g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; +uint32_t g_exception_real_epc = 0; +extern void psx_exception_longjmp(void); + +/* Called by the recompiled `rfe` opcode (after it pops the COP0 SR stack). When we + * are inside the SYNCHRONOUS exception-handler window (in_exception) AND the handler + * is returning to a REAL EPC (not the legacy boundary-sentinel fallback), arm the + * host escape so the trampoline unwinds back to psx_check_interrupts after the jr + * commits cpu->pc = real EPC. On a fiber/thread resume (in_exception==0) this is a + * no-op and the real EPC is dispatched directly — that is how a suspended thread + * resumes at its own PC. */ +void psx_rfe_mark_escape(void) { + if (!in_exception) return; + /* Arm on EVERY handler-window RFE, including LEGACY_SENTINEL deliveries. + * The old blanket decline assumed a sentinel delivery always resumes AT + * the sentinel — but a handler that switches threads (the guest's own + * cooperative scheduler moving PCB[0] in its VBlank callback) RFEs to the + * NEW thread's real EPC instead; with nothing armed, no exit path ever + * fires and in_exception wedges the VBlank authority forever (Tomba 2 + * splash livelock). The take site keeps the legacy contract intact: a + * resume that DOES land on the sentinel still declines there and exits + * through the sentinel detector's saved-GPR restore. */ + g_rfe_escape_pending = 1; + if (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) + g_exc_escape_reason = PSX_EXC_ESCAPE_RFE_RETURN; +} + +/* Called in the dispatch trampoline after a function returns (cpu->pc holds the + * real resume EPC). If an RFE armed the escape inside the synchronous handler, + * longjmp back to psx_check_interrupts (cpu->pc preserved across the longjmp). */ +void psx_rfe_escape_check(CPUState* cpu) { + /* Escape ONLY when this RFE is the synchronous handler completing on the SAME + * fiber that set up the exception (the owner of exception_jmpbuf). in_exception + * is a single global, so a thread RESUMED on a different fiber (its own real EPC + * was restored and it later RFEs) must NOT longjmp here — that would be a + * cross-fiber jump to a stale jmpbuf (crash). For that case we fall through and + * the trampoline keeps dispatching cpu->pc = the real resume EPC, which is + * exactly how a suspended thread continues at its own PC. */ + extern void* psx_fiber_current(void); /* psx_fiber.h is included further down */ + if (g_rfe_escape_pending && in_exception && + cpu->pc != PSX_EXC_SENTINEL_PC && + psx_fiber_current() == g_exception_owner_fiber) { + g_rfe_escape_pending = 0; + /* A LEGACY_SENTINEL delivery escaping to a NON-sentinel PC means the + * handler switched threads and RFE'd to the new thread's real EPC. + * Record the completion truthfully as an RFE return so the epilogue's + * restore discriminator does NOT overwrite the kernel's freshly + * restored TCB registers with the ENTERING thread's saved_gpr (the + * cross-thread corruption Fix B exists to prevent). A resume that + * lands ON the sentinel never reaches this longjmp (declined above): + * it exits through the legacy sentinel detector, whose saved-GPR + * restore contract stays keyed on the untouched LEGACY reason. */ + if (g_exc_escape_reason == PSX_EXC_ESCAPE_LEGACY_SENTINEL) + g_exc_escape_reason = PSX_EXC_ESCAPE_RFE_RETURN; + /* Same shadow-frame hardening as deferred_exception_longjmp. */ + extern void overlay_loader_shadow_escape_fixup(uint64_t target_epoch); + overlay_loader_shadow_escape_fixup(g_exc_setjmp_epoch); + longjmp(exception_jmpbuf, 1); /* same fiber: safe; lands in psx_check_interrupts */ + } +} + +void psx_get_freeze_diag(uint64_t *out_total_checks, + uint32_t *out_dispatch_count, + int *out_in_exception, + int *out_post_exc_cooldown, + uint64_t *out_exc_entries, + uint64_t *out_exc_reentry_blocks) { + if (out_total_checks) *out_total_checks = total_checks; + if (out_dispatch_count) *out_dispatch_count = dispatch_count; + if (out_in_exception) *out_in_exception = in_exception; + if (out_post_exc_cooldown) { + uint64_t now = psx_get_cycle_count(); + *out_post_exc_cooldown = (post_exception_cooldown_until > now) + ? (int)(post_exception_cooldown_until - now) : 0; + } + if (out_exc_entries) *out_exc_entries = exception_entries_total; + if (out_exc_reentry_blocks) *out_exc_reentry_blocks = exception_reentry_blocks; +} + +void interrupts_init(void) { + dispatch_count = 0; + in_exception = 0; + exception_nest_depth = 0; + g_psx_dispatch_depth = 0; + total_checks = 0; + post_exception_cooldown_until = 0; + exception_entries_total = 0; + exception_reentry_blocks = 0; + cycles_since_vblank = 0; + /* Rematch / session_reboot re-enters bring-up without process exit. + * Stale I_STAT/I_MASK from the prior match (e.g. VBlank pending + game + * mask) makes boot take a phantom IRQ at ~cycle 4 and wedge under + * netplay lockstep. Cold boot also wants zeros (BSS already is). */ + i_stat = 0; + i_mask = 0; + g_vblank_raise_count = 0; + last_sio_seq_seen = sio_get_seq(); + last_sio_progress_cycle = psx_get_cycle_count(); +} + +/* + * Called by the runtime's ReturnFromException implementation (traps.c) + * when the recompiled BIOS handler or a chain callback invokes + * B0:0x17 or SYSCALL(3) during exception handling. + * + * At this point the caller has already: + * - Restored all GPRs from the TCB save area + * - Done RFE on the saved SR + * - Set cpu->pc = saved EPC + * + * We longjmp back to psx_check_interrupts, which will clear + * in_exception and return, effectively letting the interrupted + * code resume at the saved EPC through normal dispatch. + */ +#include "psx_fiber.h" +/* Defer a longjmp to the fiber that owns the current exception setjmp. + * If we're on the owning fiber already, longjmp immediately. Otherwise + * record the requested code, switch back to the owner, and the post-switch + * check in traps.c psx_change_thread_fiber executes the longjmp on the + * correct stack. */ +static void deferred_exception_longjmp(int code) { + /* If this unwind blows through a live shadow-diff frame (a frame armed + * BEFORE the shadow started), restore the shadow-scoped globals its + * skipped epilogue would have restored — otherwise the diff instrument + * silently dies for the rest of the run (s_in_shadow stuck). No-op when + * no shadow is live or the target frame was armed inside the shadow. */ + extern void overlay_loader_shadow_escape_fixup(uint64_t target_epoch); + overlay_loader_shadow_escape_fixup(g_exc_setjmp_epoch); + if (!g_exception_owner_fiber || psx_fiber_current() == g_exception_owner_fiber) { + longjmp(exception_jmpbuf, code); + } + g_pending_exception_longjmp = code; + psx_fiber_switch(g_exception_owner_fiber); + /* If we end up back here, the owner didn't honor the flag (bug). + * Fall through to direct longjmp as a last resort — even though + * the stack is wrong, the alternative is hanging silently. */ + longjmp(exception_jmpbuf, code); +} + +void psx_exception_longjmp(void) { + debug_server_log_restore_event(2, debug_cpu_ptr ? debug_cpu_ptr->pc : 0, 1); + deferred_exception_longjmp(1); +} + +void psx_restore_state_escape(void) { + if (in_exception) { + debug_server_log_restore_event(1, debug_cpu_ptr ? debug_cpu_ptr->pc : 0, 2); + deferred_exception_longjmp(2); + } + /* Not in exception context — return normally, let caller's `return;` handle it. */ +} + +/* Cycle-budgeted precise event slicing — single source of truth. + * + * Returns the minimum guest-CPU-cycle distance to the next DELIVERABLE hardware + * interrupt: a source raises its I_STAT bit AND that bit is unmasked in i_mask + * (CPU-side SR/IE gating is checked separately at take time). UINT32_MAX means + * no maskable hardware event is currently scheduled. + * + * The two-tier block executor uses this at each block leader: if a whole block + * fits inside this distance (and the block has no IRQ-visibility side effects), + * it runs as fast compiled C; otherwise it runs through the per-instruction + * interpreter so the interrupt is taken at its exact architectural cycle (see + * PRECISE_IRQ_SLICE.md). Each peripheral owns its own next-IRQ query; this just + * takes the min. Every query is a conservative UNDER-estimate (smaller distance + * => slice more => never run past an event), so this is too. */ +uint32_t cycles_to_next_event(void) { + uint32_t best = 0xFFFFFFFFu; + /* VBLANK is paced here (interrupts.c owns cycles_since_vblank). The deferred + * card-SIO case only pushes VBlank LATER, so this estimate stays a safe + * under-estimate. */ + if (i_mask & (1u << IRQ_VBLANK)) { + uint32_t d = (cycles_since_vblank >= VBLANK_CYCLES) + ? 0u : (VBLANK_CYCLES - cycles_since_vblank); + if (d < best) best = d; + } + uint32_t t = timers_cycles_to_irq(i_mask); if (t < best) best = t; + uint32_t c = cdrom_cycles_to_irq(i_mask); if (c < best) best = c; + uint32_t d = dma_cycles_to_irq(i_mask); if (d < best) best = d; + uint32_t s = sio_cycles_to_irq(i_mask); if (s < best) best = s; + return best; +} + +static uint64_t s_need_defer, s_need_irq, s_skip_none, s_skip_sr; +static uint64_t s_skip_cooldown, s_skip_nested; + +void psx_interrupt_delivery_diag(uint64_t *need_defer, uint64_t *need_irq, + uint64_t *skip_none, uint64_t *skip_sr, + uint64_t *skip_cooldown, uint64_t *skip_nested) { + if (need_defer) *need_defer = s_need_defer; + if (need_irq) *need_irq = s_need_irq; + if (skip_none) *skip_none = s_skip_none; + if (skip_sr) *skip_sr = s_skip_sr; + if (skip_cooldown) *skip_cooldown = s_skip_cooldown; + if (skip_nested) *skip_nested = s_skip_nested; +} + +int psx_interrupt_delivery_needed(const CPUState* cpu) { + if (s_defer_switch_pending) { s_need_defer++; return 1; } + if ((i_stat & i_mask) == 0) { s_skip_none++; return 0; } + + uint32_t sr = cpu->cop0[COP0_SR]; + if (!(sr & 0x01u) || !(sr & (1u << 10))) { s_skip_sr++; return 0; } + + if (post_exception_cooldown_until != 0 && + psx_get_cycle_count() < post_exception_cooldown_until) { + s_skip_cooldown++; + return 0; + } + + if (in_exception && + (exception_nest_depth >= 2 || + g_rfe_escape_pending || + g_exc_escape_reason != PSX_EXC_ESCAPE_NONE)) { + s_skip_nested++; + return 0; + } + s_need_irq++; + return 1; +} + +void psx_check_interrupts(CPUState* cpu) { + const int save_point = s_resume_pc_given; + s_resume_pc_given = 0; + psx_cyc_batch_flush(); + extern int g_ls_suppress_record; +#define PSX_CHECK_INTERRUPTS_RETURN() do { if (g_ls_suppress_record > 0) g_ls_suppress_record--; return; } while (0) +#ifdef PSX_COSIM + extern uint32_t g_dirty_safe_resume_pc; +#define COSIM_IRQ_TAKE_PC() (g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc : s_compiled_interrupt_resume_pc) +#define COSIM_IRQ_NOTE(kind_) cosim_irq_note(cpu, (kind_), COSIM_IRQ_TAKE_PC(), g_dirty_safe_resume_pc, s_compiled_interrupt_resume_pc, cpu->cop0[COP0_SR]) +#endif + + /* MotK VLC / FMV hot edge: sticky unmasked I_STAT (CD/VBlank) while + * IEc or IM2 is clear — no architectural delivery possible. Skip the + * mid-path bookkeeping / irq_deliver_eval that used to run every BB. + * Guest-visible timing unchanged (same non-delivery). LTO can collapse + * this into the VLC call sites. */ + static uint32_t s_fast_maintenance; + if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) { + uint32_t sr = cpu->cop0[COP0_SR]; + if (!(sr & 0x01u) || !(sr & (1u << 10))) { + const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0; + if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); + if (periodic) debug_server_poll(); + return; + } + } + + /* Genuine entry fast paths. Generated resident code calls this at every + * basic-block edge, which can mean tens of millions of calls inside an FMV + * polling loop. psx_advance_cycles() has already serviced every device + * deadline before this point, so no-pending and IEc-clear checks have no + * architectural work to do. The old fast paths came after diagnostics and + * savestate_poll, making a harmless callback expensive enough to reduce + * Tomba 2's Whoopee FMV to ~1 guest fps. + * + * Idle-skip used to force the slow path on every edge so + * psx_idle_note_check could observe poll PCs. That made MotK's VLC + * (branchy, changing resume PCs, idle_skip=true) pay full IRQ bookkeeping + * millions of times per second for zero skips. Keep the fast path; run the + * idle note here when enabled, then re-check I_STAT in case a skip raised + * an event. Guest timing is unchanged — skip still advances via + * psx_advance_cycles. + * + * Poll host maintenance periodically so save/load and the debug socket stay + * responsive even when the guest spends a long time in this path. At the + * observed 1M+ block edges/s this is sub-frame latency. */ + if (g_idle_skip_enabled < 0) (void)psx_idle_skip_is_enabled(); + /* COP0 software interrupts (CAUSE.IP0/IP1 vs SR.IM0/IM1, bits 8-9): raised + * purely by guest mtc0 to CAUSE, with no INTC involvement. Games use this + * as a re-entrant exception-dispatch mechanism (Jackie Chan Stuntmaster's + * handler saves SR/EPC, sets CAUSE=0x100 + SR=0x101, and relies on the + * immediate sw-int to chain into its second stage; without it the CD INT3 + * ack never runs and the machine deadlocks with SR.IM2 stripped). */ + uint32_t sw_pending = cpu->cop0[COP0_CAUSE] & cpu->cop0[COP0_SR] & 0x0300u; + if (!in_exception && (i_stat & i_mask) == 0 && sw_pending == 0 && + !s_defer_switch_pending) { + if (g_idle_skip_enabled > 0) { + uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + s_last_interrupt_check_pc = check_pc; + s_last_interrupt_check_cycle = psx_get_cycle_count(); + psx_idle_note_check(cpu, check_pc); + } + if ((i_stat & i_mask) == 0 && sw_pending == 0) { + const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0; + if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); + if (periodic) debug_server_poll(); + return; + } + /* Idle skip advanced time and a device raised I_STAT — deliver below. */ + } + if (in_exception && !(cpu->cop0[COP0_SR] & 0x01u)) return; + + /* Mid path: unmasked IRQ already pending, ordinary compiled edge. + * MotK VLC / FMV spend most BB edges here (sticky CD/VBlank bits). + * Devices are already caught up via psx_advance_cycles, so skip + * service/idle/savestate housekeeping and go straight to delivery. */ + if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) { + uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + s_last_interrupt_check_pc = check_pc; + s_last_interrupt_check_cycle = psx_get_cycle_count(); + g_ls_suppress_record++; + total_checks++; + const int periodic = (total_checks & 0x3FFFu) == 0; + if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); + if (periodic) debug_server_poll(); + goto irq_deliver_eval; + } + + { + uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + s_last_interrupt_check_pc = check_pc; + s_last_interrupt_check_cycle = psx_get_cycle_count(); + } + g_ls_suppress_record++; + total_checks++; + if ((total_checks & 0x3FFFu) == 0) { + debug_server_poll(); + } + + /* SIO delayed IRQ delivery removed from here. + * sio_tick() is now called only from SIO register accesses + * (sio_read/sio_write) and I_STAT reads (memory.c). The BIOS + * pad detection sequence clears I_STAT bit 7 then polls I_STAT + * waiting for it to re-appear. If we tick here, the IRQ fires + * during the delay loop BEFORE the clear, and the BIOS never + * sees it. */ + + interrupts_service_scheduled_events(); + + /* Idle-loop cycle skip (psx_cycles.c): detect a side-effect-free poll + * loop by its repeated same-PC checks and fast-forward guest time to the + * next internal device event in whole loop quanta. Runs BEFORE the + * deliverability evaluation below so an event raised by the skip is + * delivered in this same check, exactly at the boundary real execution + * would have taken it. Skip when an unmasked IRQ is already pending — + * that is not an idle poll (FMV/VLC edges with sticky CD/VBlank bits), + * and the fast path already noted when I_STAT was clear. */ + if ((i_stat & i_mask) == 0) + psx_idle_note_check(cpu, s_last_interrupt_check_pc); + + /* User save states: this is a block-leader boundary with a known resume PC; + * only act outside the exception handler so a restore's stack-unwind can't + * strand a half-finished handler. Near-free when nothing is staged. A load + * longjmps to the scheduler and never returns here. */ + if (!in_exception && psx_save_poll_due(1, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); + + /* Deferred cooperative thread switch: honor at the next real thread-save + * boundary (see s_defer_switch_* above). A + * genuine in-exception ChangeThread was deferred because it was detected at a + * poisoned point (the IRQ fired deep inside a nested call, e.g. the + * async-poll callback, so the resume-PC latch desynced from the live GPRs). + * + * The dirty-RAM interpreter also pumps interrupts at synthetic transfer and + * call-return sites. Those sites are precise enough to deliver hardware IRQs, + * but not necessarily safe to snapshot a cooperative thread: the interpreter + * may have committed a candidate resume PC while the live CPUState stack/GPRs + * still describe the previous local frame. Keep the deferral pending there and + * honor it only once execution reaches a normal interrupt poll with site 0. + * Near-free when nothing is pending. */ + if (s_defer_switch_pending && !in_exception) { + if (psx_hle_scheduler_enabled()) { + uint32_t from_tcb = s_defer_switch_from; + uint32_t to_tcb = s_defer_switch_target; + uint32_t resume_pc = s_last_interrupt_check_pc; /* materialized block PC */ + if (from_tcb == 0u || to_tcb == 0u || + psx_sched_current_tcb(cpu) != from_tcb) { + /* STALE: the guest already re-scheduled cooperatively (PCB[0] no + * longer names the deferred thread) - abandon the deferral. This is + * the ONLY case we clear pending without honoring; dropping when a + * clean resume PC merely isn't available yet would LOSE the guest's + * ChangeThread and wedge (the bug this replaces). */ + s_defer_switch_pending = 0; + s_defer_switch_target = 0; + s_defer_switch_from = 0; + debug_server_log_thread_event(33, cpu, from_tcb, to_tcb, resume_pc); + } else if (g_cosim_dirty_pump_site != 0) { + /* Not a real suspend boundary: dirty interpreter pump sites expose + * committed PCs for IRQ timing, but the matching CPUState may not + * be materialized yet. */ + } else if (resume_pc != 0u && (resume_pc & 0x3u) == 0u && + psx_is_dispatchable(resume_pc)) { + /* Materialized clean boundary: re-save the deferred thread cleanly + * (this OVERWRITES any poisoned/sentinel EPC the guest handler wrote + * for it while nested), re-point PCB[0], and honor the switch. */ + extern int overlay_loader_shadow_native_thread_switch_bail(void); + if (overlay_loader_shadow_native_thread_switch_bail()) + PSX_CHECK_INTERRUPTS_RETURN(); + s_defer_switch_pending = 0; + s_defer_switch_target = 0; + s_defer_switch_from = 0; + psx_sched_save_context(cpu, from_tcb, resume_pc); + psx_sched_set_current_tcb(cpu, to_tcb); + debug_server_log_thread_event(32, cpu, from_tcb, to_tcb, resume_pc); + g_dirty_interp_active = 0; + s_compiled_interrupt_resume_pc = 0; + g_sched_escape.target_tcb = to_tcb; + g_sched_escape.resume_pc = 0; + g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB; + longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run */ + } + /* else: KEEP pending - this outermost boundary has no materialized + * resume PC (both latches 0, e.g. a scheduled-event check not tied to a + * block leader). Wait for the next block-leader boundary that does. */ + } + } + + /* Dispatch-loop maintenance only when NOT inside the exception handler. */ + if (!in_exception) { +#if SIO_MODEL_CYCLE_PACED + /* Builds without block-cycle accounting need a small dispatch-loop + * SIO quantum. With PSX_ENABLE_BLOCK_CYCLES, psx_advance_cycles() + * already drives SIO timing, so the fixed quantum would double-count. + * arms shift/ack), so this gate never opens — per-call cost on + * this hot path is one volatile load + one branch. */ +#ifndef PSX_ENABLE_BLOCK_CYCLES + if (g_sio_timing_active) { + sio_tick_quantum(); + } +#endif +#endif + dispatch_count++; + } + + /* Event ring: generic i_stat-edge backstop. Catches raises from sites we + * don't instrument precisely (memory.c MMIO acks, SIO, SPU). Bounded by + * actual transitions, not by check frequency. */ + { + static uint32_t s_last_istat = 0; + if (i_stat != s_last_istat) { + s_last_istat = i_stat; + event_ring_record(EV_ISTAT_CHANGE, 0); + } + } + +irq_deliver_eval: + /* Check if any interrupts are pending (INTC hardware or COP0 software). */ + if ((i_stat & i_mask) == 0 && sw_pending == 0) { irq_record_outcome(EV_NONE, 0, 0); PSX_CHECK_INTERRUPTS_RETURN(); } + /* Nested delivery (hardware semantics). Real R3000A has no 'in exception' + * gate — delivery is governed by SR alone. The handler normally runs with + * IEc=0 (hardware bit-shift on entry), so the SR gates below block + * re-entry faithfully on their own. A game that RE-ENABLES IEc inside the + * handler is asking for a nested interrupt and real hardware delivers it: + * Tomba2's lava attract demo parks in the kernel event-poll INSIDE the + * VBlank handler waiting for the NEXT VBlank — the old unconditional + * in_exception block starved it to ~1 fps (VBlank pending+unmasked, + * ~1100 reentry blocks/s, 1 delivery/s). The synchronous-handler + * machinery below is nesting-safe: the interrupted-context save + * (saved_gpr/hi/lo) and owner state (prev_owner_fiber, prev_pending, + * dispatch depth) are host-stack locals restored on unwind, and + * in_exception is restored to its pre-delivery value at the epilogue. + * Depth cap: a pathological IEc-re-enable loop would recurse the host + * stack; real hardware would wedge the guest instead — we stop nesting + * there (counted) so the rings expose it. */ + if (in_exception) { + /* Refinements over plain SR gating (first attempt wedged BOOT at + * f436): (1) never nest while an RFE/escape unwind is in flight — + * the guest's RFE pops SR (IEc back to 1) BEFORE the host escape + * completes, and a delivery in that window lands in half-unwound + * machinery; (2) one nesting level only — enough for the + * wait-inside-handler idiom, no host-stack pyramids. */ + if (exception_nest_depth >= 2 || + g_rfe_escape_pending || + g_exc_escape_reason != PSX_EXC_ESCAPE_NONE || + !(cpu->cop0[COP0_SR] & 0x01)) { + /* Per-guard veto attribution (always-on counters, freeze_check): + * which refinement is actually vetoing nested delivery. A wedge + * where ONE guard dominates for thousands of frames names the + * false veto (Tomba2 Whoopee-logo forensics 2026-07-03). */ + if (exception_nest_depth >= 2) g_nestgate_depth++; + else if (g_rfe_escape_pending) g_nestgate_rfepend++; + else if (g_exc_escape_reason != PSX_EXC_ESCAPE_NONE) g_nestgate_escreason++; + else g_nestgate_iec++; + exception_reentry_blocks++; + irq_record_outcome(EV_IRQ_GATE, GATE_IN_EXCEPTION, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(2u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } + /* IEc re-enabled inside the handler: fall through to the SR gates + * and deliver a nested exception, as hardware would. */ + } + + /* Post-exception cooldown: let at least one block of guest time elapse after + * RFE before the next delivery. Gated on the guest-cycle deadline (not a + * per-call countdown) so compiled and interp agree on the delivery cycle. */ + if (post_exception_cooldown_until != 0) { + if (psx_get_cycle_count() < post_exception_cooldown_until) { + irq_record_outcome(EV_IRQ_GATE, GATE_COOLDOWN, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(3u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } + post_exception_cooldown_until = 0; /* window elapsed */ + } + + /* Check COP0 SR: IEc (bit 0) must be set, and IM2 (bit 10) must be set. */ + uint32_t sr = cpu->cop0[COP0_SR]; + if (!(sr & 0x01)) { + irq_record_outcome(EV_IRQ_GATE, GATE_SR_IE, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(4u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } /* Interrupts globally disabled */ + /* Deliverability per source: the INTC line needs SR.IM2; software + * interrupts need only their own IM bit (already folded into sw_pending, + * recomputed here against the CURRENT sr — the fast-path snapshot above + * may predate an SR write earlier in this same check). */ + sw_pending = cpu->cop0[COP0_CAUSE] & sr & 0x0300u; + int hw_deliverable = ((i_stat & i_mask) != 0) && ((sr & (1 << 10)) != 0); + if (!hw_deliverable && sw_pending == 0) { + irq_record_outcome(EV_IRQ_GATE, GATE_SR_IM2, 0); +#ifdef PSX_COSIM + COSIM_IRQ_NOTE(5u); +#endif + PSX_CHECK_INTERRUPTS_RETURN(); + } /* No deliverable interrupt source (hw masked and no sw pending) */ + + /* Architectural take-PC = the resume PC (same selection the async-RFE block + * below uses): the dirty-interp commits the exact interrupted instruction in + * g_dirty_safe_resume_pc; compiled code passes its block-entry PC via + * psx_check_interrupts_at -> s_compiled_interrupt_resume_pc. Combined with the + * event's `mode`, this is the take-point for the cross-backend diff. */ + { + extern uint32_t g_dirty_safe_resume_pc; + uint32_t take_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + irq_record_outcome(EV_IRQ_DELIVER, 0, take_pc); + } + g_irq_deliver_count++; + if ((i_stat & i_mask) & (1u << IRQ_VBLANK)) g_vblank_deliver_count++; + if ((i_stat & i_mask) & (1u << IRQ_CDROM)) g_cdrom_deliver_count++; + /* IRQ-delivery context ring (MMX6 VSync-vs-CD-DMA hunt). Capture, at every IRQ + * delivery, whether the kernel VSync callback-block word at 0x80079D44 is the + * clobbered game value AND whether a CD DMA (ch3) is mid-transfer / a DMA is + * executing. If VBlank gets delivered with d44 already==0x016F0110 while the CD + * DMA is active, that is ChatGPT's class (c)+(#2): IRQ delivered in the DMA + * clobber window because the CPU isn't stalled / the event wasn't torn down. */ + { + extern int dma_cdrom_transfer_active(void); + extern int g_dma_exec_depth; + extern uint64_t s_frame_count; + g_irqctx_ring[g_irqctx_seq & (IRQCTX_RING_CAP - 1u)] = (IrqCtxEntry){ + .seq = g_irqctx_seq, .cycle = psx_get_cycle_count(), + .frame = (uint32_t)s_frame_count, .istat = i_stat, .imask = i_mask, + .sr = cpu->cop0[COP0_SR], .d44 = cpu->read_word(0x80079D44u), + .cdrom_active = (uint32_t)dma_cdrom_transfer_active(), + .dma_depth = g_dma_exec_depth, + .is_vblank = (uint32_t)(((i_stat & i_mask) & (1u << IRQ_VBLANK)) != 0), + }; + g_irqctx_seq++; + } + ls_note_exception_entry(); + /* Nested delivery: remember whether we interrupted an OUTER handler so + * the epilogue restores in_exception to the pre-delivery value instead + * of clearing it (a nested level-2 exit must leave level-1 flagged). */ + int prev_in_exception = in_exception; + in_exception = 1; + exception_nest_depth++; + exception_entries_total++; + uint32_t pre_handler_istat = i_stat; /* snapshot for cooldown decision */ + + /* Set COP0 Cause: ExcCode=0 (interrupt). IP2 reflects the INTC line, so + * set it only when the hardware source is what's being delivered; a pure + * software interrupt must present the guest-written IP0/IP1 bits + * unmodified (the guest's dispatcher discriminates stages by exactly + * these bits — see sw_pending rationale at the top of this function). */ + cpu->cop0[COP0_CAUSE] = (cpu->cop0[COP0_CAUSE] & ~0x7C) | (0 << 2); + if (hw_deliverable) + cpu->cop0[COP0_CAUSE] |= (1 << 10); + + /* Push SR exception stack: shift bits [5:0] left by 2. */ + cpu->cop0[COP0_SR] = (sr & ~0x3F) | ((sr & 0x0F) << 2); + + /* EPC: set to a sentinel value. The recompiled exception handler reads + * memory at [EPC] to check for COP2 branch delay. We use a dedicated + * address in the kernel scratch area. Address 0x80000048 is chosen + * because it's between the exception vectors (0x80-0xBF) and the + * kernel data pointer area (0x100+), and not used by the BIOS. + * + * The sentinel is the HOST escape token for the SYNCHRONOUS handler: when the + * recompiled handler RFEs to it while in_exception, psx_unknown_dispatch / + * dirty_ram_dispatch longjmp back here and resume the interrupted code via the + * host-GPR-restore below. This is unchanged (Tomba 1 / MMX6 / Ape rely on it). */ + /* Fix B: COP0.EPC = the REAL interrupted resume PC, NOT a sentinel. The recompiled + * BIOS exception handler saves COP0.EPC into the interrupted thread's TCB EPC slot, + * so a thread suspended here (ChangeThread) resumes at its OWN real PC — never via a + * single global that the next thread's IRQs overwrite (the MMX6 cooperative-thread + * freeze). The host escape out of the nested synchronous handler is keyed on the + * RFE-pending flag (psx_rfe_mark_escape / psx_rfe_escape_check), not on pc==sentinel. + * + * real_pc = the committed interrupted PC, latched by the delivery site (the dirty + * pump exposes g_dirty_safe_resume_pc; a compiled block-leader check exposes + * s_compiled_interrupt_resume_pc). When BOTH are 0 the IRQ was taken at a clean + * trampoline boundary (the interrupted function already returned, pc was 0) — there + * is no mid-function resume PC, so fall back to the legacy sentinel + saved_gpr path + * for THIS delivery only (reason = LEGACY_SENTINEL gates the saved_gpr restore and + * disarms the RFE-flag escape). */ + g_rfe_escape_pending = 0; + { + uint32_t real_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc + : s_compiled_interrupt_resume_pc; + /* Accept the real resume PC from guest RAM (<2MB) OR the BIOS ROM + * window. The old RAM-only guard rejected ROM-space block leaders + * (e.g. OpenBIOS mcWaitForStatus spinning at 0xBFC076xx during a + * card op), forcing EVERY such delivery onto the legacy sentinel. + * A compiled handler escapes the sentinel host-side, so SCPH1001 + * never noticed — but an INTERPRETED handler (OpenBIOS: the patch + * slots unbless the exception handler) saves/restores EPC + * architecturally through the TCB, churning the sentinel through + * guest state thousands of times per frame until one interleaving + * leaves it unrepaired (observed as a torn IntRP walk and a wild + * dispatch to 0x54000000 during the first memcard write at boot). + * ROM acceptance is gated on psx_is_dispatchable so a non-leader + * ROM pc still falls back to the sentinel (pre-fix behavior); + * RAM acceptance is unchanged byte-for-byte. */ + uint32_t real_phys = real_pc & 0x1FFFFFFFu; + int resume_in_ram = real_phys < 0x00200000u; + int resume_in_rom = real_phys >= 0x1FC00000u && real_phys < 0x1FC80000u && + psx_is_dispatchable(real_pc); + if (real_pc != 0u && (real_pc & 0x3u) == 0u && + (resume_in_ram || resume_in_rom)) { + cpu->cop0[COP0_EPC] = real_pc; /* architectural: the real resume PC */ + g_exception_real_epc = real_pc; + g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; /* set at the actual RFE/SYSCALL return */ + } else { + uint32_t sentinel = PSX_EXC_SENTINEL_PC; + cpu->write_word(sentinel, 0x00000000u); /* NOP, read by the handler's BD check */ + cpu->cop0[COP0_EPC] = sentinel; + g_exception_real_epc = sentinel; + g_exc_escape_reason = PSX_EXC_ESCAPE_LEGACY_SENTINEL; + } +#ifdef PSX_COSIM + cosim_irq_note(cpu, 1u, real_pc, g_dirty_safe_resume_pc, + s_compiled_interrupt_resume_pc, sr); +#endif + /* Ring exit-half init: record the entry-side resume selection now; the + * exit fields are filled at the restore decision below. No nesting is + * possible between here and there (in_exception blocks re-entry), so + * (g_irqctx_seq - 1) is this delivery's entry. */ + { + IrqCtxEntry *e = &g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)]; + e->take_pc = real_pc; + e->real_epc = g_exception_real_epc; + e->exit_pc = 0; e->exit_reason = 0; e->same_thread = 0; + e->restored = 0; e->v1_exit = 0; e->v1_saved = 0; + e->ra_exit = 0; e->ra_saved = 0; e->redirects = 0; + e->entry_sp = cpu->gpr[29]; + { extern int g_cosim_dirty_pump_site; + e->pump_site = (uint32_t)g_cosim_dirty_pump_site; } + } + } + + /* Save the interrupted code's full register state. + * + * On real hardware, the exception handler saves all GPRs to the + * TCB save area at entry, and ReturnFromException restores them + * before jumping back to EPC. The interrupted code always gets + * its exact pre-exception register values back. + * + * In our model, the recompiled handler runs as a C function and + * its `return;` goes back here — not to EPC. The handler's + * normal exit path (0xBFC10944) restores registers from the + * kernel jmpbuf (intended for longjmp to WaitEvent's caller), + * which corrupts the interrupted code's registers. + * + * We save all GPRs/HI/LO before the handler and restore them + * after, so the interrupted code resumes with its original state + * — matching real hardware behaviour. */ + uint32_t saved_gpr[32]; + uint32_t saved_hi, saved_lo; + for (int i = 0; i < 32; i++) saved_gpr[i] = cpu->gpr[i]; + saved_hi = cpu->hi; + saved_lo = cpu->lo; + + /* Same-thread discriminator input (mmx6_card_load_regression_state): the + * kernel's CURRENT-TCB pointer at exception ENTRY. PCB ptr lives at kernel + * 0x108; PCB[0] = running thread's TCB. ChangeThread inside the handler + * moves PCB[0] to the new thread, so entry-vs-exit TCB equality is the + * STRUCTURAL "no thread switch happened" test — unlike PC equality, it is + * immune to two threads parked at the SAME guest PC (shared spin loops: + * MMX6's card poll), where the PC heuristic force-restores the OLD thread's + * GPRs into the NEW thread. */ + extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */ + uint32_t entry_pcb = psx_read_word(0x108u); + uint32_t entry_tcb = entry_pcb ? psx_read_word(entry_pcb & 0x1FFFFFFFu) : 0u; + + /* Dispatch the BIOS exception handler. + * BEV (SR bit 22) selects between 0x80000080 and 0xBFC00180. + * + * setjmp is placed here so ReturnFromException (longjmp code 1) + * and RestoreState (longjmp code 2) can escape the handler call + * tree. + * + * The loop handles the PSX VSync mechanism (SaveState/RestoreState): + * - Code 0: normal entry — dispatch the handler. + * - Code 2: RestoreState redirect — re-dispatch to cpu->pc + * (e.g. VSync callback loop at 0xBFC421D8), still in exception + * context. The redirected code eventually calls ReturnFromException. + * - Code 1: ReturnFromException — exit the loop entirely. */ + uint32_t target_pc; + if (sr & 0x00400000u) { + target_pc = 0xBFC00180u; + } else { + uint32_t w0 = cpu->read_word(0x80000080u); + uint32_t w1 = cpu->read_word(0x80000084u); + uint32_t hi_val = (w0 & 0xFFFF) << 16; + int16_t lo_val = (int16_t)(w1 & 0xFFFF); + target_pc = hi_val + (uint32_t)(int32_t)lo_val; + /* The RAM exception vector is a LUI/ADDIU pair that materializes the + * installed handler address in $k0 before transferring to it. The + * host-side fast path decodes that pair and dispatches straight to the + * target, so it must also commit the pair's architectural register + * result. Vigilante 8's handler uses $k0 as its table base on its very + * first instruction; leaving the interrupted value in $k0 made it load + * a BIOS instruction word (0xAD400000) as a jump target. */ + uint32_t vector_reg = (w1 >> 16) & 31u; + if (vector_reg != 0u) cpu->gpr[vector_reg] = target_pc; + } + + /* Record which fiber owns this setjmp. Any subsequent longjmp must + * happen on this same fiber; if a non-owner fiber needs to longjmp + * it must switch back here first (see deferred_exception_longjmp). */ + void *prev_owner_fiber = g_exception_owner_fiber; + int prev_pending = g_pending_exception_longjmp; + g_exception_owner_fiber = psx_fiber_current(); + g_pending_exception_longjmp = 0; + /* A bail unwind can never be in flight at exception entry: bail-mode + * returns skip every block leader, so psx_check_interrupts is never + * reached while g_psx_call_bail is set. If it ever is, count the + * anomaly and clear so the handler dispatch isn't poisoned. */ + if (g_psx_call_bail) { + g_psx_bail_anomaly++; + g_psx_call_bail = 0; + } + /* The static BIOS exception handler is not dirty-RAM-interp code. Clear the + * interp mode flag across the handler dispatch so events recorded inside it + * are tagged STATIC. The restore sits after the loop the longjmp lands in, + * so the EPC-sentinel longjmp can't leave the flag wrong. */ + int prev_interp_active = g_dirty_interp_active; + g_dirty_interp_active = 0; + /* Ape memcard fix #3 (2026-07-07): clear the dirty-interp resume-PC latch + * across the handler dispatch, exactly like g_dirty_interp_active above. + * + * The interrupted dirty block set g_dirty_safe_resume_pc = its committed PC + * and left it LIVE across its own psx_check_interrupts call (the dirty pump + * only restores it AFTER that call returns). Without clearing it here, a + * NESTED interrupt taken inside the handler — e.g. a VBLANK delivered while + * the kernel's async-poll memory-card callback runs (in_exception, IEc + * re-enabled) — inherits the OUTER interrupted block's stale resume PC as its + * EPC. same-thread-restore then matches (cpu->pc == that stale EPC) and + * resumes the OUTER block with the CALLBACK's registers: the Ape memcard + * consumer at 0x801382CC gets v1 (jumptable index) = the callback's pointer + * 0x800B4E30, bounds-check already passed, `jr jumptable[garbage]` faults + * (DISPATCH FATAL misaligned 0x3) — or, in the soft variant, drives a wrong + * branch that skips DeliverEvent(0xF0000011,4) and the card scene wedges. + * (irqctx_ring proof: exit_pc=0x801382CC, ra_saved=0x80010094 [callback], + * v1_saved=0x800B4E30, restored=1.) + * + * Cleared here so a nested delivery latches the ACTUALLY-running code's PC: + * the callback's own dirty pump re-sets g_dirty_safe_resume_pc for its + * blocks, and compiled handler code uses s_compiled_interrupt_resume_pc. + * Restored after the dispatch loop (below), which the RFE/escape longjmp + * lands in — so the escape can't leave the latch wrong. A/B off: + * PSX_EXC_CLEAR_RESUME_LATCH=0. */ + static int s_clear_resume_latch = -1; + if (s_clear_resume_latch < 0) { + const char *e = getenv("PSX_EXC_CLEAR_RESUME_LATCH"); + s_clear_resume_latch = (e && *e) ? atoi(e) : 1; + } + uint32_t saved_dirty_resume_pc = g_dirty_safe_resume_pc; + if (s_clear_resume_latch) g_dirty_safe_resume_pc = 0; + /* Wall-time sampler phase: the handler dispatch runs recompiled static + * BIOS text. Same save/restore contract as g_dirty_interp_active above — + * the restore sits after the setjmp loop, covering longjmp landings. */ + extern int g_exec_phase; + int prev_exec_phase = g_exec_phase; + g_exec_phase = 3; + /* Dispatch-depth contract across the async handler (Tomba 2 splash, post + * overlay-floor fix). The interrupt can be delivered from a NESTED dispatch + * (the local-flow pump runs inside dirty_ram_dispatch at depth > 0). The + * handler must run as its OWN outermost context (so its tail-call trampoline + * flattens), and a ReturnFromException longjmp skips the handler frames' + * `--g_psx_dispatch_depth` decrements — so we cannot leave the counter at 0. + * Save the interrupted code's nesting here and RESTORE it after the handler; + * otherwise the outer frames unwind below zero (dispatch_depth -> -1), the + * `outermost` test misfires, and the top-level dispatch returns to PC=0 + * ("execution completed" abnormal exit). */ + int saved_dispatch_depth = g_psx_dispatch_depth; + g_psx_dispatch_depth = 0; + /* Nested delivery clobber guard: exception_jmpbuf is a single global, so a + * nested exception's setjmp overwrites the OUTER frame's context. The outer + * handler's later RFE would then longjmp into the dead inner frame (host + * stack corruption — first hit by Jackie Chan Stuntmaster's software- + * interrupt dispatcher, which nests by design). Save the armed frame here + * and restore it in the epilogue so each nesting level's RFE lands in its + * own live frame. */ + jmp_buf saved_exception_jmpbuf; + memcpy(&saved_exception_jmpbuf, &exception_jmpbuf, sizeof(jmp_buf)); + g_exc_setjmp_epoch++; /* new setjmp frame armed (see decl above) */ + for (;;) { + int jmp_val = setjmp(exception_jmpbuf); + if (jmp_val == 2) { + /* RestoreState redirect: re-dispatch to cpu->pc. + * GPRs were already set by RestoreState — do NOT restore. + * Stay in exception context so ReturnFromException works. */ + g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)].redirects++; + g_psx_dispatch_depth = 0; + debug_server_log_restore_event(3, cpu->pc, (uint32_t)jmp_val); + target_pc = cpu->pc; + continue; + } + if (jmp_val == 1) { + g_psx_dispatch_depth = 0; + debug_server_log_restore_event(4, cpu->pc, (uint32_t)jmp_val); + } + if (jmp_val == 0) { + /* Normal entry (or after RestoreState redirect): dispatch. */ + psx_dispatch(cpu, target_pc); + } + /* jmp_val 0 (normal return) or 1 (ReturnFromException): done. */ + break; + } + /* Restore previous exception-owner state. Supports nested exceptions + * if they ever arise (uncommon but harmless). */ + g_exception_owner_fiber = prev_owner_fiber; + g_pending_exception_longjmp = prev_pending; + g_dirty_interp_active = prev_interp_active; + /* Re-arm the OUTER frame's jmpbuf (see save at entry): after a nested + * delivery returns, the outer handler is live again and its RFE must land + * in the outer setjmp frame, not this exited one. */ + memcpy(&exception_jmpbuf, &saved_exception_jmpbuf, sizeof(jmp_buf)); + /* Ape memcard fix #3: restore the resume-PC latch cleared at handler entry + * (see above). Sits with the other post-dispatch-loop restores so an + * RFE/RestoreState longjmp — which lands in the setjmp loop above — can't + * strand it at 0. */ + if (s_clear_resume_latch) g_dirty_safe_resume_pc = saved_dirty_resume_pc; + g_exec_phase = prev_exec_phase; + /* Restore the interrupted code's dispatch nesting (see save above). The + * handler's frames were unwound by longjmp without decrementing, so the + * live counter is meaningless here — overwrite, don't decrement. */ + g_psx_dispatch_depth = saved_dispatch_depth; + + /* Restore the interrupted code's registers. + * + * The handler has done its work (acknowledged i_stat, delivered + * events, etc.) via MMIO writes and RAM writes — those side + * effects are in memory. We restore GPRs so the interrupted + * code continues with its pre-exception state. + * + * For SR: if the handler did ReturnFromException (RFE already + * applied, IEc restored), we keep that. If the handler exited + * normally (jmpbuf path, no RFE), IEc is still 0 from the + * exception push — we do RFE to pop the SR stack. + * + * Fix B: only restore saved_gpr on the LEGACY (jmpbuf / boundary-sentinel) + * exit. On a real RFE/ReturnFromException the recompiled BIOS handler already + * restored the RESUMED thread's GPRs from its TCB; overwriting them with our + * saved_gpr would clobber the resumed thread with the state of the thread that + * ENTERED the exception (the cross-thread corruption fix B exists to prevent). */ + /* Fix B refinement (2026-07-01, Tomba pause-menu wedge): restore the + * interrupted GPRs whenever this resume returns to the SAME thread it + * interrupted — either the LEGACY sentinel exit, OR a real-EPC RFE that + * resumes at exactly the PC we installed as EPC (no ChangeThread happened). + * + * Real hardware saves and restores ALL GPRs across every exception, so a + * transparent same-thread interrupt must give the interrupted code its exact + * pre-exception registers back. The original Fix B skipped this for all + * real-EPC resumes, trusting the recompiled BIOS handler's TCB restore — but + * for a same-thread compiled resume that restore is incomplete, so a live + * value held in a register across a block-leader IRQ check gets clobbered by + * the handler. Concretely: Tomba's pause-menu frame-wait spin holds its loop + * bound (256) in gpr[3] across 0x80016588; a VBLANK there returned gpr[3]=1, + * so the wait exited early every frame and the menu never opened. + * + * A GENUINE ChangeThread resumes a DIFFERENT thread at its own PC (!= our + * EPC); there the BIOS handler restored the NEW thread's GPRs from its TCB + * and saved_gpr holds the thread that ENTERED the exception — restoring it + * would reintroduce the MMX6 cooperative-thread corruption Fix B prevents. */ + /* Discriminator selector (verification + candidate fix, + * mmx6_card_load_regression_state — the PC heuristic below was BISECTED as + * the "A game data could not be found" regression B on MMX6 while being + * the fix for Tomba's pause menu): + * PSX_SAME_THREAD_RESTORE=0 original Fix B (legacy-sentinel-only restore) + * PSX_SAME_THREAD_RESTORE=1 PC-equality heuristic (13c5e0c behavior) + * PSX_SAME_THREAD_RESTORE=2 kernel current-TCB equality (default; + * structural same-thread test — a genuine + * ChangeThread moves PCB[0] even when the new + * thread resumes at the SAME guest PC) */ + static int s_str_mode = -1; + if (s_str_mode < 0) { + const char *e = getenv("PSX_SAME_THREAD_RESTORE"); + s_str_mode = (e && *e) ? atoi(e) : 1; /* default = 13c5e0c behavior; + mode 2 (TCB check) is the hardening candidate, pending a Tomba + pause-menu gate. The MMX6 "not found" this selector was built to + verify turned out to be a STALE GENERATED IMAGE artifact. */ + if (s_str_mode < 0 || s_str_mode > 2) s_str_mode = 1; + } + extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */ + uint32_t exit_pcb = psx_read_word(0x108u); + uint32_t exit_tcb = exit_pcb ? psx_read_word(exit_pcb & 0x1FFFFFFFu) : 0u; + int same_thread_resume = 0; + if (s_str_mode == 1) { + same_thread_resume = + (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) && + g_exception_real_epc != 0u && + same_guest_pc(cpu->pc, g_exception_real_epc); + } else if (s_str_mode == 2) { + same_thread_resume = + (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) && + g_exception_real_epc != 0u && + same_guest_pc(cpu->pc, g_exception_real_epc) && + entry_tcb != 0u && entry_tcb == exit_tcb; + } + /* Same-thread completion of a SENTINEL (legacy) delivery via an explicit + * guest RFE / ReturnFromException. The guest TCB never holds the host + * sentinel (psx_assert_no_sentinel_pc), so the kernel's restore resumed + * cpu->pc at an APPROXIMATE saved PC (typically a stale earlier dirty + * boundary) — but with PCB[0] unmoved the TRUE continuation of the + * interrupted code is the LIVE host chain below this frame. Exit exactly + * like the legacy sentinel detector: restore the interrupted GPRs and + * return pc=0 so the interrupted compiled leader continues natively. + * Without this the approximate PC surfaces up the trampoline, severs the + * live chain, and one hop later lands on an un-re-enterable mid-function + * PC — the top-level "execution completed, PC=0" abnormal exit (Tomba 2 + * splash, frame 1385). A GENUINE in-handler switch (PCB[0] moved) skips + * this and is honored/deferred by the scheduler block below. */ + if (g_exception_real_epc == (uint32_t)PSX_EXC_SENTINEL_PC && + entry_tcb != 0u && entry_tcb == exit_tcb && + (g_exc_escape_reason == PSX_EXC_ESCAPE_RFE_RETURN || + g_exc_escape_reason == PSX_EXC_ESCAPE_SYSCALL_RETURN)) { + same_thread_resume = 1; + cpu->pc = 0; /* continue the interrupted live chain */ + } + int do_restore = + (g_exc_escape_reason == PSX_EXC_ESCAPE_LEGACY_SENTINEL || same_thread_resume); + /* Ring exit-half: every delivery records which escape path it took and + * whether the interrupted GPRs were restored (Tomba menu wedge evidence). */ + { + IrqCtxEntry *e = &g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)]; + e->exit_pc = cpu->pc; + e->exit_reason = (uint32_t)g_exc_escape_reason; + e->same_thread = (uint32_t)same_thread_resume; + e->restored = (uint32_t)do_restore; + e->v1_exit = cpu->gpr[3]; + e->v1_saved = saved_gpr[3]; + e->ra_exit = cpu->gpr[31]; + e->ra_saved = saved_gpr[31]; + } + if (do_restore) { + for (int i = 0; i < 32; i++) cpu->gpr[i] = saved_gpr[i]; + cpu->hi = saved_hi; + cpu->lo = saved_lo; + } + g_rfe_escape_pending = 0; + g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; + + if (!(cpu->cop0[COP0_SR] & 0x01)) { + uint32_t sr2 = cpu->cop0[COP0_SR]; + cpu->cop0[COP0_SR] = (sr2 & 0xFFFFFFC0u) | ((sr2 >> 2) & 0x0Fu); + } + + /* Nested delivery: a level-2 exit must leave level-1 flagged. */ + in_exception = prev_in_exception; + if (exception_nest_depth > 0) exception_nest_depth--; + + /* Adaptive cooldown: if the handler acknowledged the interrupt (cleared + * some I_STAT bits), the interrupt won't immediately re-fire and we need + * no cooldown. If I_STAT is unchanged (no handler claimed the interrupt), + * give the main code a generous window to make progress — e.g. to let + * the shell finish installing handlers. On real hardware, the CPU + * executes at least one instruction between exceptions; in our model + * each "block" is many instructions, but the handler also consumes + * hundreds of sub-dispatches per invocation. */ + if ((i_stat & i_mask) != 0 && i_stat == pre_handler_istat) { + /* unclaimed: give main code guest-time to install handlers */ + post_exception_cooldown_until = psx_get_cycle_count() + POST_EXC_UNCLAIMED_COOLDOWN_CYCLES; + } else { + /* Claimed: the handler acknowledged at least one I_STAT bit. Per-source + * policy (this used to be a blanket cooldown=0 — commit 6d2cb65 — which + * pins main code under a fast-disc DMA flood: the interrupted block's + * leader re-takes the exception before its body runs): + * - SIO (card reads): 128 consecutive SIO IRQs must fire within one + * blocking wait; any gap stalls the card protocol → re-fire now. + * - DMA/VBLANK/timer/etc: guarantee a few blocks of main-code + * progress between deliveries so a flood can't starve the loop. */ + uint32_t claimed = pre_handler_istat & ~i_stat; /* bits handler cleared */ + uint32_t sio_active = (claimed | (i_stat & i_mask)) & (1u << IRQ_SIO0); + post_exception_cooldown_until = sio_active + ? 0 /* SIO: no cooldown — card reads need immediate back-to-back IRQs */ + : psx_get_cycle_count() + POST_EXC_CLAIMED_COOLDOWN_CYCLES; + } + if (g_ls_suppress_record > 0) g_ls_suppress_record--; + + /* In-exception thread switch (Ape Escape NEW GAME memcard scene). + * + * A game can run its OWN cooperative scheduler inside the kernel exception + * handler: its VBlank/exception callback moves dword_108->entry (PCB[0], the + * current-thread pointer) to the next thread, and ReturnFromException (0xF40) + * then restores THAT thread. On real hardware the RFE atomically switches + * execution to the new thread. (docs/psx_bios_disasm.txt: both ExceptionHandler + * 0xC80 and ReturnFromException 0xF40 key off dword_108->entry.) + * + * Under the HLE TCB scheduler the interrupted thread is running via a + * structured dispatch on the host stack. Returning normally here does NOT + * transfer to the new thread — the generated block leader just falls through + * to the interrupted thread's next block (it never re-reads cpu->pc). The + * interrupted thread therefore keeps physically executing while PCB[0] already + * names a different thread; a subsequent exception then saves the running + * (old) thread's EPC into the NEW thread's TCB, corrupting it. That is the + * Ape "Checking… MEMORY CARD" wedge: the transition thread's ec8 PC is written + * into main's TCB EPC, main is later RFE'd there with main's regs, and its + * store lands on the transition thread's TCB status word (kill → target_missing + * forever). See ape_thread_smear_rootcause.md. + * + * Honor the switch exactly as a cooperative ChangeThread is honored: unwind to + * psx_scheduler_run and dispatch whatever PCB[0] now points at, from its + * committed TCB context (the guest handler already saved the interrupted + * thread into ITS own TCB before the switch). Gated to the OUTERMOST exception + * (prev_in_exception==0 → no live outer exception_jmpbuf frame to skip) and to + * the HLE scheduler (the legacy fiber bridge has no g_scheduler_jmpbuf and + * switches threads by fiber). */ + if (prev_in_exception == 0 && psx_hle_scheduler_enabled() && + entry_tcb != 0u && exit_tcb != 0u && entry_tcb != exit_tcb) { + extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */ + uint32_t new_state = psx_read_word(exit_tcb & 0x1FFFFFFFu); + if (new_state == 0x4000u) { /* the new current thread must be runnable */ + /* A native-only shadow pass must never commit the handler's TCB/RAM + * changes or escape past the authoritative restore. Bail before + * configuring either immediate or deferred scheduler state. */ + extern int overlay_loader_shadow_native_thread_switch_bail(void); + if (overlay_loader_shadow_native_thread_switch_bail()) + PSX_CHECK_INTERRUPTS_RETURN(); + /* Fix #2: honor the switch ONLY at the outermost dispatch boundary, + * where the outgoing thread's guest state is fully materialized in + * CPUState. Nested inside a host call unit / dirty pump, the outgoing + * thread's PC lives only in the resume-PC latch, which desyncs from the + * live GPRs when the IRQ was taken deep inside a nested call (the + * async-poll callback) — switching there commits a poisoned TCB context + * and the resumed thread faults on a smeared jumptable index. */ + extern int g_call_unit_depth; + int at_outermost = (g_psx_dispatch_depth == 0 && g_call_unit_depth == 0); + /* Defer only when nested AND the outgoing thread has a valid resume PC + * of its OWN (g_exception_real_epc — the architectural EPC latched for + * THIS delivery). The guest handler's in-exception ReturnFromException + * (traps.c syscall 3, in_exception path) has already restored the TARGET + * thread's GPRs and set cpu->pc to the target's EPC; to resume the + * OUTGOING thread transparently we must put cpu->pc back to ITS resume PC + * as well, not leave it pointing at the target. If we have no clean + * outgoing resume PC (sentinel / clean-trampoline boundary), or the + * toggle is off, or we are already outermost, switch immediately. */ + /* Low BIOS/kernel code is already scheduler code; deferring it can + * starve a target thread by re-entering the same VBlank EPC forever. */ + uint32_t epc_phys = g_exception_real_epc & 0x1FFFFFFFu; + int low_kernel_epc = (epc_phys < 0x00010000u); + int can_defer = defer_switch_enabled() && !low_kernel_epc && !at_outermost && + g_exception_real_epc != 0u && + (g_exception_real_epc & 0x3u) == 0u && + g_exception_real_epc != (uint32_t)PSX_EXC_SENTINEL_PC && + psx_is_dispatchable(g_exception_real_epc); + if (!can_defer) { + /* Outermost / no clean deferral: honor immediately — the guest + * handler's save of the outgoing thread is consistent here (or this + * is the pre-existing baseline path). */ + debug_server_log_thread_event(30, cpu, entry_tcb, exit_tcb, cpu->pc); + /* The longjmp skips psx_check_interrupts_at's restore of the compiled + * resume-PC latch; clear it so the next thread's block-leader delivery + * recomputes real_pc cleanly rather than inheriting a stale value. */ + s_compiled_interrupt_resume_pc = 0; + /* This escape uses g_scheduler_jmpbuf (unwinding to psx_scheduler_run), + * NOT exception_jmpbuf — so it BYPASSES the landing above that restores + * g_dirty_interp_active. From the dirty-RAM entry poll the flag is 1 + * and would LEAK across the unwind; we abandon the dirty-interp + * dispatch here, so it MUST be 0 (dirty_ram_interp.c ~205-210 contract; + * no-op from compiled code where the flag was already 0). */ + g_dirty_interp_active = 0; + g_sched_escape.target_tcb = exit_tcb; + g_sched_escape.resume_pc = 0; + g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB; + longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run; never returns */ + } + /* Nested in host dispatch: DEFER. The guest handler has already saved a + * POISONED context for the outgoing thread (latched PC ahead of the live + * registers), but we do NOT switch off it. Instead resume the outgoing + * thread transparently — restoring its clean pre-exception GPRs/HI/LO/SR + * (do_restore above ran false for this genuine cross-thread case) — so it + * finishes its nested calls; record the switch as pending and honor it at + * the next outermost boundary (top of this function), re-saving the thread + * cleanly there. Keep PCB[0] naming the STILL-RUNNING thread during the + * interim so any interim exception saves into the right TCB (closes the + * mis-save window the immediate switch used to avoid); the guest's intent + * to run exit_tcb is preserved as the pending switch. Coalesces: the newest + * guest ChangeThread wins the target. */ + for (int i = 0; i < 32; i++) cpu->gpr[i] = saved_gpr[i]; + cpu->hi = saved_hi; + cpu->lo = saved_lo; + cpu->cop0[COP0_SR] = sr; /* pre-exception SR (IEc was set — gate above) */ + cpu->pc = g_exception_real_epc; /* resume the OUTGOING thread at ITS own PC, + * not the target PC the in-exception RFE + * left in cpu->pc — else it runs the target's + * code under the outgoing thread and never + * reaches its own block leaders to be honored. */ + psx_sched_set_current_tcb(cpu, entry_tcb); + s_defer_switch_from = entry_tcb; + s_defer_switch_target = exit_tcb; + s_defer_switch_pending = 1; + s_compiled_interrupt_resume_pc = 0; + debug_server_log_thread_event(31, cpu, entry_tcb, exit_tcb, cpu->pc); + /* fall through: return normally — the outgoing thread resumes. */ + } + } +#ifdef PSX_COSIM +#undef COSIM_IRQ_NOTE +#undef COSIM_IRQ_TAKE_PC +#endif +#undef PSX_CHECK_INTERRUPTS_RETURN +} + +/* Compatibility shim: the ape-flavored generated code calls + * psx_check_interrupts_at(cpu, resume_pc); the mmx6-fw baseline runtime delivers + * interrupts via psx_check_interrupts (cpu->pc / scratch sentinel). Forwarding + * here gives the mmx6 baseline interrupt behavior — sufficient to build+run the + * current generated code on the good baseline for instrumented comparison. */ +void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc) { + uint32_t prev = s_compiled_interrupt_resume_pc; + s_compiled_interrupt_resume_pc = resume_pc; + s_resume_pc_given = 1; + psx_check_interrupts(cpu); /* flushes load-charge batch on entry */ + s_compiled_interrupt_resume_pc = prev; +} + +int psx_interrupts_checked_at_current_cycle(uint32_t resume_pc) { + return s_last_interrupt_check_cycle == psx_get_cycle_count() && + same_guest_pc(s_last_interrupt_check_pc, resume_pc); +} + +void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc) { + if (psx_interrupts_checked_at_current_cycle(resume_pc)) { + return; + } + psx_check_interrupts_at(cpu, resume_pc); +} diff --git a/tests/save_point_test.cpp b/tests/save_point_test.cpp new file mode 100644 index 0000000..bf99603 --- /dev/null +++ b/tests/save_point_test.cpp @@ -0,0 +1,101 @@ +#include "save_point.h" + +#include +#include + +namespace { + +int g_request_waits = 0; +int g_asked = 0; + +void expect(bool condition, const char *message) { + if (condition) return; + std::fprintf(stderr, "FAIL: %s\n", message); + std::exit(1); +} + +constexpr int kPeriodic = 1, kBetween = 0, kMarked = 1, kUnmarked = 0; + +void a_marked_periodic_check_polls() { + int owed = 0; + g_request_waits = 0; + expect(psx_save_poll_due(kPeriodic, kMarked, &owed) == 1, "a marked periodic check polls, a request waiting or not"); + expect(owed == 0, "and owes nothing"); + expect(psx_save_poll_due(kBetween, kMarked, &owed) == 0, "a marked check between periodic ones does not poll"); +} + +void an_unmarked_check_never_polls() { + for (int waits = 0; waits < 2; ++waits) { + for (int periodic = 0; periodic < 2; ++periodic) { + for (int owed_before = 0; owed_before < 2; ++owed_before) { + int owed = owed_before; + g_request_waits = waits; + expect(psx_save_poll_due(periodic, kUnmarked, &owed) == 0, "an unmarked check does not poll, whatever else holds"); + expect(owed == (periodic ? waits : owed_before), "it owes a periodic poll only when a request waits"); + } + } + } +} + +void a_periodic_poll_missed_with_a_request_is_owed_to_the_next_marked_check() { + int owed = 0; + g_request_waits = 1; + expect(psx_save_poll_due(kPeriodic, kUnmarked, &owed) == 0 && owed == 1, "the poll is owed"); + expect(psx_save_poll_due(kBetween, kUnmarked, &owed) == 0 && owed == 1, "an unmarked check in between leaves it owed"); + expect(psx_save_poll_due(kBetween, kMarked, &owed) == 1 && owed == 0, "the next marked check takes it"); + expect(psx_save_poll_due(kBetween, kMarked, &owed) == 0, "and the one after it does not poll again"); +} + +void nothing_is_owed_when_no_request_waits() { + int owed = 0; + g_request_waits = 0; + expect(psx_save_poll_due(kPeriodic, kUnmarked, &owed) == 0 && owed == 0, "a periodic poll missed with nothing waiting is not owed"); + expect(psx_save_poll_due(kBetween, kMarked, &owed) == 0, "so the next marked check does not poll"); + owed = 1; + expect(psx_save_poll_due(kPeriodic, kUnmarked, &owed) == 0 && owed == 0, "a request served elsewhere takes the owed poll away"); +} + +void the_request_is_asked_for_only_at_an_unmarked_periodic_check() { + int owed = 0; + g_asked = 0; + for (int index = 0; index < 1000; ++index) (void)psx_save_poll_due(kBetween, kMarked, &owed); + for (int index = 0; index < 1000; ++index) (void)psx_save_poll_due(kBetween, kUnmarked, &owed); + (void)psx_save_poll_due(kPeriodic, kMarked, &owed); + expect(g_asked == 0, "the other checks do not ask whether a request waits"); + (void)psx_save_poll_due(kPeriodic, kUnmarked, &owed); + expect(g_asked == 1, "an unmarked periodic check asks once"); +} + +void periodic_polls_that_always_meet_an_unmarked_check_do_not_starve_a_request() { + constexpr int kPeriod = 16384; + int owed = 0, polls = 0, polled_at = -1; + g_request_waits = 1; + for (int check = 1; check <= 3 * kPeriod && g_request_waits; ++check) { + const int periodic = check % kPeriod == 0; + if (psx_save_poll_due(periodic, periodic ? kUnmarked : kMarked, &owed)) { + ++polls; + polled_at = check; + g_request_waits = 0; + } + } + expect(polls == 1, "the request is served"); + expect(polled_at == kPeriod + 1, "at the first marked check after the periodic one that met it"); +} + +} // namespace + +extern "C" int savestate_pending(void) { + ++g_asked; + return g_request_waits; +} + +int main() { + a_marked_periodic_check_polls(); + an_unmarked_check_never_polls(); + a_periodic_poll_missed_with_a_request_is_owed_to_the_next_marked_check(); + nothing_is_owed_when_no_request_waits(); + the_request_is_asked_for_only_at_an_unmarked_periodic_check(); + periodic_polls_that_always_meet_an_unmarked_check_do_not_starve_a_request(); + std::puts("save point tests passed"); + return 0; +} diff --git a/tests/test_save_point_contract.py b/tests/test_save_point_contract.py new file mode 100644 index 0000000..aa87912 --- /dev/null +++ b/tests/test_save_point_contract.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Source contract: a state is saved or loaded only at an interrupt check that was given its own resume address. + +A check made without one (the RAM interpreter's are) left the compiled latch as an earlier call had it, and a state +saved there kept that address with the registers of other code: one save of 315 in the measured runs. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +POLL = "savestate_poll(cpu, s_compiled_interrupt_resume_pc);" + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def between(source: str, opening: str, closing: str) -> str: + start = source.index(opening) + return source[start : source.index(closing, start)] + + +def polls_are_the_four(source: str) -> bool: + """The name is in the file six times: its declaration, the four polls and one comment of the framework's.""" + return len(re.findall(r"\bsavestate_poll\b", source)) == 6 and source.count(POLL) == 4 + + +cmake = read("CMakeLists.txt") +manifest = read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() +interrupts = read("psxrecomp-overlay/runtime/src/interrupts.c") +rule = read("psxrecomp-overlay/runtime/include/save_point.h") + +require( + " add_test(NAME disruptor_save_point_contract\n" in cmake + and " add_test(NAME disruptor_save_point\n COMMAND disruptor-save-point-test)\n" in cmake, + "this contract and the rule's unit test are registered", +) +require( + "runtime/src/interrupts.c" in manifest and "+runtime/include/save_point.h" in manifest, + "the overlay replaces the framework's interrupt check and adds the rule's header", +) + +require( + "int savestate_pending(void);\n" in rule + and "static inline int psx_save_poll_due(int periodic, int save_point, int *owed) {\n" + " if (!save_point) {\n" + " if (periodic) *owed = savestate_pending();\n" + " return 0;\n" + " }\n" + " if (!periodic && !*owed) return 0;\n" + " *owed = 0;\n" + " return 1;\n" + "}\n" + in rule, + "a check without the mark never polls and owes a periodic poll that met a request, a marked one polls when " + "periodic or owed", +) + +require( + '#include "save_point.h"\n' in interrupts + and "static uint32_t s_compiled_interrupt_resume_pc = 0;\n" in interrupts + and "static int s_resume_pc_given = 0;\n" in interrupts + and len(re.findall(r"\bs_resume_pc_given\b", interrupts)) == 4 + and interrupts.count("s_resume_pc_given = 1;") == 1 + and interrupts.count("s_resume_pc_given = 0;") == 2, + "the mark is declared off, set in one place, taken in one place and read nowhere else", +) + +require( + "void psx_check_interrupts(CPUState* cpu) {\n" + " const int save_point = s_resume_pc_given;\n" + " s_resume_pc_given = 0;\n" in interrupts, + "a check takes the mark before anything else, so a check that leaves by a long jump cannot pass it on", +) + +given = between(interrupts, "void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc) {", "\n}\n") +require( + " uint32_t prev = s_compiled_interrupt_resume_pc;\n" + " s_compiled_interrupt_resume_pc = resume_pc;\n" + " s_resume_pc_given = 1;\n" + " psx_check_interrupts(cpu);" + in given + and given.rstrip().endswith("s_compiled_interrupt_resume_pc = prev;"), + "the mark is set with the address, right before the one call it is for", +) + +check = between(interrupts, "void psx_check_interrupts(CPUState* cpu) {", "\nvoid psx_check_interrupts_at(") +fast = ( + " const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0;\n" + " if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed))\n" + f" {POLL}\n" + " if (periodic) debug_server_poll();\n" + " return;\n" +) +middle = ( + " const int periodic = (total_checks & 0x3FFFu) == 0;\n" + " if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed))\n" + f" {POLL}\n" + " if (periodic) debug_server_poll();\n" + " goto irq_deliver_eval;\n" +) +last = f" if (!in_exception && psx_save_poll_due(1, save_point, &s_save_poll_owed))\n {POLL}\n" +require( + check.count(fast) == 2 and check.count(middle) == 1 and check.count(last) == 1, + "the two fast paths and the middle path poll periodically, the end of the check at every check outside an exception", +) +require( + polls_are_the_four(interrupts) + and check.count(POLL) == 4 + and len(re.findall(r"\bpsx_save_poll_due\b", interrupts)) == 4 + and len(re.findall(r"\bsave_point\b", check)) == 5 + and len(re.findall(r"\bs_save_poll_owed\b", interrupts)) == 5 + and "static int s_save_poll_owed = 0;\n" in interrupts + and "savestate_pending" not in interrupts, + "every poll goes through the rule with this check's mark and passes the address this check was given", +) +flushed = " s_resume_pc_given = 0;\n psx_cyc_batch_flush();\n" +require( + interrupts.count(flushed) == 1 + and not polls_are_the_four( + interrupts.replace(flushed, flushed + " savestate_poll (cpu, s_compiled_interrupt_resume_pc);\n") + ) + and not polls_are_the_four(interrupts.replace(flushed, flushed + " savestate_poll(cpu, check_pc);\n")), + "a fifth poll is seen however it is spelled", +) + +print("Disruptor save point source contract: PASS") From 8dc40bcfefce895e01048c48b508675de22cff31 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:06:22 +0300 Subject: [PATCH 02/13] docs: bring the feature lists and the launcher picture up to date --- README.md | 7 ++++++- STATUS.md | 4 ++-- docs/PLAYING.md | 17 ++++++++++++++--- docs/images/launcher.png | Bin 28960 -> 26717 bytes 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c11dfe3..b50bace 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,18 @@ disc revision; the redistributable OpenBIOS backend is used for booting. the screen too, movies stay at 4:3, and the HUD retains its proportions. A HUD size setting shrinks it toward the screen edges. - Optional geometry and perspective-texture correction. +- Optional gap filling, a depth-sorted world, and experimental ambient + occlusion on top of geometry correction. - Optional 60 FPS gameplay and in-between frames. - Keyboard/controller input and optional modern WASD/mouse controls. +- Optional smooth mouse turning between the game's 256 turn steps. - Experimental vertical mouse look and weapon aim. - In-game settings for controls, display, fullscreen, VSync, volume, and mute. +- Diagnostic views and a screen recorder for reports of a fault in the picture. - Memory-card saves and twelve save-state slots. - Windows launcher with disc-image browsing, automatic copy/CUE setup, - full-disc SHA-256 verification, the game's settings, and game launch. + full-disc SHA-256 verification, the game's settings with a hint for each, + and game launch. - Optional French, German, and Japanese game content from an additional disc image you own; the supported USA disc remains required. - Optional intro skipping and a key to skip a logo or movie. diff --git a/STATUS.md b/STATUS.md index 51ce4ef..6301b77 100644 --- a/STATUS.md +++ b/STATUS.md @@ -9,8 +9,8 @@ and public-release validation are incomplete. | Gameplay | Boot, menus, FMVs, audio, and the first mission have been exercised on Windows. | | Rendering | OpenGL supports 1x–8x resolution, widescreen, geometry correction, and perspective textures. Minor edge gaps and localized texture wobble can remain. | | Controls | Modern keyboard/mouse controls are available. Vertical look and weapon aim remain experimental. | -| Settings | Controls, Enhancements, Cheats, and System tabs; supported preferences persist between runs. | -| Interpolation and diagnostics | In-between frames are experimental and off until switched on in Settings. The Diagnostics tab and experimental debug controls are removed. | +| Settings | Controls, Enhancements, Experimental, Language, System, Debug, and Cheats tabs; supported preferences persist between runs. | +| Interpolation and diagnostics | In-between frames are off until switched on in Settings. The Debug tab has diagnostic views and a screen recorder. | | Ultrawide corruption | Fixed primitive-buffer overflow. The user's 8x / 32:9 training retest stayed correct through 8,934 frames and closed normally. | | Save states | Save states require a matching build and language. The alpha.3 code-generation changes make alpha.1 and alpha.2 states incompatible; use memory-card saves for progress across releases. | | Automated checks | Windows Release build, code-generation audit, and all 59 root CTests passed. | diff --git a/docs/PLAYING.md b/docs/PLAYING.md index fe09183..ec45571 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -31,9 +31,10 @@ and share feedback. 6. At the game menu, use the arrow keys and **Enter** to select **New Game**, or choose **Practice Mode** to try a training mission. -The launcher's **Display**, **Enhancements**, **Controls** and **Audio** tabs -hold the settings of the in-game menu. A change is saved to `settings.toml` -at once and applies the next time the game starts. **Original** and +The launcher's **Display**, **Enhancements**, **Controls**, **Audio** and +**Experimental** tabs hold the settings of the in-game menu. A long page +scrolls, and a row shows a hint under the pointer. A change is saved to +`settings.toml` at once and applies the next time the game starts. **Original** and **Enhanced** set a group of them in one step, and **Reset settings** returns them to those of a new installation. The tabs are locked while the game runs. If the game stops with an error, the launcher shows the end of `startup.log`. @@ -80,6 +81,16 @@ The **Shadows** setting offers Vanilla and Improved shapes when geometry correction is enabled. HUD size ranges from 50% to 100% in widescreen and leaves the weapon size unchanged. +With geometry correction, **Gap filling** closes hairline gaps between +polygons and starts on. **Depth-sorted world** draws the world by depth where +the game's own order puts a far polygon over a near one; it starts off. +**Settings → Experimental** has ambient occlusion, which shades corners and +the floor under things and starts off. **Smooth mouse turning** in +**Settings → Controls** turns the picture between the game's 256 turn steps +while the mouse is captured; it starts off too. **Settings → Debug** has +diagnostic views and a two-second screen recorder for a report of a fault in +the picture. + **Skip the logos and the title movie** in Enhancements skips the boot intro automatically. It starts off; the other movies still play normally. Escape can skip the logo or movie currently on screen when the settings menu is closed. diff --git a/docs/images/launcher.png b/docs/images/launcher.png index a8d0b6a6d2995b5dd99edffaa6a518ab2f677767..42ce75fe8bc830753d926c5f1f4fbaff0a0b1d8d 100644 GIT binary patch literal 26717 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πCWm5fCA}UdiLh-KJ8Vtw0^T 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

^S~KFzOQu@f z1N>=~a8J8H>ic@eDS|g?DYyjKcyxmxU_Up8lXmi2nREXZG0L`A`+H*bMH!|vs?eScH_2MQWz5aV8b3DE7Fdd*j_XD1;NyD1tu=k32+xb^>L$OTWT&euDw1Cb^`k;9 zXuZ7#t$!qotPBtG-lq)f33-^pdsyNlD^k#$J!#xDJ6AU1`0ArkO5XEIOS3efKI?@V zm}kI5eImJzlv`c{MFHjgYXBU2m0U)&G)a*aMv>gfaR)_CjrSGl(f^pvd_-0AGVwMD{ zI|+cV0mJ8HeR&C2(nrTWZi1(v{M7@~0Y#3;;MkWQKuj$@;rB!Y@zWQOD;E#l;pL{#Mj*5MwxSfVrL669Yb_UDSRtFI(N7%S|uISxt2N zy{uznI`R>e6E3`6lw7w_O4v?CDZl(_`@<)_#QuY#iup%;uin>rG>^7)x0m|O_$wJL zH?LZTCoI*S=o8LxC}O!NZ!)LYTZM4v;Q&$kDCMvkkR!9)a~SU+rObMUr6qQonj`tT z4!H~umOe;zvN1C&H8)s7DU7OZ>Ti2c;To6M)P^E`Upo6PBn#+GjSuKG<#ssTUo$Rl zJx)8q)i$=ieRV(uIwLKm8J$VQYnrlj6pfw26Se=6C`e}2nQxi&Rz=qkcG~+5_R+?) z-qQbF)g6?4<_ItAgX3bXO*E(y!ZS94qIv2EjLP@aY!-|6-Hfic^eW?*m8iO6R))7E z3VEiffp_iL=Zi9h^_kZ5KS;Ekk{WNIKbKW){qk+Z$zA#G45Yr_(2#rR%jBiqwmZbO z^3$2HoSk%+x*hR~bKSN~z%ve8iPD1lB()u>MNPz|(a(au_qo*!&N2~{owkVKjcZky zYZlkbkMu2{Y~M$jCA+m9(0}q4x;KzgaOshgV32)zP1=sE4R=%^5NaiNP)bjcY`%R` ze|E=okg@A2y30|z&*{K^KTL;tjYbpvF1;pt#aW1Lm@b{xyaAeP$&!#nI2<5fC zwEx-j3H&|v<*nLr$lAqA!p7fy4u_MDvB_J!6qGWPzhq=}Gk8@6L6BLr;1e5LdTKu7 z6yX-m784o%XDQ3v%ix0B+h4ntI@ZYyHW-cVet9X0l5lxt$^VoDe6O@#-#mLS^a> zDmH=Rb{{hK*5&MJt5*54&h@y2YCSURCg1iwPd~Wz-yiPVnAkmivGZ9B>oi-+*61U~|>H1BGMue+C5>XLd_ zrBSO}&(}wdjg446*N}}&Y(i$7O7Xu+yw?8;f&V^%S)cYHLL^ zw6(Wx*VPNo**vlvGTzITqj8}JhQioy-bG0_YO1e%K*7uM$?r;G zb9TkWqJ`%-h-r5CBn~}1bP}5u#ZHVCpLVgHI~Ja{OkD7{J(FeEwylk28+6Oq%iUK? zk??p--NgHdX%`z5=u-~wE-mvH?J-7J+SjsLK1T#EOyjF`&fWJX@zSb!<94acTv+?D zvG&6siY?&54J+{YgApT4C+nz!m3fNpH=7C~T$r;ph$fZUx3=X;U$uQB`N8kdjjHrX ziiV@7)@~{W+2Qg}gsg+!kmov{O?c~hst7q6Di+h$v1S%T3FWEWPGvo%SW@nUjBWd3te$crq^GUoER+oc3ty7D+S&IjTOd{+*FrIfSZ!D ztPFZCRJT+eea|y!Z@X)Mhc-_Cexn%}WWjz0tu2a?d@{#d$t%FiYgun^`!LI?dgHQt zKvMhHq)z(`#a3s+)CGpgadcg0^01;cXX8?7%mixZa3BcZs1ds#)tjv{8y4Z%D>AX) zQXZ=5E?l1jTV z7u3oiq`3_Hf3+wyfat@w=Y)QzlLp3<_Le!d0ocW zEGlKI>+N&8I3q8+G-94cT7Big95QlRUIk|t4*Sd18!~Q{ZBKia!R6ce`Uf3f7X*%Z zs6uFh!A}DW_etp)%VU5kyljjhqFBpT{HfoC#iUdf*8FlD0)Gi$lkC?tsea@;sr7TI z_z*kn00<63-VX8}S~rs`-cKoB^|%<|?nkBeT@-oh(FaIOTwTy>1QxbxiCB9&HO_pH`gQT2lZk-s3G<2ovi>D0bHQ^ zfb@_?%_3VHf0IWvzQ6nIA>kFh+ww--(uIw6q?5j9B~Xc~im&FE(rL=Sj!?P~cG8pNFC+#58dN}Uv$VUF?_KBM>ZQ|l5HVR0rZpkkXe|Kr&wwfGo&%m*&BP@zQHJmkPh#& z2c>`yLX>}P)-Q97S#oGrod1H|L(04$K8eH5+2KmDD{4LEY+M(^(C%JcYv`oILM6u8 zC~|x%M3=iyJtrtWow&@&XVoC}Wht}rE=4`!h83tOe);GH;- zv2|f|c5SFP@NcRnyk>{-p0g1(|M_HH@Djd=lL%fR@vmZlLi#F>zUlnky%ciFM{F}h zRMt5OcD`f|PyVh!R?^;R&taihGGr|9Ce3juvJOPr{}1?Zfi9RsP^WedW%N_@`w-$Z zqyqKNu4godX&ZrY?I)F+=m3}2NE?2GLta3u;9~2BmG>&x!#KTivrDWX`;7gSk(SFR zrhh#7+qY2h5SkzTalHH@{8g$?xo#?xoqXfz&+y7>`H2UDwXpepeqO(+pRA~|CQ?*( z7iJ;+FBgQ>IsF#})Bi6Y`d>W2ex3#wJ?^M)Y1u`aVnC&(dX087Ln*!$*z**;bxtcH ztdeI>VHA{<^nSZ}HOB@dsFNPzDC5UM zM4=oU93L2aM|!Os?ti9!-BqdI?&atj7rOMmV1iiU`p0HD_MQ%;Z~n>WKWxEt!jbv; z`LBR=3nRT=?hAl#A^)M7`i<3_Hb4(n=!ZN~jPcexQtkmh^Va3-O|1N;X~L7Eq=>y6 zsm_8OyIKVuQ2KZVf8^%&s7=ZL=IGRJE~7fYxoy7a@jDXG;`{~3*nZZd(;%4_s{9Q4&*l{d=} z)jjfyzSJ^}y*sMF<95<93DHx9vSdoY5dVoGb*|E3j?hKUk;cpB>VvStrZ3e8rI%$i zCo?m6?Mo`gY9g5R!P6$ni?RBA4-L~9kv7+C+0HBKgPx49gcppCxV~t~IG01DYj8;G5Z<3jvF*;UAPpyE#o#XyD7)XXUrsLU0uq4sc7`2V=r1+*<>k zo8R=26?dtNrMVSV#jlpJ55{cS(fNO)4u4IX#tI6XB7Xuf8Wax(cQJ9XU}w3*rC`)a z$&~2akorkdcb_}pI7ib@!eEm)E(-+hJk$>Xo>v|#FdwgWBF^eda(UmWaGW}KdA^s= zLa(wLHXPNAB+CB5F2D;p^`Z@ArJeBBOx0MRJp!W8t;yX?P14(Olp#fR+q)T%ga|NmGGo3H+3l^$MsjSgD#5O-q`bta6X-5Q9BrE# z03T?6#i?F?yEs>#Tyq<92e3UZJw^rRLn0fV1^3zH7%@u)PX#- zwRkV?$a8@|xmh1=-v1})9eEKnt=u{2V1dw!0$@~mjn!k{vT&>V#$j=m)mW6Qbi>*Z#2-;H3s z-T~IG!JeA>>>+47ZMatnB2n8%C0A9aSFrbQ-E_a@lhy}-OPM)78nQC*=#*K9KW^{n4NMoW3^#zYn*hqT`M3rd@Ki%;@oZ*@pZca-Q ziMpSNC+l_opx#h1r#R^92r{WTO0TKwc=N^hO3=)7vQI_S_IxmZzMAf47Me~ick50S zZaI)24_K3xxsTj`+Qr4SOAu>^iRC1{@|~I})UqHqowM%?H}=c#3-Vw97lW@>lEu;0 z;lg!zPrOlNK+MNS*?YjoPmv#cDeno;*TL~8^p8%GNuBB1E8E@kU`Ot(ru|Qbs zvBKgaY*Mq#A8AVMv(ZkfTGlzPTgZkAKGpoLLh0R+YVzjX8!!2I+ww$g13S$z8tkG} zY9%y;EK|$V%rvA>JG$EVIT|m)%dzUjO*bFE$3il6a~hsD z4Sur5`6+XDEt=Qtw6fUZYH@V-5Ozhk>L+lMCzauOnPs=D+&sIyoFxkU)!DKN#Go`P zD1;~xs4FOzQsMAo1tfp=Jeb{L-Ffl=>HB_-yY-$yelNB-#^Sd|`LPr$s@eTdvQ-_5 zc7W3<8gWe7e$PoOf9B7&Iq-Ttyq60XpbNR;V8jJiXfZv<1q>}MFB^3U{rcape+3m1 zxHPtVACHcX%DzZ2r@S-pGOoYgcB)vwepU^FFSSNsARkAw;-N0BD2h)5^dE2)yNtXxth3Nv>FH?|J(bNxV)Je%K3_)dV?3df1e zc4fBaSb6$k{4Pm@Bz$@=INP9HZ1-{VyCLNBVjF(H&4C1V-KuobYHZGnZDUFLKPiev z>n=fTc2Vx5HGv zt~^fiHkWcU=Ok{|CBK|AHk0G9mF=0C*Im{|)H(6}OcxZv!l(w1Bdv;M&lR}Buj(W5 zD~>T#LfO7!koaWyS0;nZc7sJ`rj&S5apAl^)EkRmbbCs#o=`bSF3?2HjF1K!ht^iHM06*LqVjEyQ9Qk*GBTH*XJe%b3FJ|JwN3v{QK{hvv}GaE9RFnOX>M z!)uR8=v-yi*k2U}=qDHOUgdvqrW{_aceL65|GqaC^w;-BzxAs2j$6f_dkOT}G(sIo z5k0WtmwPh#D6Kuk+`rRvC9Qse?oNr^{N7;1WvbL66ietP14Ydyq-qp&al2A67bk0% zsa4OmetntUREx&9pMHzVSkqfXKIeaVNeYjuy7S-pNqRqN#K9KVP925Vi5*&l8$Z9z%$5_s=>^M z1H=^X43ebR=lU)zDI1--9MO-B|KT++;876Rzxlz*ul=0Y|Lz7=r5C~d<&_^dedm34 zx#ZBN54kO=uD0ZHS8faZresq*+|7m|2YXUFn;*pH3&btw=z6X$l-^{IbB_@dUOpqe z%uvmkg6~|owb89?*+8fG@;=eqEYLX+*(t|fS4?gZ59qL$M*VTKUK8r;=~NIBZ?$$% z`kp2^XMH7b;nex#(N^5mv$Bkz=beeE2lbOC1^wIv8@HMzb+BUyR5%{&oZd3=Q{6Wx z&mjF_yD}c(nHYdG+ZQzuOJmSKU2>R;QE9%d#a0xoJU<7OTF(32rT;6U(45>_@a+Qu za5ZNYhR}aj)WU|@VwQmivz1tJp#uJs#bfafxSe3gP9Eya zA#nBK9q%^Zb1P$G<61?W&6OFDZnML}JDIVOkxJusoy0-{`T(_3I^e1GW2_W2HIxW> zhKF}vk3CO4w4zX^gvF;%G+imWn4OY006`}*F#pN^en2Gf{uPD_#7n#MWpd#hB)j#b9Cg?HhOv|=;zFAh5pod`~|KZV1Pmz z_3tMpu!0UF*wIQmR&SB;p>Q4_=WJl()M2r~b|gb!+6@P*{M60N&R*&8f=K*LYC1H;{#Z3J1X8v^EBO^9Xiz z^z0rtaOLMjSXe=D7R93v-gl4AeJ;Z)G_?PU{iFd!WY;}-;r&aCeca;-cN3L~FR~gG za+U>Y)Crx5XJ0ix{A}I=#GtVfP`BIR;b9cwDoXvuL@WPExZJ3`e=acS_}a`p&4})f zTA4JCLbK@Kv_l;trMhDI{Z|zV;q7~smB{5i#~8<+UZ*`5>!_=*GrH8_v^?84J|E|D zPFFz+`w!c5fsX3|nbQoICG&KXvCBrTTr#?r8f|@hvwwgdA>GB*?x~!q_r?@z+@A#8 zWIfE?E0=Fov)TRR|8OjK?E#iD-QU|2_P?wkLHu z-Tc;*=?B=_()9rSo3@kgn6}?MfuzvcyBp4ySbkzIaN$Em^{J&c-GHbOYSB9t)b~42Z2Z%4ukT+pr=ZQSU z^vnSpbtK*?u0AnhCb@x6iT=qI#ts7vGm1zB6D&3HYF?@Ci7r%J?q#M(!kCT;Vjnff z8E59xtutXyP(#(n=eW79@PjO~Bq$mwt1bm!MW%1WHUaqiRofe)W`wb););a{vsL!{y=vpMos`9)K@Eys5I_5rT=TsldryJA1 zVH?9}y0X!ovQEB(Vl(XsAd|c{iBer4buY^7H>Tk$nV!{QggI z>tFn6$I1o{4;@}TVf`Cmp81Gj@ynpz>QSQ+;4flXSzBX!J$#(;qXUa`W5dIbuOEiw zVQ;)hxTj8U=?veWA+#qa{GTe+ALXG|jq%LV9A5m3-LN-{)WZrm@?Fi=h+(SaXG3o| zzI$`Jc$lr*{FrC_;vaVDD~5=i;=iM|X!P_Nr;tZ{+&xj@UBrKZGnFp>#Bca(AnZv5 zINf;d9sVlvY3&Emt}epzW)~^S_Jg+a-=8Y(X~dQ?Z(6m=xI5_>);!I7?R^xZobJu( z^s$GHJrkuc|1GerA!~9vWM(vf_uueF2;DeU@BVS|I@1-kUSQ@=u=g= zIXM239@fBtIrw7tjbK^cRk@&lz@vcpHLx!ACeeuIQ$+48@usz(`%khF@OU_nRci#K*7@k@Mv z>id_Qxsa${-VRXqFLHmme%JrP)=CZlpZg=TjVsT$L?~0jdLF^NW>^7 zXzvfn8?%`*tSRaedx=JwKY)P}dV*4fm5ETK;&^zSp@A)0mX<{%h?r7SeSIu@dw*yv zSd!Dy!3YaD!XY!!&mF48es&FW-u=s%NXzqkem}4eU^RD(!0=D(HH`=66|9<&y_|aZ56qYz2N)7Hv8Z*x!qvG@Ln_k3el>sJ3S`cx(UKiz$2 zR8#BPZ3Gb&0TqEw5l}!n2na|=1(7bjcM(Dr6p#`SK~Rb)p@=k*-g^xYdPjN-T?oDR z62kXJ_c&*td(S!FJ$HQL{y6>B`RtELGASztoq5gv zu#Z9oFv@QjyOo~(EHH0>gB0l?eUUa8XcqPG6|mgkcOS?%XM3Te59m(XcA(pv?$zD$ z9{kH&`gXlvsCW(%2HjryB(+P6IbVTQX)AhFQ*ivyN~_gNAt+S9wMm|95@Rv^8C*rI%ODND5@z#>;DRcI#=! zyMjWgv2&jylwt&{vBxV!L_`Dh?O%pha&mIGT-c-^;D9S=U5JT^L7{rH{a2{`|ET1g zGX+J&g+y^d@KJZoCy#dgZ#VF~t&pzXt#{bO@;kB=3@%=6*sWC#wWs10l*DVf{n^~d zEDBEwN`S3hFIA`fX>XZGl!kI4oJ%)$wFgw|;9w5i9wW0`s|_-;sG43T5+ImjVPMJps-I$_gn7A%gZ3qCdmBJb zUTu1pV)gS6>D!-Y_TgQ6i*a-#qhh^j!IX+M>4pRaO+Yr`?l(lnw<)OC>0Vm?YiAL>60L*{7Bgg$3IxTe=DxsdY?Kx?Yo|mDOGFFtNuMaKk~+X!EA>P zd&5T$1IOre3ix4>c`_%q3>Q678V5^%&bBeym0+OvNU$H zRhUh%Dv#idKme0v=qA1m8sb44((|?h!&lc;1?!BL1c$AX7TxC(=;+2L!?xK+0Akuv zWXg`9orM#8lrgH(f^U8^xarp-8^EAT_)AUHoL)qI6YDK*FaCnmOSlw?m$S+Jo zSNG^90qhiq=(HKn6nLU%y{wQ+M_z}`X#bpL7EldmDUIaG4$x_Y6Fkt+lSj9{LRgg& zVW|rz04`(3G9x7Mxz@-YRYQN)h*3#-f0Vp=Sva_?UGJFjsHbKgz6PbH+{!FF8l<{7 zpQBG9*Cys0s~V=o5omZB2M{lH^|4STOZrx{9|jxR;ck)j6LxcnEzBkv%AXI`Ek&p~+Ifrf&C5!YUc(RE`m zo;YYajKNg3%qB(>B^3u8`xKS|8gSUy8)j;n_zMrN>j=pr{n}K?5AWcZT;r{oB||WCQ!OK;b;3_1$JIciut$kkJmQ);mI%w%L+napP+A z7!#dV{||)#!g~4oA|a!z{L6@d^tiv!F%6IF~z*Tshu>g2O2I|K4>H%ZRv~OnQBSgWQfZ{W7`D;65S$Ed$c%&H<8Z-EPlrbn?|ORQbhGnb zt+{1qr&c)1Zg^2)=Cft@n)vBgrc6F!%=WFRiPDA5C*>RxdI9?!c6D#MbW^y00&=0a zP|wQS(Dn4_-7sEzdXE%YBE5{7rl!n9(yL^*m&W#f4hFeXIaAXxfxSLrgf!Qrf<#45 z{@$y_lm-tKF&s-wJH9|EPoi=s~L?)8$0rhb;HHl zB0^!-G7za5Kb97n(&q1F4mJ&k&qnxE*GH=v)t$_0$7g@=9x3DSIskw5Bo1XezaDsQLWDd7+=VzYD({&#B9kfDcmRp`6$3>IdWg{$o9HGy9PGt zoLlzv0D*Jq88KU~DZ~5gG2;s>?rRw04Z1vR&(xuOss!iM&6RZhA9bN!OHpY{G1M0l zPvt8=`-Z&)P+8F2rgMc5LP+AJOR^?@wy`!BYJ}+u7*KgxH|$*LsvS3+HBmU|QDgC) zQ_MsWO)56!p`$BZlSA~!A;4IwF%hh76_KaS-V0Y=xc{OuEWuY zDUaYV`}4V{x_8NtT~rEek2L6wg^2~uH@0@ba0n485Y^HvukCY zutz}U*TX5)8yFXsU0nwUIwsC~-l?~dN5qDy)~#x(d{#L|U4Vq!Oqb1$p6+<2svPG{p)iXVN%SJ4@K1Z>4XvV2&R6N6;3tSm zc5ao3fl{F8Z8RlOz{56+lFk-4yB#A?TbMUH)HmmZjhZ(rUrpZYu;Yq{=vy1sAPu^aY_W9JozKXKZ*GI zOTQp4E~ch`_HQG6EE*%LN-or?wY+J{e$%hz_DwDj{*xN9c;H3 zcp!zdDXg!{NM7oo)7BM^dtH@{{6-M3F_v}ksXVD-vuJmCL%XDaJ`pZ;ipajZ|m z!kKM7*vI^HYk^dtXCF6%2`n{N%{7{CL^oYuTSA#U&C&3^=&R_~mXzYoedE^R$|Hwc z>s~;cbaIjk7SdOlDwidcf`1%z)#y@xw7R2DXj9I?oq%*u%j;`x$_;xR(Xm_0@0$a+ z@Lmbq=)w7cQu?UL;+NE@0ftO$yHooVxlvp9v7k=7-dzY*>@911gCQHk=%v9RsM8~y z$=t+q=PYZPl{)gyCj25E-bZJI;cH_3yP>*l2W8ms+e2<3@wmb+1|U5GW5LqL)e!O z7>At;bJrWm*=nA)S>%gGh3zZwq_ZKyD>4+=*M{MJI{A-{`3Kvy2KRpMo8-MnGif6# zE><9eql8VI<6B579Xn?!6(m$T`Sf55KfqLN;xv0BI+DgY@4MJ6JrP~lkH<;93WT0c zRk4n*T7#atDvufy z->6V8Eh0iUyl&kfaEb@`hnP)U`sY%)bpa2?H=essf-X!E9R>I=eAP8ou6)Iku%GbJSfYEV>JO|UkV!MB0K`%asoo%Ar{y&5(BeDf6Dvg zrpOF1@rg_3JD$a3q{D#_+WH#r_{k&`<)7%okv={z>FR#bwD z(TTsV7BL-=aO36g(DYN)ESfkjF0v(lR{Vy*e4vw&^z(79mT1 zDy~&?Bxj||1JSJba}U@k_GdL6cG9>X4HdR=8Q$ze^AspR3%m0KIPJOvYYSR~hQp=% zFNW^GEu*v2EZm6o>@}{#oQ&INT+OLqyT(JW4X_LE2cC%?mKk(%x{*6-I4G2VEqTkwhZ@b^wim>zs;7JL3_tRf_O zA+m;~D@EqgCrkRKuC*dDj=7Az&#n2-#9Z1}=XB?nGe!-4+i+L8cPO$T#hi1*W>_vS zcQ4d1K_2l1&Hhl8e&TCxD)3M2_;r1<9p{9Y+iO@EH&3`8=^DDA$rURD`RLyRr;Nx< z&oig0PH~eC#c<0L9y-y~D~By~#W6*84wpu9<4e%<&23tOcrC#nS~9vXS4bw^GcOW# zi>#85p6@JwlOta*y8xlmXSt`JeLFHUH)y+x z(_BY`+NCF7v*v0_4*$T<>=U|AHikC^Jsw+g9nc|xw#?u6b+ktKlGFfjw;Xq=O2)cV z*4J$Xu57bde*fVVMtBr)&bvBeabrv{Iqp7L0Z)KVlvtXFJfv4ytEC2g7%vDbJ#s8Y8C& zaBB+;8@=iZAK2R3>h{_%V%CK>35e(KP%SB5XoyaP#mC#hnYSa~qSvhBm4B@T(;m-j zRH|?>0?aaW=jd$cUE~I2_cJSh6t8EGYR`VXV^Cu?)yrPJf4RNk)zT#YCCo#(CEg#T z8K{5`35|;nv5|!u*ts^1CT@IbGWH;kJ3hu%?KT-fh8J(>PU|EyIT%UJ5VZe{*TuBK zKx&W)FjUWMy}li+j(TY%JixT=#}S^DZJ;8C=j|xAS&RSjq~zI6U1`LDScmL zF&&*{=O`iztu>|%mBbzbsV={ekZj)V2Ta$abve++f#1yCb`p<)uTH7%e z)#c?t3`Wn*#f5etgFwQgLnV$EXX(!lzY5sM^K9ajVnFOW4dle*=RrWhJkGESCVKLG zn><`xz)w^7{rhe27;tJjvkfZv@{p)@{(r7w#yemSI_!f4 zQUkR-Q|VXSD(&W_X1A<%UnglC7hY683bK4SSVfbWRSuG+R;dn7eP$p=FQj1$;j z-vSMh`Y%6k|LduN6nqKB6kvJLzI3qhlAVaZ5%;iuDD+hx48$rZoqi=ym0w8AH;WMH{N=`cA4Z6o(E;Ye4e z+%OjoW!Ir_IF!_kJngh0Hqt!TD7PTpPe1y9H&u8HVH;8dJe2F8}fQ?CbK5jY1nVRY37f13M5Y zlsB5lLkk?L3QEEJg9}2Xx6f|sqYfDzx%%1F85z|D5s{^}OZy`BJi!=s^3GK2Sq@%s zn3Jv0V{f?N!V3bTI4kFI^#ntzSg@&S zpuF;Z9pxXiESiMhz5tPiE9h`BXycJu`XG+uIHky|7F|LpKPzi0%qog(9+B7pd_Rgt z9GXPU&v_B8aqfBQ8%=yc*XVmNFj(8I`^KU+Bo8CK?@MyThPUuF9ciiAiX;WLFIuIF zYpW-u|LFFH0MRLqxhfrq!8%Td^$KaQ;fED2PwPbL-@RbTF?UUQVY#f4PJ+qi&qd_* zy*>6HT$XuUM3I9OFhY_cLf^jkgR}*!rDkl2ea#N-_yfQW%i=#Li~op4M31^ZYRJ^B zk6X)dMFn;PxGp4civJ3cjMcf#r+BQI1YXU+@+j%p>f&Ee!DAMGVS2>Ma?iwO{N=f{ zfs5hKf4;gWa$A5Z)UiMJXAj?jNQH^69q+ieRaKICRofl9X9d3uC{4lw+W`(A^&4R; zxRW~&q-03XkC*dzn+MpTTI*oq`KOteX%At9KlbJ7tg{yTK(Z9YYL~7M)3|r5W2mHk ze^MMAi{?X=-z z7}emsT@}!(jcimDE(yxiP4q?nqnqx6|HPFcqLX)J8;t7Y5uH_m4LSEmM;D(|yS$lmaBYm| zXytX_6kgZI9j6YhKn0nx=(AY2$C+r=e7Vlea^Mn66(uc#Z$HJmsv?8+I@tgzE~ELT z_>vv^Pm|(A?OiPQzBI!0+hA^wMbX+%%G0MIpKQInu={Ic$bB2+9W-IyDi=r6quCsl z<92h76n#3#3x0RZRYIU@VrG~U$40odQ@%)Oo>ir@!M}R*F9M}sGTBCAA4!Dv`_`!^ z+|4$!M92xn6&CNjXL5**cNwvs$d`ALs;3F=L*x~2;Ukyg(Eh6q)7vq!L0cEX%ea(jbYqPWFC|Qf#RZmS z^HG>!e$etw^j`xSGJNApj@ZV6!7|Jpc3v-xjlx8m@Df0 zw&lzEu}l;-$^?}(+IaPP%#6IFYc!(k+u5fargskVUUDZ}@!A(sP|H_V)Q#1s+#QQI z2DkILtjq;VPuB9CI6<-@0%PWV^4zy7xvI44yG2|7s?$AoB$cCB(RRPOc2349i)V&I?ZMqRU;9&ej+T50^1JTop3Z z?c2KYqT~0@;waeb^R5h)(T=F`$hH8}UdFIfp>aj#%YX>;3Y~f*9YKcXw-4lw#Onr_ zRS=?sC0=ZjmvzS=PeE6a04eX3djqUODxa*6zIZPWp)l_%8 z7{L!RrwnO06bN$dAgo8VJZDk}Z0$YE`qKbq9ke708hrj)!NjuO*%!W{_%G?=?xA+~ z+hS5SM|99>agj)_XEjI z*xf;^>&9hJJ%!P>%F?6n3=l_rK65i6%{#=QsM5oE=rHe(-R9@sUFW_Os%+`kQ?2l^t`kp#fZMesw zKF+eNK|$!DjDIBgcc7J|?+iX*9?q^Pw-#{zh|)|#uOFUR2e8M^IUr)7u99WocdTga z-ew0@F-7&JPaeOj2EycijPX1H!*Ta^Wm$`{)Gsfz8`bu^=dY{`&YI6bzI|yV#-v6a zvl^$RmL;f!v>5h`5kh{^Hd()Eo5$n7cpK^eHN^72^<@3eZ&1kRt*fn}fx#?Do(-f) zL?D%bu=6>{ZqWo%lrSHliQDcz!*p+qSHOS&3kbD_dwV}4F*v#(>jY#Fx%K~|H^X&o zVJ&$(r<(zioyPN^4J^P#U8YbTGRvV#?evXy2qE7v1_&1}?`ZyC88v_L7yP$xwIbIM z0T75(Dz4#g<~Rp=)Av`g)?cQ3|McxAOihJKSaS5(LBeS_^7ZV-{1k$+?VvQ@lWS~f zU;OAmP;!?yphNcdXJ9$Ng?)i8YXBJyByB(fB2Gs-Sg81yOweoVx7>>Vo_IqWbzS7L zz30oR&b5<-v;L(87u!Wtf$6FBW6JUM>Z26?Jmi6_WGtdj6V0>_c7gM#>y@;x>}hFe z+(-^ixkVkq&CUIzyW3$T-)96#3-$zu!)eGlv|f<5Mg3zk&UNSe-1he`a}#5X&ybVH zAzkzvsI8}1L7&)@272r^3;%)Fc#ZH{7=4c@Qll2)Fl4~(B3q0I|bO`NC)>^R;KeE}JgG? zF&Brnx3ZH>#OOkIKHT`V;p%4RmH%VJ@jf5e|GI(Z4A}Ujwv3q#@h$XW8!O4L?kC$q-kMgNSAHy3y z1Id&Sk}t9sf-UxXJmI3UwtgK9TzcHhQ}k ziR11~um(H32&$y}Ui=K=WaLh=Z{W-o!I6twS4x;TucEX;I8OE}DLq1}Pd$ngP2O4U`)wqqO$G{{h=h<7M(z!_+&AA1@^XLu!b8E6!>FXXdHHQoJG<}CHUip?XJzdp{sBU% z2^$B#D^RD=!xuk4)$w?5!<;8J&gP{s$sbOz~29=~fo-`{l9H|l0235Vz zX`V50tcNjHG?~E6XDDW#U5>IZ7msnznw*bnEO?mD&uGf2Om!u&YwtrG@3M>o%>mcg zp`fmXnwbCE6;jFV);*fGqN7x7-@U1gl@ciX&>y6C#lDlAa+vK9GJMO-<%f9*6sJ{P zQdDmIS+kcyeLppuHHDs2x!i#%zztD{Z+~sn(Z~TPVOI4FEuMU;-8g*kW-AmZr5c~+ zIgdxtpAmS3jEYdios?(yz3(8dBsLz`^*AL=jdlmumF3pj8&_`^UW-tqyUiN-!s=^| zX9EL>Ew-y_RtaqP^%W~rsxHOi!d=Loz%sNPc4&(5IN7pRRKB85{4*YW-fphvx-AM_ zvE$4wB_ov;CX?7CtUO_|ySGS{F*u+^T5K!n)|ZN;zkjEjKsDf#zuqqQTRSZ%tIA37 z_?K@q>ZfC8$27)YPkbbDy^S(6B9oU{YM7u&dkZP30def|l;F@AmRK$tvN^Urj`aJ0 zz53*vKCCA1!@_T^}@*i@z|I(ox5o1>9H>-q@;loM{3zIRS_YIty0?FNA z=QJ{soFcntx1?_?t32U^?_|!G bKKEzzWcAuh_870xx>CE$;XRc`$V|pwKAWzfD z)b!RFGrJ~O2!UaM-Gxnl44+i&u8`#pSyyYxpAj#=mm7kp3)p_-&SLEdLYBK)0f}S+ zhp-zUi9yq!zRulfQ&NsJE_&Ye^XJd{dOX9i^I+EE<%Lrm0|};^utxoB%vQ^k+d^DL zh4AsI8~F!muqIAaJS8H?%r#$3Q6U+e<$G9IxxC_BjDmfRUHk+U)(UD t2D#;LJnm-wUr}%WRFUwhaqA5K?&^;dsu%Si!IKXmax%)&xera={uemOaJ>Kk From 1317c6f00d7c0d8d072ac3b90b312fe5066ac0ce Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:34:08 +0300 Subject: [PATCH 03/13] refactor: sort the tests into unit, contracts and support --- CMakeLists.txt | 144 +++++++++--------- build.sh | 2 +- src/launcher/CMakeLists.txt | 8 +- .../test_ambient_occlusion_contract.py | 2 +- .../test_backdrop_tiles_contract.py | 4 +- .../test_billboard_aspect_contract.py | 4 +- .../test_borderless_contract.py | 4 +- .../{ => contracts}/test_capsule_contract.py | 8 +- tests/{ => contracts}/test_cheats_contract.py | 2 +- .../test_debug_tab_contract.py | 4 +- .../test_depth24_sync_contract.py | 4 +- .../{ => contracts}/test_dev_menu_contract.py | 4 +- .../test_far_rendering_contract.py | 4 +- .../test_frame_kind_contract.py | 8 +- .../test_frame_rate_contract.py | 4 +- .../test_framework_overlay_new_file.py | 2 +- .../test_gap_filling_contract.py | 2 +- .../test_geometry_provenance_contract.py | 2 +- .../test_grown_quad_contract.py | 4 +- .../test_hud_scale_contract.py | 2 +- .../test_hud_widget_contract.py | 6 +- .../test_intro_skip_contract.py | 4 +- .../{ => contracts}/test_language_contract.py | 4 +- .../test_language_disc_contract.py | 6 +- .../{ => contracts}/test_launcher_contract.py | 2 +- .../test_loaded_state_contract.py | 2 +- .../test_menu_wide_contract.py | 4 +- .../test_name_entry_contract.py | 4 +- .../test_perspective_texture_contract.py | 2 +- .../{ => contracts}/test_phase_a_contract.py | 2 +- .../test_pinned_corner_contract.py | 6 +- .../test_save_point_contract.py | 2 +- .../test_smooth_turn_contract.py | 4 +- .../test_sprite_range_contract.py | 4 +- .../test_temporal_presenter_contract.py | 4 +- .../test_vertical_camera_contract.py | 2 +- .../test_world_depth_contract.py | 4 +- .../{ => support}/disruptor_language_discs.h | 2 +- .../present_rate_fakes/host_ui.h | 0 .../{ => support}/present_rate_fakes/imgui.h | 0 tests/{ => support}/stubs/psx_sdl.h | 0 tests/{ => unit}/disc_import_test.cpp | 0 .../disruptor_backdrop_tiles_test.cpp | 2 +- .../disruptor_billboard_aspect_test.cpp | 2 +- tests/{ => unit}/disruptor_capsule_test.cpp | 2 +- tests/{ => unit}/disruptor_cheats_test.cpp | 2 +- .../disruptor_far_rendering_test.cpp | 2 +- .../{ => unit}/disruptor_frame_rate_test.cpp | 2 +- .../{ => unit}/disruptor_grown_quad_test.cpp | 2 +- .../{ => unit}/disruptor_intro_skip_test.cpp | 2 +- .../disruptor_language_disc_test.cpp | 2 +- .../disruptor_language_hints_test.cpp | 2 +- .../disruptor_language_overlay_test.cpp | 6 +- tests/{ => unit}/disruptor_language_test.cpp | 4 +- tests/{ => unit}/disruptor_mouse_aim_test.cpp | 4 +- .../{ => unit}/disruptor_name_entry_test.cpp | 2 +- .../disruptor_present_rate_test.cpp | 4 +- .../disruptor_render_arena_test.cpp | 2 +- tests/{ => unit}/disruptor_restart_test.cpp | 2 +- .../disruptor_sprite_depth_test.cpp | 2 +- .../disruptor_vertical_camera_test.cpp | 2 +- tests/{ => unit}/gpu_pinned_corner_test.cpp | 0 tests/{ => unit}/gpu_temporal_match_test.cpp | 0 tests/{ => unit}/gpu_temporal_mesh_test.cpp | 0 tests/{ => unit}/gpu_temporal_sprite_test.cpp | 0 .../gpu_turned_perspective_test.cpp | 0 tests/{ => unit}/gpu_world_depth_test.cpp | 0 tests/{ => unit}/gpu_ws_frame_kind_test.cpp | 0 tests/{ => unit}/gpu_ws_hud_scale_test.cpp | 0 tests/{ => unit}/gpu_ws_hud_widget_test.cpp | 0 tests/{ => unit}/gpu_ws_menu_test.cpp | 0 tests/{ => unit}/gpu_ws_tag_match_test.cpp | 0 .../gte_precision_store_pc_test.cpp | 0 tests/{ => unit}/host_borderless_test.cpp | 2 +- tests/{ => unit}/host_pick_slot_test.cpp | 2 +- tests/{ => unit}/launcher_flow_test.cpp | 0 tests/{ => unit}/launcher_settings_test.cpp | 0 .../{ => unit}/presentation_yaw_math_test.cpp | 0 tests/{ => unit}/region_disc_test.cpp | 2 +- tests/{ => unit}/save_point_test.cpp | 0 .../user_settings_persistence_test.cpp | 0 81 files changed, 168 insertions(+), 168 deletions(-) rename tests/{ => contracts}/test_ambient_occlusion_contract.py (99%) rename tests/{ => contracts}/test_backdrop_tiles_contract.py (97%) rename tests/{ => contracts}/test_billboard_aspect_contract.py (97%) rename tests/{ => contracts}/test_borderless_contract.py (96%) rename tests/{ => contracts}/test_capsule_contract.py (97%) rename tests/{ => contracts}/test_cheats_contract.py (98%) rename tests/{ => contracts}/test_debug_tab_contract.py (97%) rename tests/{ => contracts}/test_depth24_sync_contract.py (95%) rename tests/{ => contracts}/test_dev_menu_contract.py (99%) rename tests/{ => contracts}/test_far_rendering_contract.py (98%) rename tests/{ => contracts}/test_frame_kind_contract.py (97%) rename tests/{ => contracts}/test_frame_rate_contract.py (98%) rename tests/{ => contracts}/test_framework_overlay_new_file.py (98%) rename tests/{ => contracts}/test_gap_filling_contract.py (99%) rename tests/{ => contracts}/test_geometry_provenance_contract.py (99%) rename tests/{ => contracts}/test_grown_quad_contract.py (96%) rename tests/{ => contracts}/test_hud_scale_contract.py (99%) rename tests/{ => contracts}/test_hud_widget_contract.py (97%) rename tests/{ => contracts}/test_intro_skip_contract.py (98%) rename tests/{ => contracts}/test_language_contract.py (99%) rename tests/{ => contracts}/test_language_disc_contract.py (99%) rename tests/{ => contracts}/test_launcher_contract.py (99%) rename tests/{ => contracts}/test_loaded_state_contract.py (99%) rename tests/{ => contracts}/test_menu_wide_contract.py (99%) rename tests/{ => contracts}/test_name_entry_contract.py (98%) rename tests/{ => contracts}/test_perspective_texture_contract.py (99%) rename tests/{ => contracts}/test_phase_a_contract.py (99%) rename tests/{ => contracts}/test_pinned_corner_contract.py (98%) rename tests/{ => contracts}/test_save_point_contract.py (99%) rename tests/{ => contracts}/test_smooth_turn_contract.py (98%) rename tests/{ => contracts}/test_sprite_range_contract.py (99%) rename tests/{ => contracts}/test_temporal_presenter_contract.py (99%) rename tests/{ => contracts}/test_vertical_camera_contract.py (98%) rename tests/{ => contracts}/test_world_depth_contract.py (98%) rename tests/{ => support}/disruptor_language_discs.h (99%) rename tests/{ => support}/present_rate_fakes/host_ui.h (100%) rename tests/{ => support}/present_rate_fakes/imgui.h (100%) rename tests/{ => support}/stubs/psx_sdl.h (100%) rename tests/{ => unit}/disc_import_test.cpp (100%) rename tests/{ => unit}/disruptor_backdrop_tiles_test.cpp (99%) rename tests/{ => unit}/disruptor_billboard_aspect_test.cpp (99%) rename tests/{ => unit}/disruptor_capsule_test.cpp (99%) rename tests/{ => unit}/disruptor_cheats_test.cpp (99%) rename tests/{ => unit}/disruptor_far_rendering_test.cpp (99%) rename tests/{ => unit}/disruptor_frame_rate_test.cpp (99%) rename tests/{ => unit}/disruptor_grown_quad_test.cpp (99%) rename tests/{ => unit}/disruptor_intro_skip_test.cpp (99%) rename tests/{ => unit}/disruptor_language_disc_test.cpp (99%) rename tests/{ => unit}/disruptor_language_hints_test.cpp (99%) rename tests/{ => unit}/disruptor_language_overlay_test.cpp (98%) rename tests/{ => unit}/disruptor_language_test.cpp (99%) rename tests/{ => unit}/disruptor_mouse_aim_test.cpp (99%) rename tests/{ => unit}/disruptor_name_entry_test.cpp (99%) rename tests/{ => unit}/disruptor_present_rate_test.cpp (99%) rename tests/{ => unit}/disruptor_render_arena_test.cpp (99%) rename tests/{ => unit}/disruptor_restart_test.cpp (99%) rename tests/{ => unit}/disruptor_sprite_depth_test.cpp (99%) rename tests/{ => unit}/disruptor_vertical_camera_test.cpp (99%) rename tests/{ => unit}/gpu_pinned_corner_test.cpp (100%) rename tests/{ => unit}/gpu_temporal_match_test.cpp (100%) rename tests/{ => unit}/gpu_temporal_mesh_test.cpp (100%) rename tests/{ => unit}/gpu_temporal_sprite_test.cpp (100%) rename tests/{ => unit}/gpu_turned_perspective_test.cpp (100%) rename tests/{ => unit}/gpu_world_depth_test.cpp (100%) rename tests/{ => unit}/gpu_ws_frame_kind_test.cpp (100%) rename tests/{ => unit}/gpu_ws_hud_scale_test.cpp (100%) rename tests/{ => unit}/gpu_ws_hud_widget_test.cpp (100%) rename tests/{ => unit}/gpu_ws_menu_test.cpp (100%) rename tests/{ => unit}/gpu_ws_tag_match_test.cpp (100%) rename tests/{ => unit}/gte_precision_store_pc_test.cpp (100%) rename tests/{ => unit}/host_borderless_test.cpp (97%) rename tests/{ => unit}/host_pick_slot_test.cpp (96%) rename tests/{ => unit}/launcher_flow_test.cpp (100%) rename tests/{ => unit}/launcher_settings_test.cpp (100%) rename tests/{ => unit}/presentation_yaw_math_test.cpp (100%) rename tests/{ => unit}/region_disc_test.cpp (98%) rename tests/{ => unit}/save_point_test.cpp (100%) rename tests/{ => unit}/user_settings_persistence_test.cpp (100%) diff --git a/CMakeLists.txt b/CMakeLists.txt index a477231..e1cb989 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -181,147 +181,147 @@ if(BUILD_TESTING) add_test(NAME disruptor_backdrop_tiles_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_backdrop_tiles_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_backdrop_tiles_contract.py" --require-artifacts) add_test(NAME disruptor_depth24_sync_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_depth24_sync_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_depth24_sync_contract.py") add_test(NAME disruptor_geometry_provenance_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_geometry_provenance_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_geometry_provenance_contract.py") add_test(NAME disruptor_phase_a_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_phase_a_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_phase_a_contract.py") add_test(NAME disruptor_perspective_texture_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_perspective_texture_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_perspective_texture_contract.py") add_test(NAME disruptor_pinned_corner_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_pinned_corner_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_pinned_corner_contract.py") add_test(NAME disruptor_dev_menu_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_dev_menu_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_dev_menu_contract.py") add_test(NAME disruptor_loaded_state_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_loaded_state_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_loaded_state_contract.py") add_test(NAME disruptor_save_point_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_save_point_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_save_point_contract.py") add_test(NAME disruptor_cheats_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cheats_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_cheats_contract.py") add_test(NAME disruptor_vertical_camera_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_vertical_camera_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_vertical_camera_contract.py") add_test(NAME disruptor_far_rendering_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_far_rendering_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_far_rendering_contract.py") add_test(NAME disruptor_frame_kind_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_kind_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py") add_test(NAME disruptor_temporal_presenter_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_temporal_presenter_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_temporal_presenter_contract.py") add_test(NAME disruptor_menu_wide_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_menu_wide_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_menu_wide_contract.py") add_test(NAME disruptor_hud_scale_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hud_scale_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_scale_contract.py") add_test(NAME disruptor_hud_widget_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hud_widget_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py") add_test(NAME disruptor_name_entry_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_name_entry_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_name_entry_contract.py" --require-artifacts) set_tests_properties(disruptor_name_entry_contract PROPERTIES LABELS retail) add_test(NAME disruptor_grown_quad_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_grown_quad_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_grown_quad_contract.py" --require-artifacts) add_test(NAME disruptor_sprite_range_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sprite_range_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_sprite_range_contract.py" --require-artifacts) set_tests_properties(disruptor_sprite_range_contract PROPERTIES LABELS retail) add_test(NAME disruptor_intro_skip_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_intro_skip_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_intro_skip_contract.py" --require-artifacts) add_test(NAME disruptor_language_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_language_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_language_contract.py" --require-artifacts) add_test(NAME disruptor_language_disc_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_language_disc_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_language_disc_contract.py" --require-artifacts) add_test(NAME disruptor_frame_rate_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_rate_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_rate_contract.py" --require-artifacts) add_test(NAME disruptor_billboard_aspect_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_billboard_aspect_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_billboard_aspect_contract.py") add_test(NAME disruptor_launcher_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_launcher_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_launcher_contract.py") add_test(NAME disruptor_capsule_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_capsule_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_capsule_contract.py") add_test(NAME disruptor_gap_filling_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gap_filling_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_gap_filling_contract.py") add_test(NAME disruptor_world_depth_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_world_depth_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_world_depth_contract.py") add_test(NAME disruptor_smooth_turn_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_smooth_turn_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_smooth_turn_contract.py") add_test(NAME disruptor_ambient_occlusion_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ambient_occlusion_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_ambient_occlusion_contract.py") add_test(NAME disruptor_debug_tab_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_debug_tab_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_debug_tab_contract.py") add_test(NAME disruptor_framework_overlay_new_file COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_framework_overlay_new_file.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_framework_overlay_new_file.py") # These need the retail image or the translated code. A machine without # the game leaves them out with `ctest -LE retail`. @@ -334,7 +334,7 @@ endif() if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) add_executable(disruptor-user-settings-test - tests/user_settings_persistence_test.cpp + tests/unit/user_settings_persistence_test.cpp "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp") target_include_directories(disruptor-user-settings-test PRIVATE @@ -351,46 +351,46 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-user-settings-test) add_executable(disruptor-mouse-aim-test - tests/disruptor_mouse_aim_test.cpp) + tests/unit/disruptor_mouse_aim_test.cpp) target_include_directories(disruptor-mouse-aim-test PRIVATE - tests/stubs) + tests/support/stubs) target_compile_definitions(disruptor-mouse-aim-test PRIVATE PSX_SDL3=1) target_compile_features(disruptor-mouse-aim-test PRIVATE cxx_std_20) add_executable(disruptor-cheats-test - tests/disruptor_cheats_test.cpp) + tests/unit/disruptor_cheats_test.cpp) target_include_directories(disruptor-cheats-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-cheats-test PRIVATE cxx_std_20) add_executable(disruptor-vertical-camera-test - tests/disruptor_vertical_camera_test.cpp) + tests/unit/disruptor_vertical_camera_test.cpp) target_include_directories(disruptor-vertical-camera-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-vertical-camera-test PRIVATE cxx_std_20) add_executable(disruptor-far-rendering-test - tests/disruptor_far_rendering_test.cpp) + tests/unit/disruptor_far_rendering_test.cpp) target_include_directories(disruptor-far-rendering-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-far-rendering-test PRIVATE cxx_std_20) add_executable(disruptor-render-arena-test - tests/disruptor_render_arena_test.cpp) + tests/unit/disruptor_render_arena_test.cpp) target_include_directories(disruptor-render-arena-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-render-arena-test PRIVATE cxx_std_20) add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test) add_executable(disruptor-name-entry-test - tests/disruptor_name_entry_test.cpp) + tests/unit/disruptor_name_entry_test.cpp) target_include_directories(disruptor-name-entry-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-name-entry-test PRIVATE cxx_std_20) add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test) add_executable(disruptor-backdrop-tiles-test - tests/disruptor_backdrop_tiles_test.cpp) + tests/unit/disruptor_backdrop_tiles_test.cpp) target_include_directories(disruptor-backdrop-tiles-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-backdrop-tiles-test PRIVATE cxx_std_20) @@ -398,7 +398,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-backdrop-tiles-test) add_executable(disruptor-gpu-ws-frame-kind-test - tests/gpu_ws_frame_kind_test.cpp) + tests/unit/gpu_ws_frame_kind_test.cpp) target_include_directories(disruptor-gpu-ws-frame-kind-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-frame-kind-test PRIVATE cxx_std_20) @@ -406,7 +406,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-ws-frame-kind-test) add_executable(disruptor-gpu-ws-menu-test - tests/gpu_ws_menu_test.cpp) + tests/unit/gpu_ws_menu_test.cpp) target_include_directories(disruptor-gpu-ws-menu-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-menu-test PRIVATE cxx_std_20) @@ -414,7 +414,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-ws-menu-test) add_executable(disruptor-gpu-ws-hud-scale-test - tests/gpu_ws_hud_scale_test.cpp) + tests/unit/gpu_ws_hud_scale_test.cpp) target_include_directories(disruptor-gpu-ws-hud-scale-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-hud-scale-test PRIVATE cxx_std_20) @@ -422,7 +422,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-ws-hud-scale-test) add_executable(disruptor-gpu-ws-hud-widget-test - tests/gpu_ws_hud_widget_test.cpp) + tests/unit/gpu_ws_hud_widget_test.cpp) target_include_directories(disruptor-gpu-ws-hud-widget-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-hud-widget-test PRIVATE cxx_std_20) @@ -430,7 +430,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-ws-hud-widget-test) add_executable(disruptor-intro-skip-test - tests/disruptor_intro_skip_test.cpp) + tests/unit/disruptor_intro_skip_test.cpp) target_include_directories(disruptor-intro-skip-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-intro-skip-test PRIVATE cxx_std_20) @@ -438,7 +438,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-intro-skip-test) add_executable(disruptor-frame-rate-test - tests/disruptor_frame_rate_test.cpp) + tests/unit/disruptor_frame_rate_test.cpp) target_include_directories(disruptor-frame-rate-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-frame-rate-test PRIVATE cxx_std_20) @@ -446,7 +446,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-frame-rate-test) add_executable(disruptor-language-test - tests/disruptor_language_test.cpp) + tests/unit/disruptor_language_test.cpp) target_include_directories(disruptor-language-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-language-test PRIVATE cxx_std_20) @@ -454,7 +454,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-language-test) add_executable(disruptor-language-hints-test - tests/disruptor_language_hints_test.cpp) + tests/unit/disruptor_language_hints_test.cpp) target_include_directories(disruptor-language-hints-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-language-hints-test PRIVATE cxx_std_20) @@ -462,13 +462,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-language-hints-test) add_executable(disruptor-language-disc-test - tests/disruptor_language_disc_test.cpp) + tests/unit/disruptor_language_disc_test.cpp) target_compile_features(disruptor-language-disc-test PRIVATE cxx_std_20) add_test(NAME disruptor_language_disc COMMAND disruptor-language-disc-test) add_executable(disruptor-language-overlay-test - tests/disruptor_language_overlay_test.cpp) + tests/unit/disruptor_language_overlay_test.cpp) target_include_directories(disruptor-language-overlay-test PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp-overlay/runtime/include" "${PSXRECOMP_ROOT}/runtime/include" @@ -480,29 +480,29 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-language-overlay-test tall) add_executable(disruptor-capsule-test - tests/disruptor_capsule_test.cpp) + tests/unit/disruptor_capsule_test.cpp) target_compile_features(disruptor-capsule-test PRIVATE cxx_std_20) target_include_directories(disruptor-capsule-test PRIVATE src) add_test(NAME disruptor_capsule COMMAND disruptor-capsule-test) add_executable(host-pick-slot-test - tests/host_pick_slot_test.cpp) + tests/unit/host_pick_slot_test.cpp) target_compile_features(host-pick-slot-test PRIVATE cxx_std_20) add_test(NAME host_pick_slot COMMAND host-pick-slot-test) add_executable(host-borderless-test - tests/host_borderless_test.cpp) + tests/unit/host_borderless_test.cpp) target_compile_features(host-borderless-test PRIVATE cxx_std_20) add_test(NAME host_borderless COMMAND host-borderless-test) add_test(NAME host_borderless_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_borderless_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py") add_executable(disruptor-restart-test - tests/disruptor_restart_test.cpp) + tests/unit/disruptor_restart_test.cpp) target_include_directories(disruptor-restart-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-restart-test PRIVATE cxx_std_20) @@ -510,15 +510,15 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-restart-test run "${CMAKE_CURRENT_BINARY_DIR}/disruptor_restart_test.txt") add_executable(disruptor-present-rate-test - tests/disruptor_present_rate_test.cpp) + tests/unit/disruptor_present_rate_test.cpp) target_include_directories(disruptor-present-rate-test PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/tests/present_rate_fakes") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/support/present_rate_fakes") target_compile_features(disruptor-present-rate-test PRIVATE cxx_std_20) add_test(NAME disruptor_present_rate COMMAND disruptor-present-rate-test) add_executable(disruptor-grown-quad-test - tests/disruptor_grown_quad_test.cpp) + tests/unit/disruptor_grown_quad_test.cpp) target_include_directories(disruptor-grown-quad-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-grown-quad-test PRIVATE cxx_std_20) @@ -526,7 +526,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-grown-quad-test) add_executable(disruptor-sprite-depth-test - tests/disruptor_sprite_depth_test.cpp) + tests/unit/disruptor_sprite_depth_test.cpp) target_include_directories(disruptor-sprite-depth-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-sprite-depth-test PRIVATE cxx_std_20) @@ -534,13 +534,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-sprite-depth-test) add_executable(disruptor-billboard-aspect-test - tests/disruptor_billboard_aspect_test.cpp) + tests/unit/disruptor_billboard_aspect_test.cpp) target_include_directories(disruptor-billboard-aspect-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-billboard-aspect-test PRIVATE cxx_std_20) add_executable(disruptor-gpu-temporal-sprite-test - tests/gpu_temporal_sprite_test.cpp) + tests/unit/gpu_temporal_sprite_test.cpp) target_include_directories(disruptor-gpu-temporal-sprite-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-temporal-sprite-test PRIVATE cxx_std_20) @@ -548,13 +548,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-temporal-sprite-test) add_executable(disruptor-gpu-ws-tag-match-test - tests/gpu_ws_tag_match_test.cpp) + tests/unit/gpu_ws_tag_match_test.cpp) target_include_directories(disruptor-gpu-ws-tag-match-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-tag-match-test PRIVATE cxx_std_20) add_executable(disruptor-gpu-temporal-match-test - tests/gpu_temporal_match_test.cpp) + tests/unit/gpu_temporal_match_test.cpp) target_include_directories(disruptor-gpu-temporal-match-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-temporal-match-test PRIVATE cxx_std_20) @@ -562,7 +562,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-temporal-match-test) add_executable(disruptor-gpu-temporal-mesh-test - tests/gpu_temporal_mesh_test.cpp) + tests/unit/gpu_temporal_mesh_test.cpp) target_include_directories(disruptor-gpu-temporal-mesh-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-temporal-mesh-test PRIVATE cxx_std_20) @@ -570,7 +570,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-temporal-mesh-test) add_executable(disruptor-gpu-pinned-corner-test - tests/gpu_pinned_corner_test.cpp) + tests/unit/gpu_pinned_corner_test.cpp) target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20) @@ -578,7 +578,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-pinned-corner-test) add_executable(disruptor-gpu-turned-perspective-test - tests/gpu_turned_perspective_test.cpp) + tests/unit/gpu_turned_perspective_test.cpp) target_include_directories(disruptor-gpu-turned-perspective-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-turned-perspective-test PRIVATE cxx_std_20) @@ -586,7 +586,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-turned-perspective-test) add_executable(disruptor-gpu-world-depth-test - tests/gpu_world_depth_test.cpp) + tests/unit/gpu_world_depth_test.cpp) target_include_directories(disruptor-gpu-world-depth-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-world-depth-test PRIVATE cxx_std_20) @@ -594,7 +594,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-world-depth-test) add_executable(disruptor-save-point-test - tests/save_point_test.cpp) + tests/unit/save_point_test.cpp) target_include_directories(disruptor-save-point-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-save-point-test PRIVATE cxx_std_20) @@ -602,11 +602,11 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-save-point-test) add_executable(disruptor-presentation-yaw-math-test - tests/presentation_yaw_math_test.cpp) + tests/unit/presentation_yaw_math_test.cpp) target_compile_features(disruptor-presentation-yaw-math-test PRIVATE cxx_std_20) add_executable(disruptor-gte-precision-store-pc-test - tests/gte_precision_store_pc_test.cpp + tests/unit/gte_precision_store_pc_test.cpp "${PSXRECOMP_ROOT}/runtime/src/gte.cpp") target_include_directories(disruptor-gte-precision-store-pc-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") diff --git a/build.sh b/build.sh index 01cddf6..4f911f2 100755 --- a/build.sh +++ b/build.sh @@ -86,7 +86,7 @@ if [ -n "$WITHOUT_GAME" ]; then printf '%s\n' $OBJECTS $TESTS | xargs -n 100 ninja -C "$BUILD" ctest --test-dir "$BUILD" --output-on-failure --no-tests=error -LE retail # The retail contracts still check the sources when the image is not asked for. - for contract in tests/test_*_contract.py; do + for contract in tests/contracts/test_*_contract.py; do python3 -B "$contract" done echo "Built and tested without the game: the runtime was compiled and not linked." diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 10696dc..d52c9eb 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -70,7 +70,7 @@ set_property(TARGET disruptor-launcher APPEND PROPERTY LINK_DEPENDS "${PROJECT_SOURCE_DIR}/release/windows/settings.toml") if(BUILD_TESTING) - add_executable(disruptor-disc-import-test "${PROJECT_SOURCE_DIR}/tests/disc_import_test.cpp") + add_executable(disruptor-disc-import-test "${PROJECT_SOURCE_DIR}/tests/unit/disc_import_test.cpp") set_target_properties(disruptor-disc-import-test PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") target_link_libraries(disruptor-disc-import-test PRIVATE disruptor-disc-import) @@ -81,18 +81,18 @@ if(BUILD_TESTING) endif() add_test(NAME disruptor_disc_import COMMAND disruptor-disc-import-test) - add_executable(disruptor-launcher-settings-test "${PROJECT_SOURCE_DIR}/tests/launcher_settings_test.cpp") + add_executable(disruptor-launcher-settings-test "${PROJECT_SOURCE_DIR}/tests/unit/launcher_settings_test.cpp") target_link_libraries(disruptor-launcher-settings-test PRIVATE disruptor-launcher-model) target_compile_definitions(disruptor-launcher-settings-test PRIVATE "DISRUPTOR_SOURCE_DIR=LR\"(${PROJECT_SOURCE_DIR})\"") add_test(NAME disruptor_launcher_settings COMMAND disruptor-launcher-settings-test) - add_executable(disruptor-launcher-flow-test "${PROJECT_SOURCE_DIR}/tests/launcher_flow_test.cpp") + add_executable(disruptor-launcher-flow-test "${PROJECT_SOURCE_DIR}/tests/unit/launcher_flow_test.cpp") target_link_libraries(disruptor-launcher-flow-test PRIVATE disruptor-launcher-model) add_test(NAME disruptor_launcher_flow COMMAND disruptor-launcher-flow-test) # The synthetic discs include the layout code whole, so this test takes region_disc.cpp and not the library. add_executable(disruptor-region-disc-test - "${PROJECT_SOURCE_DIR}/tests/region_disc_test.cpp" region_disc.cpp) + "${PROJECT_SOURCE_DIR}/tests/unit/region_disc_test.cpp" region_disc.cpp) target_include_directories(disruptor-region-disc-test PRIVATE "${PROJECT_SOURCE_DIR}/src") target_link_libraries(disruptor-region-disc-test PRIVATE disruptor-disc-import) add_test(NAME disruptor_region_disc COMMAND disruptor-region-disc-test) diff --git a/tests/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py similarity index 99% rename from tests/test_ambient_occlusion_contract.py rename to tests/contracts/test_ambient_occlusion_contract.py index c5374f7..3224d81 100644 --- a/tests/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_backdrop_tiles_contract.py b/tests/contracts/test_backdrop_tiles_contract.py similarity index 97% rename from tests/test_backdrop_tiles_contract.py rename to tests/contracts/test_backdrop_tiles_contract.py index b60ad84..08752e6 100644 --- a/tests/test_backdrop_tiles_contract.py +++ b/tests/contracts/test_backdrop_tiles_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 BACKDROP_BEGIN = 0x8003AF68 @@ -67,7 +67,7 @@ def require(condition: bool, message: str) -> None: "backdrop tile source, its unit test and its contract test must remain registered", ) require( - 'tests/test_backdrop_tiles_contract.py"\n --require-artifacts)' in cmake, + 'tests/contracts/test_backdrop_tiles_contract.py"\n --require-artifacts)' in cmake, "the build gate must run this contract with its inputs required", ) require( diff --git a/tests/test_billboard_aspect_contract.py b/tests/contracts/test_billboard_aspect_contract.py similarity index 97% rename from tests/test_billboard_aspect_contract.py rename to tests/contracts/test_billboard_aspect_contract.py index 7d37aee..c3c3d91 100644 --- a/tests/test_billboard_aspect_contract.py +++ b/tests/contracts/test_billboard_aspect_contract.py @@ -6,7 +6,7 @@ import sys -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -70,7 +70,7 @@ def require(condition: bool, message: str) -> None: "dirty-interpreter parity must be project-gated and post-instruction") require("PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT=1" in cmake and "src/disruptor_billboard_aspect.cpp" in cmake and - "tests/disruptor_billboard_aspect_test.cpp" in cmake, + "tests/unit/disruptor_billboard_aspect_test.cpp" in cmake, "runtime source, dirty seam, and focused behavior test must be enabled") require("gpu_ws_tag_primitive" in gpu_h and diff --git a/tests/test_borderless_contract.py b/tests/contracts/test_borderless_contract.py similarity index 96% rename from tests/test_borderless_contract.py rename to tests/contracts/test_borderless_contract.py index 0be89d4..ef8bed5 100644 --- a/tests/test_borderless_contract.py +++ b/tests/contracts/test_borderless_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -61,7 +61,7 @@ def require(condition: bool, message: str) -> None: require( "+runtime/include/host_borderless.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() and " add_test(NAME host_borderless\n COMMAND host-borderless-test)\n" in cmake - and 'tests/test_borderless_contract.py")' in cmake, + and 'tests/contracts/test_borderless_contract.py")' in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py similarity index 97% rename from tests/test_capsule_contract.py rename to tests/contracts/test_capsule_contract.py index 696a2d6..a909dff 100644 --- a/tests/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -130,10 +130,10 @@ def require(condition: bool, message: str) -> None: require( ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_capsule.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_CAPSULE=1\n" in cmake - and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../src/disruptor_capsule.cpp"\n' - in read("tests/disruptor_capsule_test.cpp") + and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/disruptor_capsule.cpp"\n' + in read("tests/unit/disruptor_capsule_test.cpp") and " add_test(NAME disruptor_capsule\n COMMAND disruptor-capsule-test)\n" in cmake - and 'tests/test_capsule_contract.py")' in cmake, + and 'tests/contracts/test_capsule_contract.py")' in cmake, "the capsule and its tests must remain built and registered", ) require( diff --git a/tests/test_cheats_contract.py b/tests/contracts/test_cheats_contract.py similarity index 98% rename from tests/test_cheats_contract.py rename to tests/contracts/test_cheats_contract.py index da5c441..135d4db 100644 --- a/tests/test_cheats_contract.py +++ b/tests/contracts/test_cheats_contract.py @@ -8,7 +8,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 diff --git a/tests/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py similarity index 97% rename from tests/test_debug_tab_contract.py rename to tests/contracts/test_debug_tab_contract.py index 4f138a0..0165e16 100644 --- a/tests/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -88,7 +88,7 @@ def between(source: str, opening: str, closing: str) -> str: and """ if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\\n"; """ in loader - and '"disruptor.diagnostic_view",' in read("tests/test_launcher_contract.py"), + and '"disruptor.diagnostic_view",' in read("tests/contracts/test_launcher_contract.py"), "the file names a view by its word, a word it does not know is no view, and the launcher is decided not to offer it", ) diff --git a/tests/test_depth24_sync_contract.py b/tests/contracts/test_depth24_sync_contract.py similarity index 95% rename from tests/test_depth24_sync_contract.py rename to tests/contracts/test_depth24_sync_contract.py index 3f40ac4..f33e4a5 100644 --- a/tests/test_depth24_sync_contract.py +++ b/tests/contracts/test_depth24_sync_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -40,7 +40,7 @@ def enclosing(source: str, block: str) -> list[str]: REGISTRATION = ( ' add_test(NAME disruptor_depth24_sync_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_depth24_sync_contract.py")\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_depth24_sync_contract.py")\n' ) require( cmake.count(REGISTRATION) == 1 and enclosing(cmake, REGISTRATION) == ["if(BUILD_TESTING)"], diff --git a/tests/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py similarity index 99% rename from tests/test_dev_menu_contract.py rename to tests/contracts/test_dev_menu_contract.py index 92d22df..b0be712 100644 --- a/tests/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -7,7 +7,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -80,7 +80,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "target_link_libraries(psx-runtime PRIVATE disruptor-imgui)" in cmake, "menu implementation must be linked only into the game runtime") require("src/disruptor_cheats.cpp" in cmake and - "tests/disruptor_cheats_test.cpp" in cmake, + "tests/unit/disruptor_cheats_test.cpp" in cmake, "version-pinned cheats and their behavioral test must be built") require("+runtime/include/host_ui.h" in manifest, "new host UI ABI header must remain reviewed in the overlay manifest") diff --git a/tests/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py similarity index 98% rename from tests/test_far_rendering_contract.py rename to tests/contracts/test_far_rendering_contract.py index 821ab31..e56bd87 100644 --- a/tests/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -6,7 +6,7 @@ import sys -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -29,7 +29,7 @@ def require(condition: bool, message: str) -> None: configs = read("game.toml") + read("game-widescreen.toml") require("src/disruptor_far_rendering.cpp" in cmake and - "tests/disruptor_far_rendering_test.cpp" in cmake and + "tests/unit/disruptor_far_rendering_test.cpp" in cmake and "add_test(NAME disruptor_far_rendering" in cmake, "runtime source and focused behavior test must remain enabled") require("PSX_HAS_DISRUPTOR_FAR_RENDERING=1" in cmake, diff --git a/tests/test_frame_kind_contract.py b/tests/contracts/test_frame_kind_contract.py similarity index 97% rename from tests/test_frame_kind_contract.py rename to tests/contracts/test_frame_kind_contract.py index 0965026..1f06686 100644 --- a/tests/test_frame_kind_contract.py +++ b/tests/contracts/test_frame_kind_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -49,10 +49,10 @@ def enclosing(source: str, block: str) -> list[str]: REGISTERED = { ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_render_arena.cpp"\n': [], ' add_test(NAME disruptor_frame_kind_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_kind_contract.py")\n': CONTRACTS, - " add_executable(disruptor-gpu-ws-frame-kind-test\n tests/gpu_ws_frame_kind_test.cpp)\n": UNIT_TESTS, + ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py")\n': CONTRACTS, + " add_executable(disruptor-gpu-ws-frame-kind-test\n tests/unit/gpu_ws_frame_kind_test.cpp)\n": UNIT_TESTS, " add_test(NAME disruptor_gpu_ws_frame_kind\n COMMAND disruptor-gpu-ws-frame-kind-test)\n": UNIT_TESTS, - " add_executable(disruptor-render-arena-test\n tests/disruptor_render_arena_test.cpp)\n": UNIT_TESTS, + " add_executable(disruptor-render-arena-test\n tests/unit/disruptor_render_arena_test.cpp)\n": UNIT_TESTS, " add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test)\n": UNIT_TESTS, } for block, conditions in REGISTERED.items(): diff --git a/tests/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py similarity index 98% rename from tests/test_frame_rate_contract.py rename to tests/contracts/test_frame_rate_contract.py index c74cd30..fb9d846 100644 --- a/tests/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -51,7 +51,7 @@ def require(condition: bool, message: str) -> None: cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") require( - 'tests/test_frame_rate_contract.py"\n --require-artifacts)' in cmake, + 'tests/contracts/test_frame_rate_contract.py"\n --require-artifacts)' in cmake, "the build gate must run this contract with its inputs required", ) require( diff --git a/tests/test_framework_overlay_new_file.py b/tests/contracts/test_framework_overlay_new_file.py similarity index 98% rename from tests/test_framework_overlay_new_file.py rename to tests/contracts/test_framework_overlay_new_file.py index 6617bab..660600a 100644 --- a/tests/test_framework_overlay_new_file.py +++ b/tests/contracts/test_framework_overlay_new_file.py @@ -9,7 +9,7 @@ import tempfile -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] APPLIER = ROOT / "tools" / "apply_framework_overlay.py" PROJECT_OVERLAY = ROOT / "psxrecomp-overlay" PROJECT_MANIFEST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" diff --git a/tests/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py similarity index 99% rename from tests/test_gap_filling_contract.py rename to tests/contracts/test_gap_filling_contract.py index 4a29417..5f985fa 100644 --- a/tests/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_geometry_provenance_contract.py b/tests/contracts/test_geometry_provenance_contract.py similarity index 99% rename from tests/test_geometry_provenance_contract.py rename to tests/contracts/test_geometry_provenance_contract.py index a61795c..722b26e 100644 --- a/tests/test_geometry_provenance_contract.py +++ b/tests/contracts/test_geometry_provenance_contract.py @@ -8,7 +8,7 @@ import sys -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] GPU = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu.c" GTE = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gte.cpp" MEMORY = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "memory.c" diff --git a/tests/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py similarity index 96% rename from tests/test_grown_quad_contract.py rename to tests/contracts/test_grown_quad_contract.py index e6a9051..73077dc 100644 --- a/tests/test_grown_quad_contract.py +++ b/tests/contracts/test_grown_quad_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -43,7 +43,7 @@ def require(condition: bool, message: str) -> None: '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_grown_quad.cpp"' in cmake and "PSX_HAS_DISRUPTOR_GROWN_QUAD=1" in cmake and "add_test(NAME disruptor_grown_quad\n" in cmake - and 'tests/test_grown_quad_contract.py"\n --require-artifacts)' in cmake, + and 'tests/contracts/test_grown_quad_contract.py"\n --require-artifacts)' in cmake, "the grown quad source, its renderer switch, its unit test and this contract with its input must stay registered", ) require( diff --git a/tests/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py similarity index 99% rename from tests/test_hud_scale_contract.py rename to tests/contracts/test_hud_scale_contract.py index 7722d05..2165a3d 100644 --- a/tests/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_hud_widget_contract.py b/tests/contracts/test_hud_widget_contract.py similarity index 97% rename from tests/test_hud_widget_contract.py rename to tests/contracts/test_hud_widget_contract.py index 74a7776..dee123f 100644 --- a/tests/test_hud_widget_contract.py +++ b/tests/contracts/test_hud_widget_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -31,10 +31,10 @@ def body(source: str, signature: str) -> str: "overlay manifest must list the HUD widget arithmetic", ) require( - " add_executable(disruptor-gpu-ws-hud-widget-test\n tests/gpu_ws_hud_widget_test.cpp)\n" in cmake + " add_executable(disruptor-gpu-ws-hud-widget-test\n tests/unit/gpu_ws_hud_widget_test.cpp)\n" in cmake and " add_test(NAME disruptor_gpu_ws_hud_widget\n COMMAND disruptor-gpu-ws-hud-widget-test)\n" in cmake and ' add_test(NAME disruptor_hud_widget_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hud_widget_contract.py")\n' in cmake + ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py")\n' in cmake and '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_hud_widgets.cpp"' in cmake, "the widget unit test, its contract and the game's widget list must remain in the build", ) diff --git a/tests/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py similarity index 98% rename from tests/test_intro_skip_contract.py rename to tests/contracts/test_intro_skip_contract.py index 71af171..53d2fa9 100644 --- a/tests/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -62,7 +62,7 @@ def require(condition: bool, message: str) -> None: ) require( - 'tests/test_intro_skip_contract.py"\n --require-artifacts)' in cmake, + 'tests/contracts/test_intro_skip_contract.py"\n --require-artifacts)' in cmake, "the build gate must run this contract with its inputs required", ) for address, word in SEAMS.items(): diff --git a/tests/test_language_contract.py b/tests/contracts/test_language_contract.py similarity index 99% rename from tests/test_language_contract.py rename to tests/contracts/test_language_contract.py index 551b8ab..a81fc48 100644 --- a/tests/test_language_contract.py +++ b/tests/contracts/test_language_contract.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -95,7 +95,7 @@ def require(condition: bool, message: str) -> None: require( '/src/disruptor_language.cpp"' in cmake and "add_test(NAME disruptor_language\n" in cmake - and 'tests/test_language_contract.py"\n --require-artifacts)' in cmake, + and 'tests/contracts/test_language_contract.py"\n --require-artifacts)' in cmake, "the language source and both of its tests must remain registered, the contract with its inputs required", ) require( diff --git a/tests/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py similarity index 99% rename from tests/test_language_disc_contract.py rename to tests/contracts/test_language_disc_contract.py index bc7dd88..27ceebe 100644 --- a/tests/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -85,8 +85,8 @@ def require(condition: bool, message: str) -> None: and "add_test(NAME disruptor_language_disc\n" in cmake and "add_test(NAME disruptor_language_overlay\n" in cmake and "add_test(NAME disruptor_language_overlay_tall\n COMMAND disruptor-language-overlay-test tall)" in cmake - and " tests/disruptor_language_overlay_test.cpp)" in cmake - and 'tests/test_language_disc_contract.py"\n --require-artifacts)' in cmake, + and " tests/unit/disruptor_language_overlay_test.cpp)" in cmake + and 'tests/contracts/test_language_disc_contract.py"\n --require-artifacts)' in cmake, "the language disc sources and their three tests must remain registered, the contract with its inputs required", ) require( diff --git a/tests/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py similarity index 99% rename from tests/test_launcher_contract.py rename to tests/contracts/test_launcher_contract.py index a797db2..b5bf35d 100644 --- a/tests/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] # Keys the game saves and the launcher leaves to the in-game menu, the command line or a hand edit. NOT_OFFERED = { diff --git a/tests/test_loaded_state_contract.py b/tests/contracts/test_loaded_state_contract.py similarity index 99% rename from tests/test_loaded_state_contract.py rename to tests/contracts/test_loaded_state_contract.py index 103b84f..baef1e6 100644 --- a/tests/test_loaded_state_contract.py +++ b/tests/contracts/test_loaded_state_contract.py @@ -7,7 +7,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_menu_wide_contract.py b/tests/contracts/test_menu_wide_contract.py similarity index 99% rename from tests/test_menu_wide_contract.py rename to tests/contracts/test_menu_wide_contract.py index fc55076..31aa9ed 100644 --- a/tests/test_menu_wide_contract.py +++ b/tests/contracts/test_menu_wide_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -195,7 +195,7 @@ def require(condition: bool, message: str) -> None: require( "+runtime/include/gpu_ws_menu.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() and " add_test(NAME disruptor_gpu_ws_menu\n COMMAND disruptor-gpu-ws-menu-test)\n" in cmake - and 'tests/test_menu_wide_contract.py")' in cmake, + and 'tests/contracts/test_menu_wide_contract.py")' in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py similarity index 98% rename from tests/test_name_entry_contract.py rename to tests/contracts/test_name_entry_contract.py index c8f7a76..3de0d7c 100644 --- a/tests/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 ROUTINE = 0x8001C484 @@ -100,7 +100,7 @@ def require(condition: bool, message: str) -> None: ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_name_entry.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_NAME_ENTRY=1\n" in cmake and " add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test)\n" in cmake - and 'tests/test_name_entry_contract.py"\n --require-artifacts)\n' + and 'tests/contracts/test_name_entry_contract.py"\n --require-artifacts)\n' " set_tests_properties(disruptor_name_entry_contract\n PROPERTIES LABELS retail)\n" in cmake, "the module, its switch for the host, its unit test and this contract with its input must stay registered", diff --git a/tests/test_perspective_texture_contract.py b/tests/contracts/test_perspective_texture_contract.py similarity index 99% rename from tests/test_perspective_texture_contract.py rename to tests/contracts/test_perspective_texture_contract.py index 8d5ca25..81af699 100644 --- a/tests/test_perspective_texture_contract.py +++ b/tests/contracts/test_perspective_texture_contract.py @@ -13,7 +13,7 @@ from math import isclose -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] OVERLAY = ROOT / "psxrecomp-overlay" / "runtime" MAIN = OVERLAY / "src" / "main.cpp" GPU = OVERLAY / "src" / "gpu.c" diff --git a/tests/test_phase_a_contract.py b/tests/contracts/test_phase_a_contract.py similarity index 99% rename from tests/test_phase_a_contract.py rename to tests/contracts/test_phase_a_contract.py index e9411e4..77f68f8 100644 --- a/tests/test_phase_a_contract.py +++ b/tests/contracts/test_phase_a_contract.py @@ -8,7 +8,7 @@ import sys -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] GPU = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu.c" GTE = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gte.cpp" GL = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu_gl_renderer.c" diff --git a/tests/test_pinned_corner_contract.py b/tests/contracts/test_pinned_corner_contract.py similarity index 98% rename from tests/test_pinned_corner_contract.py rename to tests/contracts/test_pinned_corner_contract.py index e4b6dcd..0fa9432 100644 --- a/tests/test_pinned_corner_contract.py +++ b/tests/contracts/test_pinned_corner_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -52,10 +52,10 @@ def enclosing(source: str, block: str) -> list[str]: REGISTRATION = ( ' add_test(NAME disruptor_pinned_corner_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_pinned_corner_contract.py")\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_pinned_corner_contract.py")\n' ) UNIT_TEST = ( - " add_executable(disruptor-gpu-pinned-corner-test\n tests/gpu_pinned_corner_test.cpp)\n" + " add_executable(disruptor-gpu-pinned-corner-test\n tests/unit/gpu_pinned_corner_test.cpp)\n" " target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE\n" ' "${PSXRECOMP_ROOT}/runtime/include")\n' " target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20)\n" diff --git a/tests/test_save_point_contract.py b/tests/contracts/test_save_point_contract.py similarity index 99% rename from tests/test_save_point_contract.py rename to tests/contracts/test_save_point_contract.py index aa87912..fdaff40 100644 --- a/tests/test_save_point_contract.py +++ b/tests/contracts/test_save_point_contract.py @@ -8,7 +8,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] POLL = "savestate_poll(cpu, s_compiled_interrupt_resume_pc);" diff --git a/tests/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py similarity index 98% rename from tests/test_smooth_turn_contract.py rename to tests/contracts/test_smooth_turn_contract.py index 0f37728..481c421 100644 --- a/tests/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -31,7 +31,7 @@ def between(source: str, opening: str, closing: str) -> str: require( overlay.count("+runtime/include/gpu_turned_perspective.h\n") == 1 and '#include "gpu_turned_perspective.h"\n' in renderer - and " add_executable(disruptor-gpu-turned-perspective-test\n tests/gpu_turned_perspective_test.cpp)\n" in cmake + and " add_executable(disruptor-gpu-turned-perspective-test\n tests/unit/gpu_turned_perspective_test.cpp)\n" in cmake and " add_test(NAME disruptor_gpu_turned_perspective\n COMMAND disruptor-gpu-turned-perspective-test)\n" in cmake and " add_test(NAME disruptor_smooth_turn_contract\n" in cmake, "the turned perspective's header is applied to the framework and its test and this contract are registered", diff --git a/tests/test_sprite_range_contract.py b/tests/contracts/test_sprite_range_contract.py similarity index 99% rename from tests/test_sprite_range_contract.py rename to tests/contracts/test_sprite_range_contract.py index 2ce1ece..84e7b74 100644 --- a/tests/test_sprite_range_contract.py +++ b/tests/contracts/test_sprite_range_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -187,7 +187,7 @@ def require(condition: bool, message: str) -> None: gte = read("psxrecomp-overlay/runtime/src/gte.cpp") require( - 'tests/test_sprite_range_contract.py"\n --require-artifacts)' in cmake + 'tests/contracts/test_sprite_range_contract.py"\n --require-artifacts)' in cmake and " set_tests_properties(disruptor_sprite_range_contract\n PROPERTIES LABELS retail)\n" in cmake and " add_test(NAME disruptor_sprite_depth\n" in cmake, "this contract with its input and the unit test of the range must stay registered", diff --git a/tests/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py similarity index 99% rename from tests/test_temporal_presenter_contract.py rename to tests/contracts/test_temporal_presenter_contract.py index 975af03..2d4eb27 100644 --- a/tests/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -229,7 +229,7 @@ def shader(source: str, name: str) -> str: " squashed ? (float)ws_xnum / (float)ws_xden : 1.0f, gpu_shadow_shape(), sides);" in sides and '#include "gpu_temporal_sprite.h"' in gpu and "static inline void gpu_temporal_sprite_sides(const GpuTemporalSprite *sprite, float left, float top, int w, int h," in sprite_header - and "tests/gpu_temporal_sprite_test.cpp" in read("CMakeLists.txt") + and "tests/unit/gpu_temporal_sprite_test.cpp" in read("CMakeLists.txt") and "+runtime/include/gpu_temporal_sprite.h" in read("PSXRECOMP_OVERLAY_FILES.txt") and " if (!t || t->stamp != (uint32_t)s_frame_count || !t->placed || width16 <= 0 || height16 <= 0) return;" in sizer and " t->placed = t->sized = t->shadow = t->whole = 0;" in gpu, diff --git a/tests/test_vertical_camera_contract.py b/tests/contracts/test_vertical_camera_contract.py similarity index 98% rename from tests/test_vertical_camera_contract.py rename to tests/contracts/test_vertical_camera_contract.py index 4695e11..b8f827d 100644 --- a/tests/test_vertical_camera_contract.py +++ b/tests/contracts/test_vertical_camera_contract.py @@ -6,7 +6,7 @@ import sys -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py similarity index 98% rename from tests/test_world_depth_contract.py rename to tests/contracts/test_world_depth_contract.py index 9934c50..d7776ce 100644 --- a/tests/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -33,7 +33,7 @@ def between(source: str, opening: str, closing: str) -> str: require( overlay.count("+runtime/include/gpu_world_depth.h\n") == 1 and '#include "gpu_world_depth.h"\n' in renderer - and " add_executable(disruptor-gpu-world-depth-test\n tests/gpu_world_depth_test.cpp)\n" in cmake + and " add_executable(disruptor-gpu-world-depth-test\n tests/unit/gpu_world_depth_test.cpp)\n" in cmake and " add_test(NAME disruptor_gpu_world_depth\n COMMAND disruptor-gpu-world-depth-test)\n" in cmake and " add_test(NAME disruptor_world_depth_contract\n" in cmake, "the rule's header is applied to the framework and its test and this contract are registered", diff --git a/tests/disruptor_language_discs.h b/tests/support/disruptor_language_discs.h similarity index 99% rename from tests/disruptor_language_discs.h rename to tests/support/disruptor_language_discs.h index e386c53..cb7b297 100644 --- a/tests/disruptor_language_discs.h +++ b/tests/support/disruptor_language_discs.h @@ -3,7 +3,7 @@ /* Synthetic discs for the language tests. The disc code is included whole, so that a test reaches its internals. */ -#include "../src/disruptor_language_disc.cpp" +#include "../../src/disruptor_language_disc.cpp" namespace { diff --git a/tests/present_rate_fakes/host_ui.h b/tests/support/present_rate_fakes/host_ui.h similarity index 100% rename from tests/present_rate_fakes/host_ui.h rename to tests/support/present_rate_fakes/host_ui.h diff --git a/tests/present_rate_fakes/imgui.h b/tests/support/present_rate_fakes/imgui.h similarity index 100% rename from tests/present_rate_fakes/imgui.h rename to tests/support/present_rate_fakes/imgui.h diff --git a/tests/stubs/psx_sdl.h b/tests/support/stubs/psx_sdl.h similarity index 100% rename from tests/stubs/psx_sdl.h rename to tests/support/stubs/psx_sdl.h diff --git a/tests/disc_import_test.cpp b/tests/unit/disc_import_test.cpp similarity index 100% rename from tests/disc_import_test.cpp rename to tests/unit/disc_import_test.cpp diff --git a/tests/disruptor_backdrop_tiles_test.cpp b/tests/unit/disruptor_backdrop_tiles_test.cpp similarity index 99% rename from tests/disruptor_backdrop_tiles_test.cpp rename to tests/unit/disruptor_backdrop_tiles_test.cpp index 3a7aba1..99f37a3 100644 --- a/tests/disruptor_backdrop_tiles_test.cpp +++ b/tests/unit/disruptor_backdrop_tiles_test.cpp @@ -85,7 +85,7 @@ void gpu_ws_tag_screen_tile(CPUState *cpu, std::uint32_t primitive_addr) { } // extern "C" -#include "../src/disruptor_backdrop_tiles.cpp" +#include "../../src/disruptor_backdrop_tiles.cpp" namespace { diff --git a/tests/disruptor_billboard_aspect_test.cpp b/tests/unit/disruptor_billboard_aspect_test.cpp similarity index 99% rename from tests/disruptor_billboard_aspect_test.cpp rename to tests/unit/disruptor_billboard_aspect_test.cpp index eaf570b..6f8bac7 100644 --- a/tests/disruptor_billboard_aspect_test.cpp +++ b/tests/unit/disruptor_billboard_aspect_test.cpp @@ -66,7 +66,7 @@ void disruptor_sprite_depth_packet(CPUState *, std::uint32_t, std::uint32_t) {} } // extern "C" // Include the implementation to exercise its exact private packet guards. -#include "../src/disruptor_billboard_aspect.cpp" +#include "../../src/disruptor_billboard_aspect.cpp" namespace { diff --git a/tests/disruptor_capsule_test.cpp b/tests/unit/disruptor_capsule_test.cpp similarity index 99% rename from tests/disruptor_capsule_test.cpp rename to tests/unit/disruptor_capsule_test.cpp index 641675a..efd03e5 100644 --- a/tests/disruptor_capsule_test.cpp +++ b/tests/unit/disruptor_capsule_test.cpp @@ -1,5 +1,5 @@ #define DISRUPTOR_CAPSULE_NO_HOST 1 -#include "../src/disruptor_capsule.cpp" +#include "../../src/disruptor_capsule.cpp" #include #include diff --git a/tests/disruptor_cheats_test.cpp b/tests/unit/disruptor_cheats_test.cpp similarity index 99% rename from tests/disruptor_cheats_test.cpp rename to tests/unit/disruptor_cheats_test.cpp index 88d4bbc..f5c70db 100644 --- a/tests/disruptor_cheats_test.cpp +++ b/tests/unit/disruptor_cheats_test.cpp @@ -104,7 +104,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" // Include the implementation to exercise its version-pinned internal guards. -#include "../src/disruptor_cheats.cpp" +#include "../../src/disruptor_cheats.cpp" namespace { diff --git a/tests/disruptor_far_rendering_test.cpp b/tests/unit/disruptor_far_rendering_test.cpp similarity index 99% rename from tests/disruptor_far_rendering_test.cpp rename to tests/unit/disruptor_far_rendering_test.cpp index 41b3682..4c2dd18 100644 --- a/tests/disruptor_far_rendering_test.cpp +++ b/tests/unit/disruptor_far_rendering_test.cpp @@ -109,7 +109,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" -#include "../src/disruptor_far_rendering.cpp" +#include "../../src/disruptor_far_rendering.cpp" namespace { diff --git a/tests/disruptor_frame_rate_test.cpp b/tests/unit/disruptor_frame_rate_test.cpp similarity index 99% rename from tests/disruptor_frame_rate_test.cpp rename to tests/unit/disruptor_frame_rate_test.cpp index 2e3e847..ae020d7 100644 --- a/tests/disruptor_frame_rate_test.cpp +++ b/tests/unit/disruptor_frame_rate_test.cpp @@ -71,7 +71,7 @@ int psx_mod_register_function_entry_plugin( uint32_t interrupts_cycles_to_vblank(void) { return g_cycles_to_vblank; } -#include "../src/disruptor_frame_rate.cpp" +#include "../../src/disruptor_frame_rate.cpp" namespace { diff --git a/tests/disruptor_grown_quad_test.cpp b/tests/unit/disruptor_grown_quad_test.cpp similarity index 99% rename from tests/disruptor_grown_quad_test.cpp rename to tests/unit/disruptor_grown_quad_test.cpp index 6b0c94c..b82d98a 100644 --- a/tests/disruptor_grown_quad_test.cpp +++ b/tests/unit/disruptor_grown_quad_test.cpp @@ -21,7 +21,7 @@ extern "C" std::uint32_t psx_mod_read_word(std::uint32_t address) { return found == g_memory.end() ? 0xDEADBEEFu : found->second; } -#include "../src/disruptor_grown_quad.cpp" +#include "../../src/disruptor_grown_quad.cpp" namespace { diff --git a/tests/disruptor_intro_skip_test.cpp b/tests/unit/disruptor_intro_skip_test.cpp similarity index 99% rename from tests/disruptor_intro_skip_test.cpp rename to tests/unit/disruptor_intro_skip_test.cpp index 00a5707..48d7de6 100644 --- a/tests/disruptor_intro_skip_test.cpp +++ b/tests/unit/disruptor_intro_skip_test.cpp @@ -43,7 +43,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" void psx_host_set_fast_forward_query(int (*query)(void)) { g_registered_query = query; } -#include "../src/disruptor_intro_skip.cpp" +#include "../../src/disruptor_intro_skip.cpp" namespace { diff --git a/tests/disruptor_language_disc_test.cpp b/tests/unit/disruptor_language_disc_test.cpp similarity index 99% rename from tests/disruptor_language_disc_test.cpp rename to tests/unit/disruptor_language_disc_test.cpp index 48e29b2..5996c3e 100644 --- a/tests/disruptor_language_disc_test.cpp +++ b/tests/unit/disruptor_language_disc_test.cpp @@ -1,4 +1,4 @@ -#include "disruptor_language_discs.h" +#include "../support/disruptor_language_discs.h" #include diff --git a/tests/disruptor_language_hints_test.cpp b/tests/unit/disruptor_language_hints_test.cpp similarity index 99% rename from tests/disruptor_language_hints_test.cpp rename to tests/unit/disruptor_language_hints_test.cpp index 06d5382..4fd1a06 100644 --- a/tests/disruptor_language_hints_test.cpp +++ b/tests/unit/disruptor_language_hints_test.cpp @@ -60,7 +60,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" int disruptor_language_disc_tall_hints(void) { return g_tall; } -#include "../src/disruptor_language_hints.cpp" +#include "../../src/disruptor_language_hints.cpp" namespace { diff --git a/tests/disruptor_language_overlay_test.cpp b/tests/unit/disruptor_language_overlay_test.cpp similarity index 98% rename from tests/disruptor_language_overlay_test.cpp rename to tests/unit/disruptor_language_overlay_test.cpp index 700d78e..67bfa1a 100644 --- a/tests/disruptor_language_overlay_test.cpp +++ b/tests/unit/disruptor_language_overlay_test.cpp @@ -1,4 +1,4 @@ -#include "disruptor_language_discs.h" +#include "../support/disruptor_language_discs.h" #include "iso_reader.h" #include "mod_runtime.h" @@ -67,8 +67,8 @@ extern "C" void savestate_get_integrity(uint32_t *bios_checksum, uint32_t *entry *entry_pc = g_states_entry; } -#include "../psxrecomp-overlay/runtime/src/iso_reader_c.cpp" -#include "../src/disruptor_language_source.cpp" +#include "../../psxrecomp-overlay/runtime/src/iso_reader_c.cpp" +#include "../../src/disruptor_language_source.cpp" namespace { diff --git a/tests/disruptor_language_test.cpp b/tests/unit/disruptor_language_test.cpp similarity index 99% rename from tests/disruptor_language_test.cpp rename to tests/unit/disruptor_language_test.cpp index e883afa..18aafcb 100644 --- a/tests/disruptor_language_test.cpp +++ b/tests/unit/disruptor_language_test.cpp @@ -63,8 +63,8 @@ extern "C" int disruptor_language_disc_pack(const uint8_t **data, uint32_t *size return 1; } -#include "../src/disruptor_language.cpp" -#include "../src/disruptor_language_disc.cpp" +#include "../../src/disruptor_language.cpp" +#include "../../src/disruptor_language_disc.cpp" namespace { diff --git a/tests/disruptor_mouse_aim_test.cpp b/tests/unit/disruptor_mouse_aim_test.cpp similarity index 99% rename from tests/disruptor_mouse_aim_test.cpp rename to tests/unit/disruptor_mouse_aim_test.cpp index c37d6b2..fb0051e 100644 --- a/tests/disruptor_mouse_aim_test.cpp +++ b/tests/unit/disruptor_mouse_aim_test.cpp @@ -1,5 +1,5 @@ #include "psx_sdl.h" -#include "../psxrecomp-overlay/runtime/include/gpu.h" +#include "../../psxrecomp-overlay/runtime/include/gpu.h" #include #include @@ -184,7 +184,7 @@ const char *SDL_GetError() { // Include the implementation so its internal pure helpers and state can be // exercised against the mocks above without exposing a test-only production ABI. -#include "../src/disruptor_mouse_aim.cpp" +#include "../../src/disruptor_mouse_aim.cpp" namespace { diff --git a/tests/disruptor_name_entry_test.cpp b/tests/unit/disruptor_name_entry_test.cpp similarity index 99% rename from tests/disruptor_name_entry_test.cpp rename to tests/unit/disruptor_name_entry_test.cpp index eb75d51..e3bef9b 100644 --- a/tests/disruptor_name_entry_test.cpp +++ b/tests/unit/disruptor_name_entry_test.cpp @@ -62,7 +62,7 @@ int psx_mod_register_vblank_plugin(const char *, PSXModVBlankCallback callback) } } -#include "../src/disruptor_name_entry.cpp" +#include "../../src/disruptor_name_entry.cpp" namespace { diff --git a/tests/disruptor_present_rate_test.cpp b/tests/unit/disruptor_present_rate_test.cpp similarity index 99% rename from tests/disruptor_present_rate_test.cpp rename to tests/unit/disruptor_present_rate_test.cpp index e52247b..5bc4941 100644 --- a/tests/disruptor_present_rate_test.cpp +++ b/tests/unit/disruptor_present_rate_test.cpp @@ -1,4 +1,4 @@ -#include "../src/disruptor_present_rate.h" +#include "../../src/disruptor_present_rate.h" #include #include @@ -97,7 +97,7 @@ bool SliderInt(const char *, int *value, int lowest, int highest, const char *, void TextDisabled(const char *text, ...) { g_slider.text = text; } } // namespace ImGui -#include "../src/disruptor_present_rate.cpp" +#include "../../src/disruptor_present_rate.cpp" namespace { diff --git a/tests/disruptor_render_arena_test.cpp b/tests/unit/disruptor_render_arena_test.cpp similarity index 99% rename from tests/disruptor_render_arena_test.cpp rename to tests/unit/disruptor_render_arena_test.cpp index 60b6fbf..b909f8a 100644 --- a/tests/disruptor_render_arena_test.cpp +++ b/tests/unit/disruptor_render_arena_test.cpp @@ -52,7 +52,7 @@ int psx_mod_register_function_entry_plugin( const char *, uint32_t, PSXModFunctionEntryCallback) { return 1; } } -#include "../src/disruptor_render_arena.cpp" +#include "../../src/disruptor_render_arena.cpp" int main() { CPUState cpu{}; diff --git a/tests/disruptor_restart_test.cpp b/tests/unit/disruptor_restart_test.cpp similarity index 99% rename from tests/disruptor_restart_test.cpp rename to tests/unit/disruptor_restart_test.cpp index 6e817f9..e782e35 100644 --- a/tests/disruptor_restart_test.cpp +++ b/tests/unit/disruptor_restart_test.cpp @@ -1,4 +1,4 @@ -#include "../src/disruptor_restart.cpp" +#include "../../src/disruptor_restart.cpp" #include #include diff --git a/tests/disruptor_sprite_depth_test.cpp b/tests/unit/disruptor_sprite_depth_test.cpp similarity index 99% rename from tests/disruptor_sprite_depth_test.cpp rename to tests/unit/disruptor_sprite_depth_test.cpp index 3370a99..0ad0f84 100644 --- a/tests/disruptor_sprite_depth_test.cpp +++ b/tests/unit/disruptor_sprite_depth_test.cpp @@ -124,7 +124,7 @@ extern "C" void gte_ws_x_squash(std::int32_t *num, std::int32_t *den) { *den = g_range_den; } -#include "../src/disruptor_sprite_depth.cpp" +#include "../../src/disruptor_sprite_depth.cpp" namespace { diff --git a/tests/disruptor_vertical_camera_test.cpp b/tests/unit/disruptor_vertical_camera_test.cpp similarity index 99% rename from tests/disruptor_vertical_camera_test.cpp rename to tests/unit/disruptor_vertical_camera_test.cpp index 228a1db..3cd3778 100644 --- a/tests/disruptor_vertical_camera_test.cpp +++ b/tests/unit/disruptor_vertical_camera_test.cpp @@ -132,7 +132,7 @@ void gpu_geometry_camera_projection_center_y_set(double center_y) { } // extern "C" // Include the implementation to exercise the version-pinned internal guards. -#include "../src/disruptor_vertical_camera.cpp" +#include "../../src/disruptor_vertical_camera.cpp" namespace { diff --git a/tests/gpu_pinned_corner_test.cpp b/tests/unit/gpu_pinned_corner_test.cpp similarity index 100% rename from tests/gpu_pinned_corner_test.cpp rename to tests/unit/gpu_pinned_corner_test.cpp diff --git a/tests/gpu_temporal_match_test.cpp b/tests/unit/gpu_temporal_match_test.cpp similarity index 100% rename from tests/gpu_temporal_match_test.cpp rename to tests/unit/gpu_temporal_match_test.cpp diff --git a/tests/gpu_temporal_mesh_test.cpp b/tests/unit/gpu_temporal_mesh_test.cpp similarity index 100% rename from tests/gpu_temporal_mesh_test.cpp rename to tests/unit/gpu_temporal_mesh_test.cpp diff --git a/tests/gpu_temporal_sprite_test.cpp b/tests/unit/gpu_temporal_sprite_test.cpp similarity index 100% rename from tests/gpu_temporal_sprite_test.cpp rename to tests/unit/gpu_temporal_sprite_test.cpp diff --git a/tests/gpu_turned_perspective_test.cpp b/tests/unit/gpu_turned_perspective_test.cpp similarity index 100% rename from tests/gpu_turned_perspective_test.cpp rename to tests/unit/gpu_turned_perspective_test.cpp diff --git a/tests/gpu_world_depth_test.cpp b/tests/unit/gpu_world_depth_test.cpp similarity index 100% rename from tests/gpu_world_depth_test.cpp rename to tests/unit/gpu_world_depth_test.cpp diff --git a/tests/gpu_ws_frame_kind_test.cpp b/tests/unit/gpu_ws_frame_kind_test.cpp similarity index 100% rename from tests/gpu_ws_frame_kind_test.cpp rename to tests/unit/gpu_ws_frame_kind_test.cpp diff --git a/tests/gpu_ws_hud_scale_test.cpp b/tests/unit/gpu_ws_hud_scale_test.cpp similarity index 100% rename from tests/gpu_ws_hud_scale_test.cpp rename to tests/unit/gpu_ws_hud_scale_test.cpp diff --git a/tests/gpu_ws_hud_widget_test.cpp b/tests/unit/gpu_ws_hud_widget_test.cpp similarity index 100% rename from tests/gpu_ws_hud_widget_test.cpp rename to tests/unit/gpu_ws_hud_widget_test.cpp diff --git a/tests/gpu_ws_menu_test.cpp b/tests/unit/gpu_ws_menu_test.cpp similarity index 100% rename from tests/gpu_ws_menu_test.cpp rename to tests/unit/gpu_ws_menu_test.cpp diff --git a/tests/gpu_ws_tag_match_test.cpp b/tests/unit/gpu_ws_tag_match_test.cpp similarity index 100% rename from tests/gpu_ws_tag_match_test.cpp rename to tests/unit/gpu_ws_tag_match_test.cpp diff --git a/tests/gte_precision_store_pc_test.cpp b/tests/unit/gte_precision_store_pc_test.cpp similarity index 100% rename from tests/gte_precision_store_pc_test.cpp rename to tests/unit/gte_precision_store_pc_test.cpp diff --git a/tests/host_borderless_test.cpp b/tests/unit/host_borderless_test.cpp similarity index 97% rename from tests/host_borderless_test.cpp rename to tests/unit/host_borderless_test.cpp index 181ecc3..65dd4e7 100644 --- a/tests/host_borderless_test.cpp +++ b/tests/unit/host_borderless_test.cpp @@ -1,4 +1,4 @@ -#include "../psxrecomp-overlay/runtime/include/host_borderless.h" +#include "../../psxrecomp-overlay/runtime/include/host_borderless.h" #include diff --git a/tests/host_pick_slot_test.cpp b/tests/unit/host_pick_slot_test.cpp similarity index 96% rename from tests/host_pick_slot_test.cpp rename to tests/unit/host_pick_slot_test.cpp index c692c4c..af43edd 100644 --- a/tests/host_pick_slot_test.cpp +++ b/tests/unit/host_pick_slot_test.cpp @@ -1,4 +1,4 @@ -#include "../psxrecomp-overlay/runtime/include/host_pick_slot.h" +#include "../../psxrecomp-overlay/runtime/include/host_pick_slot.h" #include #include diff --git a/tests/launcher_flow_test.cpp b/tests/unit/launcher_flow_test.cpp similarity index 100% rename from tests/launcher_flow_test.cpp rename to tests/unit/launcher_flow_test.cpp diff --git a/tests/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp similarity index 100% rename from tests/launcher_settings_test.cpp rename to tests/unit/launcher_settings_test.cpp diff --git a/tests/presentation_yaw_math_test.cpp b/tests/unit/presentation_yaw_math_test.cpp similarity index 100% rename from tests/presentation_yaw_math_test.cpp rename to tests/unit/presentation_yaw_math_test.cpp diff --git a/tests/region_disc_test.cpp b/tests/unit/region_disc_test.cpp similarity index 98% rename from tests/region_disc_test.cpp rename to tests/unit/region_disc_test.cpp index 0954636..e3bc7ce 100644 --- a/tests/region_disc_test.cpp +++ b/tests/unit/region_disc_test.cpp @@ -1,4 +1,4 @@ -#include "disruptor_language_discs.h" +#include "../support/disruptor_language_discs.h" #include "region_disc.h" diff --git a/tests/save_point_test.cpp b/tests/unit/save_point_test.cpp similarity index 100% rename from tests/save_point_test.cpp rename to tests/unit/save_point_test.cpp diff --git a/tests/user_settings_persistence_test.cpp b/tests/unit/user_settings_persistence_test.cpp similarity index 100% rename from tests/user_settings_persistence_test.cpp rename to tests/unit/user_settings_persistence_test.cpp From ae10e88c7898f3a58681ca8e6997a786d2434911 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:36:41 +0300 Subject: [PATCH 04/13] refactor: move the status and disc notes into docs, the key presets into config --- CONTRIBUTING.md | 2 +- README.md | 6 +++--- {tools => cmake}/stage_launcher.cmake | 2 +- keybinds-modern.ini => config/keybinds-modern.ini | 0 keybinds-original.ini => config/keybinds-original.ini | 0 docs/BUILD.md | 2 +- DISC.md => docs/DISC.md | 0 STATUS.md => docs/STATUS.md | 2 +- input/README.md | 2 +- run.ps1 | 4 ++-- run.sh | 4 ++-- src/launcher/CMakeLists.txt | 6 +++--- tools/package_release.py | 2 +- 13 files changed, 16 insertions(+), 16 deletions(-) rename {tools => cmake}/stage_launcher.cmake (92%) rename keybinds-modern.ini => config/keybinds-modern.ini (100%) rename keybinds-original.ini => config/keybinds-original.ini (100%) rename DISC.md => docs/DISC.md (100%) rename STATUS.md => docs/STATUS.md (97%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34b4a63..bd661e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Before submitting a change: - Run the relevant tests and review both the working diff and staged paths. - Keep experimental visual features optional. -- Update [STATUS.md](STATUS.md) when behavior, verified coverage, or a known +- Update [STATUS.md](docs/STATUS.md) when behavior, verified coverage, or a known limitation changes. Keep it focused on the current build. For bug reports, include the level, steps to reproduce, render scale/aspect, diff --git a/README.md b/README.md index b50bace..e81369b 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Place these files from your own supported disc in `input/`: - `SLUS_002.24` - `Disruptor (USA).cue` and the `.bin` track it references -Check the required hashes and disc format in [DISC.md](DISC.md). Then run from +Check the required hashes and disc format in [DISC.md](docs/DISC.md). Then run from an x64 developer PowerShell: ```powershell @@ -105,8 +105,8 @@ retest and save-state restart check passed after fixing an ultrawide rendering buffer overflow. Full campaign and release validation remain incomplete; minor geometry and texture artifacts can remain. -- [Current status and known limits](STATUS.md) +- [Current status and known limits](docs/STATUS.md) - [Build details](docs/BUILD.md) -- [Supported disc revision](DISC.md) +- [Supported disc revision](docs/DISC.md) - [Contributing](CONTRIBUTING.md) - [Third-party notices](THIRD_PARTY_NOTICES.md) diff --git a/tools/stage_launcher.cmake b/cmake/stage_launcher.cmake similarity index 92% rename from tools/stage_launcher.cmake rename to cmake/stage_launcher.cmake index e92c3dc..ed5bf93 100644 --- a/tools/stage_launcher.cmake +++ b/cmake/stage_launcher.cmake @@ -3,7 +3,7 @@ foreach(pair IN ITEMS "game.toml|game.toml" "mouse-aim.ini|mouse-aim.ini" "docs/PLAYING.md|GETTING_STARTED.md" "release/windows/Play Disruptor.cmd|Play Disruptor.cmd" - "keybinds-modern.ini|keybinds.ini" + "config/keybinds-modern.ini|keybinds.ini" "release/windows/settings.toml|settings.toml") string(REPLACE "|" ";" paths "${pair}") list(GET paths 0 source) diff --git a/keybinds-modern.ini b/config/keybinds-modern.ini similarity index 100% rename from keybinds-modern.ini rename to config/keybinds-modern.ini diff --git a/keybinds-original.ini b/config/keybinds-original.ini similarity index 100% rename from keybinds-original.ini rename to config/keybinds-original.ini diff --git a/docs/BUILD.md b/docs/BUILD.md index f900920..81f216f 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -3,7 +3,7 @@ ## Requirements - The verified Disruptor USA executable and BIN/CUE files in `input/`; see - [DISC.md](../DISC.md). + [DISC.md](DISC.md). - Git, Python 3, CMake 3.20+, and a C++20 toolchain. - Windows x64: Visual Studio with **Desktop development with C++**, or MSYS2 MinGW-w64. Ninja is recommended. diff --git a/DISC.md b/docs/DISC.md similarity index 100% rename from DISC.md rename to docs/DISC.md diff --git a/STATUS.md b/docs/STATUS.md similarity index 97% rename from STATUS.md rename to docs/STATUS.md index 6301b77..d00d04f 100644 --- a/STATUS.md +++ b/docs/STATUS.md @@ -35,5 +35,5 @@ and public-release validation are incomplete. individually exercised live. - Live volume adjustment and its relaunch persistence need user validation. -See [build details](docs/BUILD.md) and [disc requirements](DISC.md) before +See [build details](BUILD.md) and [disc requirements](DISC.md) before building or reporting a problem. diff --git a/input/README.md b/input/README.md index 5523d20..75ef03d 100644 --- a/input/README.md +++ b/input/README.md @@ -5,7 +5,7 @@ Copy these files from your own **Disruptor (USA, SLUS-00224)** disc here: - `SLUS_002.24` - `Disruptor (USA).cue` and the `.bin` track named in its `FILE` entry -Check the hashes and MODE2/2352 format in [DISC.md](../DISC.md). Build and run +Check the hashes and MODE2/2352 format in [DISC.md](../docs/DISC.md). Build and run instructions are in [docs/BUILD.md](../docs/BUILD.md). This directory's game files and the generated `SLUS_002.24.code` analysis image diff --git a/run.ps1 b/run.ps1 index b3f70cf..2462f11 100644 --- a/run.ps1 +++ b/run.ps1 @@ -36,9 +36,9 @@ if ($MouseAim -or $VerticalLook -or $ModernControls) { $RuntimeDirectory = Split-Path -Parent $Runtime $KeybindPresetName = if ($ModernControls) { - 'keybinds-modern.ini' + 'config/keybinds-modern.ini' } else { - 'keybinds-original.ini' + 'config/keybinds-original.ini' } $KeybindPreset = Join-Path $Root $KeybindPresetName Copy-Item -LiteralPath $KeybindPreset -Destination (Join-Path $RuntimeDirectory 'keybinds.ini') -Force diff --git a/run.sh b/run.sh index 79bdcd1..97473ae 100755 --- a/run.sh +++ b/run.sh @@ -57,10 +57,10 @@ if [ "$PERSPECTIVE_TEXTURES" -eq 1 ]; then fi if [ "$MODERN_CONTROLS" -eq 1 ]; then - cp "$ROOT/keybinds-modern.ini" "$BUILD/keybinds.ini" + cp "$ROOT/config/keybinds-modern.ini" "$BUILD/keybinds.ini" echo "Modern controls enabled: WASD, LMB fire, RMB psionic, Space jump, E use." else - cp "$ROOT/keybinds-original.ini" "$BUILD/keybinds.ini" + cp "$ROOT/config/keybinds-original.ini" "$BUILD/keybinds.ini" fi GAME_CONFIG=game.toml diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index d52c9eb..2a76c88 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -58,15 +58,15 @@ add_custom_command(TARGET disruptor-launcher POST_BUILD COMMAND "${CMAKE_COMMAND}" "-DSOURCE=${PROJECT_SOURCE_DIR}" "-DDESTINATION=$" - -P "${PROJECT_SOURCE_DIR}/tools/stage_launcher.cmake" + -P "${PROJECT_SOURCE_DIR}/cmake/stage_launcher.cmake" VERBATIM) set_property(TARGET disruptor-launcher APPEND PROPERTY LINK_DEPENDS - "${PROJECT_SOURCE_DIR}/tools/stage_launcher.cmake" + "${PROJECT_SOURCE_DIR}/cmake/stage_launcher.cmake" "${PROJECT_SOURCE_DIR}/game.toml" "${PROJECT_SOURCE_DIR}/mouse-aim.ini" "${PROJECT_SOURCE_DIR}/docs/PLAYING.md" "${PROJECT_SOURCE_DIR}/release/windows/Play Disruptor.cmd" - "${PROJECT_SOURCE_DIR}/keybinds-modern.ini" + "${PROJECT_SOURCE_DIR}/config/keybinds-modern.ini" "${PROJECT_SOURCE_DIR}/release/windows/settings.toml") if(BUILD_TESTING) diff --git a/tools/package_release.py b/tools/package_release.py index 6b21bfd..0f68c15 100644 --- a/tools/package_release.py +++ b/tools/package_release.py @@ -37,7 +37,7 @@ def main() -> None: "DisruptorLauncher.exe": build / "DisruptorLauncher.exe", "game.toml": ROOT / "game.toml", "mouse-aim.ini": ROOT / "mouse-aim.ini", - "keybinds.ini": ROOT / "keybinds-modern.ini", + "keybinds.ini": ROOT / "config/keybinds-modern.ini", "settings.toml": ROOT / "release/windows/settings.toml", "Play Disruptor.cmd": ROOT / "release/windows/Play Disruptor.cmd", "GETTING_STARTED.md": ROOT / "docs/PLAYING.md", From 642884cca823430247d1eb0ed119f4c5080394e1 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:38:25 +0300 Subject: [PATCH 05/13] refactor: sort the game's sources into modules under src/game --- CMakeLists.txt | 49 ++++++++++--------- src/{ => game}/disruptor.manifest | 0 src/{ => game}/disruptor.rc | 0 .../frame/frame_rate.cpp} | 2 +- .../frame/frame_rate.h} | 0 .../frame/present_rate.cpp} | 2 +- .../frame/present_rate.h} | 0 .../input/control_probe.cpp} | 0 .../input/mouse_aim.cpp} | 8 +-- .../input/mouse_aim.h} | 0 .../input/name_entry.cpp} | 0 .../input/vertical_camera.cpp} | 2 +- .../input/vertical_camera.h} | 0 .../language/language.cpp} | 8 +-- .../language/language.h} | 0 .../language/language_disc.cpp} | 4 +- .../language/language_disc.h} | 0 .../language/language_hints.cpp} | 4 +- .../language/language_pack.h} | 0 .../language/language_source.cpp} | 6 +-- .../render/backdrop_tiles.cpp} | 0 .../render/billboard_aspect.cpp} | 2 +- .../render/billboard_aspect.h} | 0 .../render/far_rendering.cpp} | 2 +- .../render/far_rendering.h} | 0 .../render/grown_quad.cpp} | 0 .../render/hud_widgets.cpp} | 0 .../render/render_arena.cpp} | 0 .../render/sprite_depth.cpp} | 0 .../session/capsule.cpp} | 2 +- .../session/capsule.h} | 0 .../session/cheats.cpp} | 2 +- .../session/cheats.h} | 0 .../session/intro_skip.cpp} | 2 +- .../session/intro_skip.h} | 0 .../session/restart.cpp} | 2 +- .../session/restart.h} | 0 .../ui/dev_menu.cpp} | 18 +++---- src/launcher/CMakeLists.txt | 2 +- src/launcher/launcher_settings.cpp | 2 +- src/launcher/region_disc.cpp | 2 +- .../test_ambient_occlusion_contract.py | 2 +- .../contracts/test_backdrop_tiles_contract.py | 4 +- .../test_billboard_aspect_contract.py | 6 +-- tests/contracts/test_capsule_contract.py | 10 ++-- tests/contracts/test_cheats_contract.py | 4 +- tests/contracts/test_debug_tab_contract.py | 2 +- tests/contracts/test_dev_menu_contract.py | 14 +++--- .../contracts/test_far_rendering_contract.py | 6 +-- tests/contracts/test_frame_kind_contract.py | 2 +- tests/contracts/test_frame_rate_contract.py | 14 +++--- tests/contracts/test_gap_filling_contract.py | 2 +- .../test_geometry_provenance_contract.py | 2 +- tests/contracts/test_grown_quad_contract.py | 4 +- tests/contracts/test_hud_scale_contract.py | 2 +- tests/contracts/test_hud_widget_contract.py | 4 +- tests/contracts/test_intro_skip_contract.py | 8 +-- tests/contracts/test_language_contract.py | 12 ++--- .../contracts/test_language_disc_contract.py | 18 +++---- tests/contracts/test_launcher_contract.py | 14 +++--- tests/contracts/test_loaded_state_contract.py | 4 +- tests/contracts/test_name_entry_contract.py | 4 +- tests/contracts/test_phase_a_contract.py | 2 +- tests/contracts/test_smooth_turn_contract.py | 4 +- tests/contracts/test_sprite_range_contract.py | 2 +- .../test_temporal_presenter_contract.py | 2 +- .../test_vertical_camera_contract.py | 6 +-- tests/contracts/test_world_depth_contract.py | 2 +- tests/support/disruptor_language_discs.h | 2 +- tests/unit/disruptor_backdrop_tiles_test.cpp | 2 +- .../unit/disruptor_billboard_aspect_test.cpp | 2 +- tests/unit/disruptor_capsule_test.cpp | 2 +- tests/unit/disruptor_cheats_test.cpp | 2 +- tests/unit/disruptor_far_rendering_test.cpp | 2 +- tests/unit/disruptor_frame_rate_test.cpp | 2 +- tests/unit/disruptor_grown_quad_test.cpp | 2 +- tests/unit/disruptor_intro_skip_test.cpp | 2 +- tests/unit/disruptor_language_hints_test.cpp | 2 +- .../unit/disruptor_language_overlay_test.cpp | 2 +- tests/unit/disruptor_language_test.cpp | 4 +- tests/unit/disruptor_mouse_aim_test.cpp | 2 +- tests/unit/disruptor_name_entry_test.cpp | 2 +- tests/unit/disruptor_present_rate_test.cpp | 4 +- tests/unit/disruptor_render_arena_test.cpp | 2 +- tests/unit/disruptor_restart_test.cpp | 2 +- tests/unit/disruptor_sprite_depth_test.cpp | 2 +- tests/unit/disruptor_vertical_camera_test.cpp | 2 +- 87 files changed, 160 insertions(+), 159 deletions(-) rename src/{ => game}/disruptor.manifest (100%) rename src/{ => game}/disruptor.rc (100%) rename src/{disruptor_frame_rate.cpp => game/frame/frame_rate.cpp} (99%) rename src/{disruptor_frame_rate.h => game/frame/frame_rate.h} (100%) rename src/{disruptor_present_rate.cpp => game/frame/present_rate.cpp} (98%) rename src/{disruptor_present_rate.h => game/frame/present_rate.h} (100%) rename src/{disruptor_control_probe.cpp => game/input/control_probe.cpp} (100%) rename src/{disruptor_mouse_aim.cpp => game/input/mouse_aim.cpp} (99%) rename src/{disruptor_mouse_aim.h => game/input/mouse_aim.h} (100%) rename src/{disruptor_name_entry.cpp => game/input/name_entry.cpp} (100%) rename src/{disruptor_vertical_camera.cpp => game/input/vertical_camera.cpp} (99%) rename src/{disruptor_vertical_camera.h => game/input/vertical_camera.h} (100%) rename src/{disruptor_language.cpp => game/language/language.cpp} (98%) rename src/{disruptor_language.h => game/language/language.h} (100%) rename src/{disruptor_language_disc.cpp => game/language/language_disc.cpp} (99%) rename src/{disruptor_language_disc.h => game/language/language_disc.h} (100%) rename src/{disruptor_language_hints.cpp => game/language/language_hints.cpp} (98%) rename src/{disruptor_language_pack.h => game/language/language_pack.h} (100%) rename src/{disruptor_language_source.cpp => game/language/language_source.cpp} (96%) rename src/{disruptor_backdrop_tiles.cpp => game/render/backdrop_tiles.cpp} (100%) rename src/{disruptor_billboard_aspect.cpp => game/render/billboard_aspect.cpp} (99%) rename src/{disruptor_billboard_aspect.h => game/render/billboard_aspect.h} (100%) rename src/{disruptor_far_rendering.cpp => game/render/far_rendering.cpp} (99%) rename src/{disruptor_far_rendering.h => game/render/far_rendering.h} (100%) rename src/{disruptor_grown_quad.cpp => game/render/grown_quad.cpp} (100%) rename src/{disruptor_hud_widgets.cpp => game/render/hud_widgets.cpp} (100%) rename src/{disruptor_render_arena.cpp => game/render/render_arena.cpp} (100%) rename src/{disruptor_sprite_depth.cpp => game/render/sprite_depth.cpp} (100%) rename src/{disruptor_capsule.cpp => game/session/capsule.cpp} (99%) rename src/{disruptor_capsule.h => game/session/capsule.h} (100%) rename src/{disruptor_cheats.cpp => game/session/cheats.cpp} (99%) rename src/{disruptor_cheats.h => game/session/cheats.h} (100%) rename src/{disruptor_intro_skip.cpp => game/session/intro_skip.cpp} (99%) rename src/{disruptor_intro_skip.h => game/session/intro_skip.h} (100%) rename src/{disruptor_restart.cpp => game/session/restart.cpp} (98%) rename src/{disruptor_restart.h => game/session/restart.h} (100%) rename src/{disruptor_dev_menu.cpp => game/ui/dev_menu.cpp} (99%) diff --git a/CMakeLists.txt b/CMakeLists.txt index e1cb989..57c6e7e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,31 +25,32 @@ option(DISRUPTOR_FRAME_INTERPOLATION ON) set(DISRUPTOR_EXTRA_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_backdrop_tiles.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_billboard_aspect.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_capsule.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_cheats.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_control_probe.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_far_rendering.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_frame_rate.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_intro_skip.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language_disc.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language_hints.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language_source.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_restart.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_mouse_aim.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_name_entry.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_render_arena.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_grown_quad.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_hud_widgets.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_sprite_depth.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_vertical_camera.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/backdrop_tiles.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/billboard_aspect.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/cheats.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/control_probe.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/far_rendering.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/frame_rate.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/intro_skip.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_disc.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_hints.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_source.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/restart.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/mouse_aim.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/render_arena.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/hud_widgets.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/sprite_depth.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/vertical_camera.cpp") # The project deliberately uses the redistributable OpenBIOS backend only. set(PSXRECOMP_BIOS_STEMS "OpenBIOS" CACHE STRING "" FORCE) set(PSX_RECOMP_UI OFF CACHE BOOL "" FORCE) +include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src") include("${PSXRECOMP_ROOT}/runtime/runtime.cmake") if(DISRUPTOR_DEV_MENU) @@ -88,10 +89,10 @@ if(DISRUPTOR_DEV_MENU) endif() list(APPEND DISRUPTOR_EXTRA_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_dev_menu.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp") if(DISRUPTOR_FRAME_INTERPOLATION) list(APPEND DISRUPTOR_EXTRA_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_present_rate.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/present_rate.cpp") endif() endif() @@ -166,9 +167,9 @@ endif() if(WIN32) # UTF-8 as the game's code page: a path in settings.toml may hold letters the system's own page lacks. if(MINGW) - target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.rc") + target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.rc") else() - target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.manifest") + target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.manifest") endif() endif() diff --git a/src/disruptor.manifest b/src/game/disruptor.manifest similarity index 100% rename from src/disruptor.manifest rename to src/game/disruptor.manifest diff --git a/src/disruptor.rc b/src/game/disruptor.rc similarity index 100% rename from src/disruptor.rc rename to src/game/disruptor.rc diff --git a/src/disruptor_frame_rate.cpp b/src/game/frame/frame_rate.cpp similarity index 99% rename from src/disruptor_frame_rate.cpp rename to src/game/frame/frame_rate.cpp index 87b7d4e..521f022 100644 --- a/src/disruptor_frame_rate.cpp +++ b/src/game/frame/frame_rate.cpp @@ -12,7 +12,7 @@ * wait counts loop iterations for its timeout and always runs at stock cost. */ -#include "disruptor_frame_rate.h" +#include "game/frame/frame_rate.h" #include "cpu_state.h" #include "interrupts.h" diff --git a/src/disruptor_frame_rate.h b/src/game/frame/frame_rate.h similarity index 100% rename from src/disruptor_frame_rate.h rename to src/game/frame/frame_rate.h diff --git a/src/disruptor_present_rate.cpp b/src/game/frame/present_rate.cpp similarity index 98% rename from src/disruptor_present_rate.cpp rename to src/game/frame/present_rate.cpp index aba9174..e5e7740 100644 --- a/src/disruptor_present_rate.cpp +++ b/src/game/frame/present_rate.cpp @@ -3,7 +3,7 @@ * Built only together with the frame interpolator. */ -#include "disruptor_present_rate.h" +#include "game/frame/present_rate.h" #include "host_ui.h" #include "imgui.h" diff --git a/src/disruptor_present_rate.h b/src/game/frame/present_rate.h similarity index 100% rename from src/disruptor_present_rate.h rename to src/game/frame/present_rate.h diff --git a/src/disruptor_control_probe.cpp b/src/game/input/control_probe.cpp similarity index 100% rename from src/disruptor_control_probe.cpp rename to src/game/input/control_probe.cpp diff --git a/src/disruptor_mouse_aim.cpp b/src/game/input/mouse_aim.cpp similarity index 99% rename from src/disruptor_mouse_aim.cpp rename to src/game/input/mouse_aim.cpp index 290cc73..87b3343 100644 --- a/src/disruptor_mouse_aim.cpp +++ b/src/game/input/mouse_aim.cpp @@ -18,10 +18,10 @@ */ #include "psx_sdl.h" -#include "disruptor_mouse_aim.h" -#include "disruptor_capsule.h" -#include "disruptor_vertical_camera.h" -#include "../psxrecomp-overlay/runtime/include/gpu.h" +#include "game/input/mouse_aim.h" +#include "game/session/capsule.h" +#include "game/input/vertical_camera.h" +#include "../../../psxrecomp-overlay/runtime/include/gpu.h" #include #include diff --git a/src/disruptor_mouse_aim.h b/src/game/input/mouse_aim.h similarity index 100% rename from src/disruptor_mouse_aim.h rename to src/game/input/mouse_aim.h diff --git a/src/disruptor_name_entry.cpp b/src/game/input/name_entry.cpp similarity index 100% rename from src/disruptor_name_entry.cpp rename to src/game/input/name_entry.cpp diff --git a/src/disruptor_vertical_camera.cpp b/src/game/input/vertical_camera.cpp similarity index 99% rename from src/disruptor_vertical_camera.cpp rename to src/game/input/vertical_camera.cpp index 5217d64..203a2e8 100644 --- a/src/disruptor_vertical_camera.cpp +++ b/src/game/input/vertical_camera.cpp @@ -14,7 +14,7 @@ * results and memory are left byte-for-byte untouched. */ -#include "disruptor_vertical_camera.h" +#include "game/input/vertical_camera.h" #include "cpu_state.h" #include "lockstep.h" diff --git a/src/disruptor_vertical_camera.h b/src/game/input/vertical_camera.h similarity index 100% rename from src/disruptor_vertical_camera.h rename to src/game/input/vertical_camera.h diff --git a/src/disruptor_language.cpp b/src/game/language/language.cpp similarity index 98% rename from src/disruptor_language.cpp rename to src/game/language/language.cpp index 7ea1891..193efe6 100644 --- a/src/disruptor_language.cpp +++ b/src/game/language/language.cpp @@ -23,14 +23,14 @@ * which characters to give that routine for the letters the other disc * writes. That swap is made for every string func_80044BDC draws. * - * The pack comes from the language disc (disruptor_language_disc.cpp) and - * holds what disruptor_language_pack.h says, by the rules written there. + * The pack comes from the language disc (language_disc.cpp) and + * holds what language_pack.h says, by the rules written there. */ -#include "disruptor_language.h" +#include "game/language/language.h" #include "cpu_state.h" -#include "disruptor_language_pack.h" +#include "game/language/language_pack.h" #include "lockstep.h" #include "mod_plugins.h" #include "psx_netplay.h" diff --git a/src/disruptor_language.h b/src/game/language/language.h similarity index 100% rename from src/disruptor_language.h rename to src/game/language/language.h diff --git a/src/disruptor_language_disc.cpp b/src/game/language/language_disc.cpp similarity index 99% rename from src/disruptor_language_disc.cpp rename to src/game/language/language_disc.cpp index 5550797..21dcc0d 100644 --- a/src/disruptor_language_disc.cpp +++ b/src/game/language/language_disc.cpp @@ -29,9 +29,9 @@ * header says, so it takes the other disc's header and that second part. */ -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" -#include "disruptor_language_pack.h" +#include "game/language/language_pack.h" #include #include diff --git a/src/disruptor_language_disc.h b/src/game/language/language_disc.h similarity index 100% rename from src/disruptor_language_disc.h rename to src/game/language/language_disc.h diff --git a/src/disruptor_language_hints.cpp b/src/game/language/language_hints.cpp similarity index 98% rename from src/disruptor_language_hints.cpp rename to src/game/language/language_hints.cpp index 0a89bb7..3d97a7f 100644 --- a/src/disruptor_language_hints.cpp +++ b/src/game/language/language_hints.cpp @@ -21,10 +21,10 @@ * code then shows the upper half. */ -#include "disruptor_language.h" +#include "game/language/language.h" #include "cpu_state.h" -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" #include "lockstep.h" #include "mod_plugins.h" #include "psx_netplay.h" diff --git a/src/disruptor_language_pack.h b/src/game/language/language_pack.h similarity index 100% rename from src/disruptor_language_pack.h rename to src/game/language/language_pack.h diff --git a/src/disruptor_language_source.cpp b/src/game/language/language_source.cpp similarity index 96% rename from src/disruptor_language_source.cpp rename to src/game/language/language_source.cpp index 66cf7f2..19220d0 100644 --- a/src/disruptor_language_source.cpp +++ b/src/game/language/language_source.cpp @@ -3,15 +3,15 @@ * * The player names an image of another region's disc. When the runtime sets * its save states up, or at the latest the first time the drive reads the - * game disc, the two are laid out as one (see disruptor_language_disc.cpp), + * game disc, the two are laid out as one (see language_disc.cpp), * and from then on every sector comes from that layout. A disc that is not a * known version, or does not fit, is refused whole and the game disc is read * as it is. */ -#include "disruptor_language.h" +#include "game/language/language.h" -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" #include "iso_overlay.h" #include "mod_plugins.h" #include "savestate.h" diff --git a/src/disruptor_backdrop_tiles.cpp b/src/game/render/backdrop_tiles.cpp similarity index 100% rename from src/disruptor_backdrop_tiles.cpp rename to src/game/render/backdrop_tiles.cpp diff --git a/src/disruptor_billboard_aspect.cpp b/src/game/render/billboard_aspect.cpp similarity index 99% rename from src/disruptor_billboard_aspect.cpp rename to src/game/render/billboard_aspect.cpp index 160f736..d9b5859 100644 --- a/src/disruptor_billboard_aspect.cpp +++ b/src/game/render/billboard_aspect.cpp @@ -14,7 +14,7 @@ * Guest RAM, Y, UVs, HUD SPRTs, and the first-person weapon path are untouched. */ -#include "disruptor_billboard_aspect.h" +#include "game/render/billboard_aspect.h" #include "cpu_state.h" #include "gpu.h" diff --git a/src/disruptor_billboard_aspect.h b/src/game/render/billboard_aspect.h similarity index 100% rename from src/disruptor_billboard_aspect.h rename to src/game/render/billboard_aspect.h diff --git a/src/disruptor_far_rendering.cpp b/src/game/render/far_rendering.cpp similarity index 99% rename from src/disruptor_far_rendering.cpp rename to src/game/render/far_rendering.cpp index a37633d..6e52bee 100644 --- a/src/disruptor_far_rendering.cpp +++ b/src/game/render/far_rendering.cpp @@ -14,7 +14,7 @@ * the new invocation is rejected, so a missed seam fails closed. */ -#include "disruptor_far_rendering.h" +#include "game/render/far_rendering.h" #include "cpu_state.h" #include "lockstep.h" diff --git a/src/disruptor_far_rendering.h b/src/game/render/far_rendering.h similarity index 100% rename from src/disruptor_far_rendering.h rename to src/game/render/far_rendering.h diff --git a/src/disruptor_grown_quad.cpp b/src/game/render/grown_quad.cpp similarity index 100% rename from src/disruptor_grown_quad.cpp rename to src/game/render/grown_quad.cpp diff --git a/src/disruptor_hud_widgets.cpp b/src/game/render/hud_widgets.cpp similarity index 100% rename from src/disruptor_hud_widgets.cpp rename to src/game/render/hud_widgets.cpp diff --git a/src/disruptor_render_arena.cpp b/src/game/render/render_arena.cpp similarity index 100% rename from src/disruptor_render_arena.cpp rename to src/game/render/render_arena.cpp diff --git a/src/disruptor_sprite_depth.cpp b/src/game/render/sprite_depth.cpp similarity index 100% rename from src/disruptor_sprite_depth.cpp rename to src/game/render/sprite_depth.cpp diff --git a/src/disruptor_capsule.cpp b/src/game/session/capsule.cpp similarity index 99% rename from src/disruptor_capsule.cpp rename to src/game/session/capsule.cpp index bfef518..a4001b5 100644 --- a/src/disruptor_capsule.cpp +++ b/src/game/session/capsule.cpp @@ -2,7 +2,7 @@ /* PSX_DISRUPTOR_CAPSULE_FRAMES= writes each replayed frame as a picture, which is whole only without a window. */ /* PSX_DISRUPTOR_CAPSULE_RECORD= is the key for a script: two seconds in, it records that many frames and ends the run. */ /* Measured: a loaded state takes its interrupts a few instructions apart, so one frame in four differs in a few bytes, nine in a row at most. */ -#include "disruptor_capsule.h" +#include "game/session/capsule.h" #include #include diff --git a/src/disruptor_capsule.h b/src/game/session/capsule.h similarity index 100% rename from src/disruptor_capsule.h rename to src/game/session/capsule.h diff --git a/src/disruptor_cheats.cpp b/src/game/session/cheats.cpp similarity index 99% rename from src/disruptor_cheats.cpp rename to src/game/session/cheats.cpp index a06a6bf..1be308f 100644 --- a/src/disruptor_cheats.cpp +++ b/src/game/session/cheats.cpp @@ -10,7 +10,7 @@ * below are guarded by source/codegen contract tests for this exact executable. */ -#include "disruptor_cheats.h" +#include "game/session/cheats.h" #include "cpu_state.h" #include "mod_plugins.h" diff --git a/src/disruptor_cheats.h b/src/game/session/cheats.h similarity index 100% rename from src/disruptor_cheats.h rename to src/game/session/cheats.h diff --git a/src/disruptor_intro_skip.cpp b/src/game/session/intro_skip.cpp similarity index 99% rename from src/disruptor_intro_skip.cpp rename to src/game/session/intro_skip.cpp index 486a83e..e166ec2 100644 --- a/src/disruptor_intro_skip.cpp +++ b/src/game/session/intro_skip.cpp @@ -19,7 +19,7 @@ * after launch instead of 14. */ -#include "disruptor_intro_skip.h" +#include "game/session/intro_skip.h" #include "cpu_state.h" #include "lockstep.h" diff --git a/src/disruptor_intro_skip.h b/src/game/session/intro_skip.h similarity index 100% rename from src/disruptor_intro_skip.h rename to src/game/session/intro_skip.h diff --git a/src/disruptor_restart.cpp b/src/game/session/restart.cpp similarity index 98% rename from src/disruptor_restart.cpp rename to src/game/session/restart.cpp index 93529d4..ccb3432 100644 --- a/src/disruptor_restart.cpp +++ b/src/game/session/restart.cpp @@ -9,7 +9,7 @@ * ended by then, the new one ends itself and the player starts the game. */ -#include "disruptor_restart.h" +#include "game/session/restart.h" #include "mod_plugins.h" diff --git a/src/disruptor_restart.h b/src/game/session/restart.h similarity index 100% rename from src/disruptor_restart.h rename to src/game/session/restart.h diff --git a/src/disruptor_dev_menu.cpp b/src/game/ui/dev_menu.cpp similarity index 99% rename from src/disruptor_dev_menu.cpp rename to src/game/ui/dev_menu.cpp index 6a4ffa5..439c393 100644 --- a/src/disruptor_dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -7,15 +7,15 @@ * gameplay APIs whose save consequences are stated in the UI. */ -#include "disruptor_cheats.h" -#include "disruptor_billboard_aspect.h" -#include "disruptor_far_rendering.h" -#include "disruptor_frame_rate.h" -#include "disruptor_capsule.h" -#include "disruptor_intro_skip.h" -#include "disruptor_mouse_aim.h" -#include "disruptor_present_rate.h" -#include "disruptor_restart.h" +#include "game/session/cheats.h" +#include "game/render/billboard_aspect.h" +#include "game/render/far_rendering.h" +#include "game/frame/frame_rate.h" +#include "game/session/capsule.h" +#include "game/session/intro_skip.h" +#include "game/input/mouse_aim.h" +#include "game/frame/present_rate.h" +#include "game/session/restart.h" #include "config_loader.h" #include "gpu.h" #include "host_ui.h" diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 2a76c88..57a6daf 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -24,7 +24,7 @@ add_library(disruptor-launcher-model STATIC launcher_settings.cpp region_disc.cpp startup_log.cpp - "${PROJECT_SOURCE_DIR}/src/disruptor_language_disc.cpp") + "${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp") target_include_directories(disruptor-launcher-model PUBLIC "${PROJECT_SOURCE_DIR}/src") target_link_libraries(disruptor-launcher-model PUBLIC disruptor-disc-import disruptor-launcher-config) diff --git a/src/launcher/launcher_settings.cpp b/src/launcher/launcher_settings.cpp index cefd0fb..4c174c7 100644 --- a/src/launcher/launcher_settings.cpp +++ b/src/launcher/launcher_settings.cpp @@ -1,6 +1,6 @@ #include "launcher_settings.h" -#include "disruptor_present_rate.h" +#include "game/frame/present_rate.h" #include diff --git a/src/launcher/region_disc.cpp b/src/launcher/region_disc.cpp index a28a567..2d21910 100644 --- a/src/launcher/region_disc.cpp +++ b/src/launcher/region_disc.cpp @@ -1,7 +1,7 @@ #include "region_disc.h" #include "disc_import.h" -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" #include #include diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 3224d81..ca1999b 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -22,7 +22,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") diff --git a/tests/contracts/test_backdrop_tiles_contract.py b/tests/contracts/test_backdrop_tiles_contract.py index 08752e6..565765d 100644 --- a/tests/contracts/test_backdrop_tiles_contract.py +++ b/tests/contracts/test_backdrop_tiles_contract.py @@ -53,7 +53,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_backdrop_tiles.cpp") +source = read("src/game/render/backdrop_tiles.cpp") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") gpu_header = read("psxrecomp-overlay/runtime/include/gpu.h") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") @@ -61,7 +61,7 @@ def require(condition: bool, message: str) -> None: audit = read("tools/audit_codegen.py") require( - "src/disruptor_backdrop_tiles.cpp" in cmake + "src/game/render/backdrop_tiles.cpp" in cmake and "disruptor_backdrop_tiles_contract" in cmake and "add_test(NAME disruptor_backdrop_tiles\n" in cmake, "backdrop tile source, its unit test and its contract test must remain registered", diff --git a/tests/contracts/test_billboard_aspect_contract.py b/tests/contracts/test_billboard_aspect_contract.py index c3c3d91..bfe1997 100644 --- a/tests/contracts/test_billboard_aspect_contract.py +++ b/tests/contracts/test_billboard_aspect_contract.py @@ -19,8 +19,8 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -source = read("src/disruptor_billboard_aspect.cpp") -header = read("src/disruptor_billboard_aspect.h") +source = read("src/game/render/billboard_aspect.cpp") +header = read("src/game/render/billboard_aspect.h") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") @@ -69,7 +69,7 @@ def require(condition: bool, message: str) -> None: "disruptor_billboard_aspect_instruction_hook(cpu, pc, insn, 1)" in dirty, "dirty-interpreter parity must be project-gated and post-instruction") require("PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT=1" in cmake and - "src/disruptor_billboard_aspect.cpp" in cmake and + "src/game/render/billboard_aspect.cpp" in cmake and "tests/unit/disruptor_billboard_aspect_test.cpp" in cmake, "runtime source, dirty seam, and focused behavior test must be enabled") diff --git a/tests/contracts/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py index a909dff..73d28ed 100644 --- a/tests/contracts/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -16,9 +16,9 @@ def require(condition: bool, message: str) -> None: host = read("psxrecomp-overlay/runtime/src/main.cpp") -capsule = read("src/disruptor_capsule.cpp") -mouse = read("src/disruptor_mouse_aim.cpp") -menu = read("src/disruptor_dev_menu.cpp") +capsule = read("src/game/session/capsule.cpp") +mouse = read("src/game/input/mouse_aim.cpp") +menu = read("src/game/ui/dev_menu.cpp") cmake = read("CMakeLists.txt") require( @@ -128,9 +128,9 @@ def require(condition: bool, message: str) -> None: "Ctrl+F12 must start and end a recording and must not reach the save state keys", ) require( - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_capsule.cpp"\n' in cmake + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_CAPSULE=1\n" in cmake - and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/disruptor_capsule.cpp"\n' + and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/game/session/capsule.cpp"\n' in read("tests/unit/disruptor_capsule_test.cpp") and " add_test(NAME disruptor_capsule\n COMMAND disruptor-capsule-test)\n" in cmake and 'tests/contracts/test_capsule_contract.py")' in cmake, diff --git a/tests/contracts/test_cheats_contract.py b/tests/contracts/test_cheats_contract.py index 135d4db..f7019b4 100644 --- a/tests/contracts/test_cheats_contract.py +++ b/tests/contracts/test_cheats_contract.py @@ -23,8 +23,8 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_cheats.cpp") -header = read("src/disruptor_cheats.h") +source = read("src/game/session/cheats.cpp") +header = read("src/game/session/cheats.h") game = read("game.toml") wide = read("game-widescreen.toml") diff --git a/tests/contracts/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py index 0165e16..521a958 100644 --- a/tests/contracts/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -22,7 +22,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index b0be712..87bd76d 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -58,11 +58,11 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: main_cpp = read("psxrecomp-overlay/runtime/src/main.cpp") gl = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gte = read("psxrecomp-overlay/runtime/src/gte.cpp") -menu = read("src/disruptor_dev_menu.cpp") -mouse = read("src/disruptor_mouse_aim.cpp") -cheats = read("src/disruptor_cheats.cpp") -cheats_h = read("src/disruptor_cheats.h") -far_rendering_h = read("src/disruptor_far_rendering.h") +menu = read("src/game/ui/dev_menu.cpp") +mouse = read("src/game/input/mouse_aim.cpp") +cheats = read("src/game/session/cheats.cpp") +cheats_h = read("src/game/session/cheats.h") +far_rendering_h = read("src/game/render/far_rendering.h") config_h = read("psxrecomp-overlay/recompiler/src/config_loader.h") config_cpp = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") run_ps1 = read("run.ps1") @@ -76,10 +76,10 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "5b17c01f69545bde732b14936d89ce0f508adb83e8b56fa82448371845172bc3" in cmake.lower(), "Dear ImGui source must remain versioned and integrity-pinned") -require("src/disruptor_dev_menu.cpp" in cmake and +require("src/game/ui/dev_menu.cpp" in cmake and "target_link_libraries(psx-runtime PRIVATE disruptor-imgui)" in cmake, "menu implementation must be linked only into the game runtime") -require("src/disruptor_cheats.cpp" in cmake and +require("src/game/session/cheats.cpp" in cmake and "tests/unit/disruptor_cheats_test.cpp" in cmake, "version-pinned cheats and their behavioral test must be built") require("+runtime/include/host_ui.h" in manifest, diff --git a/tests/contracts/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py index e56bd87..013e9a4 100644 --- a/tests/contracts/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -19,8 +19,8 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -source = read("src/disruptor_far_rendering.cpp") -header = read("src/disruptor_far_rendering.h") +source = read("src/game/render/far_rendering.cpp") +header = read("src/game/render/far_rendering.h") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") main = read("psxrecomp-overlay/runtime/src/main.cpp") @@ -28,7 +28,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") configs = read("game.toml") + read("game-widescreen.toml") -require("src/disruptor_far_rendering.cpp" in cmake and +require("src/game/render/far_rendering.cpp" in cmake and "tests/unit/disruptor_far_rendering_test.cpp" in cmake and "add_test(NAME disruptor_far_rendering" in cmake, "runtime source and focused behavior test must remain enabled") diff --git a/tests/contracts/test_frame_kind_contract.py b/tests/contracts/test_frame_kind_contract.py index 1f06686..c7fd7e1 100644 --- a/tests/contracts/test_frame_kind_contract.py +++ b/tests/contracts/test_frame_kind_contract.py @@ -47,7 +47,7 @@ def enclosing(source: str, block: str) -> list[str]: CONTRACTS = ["if(BUILD_TESTING)"] UNIT_TESTS = ["if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)"] REGISTERED = { - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_render_arena.cpp"\n': [], + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/render_arena.cpp"\n': [], ' add_test(NAME disruptor_frame_kind_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py")\n': CONTRACTS, " add_executable(disruptor-gpu-ws-frame-kind-test\n tests/unit/gpu_ws_frame_kind_test.cpp)\n": UNIT_TESTS, diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index fb9d846..fe4af39 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -43,9 +43,9 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_frame_rate.cpp") -header = read("src/disruptor_frame_rate.h") -menu = read("src/disruptor_dev_menu.cpp") +source = read("src/game/frame/frame_rate.cpp") +header = read("src/game/frame/frame_rate.h") +menu = read("src/game/ui/dev_menu.cpp") manifest = read("PSXRECOMP_OVERLAY_FILES.txt") cycle_header = read("psxrecomp-overlay/runtime/include/psx_cyc.h") cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") @@ -55,7 +55,7 @@ def require(condition: bool, message: str) -> None: "the build gate must run this contract with its inputs required", ) require( - "src/disruptor_frame_rate.cpp" in cmake + "src/game/frame/frame_rate.cpp" in cmake and "disruptor_frame_rate_contract" in cmake and "add_test(NAME disruptor_frame_rate\n" in cmake, "frame-rate source, its unit test and its contract test must remain registered", @@ -161,8 +161,8 @@ def require(condition: bool, message: str) -> None: "the divisor must charge whole cycles and carry the remainder", ) -present_rate = read("src/disruptor_present_rate.h") -present_control = read("src/disruptor_present_rate.cpp") +present_rate = read("src/game/frame/present_rate.h") +present_control = read("src/game/frame/present_rate.cpp") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") host = read("psxrecomp-overlay/runtime/src/main.cpp") require( @@ -174,7 +174,7 @@ def require(condition: bool, message: str) -> None: require( " if(DISRUPTOR_FRAME_INTERPOLATION)\n" " list(APPEND DISRUPTOR_EXTRA_SOURCES\n" - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_present_rate.cpp")\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/present_rate.cpp")\n' in cmake and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" " int present_enabled = 0;\n" diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 5f985fa..b4e4aff 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -22,7 +22,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") diff --git a/tests/contracts/test_geometry_provenance_contract.py b/tests/contracts/test_geometry_provenance_contract.py index 722b26e..6486009 100644 --- a/tests/contracts/test_geometry_provenance_contract.py +++ b/tests/contracts/test_geometry_provenance_contract.py @@ -18,7 +18,7 @@ GTE_HEADER = ( ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" ) -MOUSE = ROOT / "src" / "disruptor_mouse_aim.cpp" +MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" MAIN = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "main.cpp" CODEGEN = ( ROOT / "psxrecomp-overlay" / "recompiler" / "src" / "code_generator.cpp" diff --git a/tests/contracts/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py index 73077dc..2c9fb38 100644 --- a/tests/contracts/test_grown_quad_contract.py +++ b/tests/contracts/test_grown_quad_contract.py @@ -36,11 +36,11 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_grown_quad.cpp") +source = read("src/game/render/grown_quad.cpp") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") require( - '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_grown_quad.cpp"' in cmake + '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp"' in cmake and "PSX_HAS_DISRUPTOR_GROWN_QUAD=1" in cmake and "add_test(NAME disruptor_grown_quad\n" in cmake and 'tests/contracts/test_grown_quad_contract.py"\n --require-artifacts)' in cmake, diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index 2165a3d..2dbc67c 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -20,7 +20,7 @@ def require(condition: bool, message: str) -> None: scale_header = read("psxrecomp-overlay/runtime/include/gpu_ws_hud_scale.h") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") header = read("psxrecomp-overlay/runtime/include/gpu.h") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( diff --git a/tests/contracts/test_hud_widget_contract.py b/tests/contracts/test_hud_widget_contract.py index dee123f..178a42e 100644 --- a/tests/contracts/test_hud_widget_contract.py +++ b/tests/contracts/test_hud_widget_contract.py @@ -24,7 +24,7 @@ def body(source: str, signature: str) -> str: manifest = read("PSXRECOMP_OVERLAY_FILES.txt") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") header = read("psxrecomp-overlay/runtime/include/gpu.h") -module = read("src/disruptor_hud_widgets.cpp") +module = read("src/game/render/hud_widgets.cpp") require( "+runtime/include/gpu_ws_hud_widget.h" in manifest.splitlines(), @@ -35,7 +35,7 @@ def body(source: str, signature: str) -> str: and " add_test(NAME disruptor_gpu_ws_hud_widget\n COMMAND disruptor-gpu-ws-hud-widget-test)\n" in cmake and ' add_test(NAME disruptor_hud_widget_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py")\n' in cmake - and '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_hud_widgets.cpp"' in cmake, + and '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/hud_widgets.cpp"' in cmake, "the widget unit test, its contract and the game's widget list must remain in the build", ) require( diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index 53d2fa9..f1b839a 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -45,16 +45,16 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_intro_skip.cpp") -header = read("src/disruptor_intro_skip.h") -menu = read("src/disruptor_dev_menu.cpp") +source = read("src/game/session/intro_skip.cpp") +header = read("src/game/session/intro_skip.h") +menu = read("src/game/ui/dev_menu.cpp") generator = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") interpreter = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") audit = read("tools/audit_codegen.py") settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( - "src/disruptor_intro_skip.cpp" in cmake + "src/game/session/intro_skip.cpp" in cmake and "PSX_HAS_DISRUPTOR_INTRO_SKIP=1" in cmake and "disruptor_intro_skip_contract" in cmake and "add_test(NAME disruptor_intro_skip\n" in cmake, diff --git a/tests/contracts/test_language_contract.py b/tests/contracts/test_language_contract.py index a81fc48..a99d38a 100644 --- a/tests/contracts/test_language_contract.py +++ b/tests/contracts/test_language_contract.py @@ -87,13 +87,13 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_language.cpp") -rules = read("src/disruptor_language_pack.h") -header = read("src/disruptor_language.h") -hints = read("src/disruptor_language_hints.cpp") +source = read("src/game/language/language.cpp") +rules = read("src/game/language/language_pack.h") +header = read("src/game/language/language.h") +hints = read("src/game/language/language_hints.cpp") require( - '/src/disruptor_language.cpp"' in cmake + '/src/game/language/language.cpp"' in cmake and "add_test(NAME disruptor_language\n" in cmake and 'tests/contracts/test_language_contract.py"\n --require-artifacts)' in cmake, "the language source and both of its tests must remain registered, the contract with its inputs required", @@ -192,7 +192,7 @@ def require(condition: bool, message: str) -> None: "by the rules the disc code writes it by", ) require( - '/src/disruptor_language_hints.cpp"' in cmake + '/src/game/language/language_hints.cpp"' in cmake and "add_test(NAME disruptor_language_hints\n" in cmake and "constexpr uint32_t kRead = 0x80011888u, kLoadImage = 0x8004D348u, kMoveImage = 0x8004D410u;" in hints and "constexpr uint32_t kReadReturn = 0x80020928u, kStageReturn = 0x80020954u;" in hints diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index 27ceebe..ea70e19 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -65,23 +65,23 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") overlay_files = read("PSXRECOMP_OVERLAY_FILES.txt") -disc = read("src/disruptor_language_disc.cpp") -disc_header = read("src/disruptor_language_disc.h") -rules = read("src/disruptor_language_pack.h") -source = read("src/disruptor_language_source.cpp") +disc = read("src/game/language/language_disc.cpp") +disc_header = read("src/game/language/language_disc.h") +rules = read("src/game/language/language_pack.h") +source = read("src/game/language/language_source.cpp") header = read("psxrecomp-overlay/runtime/include/iso_overlay.h") host = read("psxrecomp-overlay/runtime/src/main.cpp") host_header = read("psxrecomp-overlay/runtime/include/host_ui.h") pick_slot = read("psxrecomp-overlay/runtime/include/host_pick_slot.h") -restart = read("src/disruptor_restart.cpp") +restart = read("src/game/session/restart.cpp") settings_header = read("psxrecomp-overlay/recompiler/src/config_loader.h") settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") reader = read("psxrecomp-overlay/runtime/src/iso_reader_c.cpp") require( - '/src/disruptor_language_disc.cpp"' in cmake - and '/src/disruptor_language_source.cpp"' in cmake + '/src/game/language/language_disc.cpp"' in cmake + and '/src/game/language/language_source.cpp"' in cmake and "add_test(NAME disruptor_language_disc\n" in cmake and "add_test(NAME disruptor_language_overlay\n" in cmake and "add_test(NAME disruptor_language_overlay_tall\n COMMAND disruptor-language-overlay-test tall)" in cmake @@ -353,7 +353,7 @@ def require(condition: bool, message: str) -> None: and " if (psx_host_pick_disc_image_poll(picked, static_cast(sizeof picked)) == 1) mark_language_disc(picked);" in menu and ' if (ImGui::Button("Restart now") && !psx_netplay_active() && flush_preferences() && disruptor_restart_arm()) {' in menu and " quit.type = SDL_QUIT;\n SDL_PushEvent(&quit);" in menu - and '/src/disruptor_restart.cpp"' in cmake + and '/src/game/session/restart.cpp"' in cmake and "add_test(NAME disruptor_restart\n" in cmake and " if (!before) return GetLastError() == ERROR_INVALID_PARAMETER;" in restart and " return waited == WAIT_OBJECT_0;" in restart diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index b5bf35d..c9d187d 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -43,7 +43,7 @@ def plain_values(text: str) -> dict[str, str]: launcher_cmake = read("src/launcher/CMakeLists.txt") cmake = read("CMakeLists.txt") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -menu = read("src/disruptor_dev_menu.cpp") + read("src/disruptor_present_rate.cpp") +menu = read("src/game/ui/dev_menu.cpp") + read("src/game/frame/present_rate.cpp") rows = re.findall(r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", Page::(\w+), L"([^"]+)", (-?\d+)', table) keys = [key for _, key, _, _, _ in rows] @@ -76,7 +76,7 @@ def plain_values(text: str) -> dict[str, str]: shipped = plain_values(read("release/windows/settings.toml")) require(not set(shipped) - set(keys), f"the shipped settings.toml sets keys the launcher does not show: {sorted(set(shipped) - set(keys))}") -mouse = read("src/disruptor_mouse_aim.cpp") +mouse = read("src/game/input/mouse_aim.cpp") require( "constexpr double kDefaultHorizontalSensitivity = 0.080;" in mouse and "constexpr double kDefaultVerticalSensitivity = 0.080;" in mouse @@ -91,7 +91,7 @@ def plain_values(text: str) -> dict[str, str]: require( '"${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp"' in launcher_cmake - and '"${PROJECT_SOURCE_DIR}/src/disruptor_language_disc.cpp"' in launcher_cmake + and '"${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp"' in launcher_cmake and " Settings fresh = PSXRecompV4::load_user_settings(path_);" in table and " if (!PSXRecompV4::save_user_settings(path_, fresh)) return false;" in table and " if (!readable_) return false;\n alter(fresh);" in table, @@ -145,10 +145,10 @@ def plain_values(text: str) -> dict[str, str]: "drag and drop, the per-monitor layout and the log tail must stay", ) require( - UTF8_PAGE in read("src/disruptor.manifest") and UTF8_PAGE in read("src/launcher/launcher.manifest") - and '1 RT_MANIFEST "disruptor.manifest"' in read("src/disruptor.rc") - and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.manifest")' in cmake - and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.rc")' in cmake, + UTF8_PAGE in read("src/game/disruptor.manifest") and UTF8_PAGE in read("src/launcher/launcher.manifest") + and '1 RT_MANIFEST "disruptor.manifest"' in read("src/game/disruptor.rc") + and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.manifest")' in cmake + and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.rc")' in cmake, "the game and the launcher must both run with UTF-8 as their code page, or a path outside it cannot be named", ) require( diff --git a/tests/contracts/test_loaded_state_contract.py b/tests/contracts/test_loaded_state_contract.py index baef1e6..742b49e 100644 --- a/tests/contracts/test_loaded_state_contract.py +++ b/tests/contracts/test_loaded_state_contract.py @@ -24,8 +24,8 @@ def require(condition: bool, message: str) -> None: state = read("psxrecomp-overlay/runtime/src/boot_state.c") cycles = read("psxrecomp-overlay/runtime/include/psx_cyc.h") latch = read("psxrecomp/runtime/src/fntrace.c") -menu = read("src/disruptor_dev_menu.cpp") -rate = read("src/disruptor_frame_rate.cpp") +menu = read("src/game/ui/dev_menu.cpp") +rate = read("src/game/frame/frame_rate.cpp") require( "static void engage_widescreen_at_game_entry(void) {\n" diff --git a/tests/contracts/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py index 3de0d7c..3f68dc6 100644 --- a/tests/contracts/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -93,11 +93,11 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -module = read("src/disruptor_name_entry.cpp") +module = read("src/game/input/name_entry.cpp") host = read("psxrecomp-overlay/runtime/src/main.cpp") require( - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_name_entry.cpp"\n' in cmake + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_NAME_ENTRY=1\n" in cmake and " add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test)\n" in cmake and 'tests/contracts/test_name_entry_contract.py"\n --require-artifacts)\n' diff --git a/tests/contracts/test_phase_a_contract.py b/tests/contracts/test_phase_a_contract.py index 77f68f8..94de2be 100644 --- a/tests/contracts/test_phase_a_contract.py +++ b/tests/contracts/test_phase_a_contract.py @@ -16,7 +16,7 @@ GTE_HEADER = ( ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" ) -MOUSE = ROOT / "src" / "disruptor_mouse_aim.cpp" +MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" OVERLAY_LIST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index 481c421..08106be 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -24,8 +24,8 @@ def between(source: str, opening: str, closing: str) -> str: overlay = read("PSXRECOMP_OVERLAY_FILES.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -mouse = read("src/disruptor_mouse_aim.cpp") -menu = read("src/disruptor_dev_menu.cpp") +mouse = read("src/game/input/mouse_aim.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") require( diff --git a/tests/contracts/test_sprite_range_contract.py b/tests/contracts/test_sprite_range_contract.py index 84e7b74..2fe9737 100644 --- a/tests/contracts/test_sprite_range_contract.py +++ b/tests/contracts/test_sprite_range_contract.py @@ -183,7 +183,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_sprite_depth.cpp") +source = read("src/game/render/sprite_depth.cpp") gte = read("psxrecomp-overlay/runtime/src/gte.cpp") require( diff --git a/tests/contracts/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py index 2d4eb27..db27fa2 100644 --- a/tests/contracts/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -39,7 +39,7 @@ def shader(source: str, name: str) -> str: temporal = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -billboards = read("src/disruptor_billboard_aspect.cpp") +billboards = read("src/game/render/billboard_aspect.cpp") require( "disruptor_temporal_presenter_contract" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, diff --git a/tests/contracts/test_vertical_camera_contract.py b/tests/contracts/test_vertical_camera_contract.py index b8f827d..c5b8f0c 100644 --- a/tests/contracts/test_vertical_camera_contract.py +++ b/tests/contracts/test_vertical_camera_contract.py @@ -19,9 +19,9 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -camera = read("src/disruptor_vertical_camera.cpp") -camera_h = read("src/disruptor_vertical_camera.h") -mouse = read("src/disruptor_mouse_aim.cpp") +camera = read("src/game/input/vertical_camera.cpp") +camera_h = read("src/game/input/vertical_camera.h") +mouse = read("src/game/input/mouse_aim.cpp") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index d7776ce..ab6359c 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -26,7 +26,7 @@ def between(source: str, opening: str, closing: str) -> str: header = read("psxrecomp-overlay/runtime/include/gpu_world_depth.h") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") presenter = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") diff --git a/tests/support/disruptor_language_discs.h b/tests/support/disruptor_language_discs.h index cb7b297..4f715e4 100644 --- a/tests/support/disruptor_language_discs.h +++ b/tests/support/disruptor_language_discs.h @@ -3,7 +3,7 @@ /* Synthetic discs for the language tests. The disc code is included whole, so that a test reaches its internals. */ -#include "../../src/disruptor_language_disc.cpp" +#include "../../src/game/language/language_disc.cpp" namespace { diff --git a/tests/unit/disruptor_backdrop_tiles_test.cpp b/tests/unit/disruptor_backdrop_tiles_test.cpp index 99f37a3..309df41 100644 --- a/tests/unit/disruptor_backdrop_tiles_test.cpp +++ b/tests/unit/disruptor_backdrop_tiles_test.cpp @@ -85,7 +85,7 @@ void gpu_ws_tag_screen_tile(CPUState *cpu, std::uint32_t primitive_addr) { } // extern "C" -#include "../../src/disruptor_backdrop_tiles.cpp" +#include "../../src/game/render/backdrop_tiles.cpp" namespace { diff --git a/tests/unit/disruptor_billboard_aspect_test.cpp b/tests/unit/disruptor_billboard_aspect_test.cpp index 6f8bac7..f432096 100644 --- a/tests/unit/disruptor_billboard_aspect_test.cpp +++ b/tests/unit/disruptor_billboard_aspect_test.cpp @@ -66,7 +66,7 @@ void disruptor_sprite_depth_packet(CPUState *, std::uint32_t, std::uint32_t) {} } // extern "C" // Include the implementation to exercise its exact private packet guards. -#include "../../src/disruptor_billboard_aspect.cpp" +#include "../../src/game/render/billboard_aspect.cpp" namespace { diff --git a/tests/unit/disruptor_capsule_test.cpp b/tests/unit/disruptor_capsule_test.cpp index efd03e5..933d541 100644 --- a/tests/unit/disruptor_capsule_test.cpp +++ b/tests/unit/disruptor_capsule_test.cpp @@ -1,5 +1,5 @@ #define DISRUPTOR_CAPSULE_NO_HOST 1 -#include "../../src/disruptor_capsule.cpp" +#include "../../src/game/session/capsule.cpp" #include #include diff --git a/tests/unit/disruptor_cheats_test.cpp b/tests/unit/disruptor_cheats_test.cpp index f5c70db..cdfbe04 100644 --- a/tests/unit/disruptor_cheats_test.cpp +++ b/tests/unit/disruptor_cheats_test.cpp @@ -104,7 +104,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" // Include the implementation to exercise its version-pinned internal guards. -#include "../../src/disruptor_cheats.cpp" +#include "../../src/game/session/cheats.cpp" namespace { diff --git a/tests/unit/disruptor_far_rendering_test.cpp b/tests/unit/disruptor_far_rendering_test.cpp index 4c2dd18..e0a2bc1 100644 --- a/tests/unit/disruptor_far_rendering_test.cpp +++ b/tests/unit/disruptor_far_rendering_test.cpp @@ -109,7 +109,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" -#include "../../src/disruptor_far_rendering.cpp" +#include "../../src/game/render/far_rendering.cpp" namespace { diff --git a/tests/unit/disruptor_frame_rate_test.cpp b/tests/unit/disruptor_frame_rate_test.cpp index ae020d7..3d18c22 100644 --- a/tests/unit/disruptor_frame_rate_test.cpp +++ b/tests/unit/disruptor_frame_rate_test.cpp @@ -71,7 +71,7 @@ int psx_mod_register_function_entry_plugin( uint32_t interrupts_cycles_to_vblank(void) { return g_cycles_to_vblank; } -#include "../../src/disruptor_frame_rate.cpp" +#include "../../src/game/frame/frame_rate.cpp" namespace { diff --git a/tests/unit/disruptor_grown_quad_test.cpp b/tests/unit/disruptor_grown_quad_test.cpp index b82d98a..a1fd507 100644 --- a/tests/unit/disruptor_grown_quad_test.cpp +++ b/tests/unit/disruptor_grown_quad_test.cpp @@ -21,7 +21,7 @@ extern "C" std::uint32_t psx_mod_read_word(std::uint32_t address) { return found == g_memory.end() ? 0xDEADBEEFu : found->second; } -#include "../../src/disruptor_grown_quad.cpp" +#include "../../src/game/render/grown_quad.cpp" namespace { diff --git a/tests/unit/disruptor_intro_skip_test.cpp b/tests/unit/disruptor_intro_skip_test.cpp index 48d7de6..3de0806 100644 --- a/tests/unit/disruptor_intro_skip_test.cpp +++ b/tests/unit/disruptor_intro_skip_test.cpp @@ -43,7 +43,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" void psx_host_set_fast_forward_query(int (*query)(void)) { g_registered_query = query; } -#include "../../src/disruptor_intro_skip.cpp" +#include "../../src/game/session/intro_skip.cpp" namespace { diff --git a/tests/unit/disruptor_language_hints_test.cpp b/tests/unit/disruptor_language_hints_test.cpp index 4fd1a06..f5cad85 100644 --- a/tests/unit/disruptor_language_hints_test.cpp +++ b/tests/unit/disruptor_language_hints_test.cpp @@ -60,7 +60,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" int disruptor_language_disc_tall_hints(void) { return g_tall; } -#include "../../src/disruptor_language_hints.cpp" +#include "../../src/game/language/language_hints.cpp" namespace { diff --git a/tests/unit/disruptor_language_overlay_test.cpp b/tests/unit/disruptor_language_overlay_test.cpp index 67bfa1a..c5252d8 100644 --- a/tests/unit/disruptor_language_overlay_test.cpp +++ b/tests/unit/disruptor_language_overlay_test.cpp @@ -68,7 +68,7 @@ extern "C" void savestate_get_integrity(uint32_t *bios_checksum, uint32_t *entry } #include "../../psxrecomp-overlay/runtime/src/iso_reader_c.cpp" -#include "../../src/disruptor_language_source.cpp" +#include "../../src/game/language/language_source.cpp" namespace { diff --git a/tests/unit/disruptor_language_test.cpp b/tests/unit/disruptor_language_test.cpp index 18aafcb..a962e92 100644 --- a/tests/unit/disruptor_language_test.cpp +++ b/tests/unit/disruptor_language_test.cpp @@ -63,8 +63,8 @@ extern "C" int disruptor_language_disc_pack(const uint8_t **data, uint32_t *size return 1; } -#include "../../src/disruptor_language.cpp" -#include "../../src/disruptor_language_disc.cpp" +#include "../../src/game/language/language.cpp" +#include "../../src/game/language/language_disc.cpp" namespace { diff --git a/tests/unit/disruptor_mouse_aim_test.cpp b/tests/unit/disruptor_mouse_aim_test.cpp index fb0051e..5a3e40f 100644 --- a/tests/unit/disruptor_mouse_aim_test.cpp +++ b/tests/unit/disruptor_mouse_aim_test.cpp @@ -184,7 +184,7 @@ const char *SDL_GetError() { // Include the implementation so its internal pure helpers and state can be // exercised against the mocks above without exposing a test-only production ABI. -#include "../../src/disruptor_mouse_aim.cpp" +#include "../../src/game/input/mouse_aim.cpp" namespace { diff --git a/tests/unit/disruptor_name_entry_test.cpp b/tests/unit/disruptor_name_entry_test.cpp index e3bef9b..be1e3b1 100644 --- a/tests/unit/disruptor_name_entry_test.cpp +++ b/tests/unit/disruptor_name_entry_test.cpp @@ -62,7 +62,7 @@ int psx_mod_register_vblank_plugin(const char *, PSXModVBlankCallback callback) } } -#include "../../src/disruptor_name_entry.cpp" +#include "../../src/game/input/name_entry.cpp" namespace { diff --git a/tests/unit/disruptor_present_rate_test.cpp b/tests/unit/disruptor_present_rate_test.cpp index 5bc4941..58540ba 100644 --- a/tests/unit/disruptor_present_rate_test.cpp +++ b/tests/unit/disruptor_present_rate_test.cpp @@ -1,4 +1,4 @@ -#include "../../src/disruptor_present_rate.h" +#include "../../src/game/frame/present_rate.h" #include #include @@ -97,7 +97,7 @@ bool SliderInt(const char *, int *value, int lowest, int highest, const char *, void TextDisabled(const char *text, ...) { g_slider.text = text; } } // namespace ImGui -#include "../../src/disruptor_present_rate.cpp" +#include "../../src/game/frame/present_rate.cpp" namespace { diff --git a/tests/unit/disruptor_render_arena_test.cpp b/tests/unit/disruptor_render_arena_test.cpp index b909f8a..4cc9f56 100644 --- a/tests/unit/disruptor_render_arena_test.cpp +++ b/tests/unit/disruptor_render_arena_test.cpp @@ -52,7 +52,7 @@ int psx_mod_register_function_entry_plugin( const char *, uint32_t, PSXModFunctionEntryCallback) { return 1; } } -#include "../../src/disruptor_render_arena.cpp" +#include "../../src/game/render/render_arena.cpp" int main() { CPUState cpu{}; diff --git a/tests/unit/disruptor_restart_test.cpp b/tests/unit/disruptor_restart_test.cpp index e782e35..42322bc 100644 --- a/tests/unit/disruptor_restart_test.cpp +++ b/tests/unit/disruptor_restart_test.cpp @@ -1,4 +1,4 @@ -#include "../../src/disruptor_restart.cpp" +#include "../../src/game/session/restart.cpp" #include #include diff --git a/tests/unit/disruptor_sprite_depth_test.cpp b/tests/unit/disruptor_sprite_depth_test.cpp index 0ad0f84..d416903 100644 --- a/tests/unit/disruptor_sprite_depth_test.cpp +++ b/tests/unit/disruptor_sprite_depth_test.cpp @@ -124,7 +124,7 @@ extern "C" void gte_ws_x_squash(std::int32_t *num, std::int32_t *den) { *den = g_range_den; } -#include "../../src/disruptor_sprite_depth.cpp" +#include "../../src/game/render/sprite_depth.cpp" namespace { diff --git a/tests/unit/disruptor_vertical_camera_test.cpp b/tests/unit/disruptor_vertical_camera_test.cpp index 3cd3778..b10789d 100644 --- a/tests/unit/disruptor_vertical_camera_test.cpp +++ b/tests/unit/disruptor_vertical_camera_test.cpp @@ -132,7 +132,7 @@ void gpu_geometry_camera_projection_center_y_set(double center_y) { } // extern "C" // Include the implementation to exercise the version-pinned internal guards. -#include "../../src/disruptor_vertical_camera.cpp" +#include "../../src/game/input/vertical_camera.cpp" namespace { From 49140a59b1d61d590e4be931b70938b2d806e8d3 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:45:19 +0300 Subject: [PATCH 06/13] refactor: register the tests through two helper functions --- CMakeLists.txt | 553 ++++-------------- cmake/DisruptorTests.cmake | 56 ++ .../test_ambient_occlusion_contract.py | 2 +- .../contracts/test_backdrop_tiles_contract.py | 6 +- tests/contracts/test_borderless_contract.py | 5 +- tests/contracts/test_capsule_contract.py | 4 +- tests/contracts/test_cheats_contract.py | 2 +- tests/contracts/test_debug_tab_contract.py | 2 +- tests/contracts/test_depth24_sync_contract.py | 5 +- .../contracts/test_far_rendering_contract.py | 2 +- tests/contracts/test_frame_kind_contract.py | 9 +- tests/contracts/test_frame_rate_contract.py | 8 +- tests/contracts/test_gap_filling_contract.py | 2 +- tests/contracts/test_grown_quad_contract.py | 4 +- tests/contracts/test_hud_scale_contract.py | 2 +- tests/contracts/test_hud_widget_contract.py | 6 +- tests/contracts/test_intro_skip_contract.py | 6 +- tests/contracts/test_language_contract.py | 6 +- .../contracts/test_language_disc_contract.py | 11 +- tests/contracts/test_launcher_contract.py | 2 +- tests/contracts/test_menu_wide_contract.py | 4 +- tests/contracts/test_name_entry_contract.py | 6 +- .../contracts/test_pinned_corner_contract.py | 13 +- tests/contracts/test_save_point_contract.py | 4 +- tests/contracts/test_smooth_turn_contract.py | 6 +- tests/contracts/test_sprite_range_contract.py | 5 +- .../test_temporal_presenter_contract.py | 2 +- .../test_test_registration_contract.py | 164 ++++++ .../test_vertical_camera_contract.py | 2 +- tests/contracts/test_world_depth_contract.py | 5 +- 30 files changed, 378 insertions(+), 526 deletions(-) create mode 100644 cmake/DisruptorTests.cmake create mode 100644 tests/contracts/test_test_registration_contract.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 57c6e7e..2c51d93 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -179,466 +179,69 @@ if(WIN32) endif() if(BUILD_TESTING) find_package(Python3 REQUIRED COMPONENTS Interpreter) - - add_test(NAME disruptor_backdrop_tiles_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_backdrop_tiles_contract.py" - --require-artifacts) - - add_test(NAME disruptor_depth24_sync_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_depth24_sync_contract.py") - - add_test(NAME disruptor_geometry_provenance_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_geometry_provenance_contract.py") - - add_test(NAME disruptor_phase_a_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_phase_a_contract.py") - - add_test(NAME disruptor_perspective_texture_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_perspective_texture_contract.py") - - add_test(NAME disruptor_pinned_corner_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_pinned_corner_contract.py") - - add_test(NAME disruptor_dev_menu_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_dev_menu_contract.py") - - add_test(NAME disruptor_loaded_state_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_loaded_state_contract.py") - - add_test(NAME disruptor_save_point_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_save_point_contract.py") - - add_test(NAME disruptor_cheats_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_cheats_contract.py") - - add_test(NAME disruptor_vertical_camera_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_vertical_camera_contract.py") - - add_test(NAME disruptor_far_rendering_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_far_rendering_contract.py") - - add_test(NAME disruptor_frame_kind_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py") - - add_test(NAME disruptor_temporal_presenter_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_temporal_presenter_contract.py") - - add_test(NAME disruptor_menu_wide_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_menu_wide_contract.py") - - add_test(NAME disruptor_hud_scale_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_scale_contract.py") - - add_test(NAME disruptor_hud_widget_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py") - - add_test(NAME disruptor_name_entry_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_name_entry_contract.py" - --require-artifacts) - set_tests_properties(disruptor_name_entry_contract - PROPERTIES LABELS retail) - - add_test(NAME disruptor_grown_quad_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_grown_quad_contract.py" - --require-artifacts) - - add_test(NAME disruptor_sprite_range_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_sprite_range_contract.py" - --require-artifacts) - set_tests_properties(disruptor_sprite_range_contract - PROPERTIES LABELS retail) - - add_test(NAME disruptor_intro_skip_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_intro_skip_contract.py" - --require-artifacts) - - add_test(NAME disruptor_language_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_language_contract.py" - --require-artifacts) - - add_test(NAME disruptor_language_disc_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_language_disc_contract.py" - --require-artifacts) - - add_test(NAME disruptor_frame_rate_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_rate_contract.py" - --require-artifacts) - - add_test(NAME disruptor_billboard_aspect_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_billboard_aspect_contract.py") - - add_test(NAME disruptor_launcher_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_launcher_contract.py") - - add_test(NAME disruptor_capsule_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_capsule_contract.py") - - add_test(NAME disruptor_gap_filling_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_gap_filling_contract.py") - - add_test(NAME disruptor_world_depth_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_world_depth_contract.py") - - add_test(NAME disruptor_smooth_turn_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_smooth_turn_contract.py") - - add_test(NAME disruptor_ambient_occlusion_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_ambient_occlusion_contract.py") - - add_test(NAME disruptor_debug_tab_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_debug_tab_contract.py") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/DisruptorTests.cmake") + + disruptor_contract(backdrop_tiles RETAIL) + disruptor_contract(depth24_sync) + disruptor_contract(geometry_provenance) + disruptor_contract(phase_a) + disruptor_contract(perspective_texture) + disruptor_contract(pinned_corner) + disruptor_contract(dev_menu) + disruptor_contract(loaded_state) + disruptor_contract(save_point) + disruptor_contract(cheats) + disruptor_contract(vertical_camera) + disruptor_contract(far_rendering) + disruptor_contract(frame_kind) + disruptor_contract(temporal_presenter) + disruptor_contract(menu_wide) + disruptor_contract(hud_scale) + disruptor_contract(hud_widget) + disruptor_contract(name_entry RETAIL) + disruptor_contract(grown_quad RETAIL) + disruptor_contract(sprite_range RETAIL) + disruptor_contract(intro_skip RETAIL) + disruptor_contract(language RETAIL) + disruptor_contract(language_disc RETAIL) + disruptor_contract(frame_rate RETAIL) + disruptor_contract(billboard_aspect) + disruptor_contract(launcher) + disruptor_contract(capsule) + disruptor_contract(gap_filling) + disruptor_contract(world_depth) + disruptor_contract(smooth_turn) + disruptor_contract(ambient_occlusion) + disruptor_contract(debug_tab) + disruptor_contract(test_registration) add_test(NAME disruptor_framework_overlay_new_file COMMAND "${Python3_EXECUTABLE}" -B "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_framework_overlay_new_file.py") - - # These need the retail image or the translated code. A machine without - # the game leaves them out with `ctest -LE retail`. - set_tests_properties( - disruptor_backdrop_tiles_contract disruptor_grown_quad_contract - disruptor_intro_skip_contract disruptor_language_contract - disruptor_language_disc_contract disruptor_frame_rate_contract - PROPERTIES LABELS retail) endif() if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) - add_executable(disruptor-user-settings-test - tests/unit/user_settings_persistence_test.cpp - "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" - "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp") - target_include_directories(disruptor-user-settings-test PRIVATE - "${PSXRECOMP_ROOT}/recompiler/src" - "${PSXRECOMP_ROOT}/recompiler/include" - "${PSXRECOMP_ROOT}/recompiler/lib/fmt/include" - "${PSXRECOMP_ROOT}/recompiler/lib/toml11") - target_compile_definitions(disruptor-user-settings-test PRIVATE - FMT_HEADER_ONLY=1 - "$<$:NOMINMAX>" - "$<$:WIN32_LEAN_AND_MEAN>") - target_compile_features(disruptor-user-settings-test PRIVATE cxx_std_20) - add_test(NAME disruptor_user_settings_persistence - COMMAND disruptor-user-settings-test) - - add_executable(disruptor-mouse-aim-test - tests/unit/disruptor_mouse_aim_test.cpp) - target_include_directories(disruptor-mouse-aim-test PRIVATE - tests/support/stubs) - target_compile_definitions(disruptor-mouse-aim-test PRIVATE PSX_SDL3=1) - target_compile_features(disruptor-mouse-aim-test PRIVATE cxx_std_20) - - add_executable(disruptor-cheats-test - tests/unit/disruptor_cheats_test.cpp) - target_include_directories(disruptor-cheats-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-cheats-test PRIVATE cxx_std_20) - - add_executable(disruptor-vertical-camera-test - tests/unit/disruptor_vertical_camera_test.cpp) - target_include_directories(disruptor-vertical-camera-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-vertical-camera-test PRIVATE cxx_std_20) - - add_executable(disruptor-far-rendering-test - tests/unit/disruptor_far_rendering_test.cpp) - target_include_directories(disruptor-far-rendering-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-far-rendering-test PRIVATE cxx_std_20) - - add_executable(disruptor-render-arena-test - tests/unit/disruptor_render_arena_test.cpp) - target_include_directories(disruptor-render-arena-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-render-arena-test PRIVATE cxx_std_20) - add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test) - - add_executable(disruptor-name-entry-test - tests/unit/disruptor_name_entry_test.cpp) - target_include_directories(disruptor-name-entry-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-name-entry-test PRIVATE cxx_std_20) - add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test) - - add_executable(disruptor-backdrop-tiles-test - tests/unit/disruptor_backdrop_tiles_test.cpp) - target_include_directories(disruptor-backdrop-tiles-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-backdrop-tiles-test PRIVATE cxx_std_20) - add_test(NAME disruptor_backdrop_tiles - COMMAND disruptor-backdrop-tiles-test) - - add_executable(disruptor-gpu-ws-frame-kind-test - tests/unit/gpu_ws_frame_kind_test.cpp) - target_include_directories(disruptor-gpu-ws-frame-kind-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-frame-kind-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_frame_kind - COMMAND disruptor-gpu-ws-frame-kind-test) - - add_executable(disruptor-gpu-ws-menu-test - tests/unit/gpu_ws_menu_test.cpp) - target_include_directories(disruptor-gpu-ws-menu-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-menu-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_menu - COMMAND disruptor-gpu-ws-menu-test) - - add_executable(disruptor-gpu-ws-hud-scale-test - tests/unit/gpu_ws_hud_scale_test.cpp) - target_include_directories(disruptor-gpu-ws-hud-scale-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-hud-scale-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_hud_scale - COMMAND disruptor-gpu-ws-hud-scale-test) - - add_executable(disruptor-gpu-ws-hud-widget-test - tests/unit/gpu_ws_hud_widget_test.cpp) - target_include_directories(disruptor-gpu-ws-hud-widget-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-hud-widget-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_hud_widget - COMMAND disruptor-gpu-ws-hud-widget-test) - - add_executable(disruptor-intro-skip-test - tests/unit/disruptor_intro_skip_test.cpp) - target_include_directories(disruptor-intro-skip-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-intro-skip-test PRIVATE cxx_std_20) - add_test(NAME disruptor_intro_skip - COMMAND disruptor-intro-skip-test) - - add_executable(disruptor-frame-rate-test - tests/unit/disruptor_frame_rate_test.cpp) - target_include_directories(disruptor-frame-rate-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-frame-rate-test PRIVATE cxx_std_20) - add_test(NAME disruptor_frame_rate - COMMAND disruptor-frame-rate-test) - - add_executable(disruptor-language-test - tests/unit/disruptor_language_test.cpp) - target_include_directories(disruptor-language-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-language-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language - COMMAND disruptor-language-test) - - add_executable(disruptor-language-hints-test - tests/unit/disruptor_language_hints_test.cpp) - target_include_directories(disruptor-language-hints-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-language-hints-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language_hints - COMMAND disruptor-language-hints-test) - - add_executable(disruptor-language-disc-test - tests/unit/disruptor_language_disc_test.cpp) - target_compile_features(disruptor-language-disc-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language_disc - COMMAND disruptor-language-disc-test) - - add_executable(disruptor-language-overlay-test - tests/unit/disruptor_language_overlay_test.cpp) - target_include_directories(disruptor-language-overlay-test PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp-overlay/runtime/include" - "${PSXRECOMP_ROOT}/runtime/include" - "${CMAKE_CURRENT_SOURCE_DIR}/src") - target_compile_features(disruptor-language-overlay-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language_overlay - COMMAND disruptor-language-overlay-test) - add_test(NAME disruptor_language_overlay_tall - COMMAND disruptor-language-overlay-test tall) - - add_executable(disruptor-capsule-test - tests/unit/disruptor_capsule_test.cpp) - target_compile_features(disruptor-capsule-test PRIVATE cxx_std_20) - target_include_directories(disruptor-capsule-test PRIVATE src) - add_test(NAME disruptor_capsule - COMMAND disruptor-capsule-test) - - add_executable(host-pick-slot-test - tests/unit/host_pick_slot_test.cpp) - target_compile_features(host-pick-slot-test PRIVATE cxx_std_20) - add_test(NAME host_pick_slot - COMMAND host-pick-slot-test) - - add_executable(host-borderless-test - tests/unit/host_borderless_test.cpp) - target_compile_features(host-borderless-test PRIVATE cxx_std_20) - add_test(NAME host_borderless - COMMAND host-borderless-test) - add_test(NAME host_borderless_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py") - - add_executable(disruptor-restart-test - tests/unit/disruptor_restart_test.cpp) - target_include_directories(disruptor-restart-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-restart-test PRIVATE cxx_std_20) - add_test(NAME disruptor_restart - COMMAND disruptor-restart-test run "${CMAKE_CURRENT_BINARY_DIR}/disruptor_restart_test.txt") - - add_executable(disruptor-present-rate-test - tests/unit/disruptor_present_rate_test.cpp) - target_include_directories(disruptor-present-rate-test PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/tests/support/present_rate_fakes") - target_compile_features(disruptor-present-rate-test PRIVATE cxx_std_20) - add_test(NAME disruptor_present_rate - COMMAND disruptor-present-rate-test) - - add_executable(disruptor-grown-quad-test - tests/unit/disruptor_grown_quad_test.cpp) - target_include_directories(disruptor-grown-quad-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-grown-quad-test PRIVATE cxx_std_20) - add_test(NAME disruptor_grown_quad - COMMAND disruptor-grown-quad-test) - - add_executable(disruptor-sprite-depth-test - tests/unit/disruptor_sprite_depth_test.cpp) - target_include_directories(disruptor-sprite-depth-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-sprite-depth-test PRIVATE cxx_std_20) - add_test(NAME disruptor_sprite_depth - COMMAND disruptor-sprite-depth-test) - - add_executable(disruptor-billboard-aspect-test - tests/unit/disruptor_billboard_aspect_test.cpp) - target_include_directories(disruptor-billboard-aspect-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-billboard-aspect-test PRIVATE cxx_std_20) - - add_executable(disruptor-gpu-temporal-sprite-test - tests/unit/gpu_temporal_sprite_test.cpp) - target_include_directories(disruptor-gpu-temporal-sprite-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-temporal-sprite-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_temporal_sprite - COMMAND disruptor-gpu-temporal-sprite-test) - - add_executable(disruptor-gpu-ws-tag-match-test - tests/unit/gpu_ws_tag_match_test.cpp) - target_include_directories(disruptor-gpu-ws-tag-match-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-tag-match-test PRIVATE cxx_std_20) - - add_executable(disruptor-gpu-temporal-match-test - tests/unit/gpu_temporal_match_test.cpp) - target_include_directories(disruptor-gpu-temporal-match-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-temporal-match-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_temporal_match - COMMAND disruptor-gpu-temporal-match-test) - - add_executable(disruptor-gpu-temporal-mesh-test - tests/unit/gpu_temporal_mesh_test.cpp) - target_include_directories(disruptor-gpu-temporal-mesh-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-temporal-mesh-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_temporal_mesh - COMMAND disruptor-gpu-temporal-mesh-test) - - add_executable(disruptor-gpu-pinned-corner-test - tests/unit/gpu_pinned_corner_test.cpp) - target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_pinned_corner - COMMAND disruptor-gpu-pinned-corner-test) - - add_executable(disruptor-gpu-turned-perspective-test - tests/unit/gpu_turned_perspective_test.cpp) - target_include_directories(disruptor-gpu-turned-perspective-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-turned-perspective-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_turned_perspective - COMMAND disruptor-gpu-turned-perspective-test) - - add_executable(disruptor-gpu-world-depth-test - tests/unit/gpu_world_depth_test.cpp) - target_include_directories(disruptor-gpu-world-depth-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-world-depth-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_world_depth - COMMAND disruptor-gpu-world-depth-test) - - add_executable(disruptor-save-point-test - tests/unit/save_point_test.cpp) - target_include_directories(disruptor-save-point-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-save-point-test PRIVATE cxx_std_20) - add_test(NAME disruptor_save_point - COMMAND disruptor-save-point-test) - - add_executable(disruptor-presentation-yaw-math-test - tests/unit/presentation_yaw_math_test.cpp) - target_compile_features(disruptor-presentation-yaw-math-test PRIVATE cxx_std_20) - - add_executable(disruptor-gte-precision-store-pc-test - tests/unit/gte_precision_store_pc_test.cpp - "${PSXRECOMP_ROOT}/runtime/src/gte.cpp") - target_include_directories(disruptor-gte-precision-store-pc-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_definitions(disruptor-gte-precision-store-pc-test PRIVATE - PSX_NO_DEBUG_TOOLS=1 - PSX_GTE_REGISTER_TEST=1 - PSX_ENABLE_BLOCK_CYCLES=1) - target_compile_features(disruptor-gte-precision-store-pc-test PRIVATE - cxx_std_20) - - add_test(NAME disruptor_mouse_aim - COMMAND disruptor-mouse-aim-test) - add_test(NAME disruptor_cheats - COMMAND disruptor-cheats-test) - add_test(NAME disruptor_vertical_camera - COMMAND disruptor-vertical-camera-test) - add_test(NAME disruptor_far_rendering - COMMAND disruptor-far-rendering-test) - add_test(NAME disruptor_billboard_aspect - COMMAND disruptor-billboard-aspect-test) - add_test(NAME disruptor_gpu_ws_tag_match - COMMAND disruptor-gpu-ws-tag-match-test) + disruptor_unit_test(disruptor_user_settings_persistence tests/unit/user_settings_persistence_test.cpp + TARGET disruptor-user-settings-test + SOURCES + "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" + "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp" + INCLUDES + "${PSXRECOMP_ROOT}/recompiler/src" + "${PSXRECOMP_ROOT}/recompiler/include" + "${PSXRECOMP_ROOT}/recompiler/lib/fmt/include" + "${PSXRECOMP_ROOT}/recompiler/lib/toml11" + DEFINITIONS + FMT_HEADER_ONLY=1 + "$<$:NOMINMAX>" + "$<$:WIN32_LEAN_AND_MEAN>") + + disruptor_unit_test(disruptor_mouse_aim tests/unit/disruptor_mouse_aim_test.cpp + INCLUDES tests/support/stubs + DEFINITIONS PSX_SDL3=1) set_tests_properties(disruptor_mouse_aim PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_MOUSE_AIM=;PSX_DISRUPTOR_VERTICAL_LOOK=;PSX_DISRUPTOR_MODERN_CONTROLS=;PSX_DISRUPTOR_HIGH_PRECISION_CAMERA=") - add_test(NAME disruptor_presentation_yaw_math - COMMAND disruptor-presentation-yaw-math-test) - - add_test(NAME disruptor_gte_precision_store_pc - COMMAND disruptor-gte-precision-store-pc-test) - add_test(NAME disruptor_mouse_aim_registration COMMAND disruptor-mouse-aim-test --registration) set_tests_properties(disruptor_mouse_aim_registration PROPERTIES @@ -658,4 +261,54 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-mouse-aim-test --registration) set_tests_properties(disruptor_high_precision_camera_registration PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_MOUSE_AIM=;PSX_DISRUPTOR_VERTICAL_LOOK=;PSX_DISRUPTOR_MODERN_CONTROLS=;PSX_DISRUPTOR_HIGH_PRECISION_CAMERA=1") + + disruptor_unit_test(disruptor_cheats tests/unit/disruptor_cheats_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_vertical_camera tests/unit/disruptor_vertical_camera_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_far_rendering tests/unit/disruptor_far_rendering_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_render_arena tests/unit/disruptor_render_arena_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_name_entry tests/unit/disruptor_name_entry_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_backdrop_tiles tests/unit/disruptor_backdrop_tiles_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_intro_skip tests/unit/disruptor_intro_skip_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_frame_rate tests/unit/disruptor_frame_rate_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_language tests/unit/disruptor_language_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_language_hints tests/unit/disruptor_language_hints_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_language_disc tests/unit/disruptor_language_disc_test.cpp) + disruptor_unit_test(disruptor_language_overlay tests/unit/disruptor_language_overlay_test.cpp + INCLUDES + "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp-overlay/runtime/include" + "${PSXRECOMP_ROOT}/runtime/include" + "${CMAKE_CURRENT_SOURCE_DIR}/src") + add_test(NAME disruptor_language_overlay_tall + COMMAND disruptor-language-overlay-test tall) + disruptor_unit_test(disruptor_capsule tests/unit/disruptor_capsule_test.cpp INCLUDES src) + disruptor_unit_test(disruptor_restart tests/unit/disruptor_restart_test.cpp RUNTIME_HEADERS + ARGUMENTS run "${CMAKE_CURRENT_BINARY_DIR}/disruptor_restart_test.txt") + disruptor_unit_test(disruptor_present_rate tests/unit/disruptor_present_rate_test.cpp + INCLUDES "${CMAKE_CURRENT_SOURCE_DIR}/tests/support/present_rate_fakes") + disruptor_unit_test(disruptor_grown_quad tests/unit/disruptor_grown_quad_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_sprite_depth tests/unit/disruptor_sprite_depth_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_billboard_aspect tests/unit/disruptor_billboard_aspect_test.cpp RUNTIME_HEADERS) + + disruptor_unit_test(disruptor_gpu_ws_frame_kind tests/unit/gpu_ws_frame_kind_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_menu tests/unit/gpu_ws_menu_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_hud_scale tests/unit/gpu_ws_hud_scale_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_hud_widget tests/unit/gpu_ws_hud_widget_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_tag_match tests/unit/gpu_ws_tag_match_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_temporal_sprite tests/unit/gpu_temporal_sprite_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_temporal_match tests/unit/gpu_temporal_match_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_temporal_mesh tests/unit/gpu_temporal_mesh_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_pinned_corner tests/unit/gpu_pinned_corner_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_turned_perspective tests/unit/gpu_turned_perspective_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_world_depth tests/unit/gpu_world_depth_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_save_point tests/unit/save_point_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_presentation_yaw_math tests/unit/presentation_yaw_math_test.cpp) + disruptor_unit_test(disruptor_gte_precision_store_pc tests/unit/gte_precision_store_pc_test.cpp RUNTIME_HEADERS + SOURCES "${PSXRECOMP_ROOT}/runtime/src/gte.cpp" + DEFINITIONS PSX_NO_DEBUG_TOOLS=1 PSX_GTE_REGISTER_TEST=1 PSX_ENABLE_BLOCK_CYCLES=1) + + disruptor_unit_test(host_pick_slot tests/unit/host_pick_slot_test.cpp) + disruptor_unit_test(host_borderless tests/unit/host_borderless_test.cpp) + add_test(NAME host_borderless_contract + COMMAND "${Python3_EXECUTABLE}" -B + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py") endif() diff --git a/cmake/DisruptorTests.cmake b/cmake/DisruptorTests.cmake new file mode 100644 index 0000000..bd5391a --- /dev/null +++ b/cmake/DisruptorTests.cmake @@ -0,0 +1,56 @@ +# disruptor_contract( [RETAIL]) +# +# Registers tests/contracts/test__contract.py as the test disruptor__contract. +# RETAIL: the contract needs the retail image or the translated code. It is passed +# --require-artifacts and labelled so that `ctest -LE retail` leaves it out. +function(disruptor_contract name) + cmake_parse_arguments(PARSE_ARGV 1 CONTRACT "RETAIL" "" "") + if(CONTRACT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_contract(${name}): unknown arguments ${CONTRACT_UNPARSED_ARGUMENTS}") + endif() + set(script "${PROJECT_SOURCE_DIR}/tests/contracts/test_${name}_contract.py") + if(NOT EXISTS "${script}") + message(FATAL_ERROR "disruptor_contract(${name}): ${script} does not exist") + endif() + set(arguments "") + if(CONTRACT_RETAIL) + set(arguments --require-artifacts) + endif() + add_test(NAME disruptor_${name}_contract + COMMAND "${Python3_EXECUTABLE}" -B "${script}" ${arguments}) + if(CONTRACT_RETAIL) + set_tests_properties(disruptor_${name}_contract PROPERTIES LABELS retail) + endif() +endfunction() + +# disruptor_unit_test( [TARGET ] [RUNTIME_HEADERS] +# [SOURCES ...] [INCLUDES ...] [DEFINITIONS ...] +# [ARGUMENTS ...]) +# +# Builds one C++20 executable from , a path from the project's root, and registers +# it as the test . The executable is named after the test, disruptor_far_rendering +# giving disruptor-far-rendering-test, unless TARGET names it. +# RUNTIME_HEADERS puts the framework's runtime headers on its include path. +function(disruptor_unit_test test source) + cmake_parse_arguments(PARSE_ARGV 2 UNIT "RUNTIME_HEADERS" "TARGET" + "SOURCES;INCLUDES;DEFINITIONS;ARGUMENTS") + if(UNIT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_unit_test(${test}): unknown arguments ${UNIT_UNPARSED_ARGUMENTS}") + endif() + set(target "${UNIT_TARGET}") + if(NOT target) + string(REPLACE "_" "-" target "${test}-test") + endif() + add_executable(${target} "${PROJECT_SOURCE_DIR}/${source}" ${UNIT_SOURCES}) + if(UNIT_RUNTIME_HEADERS) + target_include_directories(${target} PRIVATE "${PSXRECOMP_ROOT}/runtime/include") + endif() + if(UNIT_INCLUDES) + target_include_directories(${target} PRIVATE ${UNIT_INCLUDES}) + endif() + if(UNIT_DEFINITIONS) + target_compile_definitions(${target} PRIVATE ${UNIT_DEFINITIONS}) + endif() + target_compile_features(${target} PRIVATE cxx_std_20) + add_test(NAME ${test} COMMAND ${target} ${UNIT_ARGUMENTS}) +endfunction() diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index ca1999b..1dcbe82 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -27,7 +27,7 @@ def between(source: str, opening: str, closing: str) -> str: loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") -require(" add_test(NAME disruptor_ambient_occlusion_contract\n" in cmake, "this contract is registered") +require(" disruptor_contract(ambient_occlusion)\n" in cmake, "this contract is registered") require( '" v_deep = abs(a_camz) > 16.0 ? 1.0 : -1000.0;\\n"' in renderer diff --git a/tests/contracts/test_backdrop_tiles_contract.py b/tests/contracts/test_backdrop_tiles_contract.py index 565765d..a011091 100644 --- a/tests/contracts/test_backdrop_tiles_contract.py +++ b/tests/contracts/test_backdrop_tiles_contract.py @@ -62,12 +62,12 @@ def require(condition: bool, message: str) -> None: require( "src/game/render/backdrop_tiles.cpp" in cmake - and "disruptor_backdrop_tiles_contract" in cmake - and "add_test(NAME disruptor_backdrop_tiles\n" in cmake, + and " disruptor_contract(backdrop_tiles RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_backdrop_tiles " in cmake, "backdrop tile source, its unit test and its contract test must remain registered", ) require( - 'tests/contracts/test_backdrop_tiles_contract.py"\n --require-artifacts)' in cmake, + " disruptor_contract(backdrop_tiles RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) require( diff --git a/tests/contracts/test_borderless_contract.py b/tests/contracts/test_borderless_contract.py index ef8bed5..e2202fa 100644 --- a/tests/contracts/test_borderless_contract.py +++ b/tests/contracts/test_borderless_contract.py @@ -60,8 +60,9 @@ def require(condition: bool, message: str) -> None: ) require( "+runtime/include/host_borderless.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() - and " add_test(NAME host_borderless\n COMMAND host-borderless-test)\n" in cmake - and 'tests/contracts/test_borderless_contract.py")' in cmake, + and " disruptor_unit_test(host_borderless " in cmake + and " add_test(NAME host_borderless_contract\n" in cmake + and '/tests/contracts/test_borderless_contract.py")' in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/contracts/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py index 73d28ed..63e2b09 100644 --- a/tests/contracts/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -132,8 +132,8 @@ def require(condition: bool, message: str) -> None: and " PSX_HAS_DISRUPTOR_CAPSULE=1\n" in cmake and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/game/session/capsule.cpp"\n' in read("tests/unit/disruptor_capsule_test.cpp") - and " add_test(NAME disruptor_capsule\n COMMAND disruptor-capsule-test)\n" in cmake - and 'tests/contracts/test_capsule_contract.py")' in cmake, + and " disruptor_unit_test(disruptor_capsule " in cmake + and " disruptor_contract(capsule)\n" in cmake, "the capsule and its tests must remain built and registered", ) require( diff --git a/tests/contracts/test_cheats_contract.py b/tests/contracts/test_cheats_contract.py index f7019b4..7723aff 100644 --- a/tests/contracts/test_cheats_contract.py +++ b/tests/contracts/test_cheats_contract.py @@ -29,7 +29,7 @@ def require(condition: bool, message: str) -> None: wide = read("game-widescreen.toml") require("disruptor_cheats_test.cpp" in cmake and - "add_test(NAME disruptor_cheats" in cmake, + " disruptor_unit_test(disruptor_cheats " in cmake, "behavioral cheat test must remain registered") for config, name in ((game, "game.toml"), (wide, "game-widescreen.toml")): entry_match = re.search( diff --git a/tests/contracts/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py index 521a958..d097fe5 100644 --- a/tests/contracts/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -26,7 +26,7 @@ def between(source: str, opening: str, closing: str) -> str: loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") -require(" add_test(NAME disruptor_debug_tab_contract\n" in cmake, "this contract is registered") +require(" disruptor_contract(debug_tab)\n" in cmake, "this contract is registered") debug = between(menu, "void draw_debug_tab() {", "void draw_cheats_tab() {") require( diff --git a/tests/contracts/test_depth24_sync_contract.py b/tests/contracts/test_depth24_sync_contract.py index f33e4a5..69d0d20 100644 --- a/tests/contracts/test_depth24_sync_contract.py +++ b/tests/contracts/test_depth24_sync_contract.py @@ -38,10 +38,7 @@ def enclosing(source: str, block: str) -> list[str]: gpu = read("psxrecomp-overlay/runtime/src/gpu.c") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -REGISTRATION = ( - ' add_test(NAME disruptor_depth24_sync_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_depth24_sync_contract.py")\n' -) +REGISTRATION = " disruptor_contract(depth24_sync)\n" require( cmake.count(REGISTRATION) == 1 and enclosing(cmake, REGISTRATION) == ["if(BUILD_TESTING)"], "this contract must stay registered as written, under the condition every contract of the repository is under", diff --git a/tests/contracts/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py index 013e9a4..78dae0a 100644 --- a/tests/contracts/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -30,7 +30,7 @@ def require(condition: bool, message: str) -> None: require("src/game/render/far_rendering.cpp" in cmake and "tests/unit/disruptor_far_rendering_test.cpp" in cmake and - "add_test(NAME disruptor_far_rendering" in cmake, + " disruptor_unit_test(disruptor_far_rendering " in cmake, "runtime source and focused behavior test must remain enabled") require("PSX_HAS_DISRUPTOR_FAR_RENDERING=1" in cmake, "the Disruptor target must opt into dirty-interpreter far hooks") diff --git a/tests/contracts/test_frame_kind_contract.py b/tests/contracts/test_frame_kind_contract.py index c7fd7e1..01475e3 100644 --- a/tests/contracts/test_frame_kind_contract.py +++ b/tests/contracts/test_frame_kind_contract.py @@ -48,12 +48,9 @@ def enclosing(source: str, block: str) -> list[str]: UNIT_TESTS = ["if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)"] REGISTERED = { ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/render_arena.cpp"\n': [], - ' add_test(NAME disruptor_frame_kind_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py")\n': CONTRACTS, - " add_executable(disruptor-gpu-ws-frame-kind-test\n tests/unit/gpu_ws_frame_kind_test.cpp)\n": UNIT_TESTS, - " add_test(NAME disruptor_gpu_ws_frame_kind\n COMMAND disruptor-gpu-ws-frame-kind-test)\n": UNIT_TESTS, - " add_executable(disruptor-render-arena-test\n tests/unit/disruptor_render_arena_test.cpp)\n": UNIT_TESTS, - " add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test)\n": UNIT_TESTS, + " disruptor_contract(frame_kind)\n": CONTRACTS, + " disruptor_unit_test(disruptor_gpu_ws_frame_kind tests/unit/gpu_ws_frame_kind_test.cpp RUNTIME_HEADERS)\n": UNIT_TESTS, + " disruptor_unit_test(disruptor_render_arena tests/unit/disruptor_render_arena_test.cpp RUNTIME_HEADERS)\n": UNIT_TESTS, } for block, conditions in REGISTERED.items(): require( diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index fe4af39..1d63952 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -51,13 +51,13 @@ def require(condition: bool, message: str) -> None: cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") require( - 'tests/contracts/test_frame_rate_contract.py"\n --require-artifacts)' in cmake, + " disruptor_contract(frame_rate RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) require( "src/game/frame/frame_rate.cpp" in cmake - and "disruptor_frame_rate_contract" in cmake - and "add_test(NAME disruptor_frame_rate\n" in cmake, + and " disruptor_contract(frame_rate RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_frame_rate " in cmake, "frame-rate source, its unit test and its contract test must remain registered", ) @@ -166,7 +166,7 @@ def require(condition: bool, message: str) -> None: renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") host = read("psxrecomp-overlay/runtime/src/main.cpp") require( - "add_test(NAME disruptor_present_rate\n" in cmake + " disruptor_unit_test(disruptor_present_rate " in cmake and "constexpr int kDisruptorPresentRateLowest = 30;" in present_rate and "constexpr int kDisruptorPresentRateLeastTop = 120;" in present_rate, "the in-between frame rate control must run from 30 FPS to at least 120 and keep its unit test", diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index b4e4aff..5d61efb 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -26,7 +26,7 @@ def between(source: str, opening: str, closing: str) -> str: launcher = read("src/launcher/launcher_settings.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -require(" add_test(NAME disruptor_gap_filling_contract\n" in cmake, "this contract is registered") +require(" disruptor_contract(gap_filling)\n" in cmake, "this contract is registered") require( " const int filler = s_grown_under && exact && s_next_world == 2;\n" in renderer diff --git a/tests/contracts/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py index 2c9fb38..d2f59d8 100644 --- a/tests/contracts/test_grown_quad_contract.py +++ b/tests/contracts/test_grown_quad_contract.py @@ -42,8 +42,8 @@ def require(condition: bool, message: str) -> None: require( '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp"' in cmake and "PSX_HAS_DISRUPTOR_GROWN_QUAD=1" in cmake - and "add_test(NAME disruptor_grown_quad\n" in cmake - and 'tests/contracts/test_grown_quad_contract.py"\n --require-artifacts)' in cmake, + and " disruptor_unit_test(disruptor_grown_quad " in cmake + and " disruptor_contract(grown_quad RETAIL)\n" in cmake, "the grown quad source, its renderer switch, its unit test and this contract with its input must stay registered", ) require( diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index 2dbc67c..ab2d45c 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -28,7 +28,7 @@ def require(condition: bool, message: str) -> None: "overlay manifest must list the HUD scale arithmetic", ) require( - "add_test(NAME disruptor_gpu_ws_hud_scale\n" in cmake and "disruptor_hud_scale_contract" in cmake, + " disruptor_unit_test(disruptor_gpu_ws_hud_scale " in cmake and " disruptor_contract(hud_scale)\n" in cmake, "the HUD scale unit test and its contract must remain registered", ) require( diff --git a/tests/contracts/test_hud_widget_contract.py b/tests/contracts/test_hud_widget_contract.py index 178a42e..9bacf9e 100644 --- a/tests/contracts/test_hud_widget_contract.py +++ b/tests/contracts/test_hud_widget_contract.py @@ -31,10 +31,8 @@ def body(source: str, signature: str) -> str: "overlay manifest must list the HUD widget arithmetic", ) require( - " add_executable(disruptor-gpu-ws-hud-widget-test\n tests/unit/gpu_ws_hud_widget_test.cpp)\n" in cmake - and " add_test(NAME disruptor_gpu_ws_hud_widget\n COMMAND disruptor-gpu-ws-hud-widget-test)\n" in cmake - and ' add_test(NAME disruptor_hud_widget_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py")\n' in cmake + " disruptor_unit_test(disruptor_gpu_ws_hud_widget tests/unit/gpu_ws_hud_widget_test.cpp RUNTIME_HEADERS)\n" in cmake + and " disruptor_contract(hud_widget)\n" in cmake and '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/hud_widgets.cpp"' in cmake, "the widget unit test, its contract and the game's widget list must remain in the build", ) diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index f1b839a..d66bca2 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -56,13 +56,13 @@ def require(condition: bool, message: str) -> None: require( "src/game/session/intro_skip.cpp" in cmake and "PSX_HAS_DISRUPTOR_INTRO_SKIP=1" in cmake - and "disruptor_intro_skip_contract" in cmake - and "add_test(NAME disruptor_intro_skip\n" in cmake, + and " disruptor_contract(intro_skip RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_intro_skip " in cmake, "intro-skip source, its interpreter switch and both tests must remain registered", ) require( - 'tests/contracts/test_intro_skip_contract.py"\n --require-artifacts)' in cmake, + " disruptor_contract(intro_skip RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) for address, word in SEAMS.items(): diff --git a/tests/contracts/test_language_contract.py b/tests/contracts/test_language_contract.py index a99d38a..0baa532 100644 --- a/tests/contracts/test_language_contract.py +++ b/tests/contracts/test_language_contract.py @@ -94,8 +94,8 @@ def require(condition: bool, message: str) -> None: require( '/src/game/language/language.cpp"' in cmake - and "add_test(NAME disruptor_language\n" in cmake - and 'tests/contracts/test_language_contract.py"\n --require-artifacts)' in cmake, + and " disruptor_unit_test(disruptor_language " in cmake + and " disruptor_contract(language RETAIL)\n" in cmake, "the language source and both of its tests must remain registered, the contract with its inputs required", ) require( @@ -193,7 +193,7 @@ def require(condition: bool, message: str) -> None: ) require( '/src/game/language/language_hints.cpp"' in cmake - and "add_test(NAME disruptor_language_hints\n" in cmake + and " disruptor_unit_test(disruptor_language_hints " in cmake and "constexpr uint32_t kRead = 0x80011888u, kLoadImage = 0x8004D348u, kMoveImage = 0x8004D410u;" in hints and "constexpr uint32_t kReadReturn = 0x80020928u, kStageReturn = 0x80020954u;" in hints and "constexpr std::array kMoves = {{{0x80043508u, 0xA0u}, {0x80043518u, 0x190u}}};" in hints diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index ea70e19..581ff28 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -82,11 +82,10 @@ def require(condition: bool, message: str) -> None: require( '/src/game/language/language_disc.cpp"' in cmake and '/src/game/language/language_source.cpp"' in cmake - and "add_test(NAME disruptor_language_disc\n" in cmake - and "add_test(NAME disruptor_language_overlay\n" in cmake + and " disruptor_unit_test(disruptor_language_disc " in cmake + and " disruptor_unit_test(disruptor_language_overlay tests/unit/disruptor_language_overlay_test.cpp\n" in cmake and "add_test(NAME disruptor_language_overlay_tall\n COMMAND disruptor-language-overlay-test tall)" in cmake - and " tests/unit/disruptor_language_overlay_test.cpp)" in cmake - and 'tests/contracts/test_language_disc_contract.py"\n --require-artifacts)' in cmake, + and " disruptor_contract(language_disc RETAIL)\n" in cmake, "the language disc sources and their three tests must remain registered, the contract with its inputs required", ) require( @@ -343,7 +342,7 @@ def require(condition: bool, message: str) -> None: and " return s_disc_pick.take(out, size);" in host and " if (state_ != State::kNone) return false;\n state_ = State::kOpen;" in pick_slot and " if (state_ != State::kClosed) return 0;\n state_ = State::kNone;" in pick_slot - and "add_test(NAME host_pick_slot\n" in cmake + and " disruptor_unit_test(host_pick_slot " in cmake and "CLSID_FileOpenDialog" in host and "FOS_NOCHANGEDIR" in host and "GetOpenFileName" not in host.replace("IFileOpenDialog, not GetOpenFileName:", "") @@ -354,7 +353,7 @@ def require(condition: bool, message: str) -> None: and ' if (ImGui::Button("Restart now") && !psx_netplay_active() && flush_preferences() && disruptor_restart_arm()) {' in menu and " quit.type = SDL_QUIT;\n SDL_PushEvent(&quit);" in menu and '/src/game/session/restart.cpp"' in cmake - and "add_test(NAME disruptor_restart\n" in cmake + and " disruptor_unit_test(disruptor_restart " in cmake and " if (!before) return GetLastError() == ERROR_INVALID_PARAMETER;" in restart and " return waited == WAIT_OBJECT_0;" in restart and " if (!earlier_game_ended(std::strtoul(earlier, nullptr, 10), kLongestWait)) {" in restart diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index c9d187d..6f2fc11 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -155,7 +155,7 @@ def plain_values(text: str) -> dict[str, str]: "add_test(NAME disruptor_launcher_settings COMMAND disruptor-launcher-settings-test)" in launcher_cmake and "add_test(NAME disruptor_launcher_flow COMMAND disruptor-launcher-flow-test)" in launcher_cmake and "add_test(NAME disruptor_region_disc COMMAND disruptor-region-disc-test)" in launcher_cmake - and "add_test(NAME disruptor_launcher_contract" in cmake, + and " disruptor_contract(launcher)\n" in cmake, "the launcher's tests must remain registered", ) diff --git a/tests/contracts/test_menu_wide_contract.py b/tests/contracts/test_menu_wide_contract.py index 31aa9ed..33bd4dd 100644 --- a/tests/contracts/test_menu_wide_contract.py +++ b/tests/contracts/test_menu_wide_contract.py @@ -194,8 +194,8 @@ def require(condition: bool, message: str) -> None: ) require( "+runtime/include/gpu_ws_menu.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() - and " add_test(NAME disruptor_gpu_ws_menu\n COMMAND disruptor-gpu-ws-menu-test)\n" in cmake - and 'tests/contracts/test_menu_wide_contract.py")' in cmake, + and " disruptor_unit_test(disruptor_gpu_ws_menu " in cmake + and " disruptor_contract(menu_wide)\n" in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/contracts/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py index 3f68dc6..ccb38e7 100644 --- a/tests/contracts/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -99,10 +99,8 @@ def require(condition: bool, message: str) -> None: require( ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_NAME_ENTRY=1\n" in cmake - and " add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test)\n" in cmake - and 'tests/contracts/test_name_entry_contract.py"\n --require-artifacts)\n' - " set_tests_properties(disruptor_name_entry_contract\n PROPERTIES LABELS retail)\n" - in cmake, + and " disruptor_unit_test(disruptor_name_entry " in cmake + and " disruptor_contract(name_entry RETAIL)\n" in cmake, "the module, its switch for the host, its unit test and this contract with its input must stay registered", ) require( diff --git a/tests/contracts/test_pinned_corner_contract.py b/tests/contracts/test_pinned_corner_contract.py index 0fa9432..8defb02 100644 --- a/tests/contracts/test_pinned_corner_contract.py +++ b/tests/contracts/test_pinned_corner_contract.py @@ -50,17 +50,8 @@ def enclosing(source: str, block: str) -> list[str]: temporal = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") mesh = read("psxrecomp-overlay/runtime/include/gpu_temporal_mesh.h") -REGISTRATION = ( - ' add_test(NAME disruptor_pinned_corner_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_pinned_corner_contract.py")\n' -) -UNIT_TEST = ( - " add_executable(disruptor-gpu-pinned-corner-test\n tests/unit/gpu_pinned_corner_test.cpp)\n" - " target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE\n" - ' "${PSXRECOMP_ROOT}/runtime/include")\n' - " target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20)\n" - " add_test(NAME disruptor_gpu_pinned_corner\n COMMAND disruptor-gpu-pinned-corner-test)\n" -) +REGISTRATION = " disruptor_contract(pinned_corner)\n" +UNIT_TEST = " disruptor_unit_test(disruptor_gpu_pinned_corner tests/unit/gpu_pinned_corner_test.cpp RUNTIME_HEADERS)\n" require( cmake.count(REGISTRATION) == 1 and enclosing(cmake, REGISTRATION) == ["if(BUILD_TESTING)"], "this contract must stay registered as written, under the condition every contract of the repository is under", diff --git a/tests/contracts/test_save_point_contract.py b/tests/contracts/test_save_point_contract.py index fdaff40..a7cc45d 100644 --- a/tests/contracts/test_save_point_contract.py +++ b/tests/contracts/test_save_point_contract.py @@ -37,8 +37,8 @@ def polls_are_the_four(source: str) -> bool: rule = read("psxrecomp-overlay/runtime/include/save_point.h") require( - " add_test(NAME disruptor_save_point_contract\n" in cmake - and " add_test(NAME disruptor_save_point\n COMMAND disruptor-save-point-test)\n" in cmake, + " disruptor_contract(save_point)\n" in cmake + and " disruptor_unit_test(disruptor_save_point " in cmake, "this contract and the rule's unit test are registered", ) require( diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index 08106be..fcae32e 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -31,9 +31,9 @@ def between(source: str, opening: str, closing: str) -> str: require( overlay.count("+runtime/include/gpu_turned_perspective.h\n") == 1 and '#include "gpu_turned_perspective.h"\n' in renderer - and " add_executable(disruptor-gpu-turned-perspective-test\n tests/unit/gpu_turned_perspective_test.cpp)\n" in cmake - and " add_test(NAME disruptor_gpu_turned_perspective\n COMMAND disruptor-gpu-turned-perspective-test)\n" in cmake - and " add_test(NAME disruptor_smooth_turn_contract\n" in cmake, + and " disruptor_unit_test(disruptor_gpu_turned_perspective tests/unit/gpu_turned_perspective_test.cpp RUNTIME_HEADERS)\n" + in cmake + and " disruptor_contract(smooth_turn)\n" in cmake, "the turned perspective's header is applied to the framework and its test and this contract are registered", ) diff --git a/tests/contracts/test_sprite_range_contract.py b/tests/contracts/test_sprite_range_contract.py index 2fe9737..997114e 100644 --- a/tests/contracts/test_sprite_range_contract.py +++ b/tests/contracts/test_sprite_range_contract.py @@ -187,9 +187,8 @@ def require(condition: bool, message: str) -> None: gte = read("psxrecomp-overlay/runtime/src/gte.cpp") require( - 'tests/contracts/test_sprite_range_contract.py"\n --require-artifacts)' in cmake - and " set_tests_properties(disruptor_sprite_range_contract\n PROPERTIES LABELS retail)\n" in cmake - and " add_test(NAME disruptor_sprite_depth\n" in cmake, + " disruptor_contract(sprite_range RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_sprite_depth " in cmake, "this contract with its input and the unit test of the range must stay registered", ) require( diff --git a/tests/contracts/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py index db27fa2..9f77a83 100644 --- a/tests/contracts/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -42,7 +42,7 @@ def shader(source: str, name: str) -> str: billboards = read("src/game/render/billboard_aspect.cpp") require( - "disruptor_temporal_presenter_contract" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, + " disruptor_contract(temporal_presenter)\n" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, "the presenter contract and the sprite-depth interpreter switch must remain registered", ) diff --git a/tests/contracts/test_test_registration_contract.py b/tests/contracts/test_test_registration_contract.py new file mode 100644 index 0000000..3687686 --- /dev/null +++ b/tests/contracts/test_test_registration_contract.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Source contract: every contract and every unit test in the tree is registered once, through the two helper functions. + +A contract or a test file that nothing registers is never run, and nothing else would say so. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +ARTIFACTS = "--require-" + "artifacts" +EXPLICIT = { + "test_borderless_contract.py": "host_borderless_contract", + "test_framework_overlay_new_file.py": "disruptor_framework_overlay_new_file", +} +CONTRACT_HELPER = """function(disruptor_contract name) + cmake_parse_arguments(PARSE_ARGV 1 CONTRACT "RETAIL" "" "") + if(CONTRACT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_contract(${name}): unknown arguments ${CONTRACT_UNPARSED_ARGUMENTS}") + endif() + set(script "${PROJECT_SOURCE_DIR}/tests/contracts/test_${name}_contract.py") + if(NOT EXISTS "${script}") + message(FATAL_ERROR "disruptor_contract(${name}): ${script} does not exist") + endif() + set(arguments "") + if(CONTRACT_RETAIL) + set(arguments --require-artifacts) + endif() + add_test(NAME disruptor_${name}_contract + COMMAND "${Python3_EXECUTABLE}" -B "${script}" ${arguments}) + if(CONTRACT_RETAIL) + set_tests_properties(disruptor_${name}_contract PROPERTIES LABELS retail) + endif() +endfunction() +""" +UNIT_HELPER = """function(disruptor_unit_test test source) + cmake_parse_arguments(PARSE_ARGV 2 UNIT "RUNTIME_HEADERS" "TARGET" + "SOURCES;INCLUDES;DEFINITIONS;ARGUMENTS") + if(UNIT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_unit_test(${test}): unknown arguments ${UNIT_UNPARSED_ARGUMENTS}") + endif() + set(target "${UNIT_TARGET}") + if(NOT target) + string(REPLACE "_" "-" target "${test}-test") + endif() + add_executable(${target} "${PROJECT_SOURCE_DIR}/${source}" ${UNIT_SOURCES}) + if(UNIT_RUNTIME_HEADERS) + target_include_directories(${target} PRIVATE "${PSXRECOMP_ROOT}/runtime/include") + endif() + if(UNIT_INCLUDES) + target_include_directories(${target} PRIVATE ${UNIT_INCLUDES}) + endif() + if(UNIT_DEFINITIONS) + target_compile_definitions(${target} PRIVATE ${UNIT_DEFINITIONS}) + endif() + target_compile_features(${target} PRIVATE cxx_std_20) + add_test(NAME ${test} COMMAND ${target} ${UNIT_ARGUMENTS}) +endfunction() +""" + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def contract_faults(cmake: str, scripts: dict[str, bool]) -> list[str]: + """What is wrong with how the build file registers these contract files, each given with whether it takes artifacts.""" + faults = [] + for script, retail in sorted(scripts.items()): + if script in EXPLICIT: + names = cmake.count(f" add_test(NAME {EXPLICIT[script]}\n") + runs = cmake.count(f"/tests/contracts/{script}") + if names != 1 or runs != 1: + faults.append(f"{script} is named {names} times and run {runs} times, not once each") + continue + name = re.fullmatch(r"test_(\w+)_contract\.py", script) + if name is None: + faults.append(f"{script} is not named as a contract") + continue + plain = cmake.count(f" disruptor_contract({name.group(1)})\n") + marked = cmake.count(f" disruptor_contract({name.group(1)} RETAIL)\n") + if (plain, marked) != ((0, 1) if retail else (1, 0)): + faults.append(f"{script} is registered {plain} times plainly and {marked} times as retail") + if f"/tests/contracts/{script}" in cmake: + faults.append(f"{script} is also run by name") + registered = re.findall(r"^\s*disruptor_contract\((\w+)", cmake, re.MULTILINE) + faults += [ + f"{name} is registered and has no file" for name in registered if f"test_{name}_contract.py" not in scripts + ] + return faults + + +def unit_faults(builds: str, cmake: str, sources: list[str]) -> list[str]: + """What is wrong with how the build files register these unit test files.""" + faults = [f"{source} is built {builds.count(source)} times" for source in sources if builds.count(source) != 1] + tail = cmake.split("include(CTest)")[1] + units = re.findall(r"^\s*disruptor_unit_test\((\w+) (tests/unit/\w+\.cpp)", tail, re.MULTILINE) + tests = [test for test, _ in units] + faults += [f"{test} is registered twice" for test in sorted(set(tests)) if tests.count(test) > 1] + faults += [f"{source} has no file" for _, source in units if source not in sources] + if "add_executable(" in tail: + faults.append("an executable is built past the helper") + return faults + + +cmake = read("CMakeLists.txt") +launcher_cmake = read("src/launcher/CMakeLists.txt") +helpers = read("cmake/DisruptorTests.cmake") + +require( + ' include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/DisruptorTests.cmake")\n' in cmake + and cmake.index("find_package(Python3 REQUIRED COMPONENTS Interpreter)") < cmake.index("cmake/DisruptorTests.cmake") + and cmake.index("cmake/DisruptorTests.cmake") < cmake.index("disruptor_contract("), + "the helpers are loaded after Python is found and before the first registration", +) +require( + helpers.count(CONTRACT_HELPER) == 1 and helpers.count("\nfunction(") == 2, + "a contract runs its own file, and a retail one is told to require its artifacts and is labelled retail", +) +require( + helpers.count(UNIT_HELPER) == 1, + "a unit test is one executable named after its test, built with what it was given and always run under that name", +) + +scripts = { + path.name: ARTIFACTS in path.read_text(encoding="utf-8") + for path in (ROOT / "tests/contracts").glob("test_*.py") + if path.name != Path(__file__).name +} +scripts[Path(__file__).name] = False +require(contract_faults(cmake, scripts) == [], "; ".join(contract_faults(cmake, scripts))) +require(sum(scripts.values()) == 8, "eight contracts take the retail artifacts") + +sources = sorted(f"tests/unit/{path.name}" for path in (ROOT / "tests/unit").glob("*.cpp")) +require( + unit_faults(cmake + launcher_cmake, cmake, sources) == [], + "; ".join(unit_faults(cmake + launcher_cmake, cmake, sources)), +) + +plain_line, retail_line = " disruptor_contract(depth24_sync)\n", " disruptor_contract(name_entry RETAIL)\n" +again = ' add_test(NAME host_borderless_again\n COMMAND x "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py")\n' +unit_line = " disruptor_unit_test(host_pick_slot tests/unit/host_pick_slot_test.cpp)\n" +require( + cmake.count(plain_line) == cmake.count(retail_line) == cmake.count(unit_line) == 1 + and contract_faults(cmake.replace(plain_line, ""), scripts) != [] + and contract_faults(cmake.replace(plain_line, plain_line * 2), scripts) != [] + and contract_faults(cmake.replace(plain_line, " disruptor_contract(depth24_sync RETAIL)\n"), scripts) != [] + and contract_faults(cmake.replace(retail_line, " disruptor_contract(name_entry)\n"), scripts) != [] + and contract_faults(cmake.replace(plain_line, plain_line + " disruptor_contract(no_such)\n"), scripts) != [] + and contract_faults(cmake.replace(plain_line, plain_line + again), scripts) != [] + and contract_faults(cmake, scripts | {"test_new_contract.py": False}) != [] + and unit_faults((cmake + launcher_cmake).replace(unit_line, ""), cmake.replace(unit_line, ""), sources) != [] + and unit_faults(cmake + launcher_cmake + unit_line, cmake + unit_line, sources) != [] + and unit_faults(cmake + launcher_cmake, cmake, [*sources, "tests/unit/new_test.cpp"]) != [] + and unit_faults(cmake + launcher_cmake, cmake + " add_executable(extra x.cpp)\n", sources) != [], + "a missing, doubled, mislabelled or fileless registration is seen, in the contracts and in the unit tests", +) + +print("Disruptor test registration source contract: PASS") diff --git a/tests/contracts/test_vertical_camera_contract.py b/tests/contracts/test_vertical_camera_contract.py index c5b8f0c..66a3e8a 100644 --- a/tests/contracts/test_vertical_camera_contract.py +++ b/tests/contracts/test_vertical_camera_contract.py @@ -84,7 +84,7 @@ def require(condition: bool, message: str) -> None: require("gpu_geometry_camera_projection_center_y_set" in camera and "gpu_geometry_camera_projection_center_y_set" in gpu, "guest and presentation horizons must share one renderer setter") -require("disruptor-vertical-camera-test" in cmake and +require(" disruptor_unit_test(disruptor_vertical_camera " in cmake and "disruptor_vertical_look_registration" in cmake, "behavior and vertical-only registration tests must remain enabled") require("disruptor_vertical_camera_instruction_hook" in camera_h, diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index ab6359c..5dd1bf0 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -33,9 +33,8 @@ def between(source: str, opening: str, closing: str) -> str: require( overlay.count("+runtime/include/gpu_world_depth.h\n") == 1 and '#include "gpu_world_depth.h"\n' in renderer - and " add_executable(disruptor-gpu-world-depth-test\n tests/unit/gpu_world_depth_test.cpp)\n" in cmake - and " add_test(NAME disruptor_gpu_world_depth\n COMMAND disruptor-gpu-world-depth-test)\n" in cmake - and " add_test(NAME disruptor_world_depth_contract\n" in cmake, + and " disruptor_unit_test(disruptor_gpu_world_depth tests/unit/gpu_world_depth_test.cpp RUNTIME_HEADERS)\n" in cmake + and " disruptor_contract(world_depth)\n" in cmake, "the rule's header is applied to the framework and its test and this contract are registered", ) From bd308e1250002813ba81b67b7dad0430ad721788 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:46:25 +0300 Subject: [PATCH 07/13] docs: describe the layout in the build guide --- docs/BUILD.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/BUILD.md b/docs/BUILD.md index 81f216f..b1211b1 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -129,6 +129,27 @@ files are listed in `PSXRECOMP_OVERLAY_FILES.txt`. Runtime preferences live in `settings.toml` beside the executable; game files, generated code, and build directories are ignored by Git. +## Layout + +| Folder | Holds | +| --- | --- | +| `src/game/` | The game's own module by area: `input`, `render`, `frame`, `language`, `session`, `ui` | +| `src/launcher/` | The Windows launcher | +| `psxrecomp-overlay/` | The framework files this project replaces or adds | +| `tests/unit/` | Unit tests, one executable each | +| `tests/contracts/` | Source contracts, run by Python | +| `tests/support/` | Stubs and fakes the unit tests build against | +| `cmake/` | Build helpers: test registration, launcher staging | +| `config/` | Key presets | +| `tools/` | Regeneration, the overlay, packaging, capsule replay | + +`game.toml`, `game-widescreen.toml` and `mouse-aim.ini` stay at the root: the +game is started from there and reads them by those names. + +A unit test is registered in `CMakeLists.txt` with +`disruptor_unit_test( tests/unit/)` and a contract with +`disruptor_contract()` for `tests/contracts/test__contract.py`. + ## Replay capsules Ctrl+F12 in the game starts a recording and Ctrl+F12 again ends it. The game From 2cf126aa170735d82d8388e22bf4271214a912e6 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 02:10:51 +0300 Subject: [PATCH 08/13] refactor: describe the settings once, in a table the launcher and the game can share --- docs/BUILD.md | 2 +- src/game/settings/options.cpp | 210 ++++++++++++++++ src/game/settings/options.h | 35 +++ src/launcher/CMakeLists.txt | 3 +- src/launcher/launcher_settings.cpp | 235 +++--------------- src/launcher/launcher_settings.h | 37 +-- src/launcher/main.cpp | 30 ++- src/launcher/startup_log.cpp | 12 +- .../test_ambient_occlusion_contract.py | 12 +- tests/contracts/test_gap_filling_contract.py | 7 +- tests/contracts/test_launcher_contract.py | 25 +- tests/contracts/test_smooth_turn_contract.py | 8 +- tests/contracts/test_world_depth_contract.py | 7 +- tests/unit/launcher_settings_test.cpp | 16 +- 14 files changed, 376 insertions(+), 263 deletions(-) create mode 100644 src/game/settings/options.cpp create mode 100644 src/game/settings/options.h diff --git a/docs/BUILD.md b/docs/BUILD.md index b1211b1..b4f5782 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -133,7 +133,7 @@ directories are ignored by Git. | Folder | Holds | | --- | --- | -| `src/game/` | The game's own module by area: `input`, `render`, `frame`, `language`, `session`, `ui` | +| `src/game/` | The game's own module by area: `input`, `render`, `frame`, `language`, `session`, `settings`, `ui` | | `src/launcher/` | The Windows launcher | | `psxrecomp-overlay/` | The framework files this project replaces or adds | | `tests/unit/` | Unit tests, one executable each | diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp new file mode 100644 index 0000000..c9dd02d --- /dev/null +++ b/src/game/settings/options.cpp @@ -0,0 +1,210 @@ +#include "game/settings/options.h" + +#include "game/frame/present_rate.h" + +#include +#include +#include +#include + +namespace disruptor::settings { +namespace { +using Saved = bool (*)(const Settings&); +using Get = int (*)(const Settings&); +using Set = void (*)(Settings&, int); + +template bool saved(const Settings& settings) { return settings.*Has; } +template int read(const Settings& settings) { return static_cast(settings.*Value); } +template void write(Settings& settings, int value) { + settings.*Has = true; + settings.*Value = static_cast>(value); +} + +constexpr int aspect_widths[] = {4, 16, 21, 32}; +constexpr int aspect_heights[] = {3, 9, 9, 9}; +constexpr int widest_aspect = 3, window_aspect = 4; + +bool aspect_saved(const Settings& settings) { return settings.has_aspect_ratio || settings.has_adaptive_view; } + +int aspect(const Settings& settings) { + if (settings.has_adaptive_view && settings.adaptive_view) return window_aspect; + for (int index = 0; index <= widest_aspect; ++index) + if (settings.aspect_num * aspect_heights[index] == settings.aspect_den * aspect_widths[index]) return index; + return -1; +} + +void set_aspect(Settings& settings, int index) { + const int fixed = index == window_aspect ? widest_aspect : index; + settings.has_aspect_ratio = settings.has_adaptive_view = true; + settings.adaptive_view = index == window_aspect; + settings.aspect_num = aspect_widths[fixed]; + settings.aspect_den = aspect_heights[fixed]; +} + +int scale(const Settings& settings) { return settings.supersampling - 1; } +void set_scale(Settings& settings, int index) { settings.has_supersampling = true; settings.supersampling = index + 1; } +int vsync(const Settings& settings) { return std::clamp(settings.vsync + 1, 0, 2); } +void set_vsync(Settings& settings, int index) { settings.has_vsync = true; settings.vsync = index - 1; } + +template int thousandths(const Settings& settings) { + return static_cast(std::lround(settings.*Value * 1000.0)); +} +template void set_thousandths(Settings& settings, int value) { + settings.*Has = true; + settings.*Value = value / 1000.0; +} + +constexpr const char* display_modes[] = {"Windowed", "Borderless fullscreen", "Exclusive fullscreen"}; +constexpr const char* aspects[] = {"4:3", "16:9", "21:9", "32:9", "Match window (up to 32:9)"}; +constexpr const char* scales[] = {"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"}; +constexpr const char* vsyncs[] = {"Adaptive", "Immediate", "Synchronised"}; +constexpr const char* shadows[] = {"Vanilla", "Improved"}; + +constexpr const char* geometry_key = "disruptor.geometry_correction"; +constexpr const char* frames_key = "video.frame_interpolation"; +constexpr const char* rate_key = "video.frame_interpolation_fps"; +constexpr const char* occlusion_key = "disruptor.ambient_occlusion"; +constexpr const char* aim_key = "disruptor.mouse_aim"; + +template +constexpr Option toggle(const char* key, const char* label, int initial, const char* hint, + const char* needs = nullptr) { + return {key, Kind::toggle, label, saved, read, write, initial, 0, 1, {}, needs, + Unit::none, hint}; +} + +constexpr Option choice(const char* key, const char* label, int initial, const char* hint, + std::span choices, Saved saved, Get get, Set set) { + return {key, Kind::choice, label, saved, get, set, initial, 0, static_cast(choices.size()) - 1, + choices, nullptr, Unit::none, hint}; +} + +constexpr Option slider(const char* key, const char* label, int initial, const char* hint, + int lowest, int highest, Unit unit, Saved saved, Get get, Set set, + const char* needs = nullptr) { + return {key, Kind::slider, label, saved, get, set, initial, lowest, highest, {}, needs, unit, hint}; +} + +constexpr Option option_list[] = { + choice("video.fullscreen", "Display mode", 0, + "A window, a borderless window the size of the screen, or exclusive fullscreen. Alt+Enter " + "switches between a window and fullscreen while playing.", display_modes, + saved<&Settings::has_fullscreen>, read<&Settings::fullscreen>, + write<&Settings::has_fullscreen, &Settings::fullscreen>), + choice("video.aspect_ratio", "Aspect ratio", 0, + "4:3 is the original picture. The wider choices show more of the world at the sides. Match " + "window follows the window's shape, up to 32:9. Movies stay 4:3.", aspects, + aspect_saved, aspect, set_aspect), + choice("video.supersampling", "Internal resolution scale", 3, + "Draws the picture at this multiple of the original resolution. Higher is sharper. What it " + "costs the graphics card grows roughly with the square of the value.", scales, + saved<&Settings::has_supersampling>, scale, set_scale), + choice("video.vsync", "VSync", 2, + "Synchronised waits for the display, so the picture never tears. Immediate does not wait. " + "Adaptive waits only while the game keeps up with the display.", + vsyncs, saved<&Settings::has_vsync>, vsync, set_vsync), + slider("disruptor.hud_scale", "HUD size", 100, + "Widescreen only. Shrinks the health and ammunition displays toward their screen edges. " + "The weapon keeps its size.", 50, 100, Unit::percent, + saved<&Settings::has_hud_scale>, read<&Settings::hud_scale>, + write<&Settings::has_hud_scale, &Settings::hud_scale>), + + toggle<&Settings::has_geometry_correction, &Settings::geometry_correction>( + "disruptor.geometry_correction", "Exact-provenance geometry", 1, + "Places polygons from the game's own arithmetic before it is rounded to whole pixels, which " + "steadies the PlayStation's wobbling edges. Other options need it."), + toggle<&Settings::has_perspective_textures, &Settings::perspective_textures>( + "disruptor.perspective_textures", "Perspective-correct world textures", 1, + "Draws wall and floor textures in true perspective. The PlayStation's own way bends them near " + "the viewer.", geometry_key), + toggle<&Settings::has_gap_filling, &Settings::gap_filling>( + "disruptor.gap_filling", "Gap filling", 1, + "Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", geometry_key), + toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>( + "disruptor.depth_sorted_world", "Depth-sorted world", 0, + "Draws world polygons by their depth where the game's own order puts a far one over a near " + "one.", geometry_key), + choice("disruptor.improved_shadows", "Shadows", 0, + "Vanilla is the game's own shape. Improved lays a shadow flat on the floor: as wide as the " + "game makes it and as tall as the floor's perspective gives. Improved needs exact " + "geometry.", shadows, + saved<&Settings::has_improved_shadows>, read<&Settings::improved_shadows>, + write<&Settings::has_improved_shadows, &Settings::improved_shadows>), + toggle<&Settings::has_frame_unlock, &Settings::frame_unlock>( + "disruptor.frame_unlock", "60 FPS gameplay", 0, + "Lets the game run at up to 60 frames a second in place of its original rate."), + toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>( + "video.frame_interpolation", "In-between frames", 0, + "Shows extra pictures between the game's frames, built from the game's own geometry, for " + "smoother motion.", geometry_key), + slider("video.frame_interpolation_fps", "In-between frame rate", 0, + "Pictures shown per second while in-between frames are on.", + kDisruptorPresentRateLowest, kDisruptorPresentRateHighest, Unit::frames, + saved<&Settings::has_frame_interpolation_fps>, read<&Settings::frame_interpolation_fps>, + write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key), + toggle<&Settings::has_skip_intro, &Settings::skip_intro>( + "disruptor.skip_intro", "Skip the logos and the title movie", 0, + "Starts at the main menu without the logos and the title movie. Escape skips a logo or a " + "movie either way."), + + toggle<&Settings::has_mouse_aim, &Settings::mouse_aim>( + "disruptor.mouse_aim", "Horizontal mouse aim", 1, + "Turns the view with the mouse. In the game a middle-click captures the mouse, and a middle- " + "click or Escape releases it."), + toggle<&Settings::has_modern_controls, &Settings::modern_controls>( + "disruptor.modern_controls", "Modern keyboard and mouse controls", 1, + "WASD moves, the left mouse button fires, the right one uses psionics, Space jumps and E " + "interacts."), + slider("disruptor.horizontal_sensitivity", "Horizontal sensitivity", 80, + "How far the view turns for a movement of the mouse.", 5, 2000, Unit::thousandths, + saved<&Settings::has_horizontal_sensitivity>, thousandths<&Settings::horizontal_sensitivity>, + set_thousandths<&Settings::has_horizontal_sensitivity, &Settings::horizontal_sensitivity>), + toggle<&Settings::has_invert_horizontal, &Settings::invert_horizontal>( + "disruptor.invert_horizontal", "Invert horizontal mouse", 0, + "Turns the view left when the mouse moves right."), + toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( + "disruptor.high_precision_camera", "Smooth mouse turning", 0, + "The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " + "a step in between. Needs exact geometry.", aim_key), + toggle<&Settings::has_vertical_look, &Settings::vertical_look>( + "disruptor.vertical_look", "Vertical mouse look", 0, + "Looks up and down with the mouse, to about 31 degrees each way. The original game has no " + "such view. Experimental."), + slider("disruptor.vertical_sensitivity", "Vertical sensitivity", 80, + "How far the view tilts for a movement of the mouse.", 5, 2000, Unit::thousandths, + saved<&Settings::has_vertical_sensitivity>, thousandths<&Settings::vertical_sensitivity>, + set_thousandths<&Settings::has_vertical_sensitivity, &Settings::vertical_sensitivity>), + toggle<&Settings::has_invert_vertical, &Settings::invert_vertical>( + "disruptor.invert_vertical", "Invert vertical mouse", 0, + "Looks down when the mouse moves forward."), + + slider("audio.master_volume", "Master volume", 100, + "The loudness of everything the game plays.", 0, 100, Unit::percent, + saved<&Settings::has_master_volume>, read<&Settings::master_volume>, + write<&Settings::has_master_volume, &Settings::master_volume>), + toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted", "Mute all audio", 0, + "Silences the game and keeps the volume setting."), + + toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>( + "disruptor.ambient_occlusion", "Ambient occlusion", 0, + "Shades corners and the floor under things. Unfinished.", geometry_key), + slider("disruptor.occlusion_reach", "Occlusion reach", 160, + "How far from a corner or an object the shade spreads.", 50, 800, Unit::none, + saved<&Settings::has_occlusion_reach>, read<&Settings::occlusion_reach>, + write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key), + slider("disruptor.occlusion_strength", "Occlusion strength", 80, + "How dark the shade is.", 0, 300, Unit::percent, + saved<&Settings::has_occlusion_strength>, read<&Settings::occlusion_strength>, + write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key), +}; +} // namespace + +std::span options() { return option_list; } + +const Option* find_option(const char* key) { + for (const auto& option : option_list) + if (std::string_view(option.key) == key) return &option; + return nullptr; +} + +} // namespace disruptor::settings diff --git a/src/game/settings/options.h b/src/game/settings/options.h new file mode 100644 index 0000000..b6e5dd9 --- /dev/null +++ b/src/game/settings/options.h @@ -0,0 +1,35 @@ +#pragma once + +#include "config_loader.h" + +#include + +// The settings a player can change, said once for the launcher and for the in-game menu. +namespace disruptor::settings { + +using Settings = PSXRecompV4::UserSettings; + +enum class Kind { toggle, choice, slider }; +enum class Unit { none, percent, frames, thousandths }; + +// A value is 0 or 1 for a toggle, an index into `choices` for a choice and a whole number of `unit` for a slider. +struct Option { + const char* key; // "section.key" in settings.toml + Kind kind; + const char* label; // UTF-8, as is `hint` and every choice + bool (*saved)(const Settings&); + int (*get)(const Settings&); + void (*set)(Settings&, int); + int initial; // what a fresh installation runs with + int lowest; + int highest; + std::span choices; + const char* needs; // the toggle that must be on for this option to apply + Unit unit; + const char* hint; +}; + +std::span options(); +const Option* find_option(const char* key); + +} // namespace disruptor::settings diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 57a6daf..d5ccca3 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -24,7 +24,8 @@ add_library(disruptor-launcher-model STATIC launcher_settings.cpp region_disc.cpp startup_log.cpp - "${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp") + "${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp" + "${PROJECT_SOURCE_DIR}/src/game/settings/options.cpp") target_include_directories(disruptor-launcher-model PUBLIC "${PROJECT_SOURCE_DIR}/src") target_link_libraries(disruptor-launcher-model PUBLIC disruptor-disc-import disruptor-launcher-config) diff --git a/src/launcher/launcher_settings.cpp b/src/launcher/launcher_settings.cpp index 4c174c7..98a4e72 100644 --- a/src/launcher/launcher_settings.cpp +++ b/src/launcher/launcher_settings.cpp @@ -5,203 +5,44 @@ #include #include -#include #include #include -#include #include namespace fs = std::filesystem; namespace disruptor::launcher { namespace { -using Saved = bool (*)(const Settings&); -using Get = int (*)(const Settings&); -using Set = void (*)(Settings&, int); - -template bool saved(const Settings& settings) { return settings.*Has; } -template int read(const Settings& settings) { return static_cast(settings.*Value); } -template void write(Settings& settings, int value) { - settings.*Has = true; - settings.*Value = static_cast>(value); -} - -constexpr int aspect_widths[] = {4, 16, 21, 32}; -constexpr int aspect_heights[] = {3, 9, 9, 9}; -constexpr int widest_aspect = 3, window_aspect = 4; - -bool aspect_saved(const Settings& settings) { return settings.has_aspect_ratio || settings.has_adaptive_view; } - -int aspect(const Settings& settings) { - if (settings.has_adaptive_view && settings.adaptive_view) return window_aspect; - for (int index = 0; index <= widest_aspect; ++index) - if (settings.aspect_num * aspect_heights[index] == settings.aspect_den * aspect_widths[index]) return index; - return -1; -} - -void set_aspect(Settings& settings, int index) { - const int fixed = index == window_aspect ? widest_aspect : index; - settings.has_aspect_ratio = settings.has_adaptive_view = true; - settings.adaptive_view = index == window_aspect; - settings.aspect_num = aspect_widths[fixed]; - settings.aspect_den = aspect_heights[fixed]; -} - -int scale(const Settings& settings) { return settings.supersampling - 1; } -void set_scale(Settings& settings, int index) { settings.has_supersampling = true; settings.supersampling = index + 1; } -int vsync(const Settings& settings) { return std::clamp(settings.vsync + 1, 0, 2); } -void set_vsync(Settings& settings, int index) { settings.has_vsync = true; settings.vsync = index - 1; } - -template int thousandths(const Settings& settings) { - return static_cast(std::lround(settings.*Value * 1000.0)); -} -template void set_thousandths(Settings& settings, int value) { - settings.*Has = true; - settings.*Value = value / 1000.0; -} - -constexpr const wchar_t* display_modes[] = {L"Windowed", L"Borderless fullscreen", L"Exclusive fullscreen"}; -constexpr const wchar_t* aspects[] = {L"4:3", L"16:9", L"21:9", L"32:9", L"Match window (up to 32:9)"}; -constexpr const wchar_t* scales[] = {L"1x (native)", L"2x", L"3x", L"4x", L"5x", L"6x", L"7x", L"8x"}; -constexpr const wchar_t* vsyncs[] = {L"Adaptive", L"Immediate", L"Synchronised"}; -constexpr const wchar_t* shadows[] = {L"Vanilla", L"Improved"}; constexpr const wchar_t* pages[] = {L"Display", L"Enhancements", L"Controls", L"Audio", L"Experimental"}; - -constexpr const char* geometry_key = "disruptor.geometry_correction"; -constexpr const char* frames_key = "video.frame_interpolation"; constexpr const char* rate_key = "video.frame_interpolation_fps"; -constexpr const char* occlusion_key = "disruptor.ambient_occlusion"; -constexpr const char* aim_key = "disruptor.mouse_aim"; - -template -constexpr Option toggle(const char* key, Page page, const wchar_t* label, int initial, const wchar_t* hint, - const char* needs = nullptr) { - return {key, page, Kind::toggle, label, saved, read, write, initial, 0, 1, {}, needs, - Unit::none, hint}; -} - -constexpr Option choice(const char* key, Page page, const wchar_t* label, int initial, const wchar_t* hint, - std::span choices, Saved saved, Get get, Set set) { - return {key, page, Kind::choice, label, saved, get, set, initial, 0, static_cast(choices.size()) - 1, - choices, nullptr, Unit::none, hint}; -} - -constexpr Option slider(const char* key, Page page, const wchar_t* label, int initial, const wchar_t* hint, - int lowest, int highest, Unit unit, Saved saved, Get get, Set set, - const char* needs = nullptr) { - return {key, page, Kind::slider, label, saved, get, set, initial, lowest, highest, {}, needs, unit, hint}; -} - -constexpr Option option_list[] = { - choice("video.fullscreen", Page::display, L"Display mode", 0, - L"A window, a borderless window the size of the screen, or exclusive fullscreen. Alt+Enter " - L"switches between a window and fullscreen while playing.", display_modes, - saved<&Settings::has_fullscreen>, read<&Settings::fullscreen>, - write<&Settings::has_fullscreen, &Settings::fullscreen>), - choice("video.aspect_ratio", Page::display, L"Aspect ratio", 0, - L"4:3 is the original picture. The wider choices show more of the world at the sides. Match " - L"window follows the window's shape, up to 32:9. Movies stay 4:3.", aspects, - aspect_saved, aspect, set_aspect), - choice("video.supersampling", Page::display, L"Internal resolution scale", 3, - L"Draws the picture at this multiple of the original resolution. Higher is sharper. What it " - L"costs the graphics card grows roughly with the square of the value.", scales, - saved<&Settings::has_supersampling>, scale, set_scale), - choice("video.vsync", Page::display, L"VSync", 2, - L"Synchronised waits for the display, so the picture never tears. Immediate does not wait. " - L"Adaptive waits only while the game keeps up with the display.", - vsyncs, saved<&Settings::has_vsync>, vsync, set_vsync), - slider("disruptor.hud_scale", Page::display, L"HUD size", 100, - L"Widescreen only. Shrinks the health and ammunition displays toward their screen edges. " - L"The weapon keeps its size.", 50, 100, Unit::percent, - saved<&Settings::has_hud_scale>, read<&Settings::hud_scale>, - write<&Settings::has_hud_scale, &Settings::hud_scale>), - - toggle<&Settings::has_geometry_correction, &Settings::geometry_correction>( - "disruptor.geometry_correction", Page::enhancements, L"Exact-provenance geometry", 1, - L"Places polygons from the game's own arithmetic before it is rounded to whole pixels, which " - L"steadies the PlayStation's wobbling edges. Other options need it."), - toggle<&Settings::has_perspective_textures, &Settings::perspective_textures>( - "disruptor.perspective_textures", Page::enhancements, L"Perspective-correct world textures", 1, - L"Draws wall and floor textures in true perspective. The PlayStation's own way bends them near " - L"the viewer.", geometry_key), - toggle<&Settings::has_gap_filling, &Settings::gap_filling>( - "disruptor.gap_filling", Page::enhancements, L"Gap filling", 1, - L"Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", geometry_key), - toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>( - "disruptor.depth_sorted_world", Page::enhancements, L"Depth-sorted world", 0, - L"Draws world polygons by their depth where the game's own order puts a far one over a near " - L"one.", geometry_key), - choice("disruptor.improved_shadows", Page::enhancements, L"Shadows", 0, - L"Vanilla is the game's own shape. Improved lays a shadow flat on the floor: as wide as the " - L"game makes it and as tall as the floor's perspective gives. Improved needs exact " - L"geometry.", shadows, - saved<&Settings::has_improved_shadows>, read<&Settings::improved_shadows>, - write<&Settings::has_improved_shadows, &Settings::improved_shadows>), - toggle<&Settings::has_frame_unlock, &Settings::frame_unlock>( - "disruptor.frame_unlock", Page::enhancements, L"60 FPS gameplay", 0, - L"Lets the game run at up to 60 frames a second in place of its original rate."), - toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>( - "video.frame_interpolation", Page::enhancements, L"In-between frames", 0, - L"Shows extra pictures between the game's frames, built from the game's own geometry, for " - L"smoother motion.", geometry_key), - slider("video.frame_interpolation_fps", Page::enhancements, L"In-between frame rate", 0, - L"Pictures shown per second while in-between frames are on.", - kDisruptorPresentRateLowest, kDisruptorPresentRateHighest, Unit::frames, - saved<&Settings::has_frame_interpolation_fps>, read<&Settings::frame_interpolation_fps>, - write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key), - toggle<&Settings::has_skip_intro, &Settings::skip_intro>( - "disruptor.skip_intro", Page::enhancements, L"Skip the logos and the title movie", 0, - L"Starts at the main menu without the logos and the title movie. Escape skips a logo or a " - L"movie either way."), - - toggle<&Settings::has_mouse_aim, &Settings::mouse_aim>( - "disruptor.mouse_aim", Page::controls, L"Horizontal mouse aim", 1, - L"Turns the view with the mouse. In the game a middle-click captures the mouse, and a middle- " - L"click or Escape releases it."), - toggle<&Settings::has_modern_controls, &Settings::modern_controls>( - "disruptor.modern_controls", Page::controls, L"Modern keyboard and mouse controls", 1, - L"WASD moves, the left mouse button fires, the right one uses psionics, Space jumps and E " - L"interacts."), - slider("disruptor.horizontal_sensitivity", Page::controls, L"Horizontal sensitivity", 80, - L"How far the view turns for a movement of the mouse.", 5, 2000, Unit::thousandths, - saved<&Settings::has_horizontal_sensitivity>, thousandths<&Settings::horizontal_sensitivity>, - set_thousandths<&Settings::has_horizontal_sensitivity, &Settings::horizontal_sensitivity>), - toggle<&Settings::has_invert_horizontal, &Settings::invert_horizontal>( - "disruptor.invert_horizontal", Page::controls, L"Invert horizontal mouse", 0, - L"Turns the view left when the mouse moves right."), - toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( - "disruptor.high_precision_camera", Page::controls, L"Smooth mouse turning", 0, - L"The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " - L"a step in between. Needs exact geometry.", aim_key), - toggle<&Settings::has_vertical_look, &Settings::vertical_look>( - "disruptor.vertical_look", Page::controls, L"Vertical mouse look", 0, - L"Looks up and down with the mouse, to about 31 degrees each way. The original game has no " - L"such view. Experimental."), - slider("disruptor.vertical_sensitivity", Page::controls, L"Vertical sensitivity", 80, - L"How far the view tilts for a movement of the mouse.", 5, 2000, Unit::thousandths, - saved<&Settings::has_vertical_sensitivity>, thousandths<&Settings::vertical_sensitivity>, - set_thousandths<&Settings::has_vertical_sensitivity, &Settings::vertical_sensitivity>), - toggle<&Settings::has_invert_vertical, &Settings::invert_vertical>( - "disruptor.invert_vertical", Page::controls, L"Invert vertical mouse", 0, - L"Looks down when the mouse moves forward."), - - slider("audio.master_volume", Page::audio, L"Master volume", 100, - L"The loudness of everything the game plays.", 0, 100, Unit::percent, - saved<&Settings::has_master_volume>, read<&Settings::master_volume>, - write<&Settings::has_master_volume, &Settings::master_volume>), - toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted", Page::audio, L"Mute all audio", 0, - L"Silences the game and keeps the volume setting."), - toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>( - "disruptor.ambient_occlusion", Page::experimental, L"Ambient occlusion", 0, - L"Shades corners and the floor under things. Unfinished.", geometry_key), - slider("disruptor.occlusion_reach", Page::experimental, L"Occlusion reach", 160, - L"How far from a corner or an object the shade spreads.", 50, 800, Unit::none, - saved<&Settings::has_occlusion_reach>, read<&Settings::occlusion_reach>, - write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key), - slider("disruptor.occlusion_strength", Page::experimental, L"Occlusion strength", 80, - L"How dark the shade is.", 0, 300, Unit::percent, - saved<&Settings::has_occlusion_strength>, read<&Settings::occlusion_strength>, - write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key), +constexpr Placement placed[] = { + {"video.fullscreen", Page::display}, + {"video.aspect_ratio", Page::display}, + {"video.supersampling", Page::display}, + {"video.vsync", Page::display}, + {"disruptor.hud_scale", Page::display}, + {"disruptor.geometry_correction", Page::enhancements}, + {"disruptor.perspective_textures", Page::enhancements}, + {"disruptor.gap_filling", Page::enhancements}, + {"disruptor.depth_sorted_world", Page::enhancements}, + {"disruptor.improved_shadows", Page::enhancements}, + {"disruptor.frame_unlock", Page::enhancements}, + {"video.frame_interpolation", Page::enhancements}, + {"video.frame_interpolation_fps", Page::enhancements}, + {"disruptor.skip_intro", Page::enhancements}, + {"disruptor.mouse_aim", Page::controls}, + {"disruptor.modern_controls", Page::controls}, + {"disruptor.horizontal_sensitivity", Page::controls}, + {"disruptor.invert_horizontal", Page::controls}, + {"disruptor.high_precision_camera", Page::controls}, + {"disruptor.vertical_look", Page::controls}, + {"disruptor.vertical_sensitivity", Page::controls}, + {"disruptor.invert_vertical", Page::controls}, + {"audio.master_volume", Page::audio}, + {"audio.muted", Page::audio}, + {"disruptor.ambient_occlusion", Page::experimental}, + {"disruptor.occlusion_reach", Page::experimental}, + {"disruptor.occlusion_strength", Page::experimental}, }; constexpr PresetValue original_values[] = { @@ -226,14 +67,16 @@ bool listed(const std::vector& discs, const std::string& path) { } } // namespace -std::span options() { return option_list; } +std::span layout() { return placed; } std::span presets() { return preset_list; } std::span page_labels() { return pages; } -const Option* find_option(const char* key) { - for (const auto& option : option_list) - if (std::string_view(option.key) == key) return &option; - return nullptr; +std::wstring wide(std::string_view utf8_text) { + const int bytes = static_cast(utf8_text.size()); + const int size = MultiByteToWideChar(CP_UTF8, 0, utf8_text.data(), bytes, nullptr, 0); + std::wstring text(static_cast(std::max(size, 0)), L'\0'); + if (size > 0) MultiByteToWideChar(CP_UTF8, 0, utf8_text.data(), bytes, text.data(), size); + return text; } std::string utf8(const fs::path& path) { @@ -285,7 +128,7 @@ bool SettingsFile::applies(const Option& option) const { void SettingsFile::put(Settings& settings, const Option& option, int value) { option.set(settings, value); if (option.kind != Kind::toggle || value != 0) return; - for (const auto& other : option_list) { + for (const auto& other : options()) { if (other.kind == Kind::toggle && other.needs && std::string_view(other.needs) == option.key && stored(settings, other) != 0) put(settings, other, 0); @@ -314,7 +157,7 @@ bool SettingsFile::apply(const Preset& preset) { bool SettingsFile::reset() { return change([](Settings& settings) { - for (const auto& option : option_list) option.set(settings, option.initial); + for (const auto& option : options()) option.set(settings, option.initial); }); } @@ -326,9 +169,9 @@ bool shipped(const Option& option) { bool SettingsFile::complete() { if (!load()) return false; const auto whole = [this](const Option& option) { return !shipped(option) || option.saved(settings_); }; - if (std::all_of(std::begin(option_list), std::end(option_list), whole)) return true; + if (std::all_of(options().begin(), options().end(), whole)) return true; return change([](Settings& settings) { - for (const auto& option : option_list) + for (const auto& option : options()) if (shipped(option) && !option.saved(settings)) option.set(settings, option.initial); }); } diff --git a/src/launcher/launcher_settings.h b/src/launcher/launcher_settings.h index dc7da7d..4b29f9f 100644 --- a/src/launcher/launcher_settings.h +++ b/src/launcher/launcher_settings.h @@ -1,48 +1,37 @@ #pragma once -#include "config_loader.h" +#include "game/settings/options.h" #include #include #include +#include #include namespace disruptor::launcher { -using Settings = PSXRecompV4::UserSettings; +using settings::find_option; +using settings::Kind; +using settings::Option; +using settings::options; +using settings::Settings; +using settings::Unit; enum class Page { display, enhancements, controls, audio, experimental }; -enum class Kind { toggle, choice, slider }; -enum class Unit { none, percent, frames, thousandths }; - -// A value is 0 or 1 for a toggle, an index into `choices` for a choice and a whole number of `unit` for a slider. -struct Option { - const char* key; // "section.key" in settings.toml - Page page; - Kind kind; - const wchar_t* label; - bool (*saved)(const Settings&); - int (*get)(const Settings&); - void (*set)(Settings&, int); - int initial; // what a fresh installation runs with - int lowest; - int highest; - std::span choices; - const char* needs; // the toggle that must be on for this row to apply - Unit unit; - const wchar_t* hint; // shown when the pointer rests on the row -}; +// A row of the settings pages. The rows of a page are shown in the order they are listed. +struct Placement { const char* key; Page page; }; struct PresetValue { const char* key; int value; }; struct Preset { const wchar_t* label; std::span values; }; -std::span options(); +std::span layout(); std::span presets(); -const Option* find_option(const char* key); // Whether a release's settings.toml names the row: for these the game's own value is not the one a release runs with. bool shipped(const Option& option); std::span page_labels(); +// Bytes that are not UTF-8 come out as U+FFFD. +std::wstring wide(std::string_view utf8_text); std::string utf8(const std::filesystem::path& path); std::filesystem::path from_utf8(const std::string& text); // The PSX_ variables of this process, which the game it starts lets replace settings for that run. diff --git a/src/launcher/main.cpp b/src/launcher/main.cpp index 83e76bb..0ab83b7 100644 --- a/src/launcher/main.cpp +++ b/src/launcher/main.cpp @@ -114,7 +114,13 @@ std::optional pick_image(HWND owner, const wchar_t* title) { struct Update { std::wstring status; unsigned percent; }; struct Placed { HWND window; int x, y, width, height; bool heading; }; -struct Row { const Option* option; int order = 0; HWND label = nullptr, control = nullptr, value = nullptr; }; +struct Row { + const Option* option; + Page page; + int order = 0; + std::wstring hint; + HWND label = nullptr, control = nullptr, value = nullptr; +}; struct App { fs::path root; @@ -273,10 +279,10 @@ struct App { } const bool on = editable() && settings.applies(option); const int slot = row.order - first; - const bool here = static_cast(option.page) == page && slot >= 0 && slot < page_slots; + const bool here = static_cast(row.page) == page && slot >= 0 && slot < page_slots; if (here) put_row(row, slot); const int shown = here ? SW_SHOW : SW_HIDE; - if (here) tip_of(slot, option.hint); + if (here) tip_of(slot, row.hint.c_str()); for (HWND part : {row.label, row.control, row.value}) { if (!part) continue; EnableWindow(part, on); @@ -413,20 +419,22 @@ void create_controls(App& app) { } std::vector rows_on_page(page_labels().size(), 0); index = 0; - for (const auto& option : options()) { - const int order = rows_on_page[static_cast(option.page)]++, y = 38 + 28 * std::min(order, page_slots - 1); + for (const auto& placement : layout()) { + const Option& option = *find_option(placement.key); + const int order = rows_on_page[static_cast(placement.page)]++, y = 38 + 28 * std::min(order, page_slots - 1); + const std::wstring label = wide(option.label); const int id = option_base + index++; - Row row{&option, order}; + Row row{&option, placement.page, order, wide(option.hint)}; if (option.kind == Kind::toggle) { - row.control = app.add(app.tabs, L"BUTTON", option.label, BS_AUTOCHECKBOX | WS_TABSTOP, 0, + row.control = app.add(app.tabs, L"BUTTON", label.c_str(), BS_AUTOCHECKBOX | WS_TABSTOP, 0, 18, y, 560, 24, id); } else { - row.label = app.add(app.tabs, L"STATIC", option.label, SS_LEFT, 0, 18, y + 4, 250, 20); + row.label = app.add(app.tabs, L"STATIC", label.c_str(), SS_LEFT, 0, 18, y + 4, 250, 20); if (option.kind == Kind::choice) { row.control = app.add(app.tabs, WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP, 0, 280, y, 250, 240, id); - for (const wchar_t* label : option.choices) - SendMessageW(row.control, CB_ADDSTRING, 0, reinterpret_cast(label)); + for (const char* choice : option.choices) + SendMessageW(row.control, CB_ADDSTRING, 0, reinterpret_cast(wide(choice).c_str())); } else { row.control = app.add(app.tabs, TRACKBAR_CLASSW, L"", TBS_HORZ | TBS_NOTICKS | WS_TABSTOP, 0, 274, y, 256, 26, id); @@ -443,7 +451,7 @@ void create_controls(App& app) { for (const auto& row : app.rows) { TTTOOLINFOW tool{sizeof(tool), TTF_IDISHWND | TTF_SUBCLASS, app.tabs}; tool.uId = reinterpret_cast(row.control); - tool.lpszText = const_cast(row.option->hint); + tool.lpszText = const_cast(row.hint.c_str()); SendMessageW(app.tips, TTM_ADDTOOLW, 0, reinterpret_cast(&tool)); } for (int slot = 0; slot < page_slots; ++slot) { diff --git a/src/launcher/startup_log.cpp b/src/launcher/startup_log.cpp index e4851ae..9ff3956 100644 --- a/src/launcher/startup_log.cpp +++ b/src/launcher/startup_log.cpp @@ -1,6 +1,6 @@ #include "startup_log.h" -#include +#include "launcher_settings.h" #include #include @@ -10,14 +10,6 @@ namespace fs = std::filesystem; namespace disruptor::launcher { namespace { constexpr std::streamoff most_bytes = 16384; - -// Bytes that are not UTF-8 come out as U+FFFD: a log in another encoding is still shown. -std::wstring decode(const std::string& text) { - const int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0); - std::wstring result(static_cast(std::max(size, 0)), L'\0'); - if (size > 0) MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), result.data(), size); - return result; -} } // namespace std::wstring log_tail(const fs::path& log, size_t lines) { @@ -43,7 +35,7 @@ std::wstring log_tail(const fs::path& log, size_t lines) { } std::string joined; for (auto line = kept.rbegin(); line != kept.rend(); ++line) joined += *line + "\r\n"; - return decode(joined); + return wide(joined); } } // namespace disruptor::launcher diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 1dcbe82..cd732f9 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -24,6 +24,7 @@ def between(source: str, opening: str, closing: str) -> str: renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") @@ -155,16 +156,19 @@ def between(source: str, opening: str, closing: str) -> str: "the settings file keeps the switch, off until made, and a reach and a strength only inside the sliders' bounds", ) -switch = between(launcher, '"disruptor.ambient_occlusion", Page::experimental, L"Ambient occlusion", 0,', "),\n") -reach = between(launcher, 'slider("disruptor.occlusion_reach", Page::experimental, L"Occlusion reach", 160,', "),\n") -strength = between(launcher, 'slider("disruptor.occlusion_strength", Page::experimental, L"Occlusion strength", 80,', "),\n") +switch = between(options, '"disruptor.ambient_occlusion", "Ambient occlusion", 0,', "),\n") +reach = between(options, 'slider("disruptor.occlusion_reach", "Occlusion reach", 160,', "),\n") +strength = between(options, 'slider("disruptor.occlusion_strength", "Occlusion strength", 80,', "),\n") require( switch.endswith("geometry_key") and "50, 800, Unit::none," in reach and reach.endswith("occlusion_key") and "0, 300, Unit::percent," in strength and strength.endswith("occlusion_key") - and 'constexpr const char* occlusion_key = "disruptor.ambient_occlusion";\n' in launcher + and 'constexpr const char* occlusion_key = "disruptor.ambient_occlusion";\n' in options + and ' {"disruptor.ambient_occlusion", Page::experimental},\n' + ' {"disruptor.occlusion_reach", Page::experimental},\n' + ' {"disruptor.occlusion_strength", Page::experimental},\n' in launcher and launcher.count("Page::experimental") == 3, "the launcher's Experimental page holds these three rows: the switch needs geometry correction, the sliders the switch, " "with the menu's own bounds", diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 5d61efb..7f6c559 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -24,6 +24,7 @@ def between(source: str, opening: str, closing: str) -> str: renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require(" disruptor_contract(gap_filling)\n" in cmake, "this contract is registered") @@ -177,9 +178,11 @@ def between(source: str, opening: str, closing: str) -> str: "the settings file keeps the choice, which is on until changed", ) -row = between(launcher, '"disruptor.gap_filling", Page::enhancements, L"Gap filling", 1,', "),\n") +row = between(options, '"disruptor.gap_filling", "Gap filling", 1,', "),\n") require( - row.endswith("geometry_key") and launcher.count('"disruptor.gap_filling"') == 1, + row.endswith("geometry_key") + and options.count('"disruptor.gap_filling"') == launcher.count('"disruptor.gap_filling"') == 1 + and ' {"disruptor.gap_filling", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, on at first and only with geometry correction", ) diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index 6f2fc11..9c61bd7 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -38,6 +38,7 @@ def plain_values(text: str) -> dict[str, str]: table = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") window = read("src/launcher/main.cpp") region = read("src/launcher/region_disc.cpp") launcher_cmake = read("src/launcher/CMakeLists.txt") @@ -45,10 +46,20 @@ def plain_values(text: str) -> dict[str, str]: loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") menu = read("src/game/ui/dev_menu.cpp") + read("src/game/frame/present_rate.cpp") -rows = re.findall(r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", Page::(\w+), L"([^"]+)", (-?\d+)', table) -keys = [key for _, key, _, _, _ in rows] -initial = {key: int(value) for _, key, _, _, value in rows} -require(len(rows) == 27 and len(set(keys)) == 27, "the launcher's table must hold its 27 rows, each key once") +ROW = r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", "([^"]+)", (-?\d+)' +rows = re.findall(ROW, options) +keys = [key for _, key, _, _ in rows] +initial = {key: int(value) for _, key, _, value in rows} +require(len(rows) == 27 and len(set(keys)) == 27, "the shared table must hold its 27 options, each key once") +placed = re.findall(r'^ \{"([\w.]+)", Page::\w+\},$', table, re.MULTILINE) +require( + sorted(placed) == sorted(keys) + and not re.findall(ROW, table) + and ' "${PROJECT_SOURCE_DIR}/src/game/settings/options.cpp")\n' in launcher_cmake + and "std::span layout() { return placed; }\n" in table + and " for (const auto& placement : layout()) {\n const Option& option = *find_option(placement.key);\n" in window, + "the launcher puts each shared option on one page and describes none itself", +) writer = loader[loader.index("bool save_user_settings(") :] saved, section = set(), "" @@ -65,14 +76,14 @@ def plain_values(text: str) -> dict[str, str]: f"keys listed here are no longer saved: {sorted((NOT_OFFERED | COVERED) - saved)}", ) -for kind, key, _, label, _ in rows: +for kind, key, label, _ in rows: require(f'"{label}"' in menu, f"the row {key} must be named as the in-game menu names it: {label}") for label in ( "Windowed", "Borderless fullscreen", "Exclusive fullscreen", "16:9", "21:9", "32:9", "Match window (up to 32:9)", "1x (native)", "8x", "Adaptive", "Immediate", "Synchronised", ): - require(f'L"{label}"' in table and f'"{label}"' in menu, f"the choice {label} must read as in the in-game menu") -require('L"Vanilla", L"Improved"' in table and '"Vanilla\\0Improved\\0"' in menu, "the shadow choices must match") + require(f'"{label}"' in options and f'"{label}"' in menu, f"the choice {label} must read as in the in-game menu") +require('"Vanilla", "Improved"' in options and '"Vanilla\\0Improved\\0"' in menu, "the shadow choices must match") shipped = plain_values(read("release/windows/settings.toml")) require(not set(shipped) - set(keys), f"the shipped settings.toml sets keys the launcher does not show: {sorted(set(shipped) - set(keys))}") diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index fcae32e..9e2a286 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -27,6 +27,7 @@ def between(source: str, opening: str, closing: str) -> str: mouse = read("src/game/input/mouse_aim.cpp") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") require( overlay.count("+runtime/include/gpu_turned_perspective.h\n") == 1 @@ -117,11 +118,12 @@ def between(source: str, opening: str, closing: str) -> str: "into the file's settings, applied at the next start with the environment first, and applied when another run saved it", ) -row = between(launcher, '"disruptor.high_precision_camera", Page::controls, L"Smooth mouse turning", 0,', "),\n") +row = between(options, '"disruptor.high_precision_camera", "Smooth mouse turning", 0,', "),\n") require( row.endswith("aim_key") - and 'constexpr const char* aim_key = "disruptor.mouse_aim";\n' in launcher - and launcher.count('"disruptor.high_precision_camera"') == 1, + and 'constexpr const char* aim_key = "disruptor.mouse_aim";\n' in options + and options.count('"disruptor.high_precision_camera"') == launcher.count('"disruptor.high_precision_camera"') == 1 + and ' {"disruptor.high_precision_camera", Page::controls},\n' in launcher, "the launcher offers it on the Controls page, off at first and only with mouse aim", ) diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 5dd1bf0..9cfdec0 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -28,6 +28,7 @@ def between(source: str, opening: str, closing: str) -> str: presenter = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( @@ -160,9 +161,11 @@ def between(source: str, opening: str, closing: str) -> str: "the settings file keeps the choice, which is off until made", ) -row = between(launcher, '"disruptor.depth_sorted_world", Page::enhancements, L"Depth-sorted world", 0,', "),\n") +row = between(options, '"disruptor.depth_sorted_world", "Depth-sorted world", 0,', "),\n") require( - row.endswith("geometry_key") and launcher.count('"disruptor.depth_sorted_world"') == 1, + row.endswith("geometry_key") + and options.count('"disruptor.depth_sorted_world"') == launcher.count('"disruptor.depth_sorted_world"') == 1 + and ' {"disruptor.depth_sorted_world", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, off at first and only with geometry correction", ) diff --git a/tests/unit/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp index 1bf44c1..9dac975 100644 --- a/tests/unit/launcher_settings_test.cpp +++ b/tests/unit/launcher_settings_test.cpp @@ -3,6 +3,7 @@ #include +#include #include #include @@ -54,8 +55,7 @@ void table() { one.highest == static_cast(one.choices.size()) - 1, "a choice starts outside its list"); if (one.kind == Kind::toggle) require(one.initial == 0 || one.initial == 1, "a toggle starts as neither"); if (one.needs) require(option(one.needs).kind == Kind::toggle && &option(one.needs) != &one, "a row needs no toggle"); - require(static_cast(one.page) < page_labels().size(), "a row is on no page"); - require(one.hint && std::wcslen(one.hint) >= 20 && std::wcslen(one.hint) <= 240, "a row has no hint to show"); + require(one.hint && std::strlen(one.hint) >= 20 && std::strlen(one.hint) <= 240, "a row has no hint to show"); } require(presets().size() == 2, "the two presets are expected"); for (const auto& preset : presets()) @@ -64,6 +64,17 @@ void table() { "a preset sets a value outside its option"); } +void placements() { + std::set placed; + for (const auto& row : layout()) { + require(find_option(row.key) != nullptr, "a row names no option"); + require(placed.insert(row.key).second, "an option is on two rows"); + require(static_cast(row.page) < page_labels().size(), "a row is on no page"); + } + require(placed.size() == options().size(), "an option is on no page"); + require(wide("caf\xC3\xA9") == L"café" && wide("").empty(), "an option's text is read as UTF-8"); +} + void absent(const fs::path& folder) { SettingsFile settings(folder / "none" / "settings.toml"); require(settings.load() && settings.readable(), "a missing file must read as defaults"); @@ -416,6 +427,7 @@ int main() { fs::remove_all(folder); fs::create_directories(folder); table(); + placements(); absent(folder); one_change(folder); round_trip(folder); From f1a796d3a8800d0e55e499d6e70d2dcc1127be1e Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 02:15:34 +0300 Subject: [PATCH 09/13] fix: remove the stray space in the mouse aim hint's middle-click --- src/game/settings/options.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp index c9dd02d..046131c 100644 --- a/src/game/settings/options.cpp +++ b/src/game/settings/options.cpp @@ -149,8 +149,8 @@ constexpr Option option_list[] = { toggle<&Settings::has_mouse_aim, &Settings::mouse_aim>( "disruptor.mouse_aim", "Horizontal mouse aim", 1, - "Turns the view with the mouse. In the game a middle-click captures the mouse, and a middle- " - "click or Escape releases it."), + "Turns the view with the mouse. In the game a middle-click captures the mouse, and a " + "middle-click or Escape releases it."), toggle<&Settings::has_modern_controls, &Settings::modern_controls>( "disruptor.modern_controls", "Modern keyboard and mouse controls", 1, "WASD moves, the left mouse button fires, the right one uses psionics, Space jumps and E " From ab0599505da476e7b9f95d9fa9f9d981a2eee7d5 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 02:28:45 +0300 Subject: [PATCH 10/13] refactor: stage and save the menu's settings through the shared table --- CMakeLists.txt | 1 + src/game/ui/dev_menu.cpp | 381 +++++------------- .../test_ambient_occlusion_contract.py | 45 ++- tests/contracts/test_debug_tab_contract.py | 6 +- tests/contracts/test_dev_menu_contract.py | 100 +++-- tests/contracts/test_frame_rate_contract.py | 35 +- tests/contracts/test_gap_filling_contract.py | 30 +- tests/contracts/test_hud_scale_contract.py | 19 +- tests/contracts/test_intro_skip_contract.py | 19 +- .../contracts/test_language_disc_contract.py | 4 +- tests/contracts/test_smooth_turn_contract.py | 30 +- tests/contracts/test_world_depth_contract.py | 34 +- tests/unit/launcher_settings_test.cpp | 41 ++ 13 files changed, 341 insertions(+), 404 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c51d93..0bae398 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,7 @@ if(DISRUPTOR_DEV_MENU) endif() list(APPEND DISRUPTOR_EXTRA_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/options.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp") if(DISRUPTOR_FRAME_INTERPOLATION) list(APPEND DISRUPTOR_EXTRA_SOURCES diff --git a/src/game/ui/dev_menu.cpp b/src/game/ui/dev_menu.cpp index 439c393..ed6262c 100644 --- a/src/game/ui/dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -16,6 +16,7 @@ #include "game/input/mouse_aim.h" #include "game/frame/present_rate.h" #include "game/session/restart.h" +#include "game/settings/options.h" #include "config_loader.h" #include "gpu.h" #include "host_ui.h" @@ -38,6 +39,7 @@ #include #include #include +#include #include #include @@ -82,45 +84,27 @@ struct DevMenuState { DevMenuState g_menu; namespace fs = std::filesystem; - -enum PreferenceDirty : uint32_t { - PREF_MOUSE_AIM = 1u << 0, - PREF_MODERN_CONTROLS = 1u << 1, - PREF_SENSITIVITY = 1u << 2, - PREF_INVERT_X = 1u << 3, - PREF_GEOMETRY = 1u << 5, - PREF_TEXTURES = 1u << 6, - PREF_VSYNC = 1u << 7, - PREF_VERTICAL_LOOK = 1u << 10, - PREF_VERTICAL_SENS = 1u << 11, - PREF_INVERT_Y = 1u << 12, - PREF_ASPECT = 1u << 13, - PREF_SUPERSAMPLING = 1u << 14, - PREF_FULLSCREEN = 1u << 15, - PREF_MASTER_VOLUME = 1u << 16, - PREF_AUDIO_MUTED = 1u << 17, - PREF_HUD_SCALE = 1u << 18, - PREF_FRAME_UNLOCK = 1u << 19, - PREF_SHADOW_SHAPE = 1u << 20, - PREF_PRESENT_RATE = 1u << 21, - PREF_SKIP_INTRO = 1u << 22, - PREF_PRESENT_ENABLED = 1u << 23, - PREF_LANGUAGE_DISC = 1u << 24, - PREF_OCCLUSION = 1u << 25, - PREF_OCCLUSION_SHAPE = 1u << 26, - PREF_GAP_FILLING = 1u << 27, - PREF_WORLD_DEPTH = 1u << 28, - PREF_DIAGNOSTIC_VIEW = 1u << 29, - PREF_SMOOTH_TURN = 1u << 30, -}; +using disruptor::settings::find_option; +using disruptor::settings::Option; struct PreferenceState { fs::path path; PSXRecompV4::UserSettings pending; - uint32_t dirty = 0; + std::set changed; + bool language_disc_changed = false; + bool diagnostic_view_changed = false; bool loaded = false; bool save_failed = false; std::string status; + + bool dirty() const { + return !changed.empty() || language_disc_changed || + diagnostic_view_changed; + } + void clean() { + changed.clear(); + language_disc_changed = diagnostic_view_changed = false; + } }; PreferenceState g_preferences; @@ -203,65 +187,45 @@ void set_modern_controls_live(bool enabled) { disruptor_modern_controls_set_enabled(enabled ? 1 : 0); } -void mark_mouse_aim(bool value) { - g_preferences.pending.has_mouse_aim = true; - g_preferences.pending.mouse_aim = value; - g_preferences.dirty |= PREF_MOUSE_AIM; +void mark(const char *key, int value) { + const Option *option = find_option(key); + option->set(g_preferences.pending, value); + g_preferences.changed.insert(option); } -void mark_modern_controls(bool value) { - g_preferences.pending.has_modern_controls = true; - g_preferences.pending.modern_controls = value; - g_preferences.dirty |= PREF_MODERN_CONTROLS; +bool changed(const char *key) { + return g_preferences.changed.contains(find_option(key)); } +void mark_mouse_aim(bool value) { mark("disruptor.mouse_aim", value); } + +void mark_modern_controls(bool value) { mark("disruptor.modern_controls", value); } + void mark_sensitivity(double value) { g_preferences.pending.has_horizontal_sensitivity = true; g_preferences.pending.horizontal_sensitivity = value; - g_preferences.dirty |= PREF_SENSITIVITY; + g_preferences.changed.insert( + find_option("disruptor.horizontal_sensitivity")); } -void mark_invert_x(bool value) { - g_preferences.pending.has_invert_horizontal = true; - g_preferences.pending.invert_horizontal = value; - g_preferences.dirty |= PREF_INVERT_X; -} +void mark_invert_x(bool value) { mark("disruptor.invert_horizontal", value); } -void mark_vertical_look(bool value) { - g_preferences.pending.has_vertical_look = true; - g_preferences.pending.vertical_look = value; - g_preferences.dirty |= PREF_VERTICAL_LOOK; -} +void mark_vertical_look(bool value) { mark("disruptor.vertical_look", value); } void mark_vertical_sensitivity(double value) { g_preferences.pending.has_vertical_sensitivity = true; g_preferences.pending.vertical_sensitivity = value; - g_preferences.dirty |= PREF_VERTICAL_SENS; + g_preferences.changed.insert( + find_option("disruptor.vertical_sensitivity")); } -void mark_invert_y(bool value) { - g_preferences.pending.has_invert_vertical = true; - g_preferences.pending.invert_vertical = value; - g_preferences.dirty |= PREF_INVERT_Y; -} +void mark_invert_y(bool value) { mark("disruptor.invert_vertical", value); } -void mark_geometry(bool value) { - g_preferences.pending.has_geometry_correction = true; - g_preferences.pending.geometry_correction = value; - g_preferences.dirty |= PREF_GEOMETRY; -} +void mark_geometry(bool value) { mark("disruptor.geometry_correction", value); } -void mark_textures(bool value) { - g_preferences.pending.has_perspective_textures = true; - g_preferences.pending.perspective_textures = value; - g_preferences.dirty |= PREF_TEXTURES; -} +void mark_textures(bool value) { mark("disruptor.perspective_textures", value); } -void mark_vsync(int value) { - g_preferences.pending.has_vsync = true; - g_preferences.pending.vsync = value; - g_preferences.dirty |= PREF_VSYNC; -} +void mark_vsync(int value) { mark("video.vsync", value + 1); } void mark_aspect(int numerator, int denominator, bool adaptive) { g_preferences.pending.has_aspect_ratio = true; @@ -271,88 +235,42 @@ void mark_aspect(int numerator, int denominator, bool adaptive) { * settings update as the fallback/initial aspect. */ g_preferences.pending.has_adaptive_view = true; g_preferences.pending.adaptive_view = adaptive; - g_preferences.dirty |= PREF_ASPECT; + g_preferences.changed.insert(find_option("video.aspect_ratio")); } -void mark_supersampling(int value) { - g_preferences.pending.has_supersampling = true; - g_preferences.pending.supersampling = value; - g_preferences.dirty |= PREF_SUPERSAMPLING; -} +void mark_supersampling(int value) { mark("video.supersampling", value - 1); } -void mark_fullscreen(int value) { - g_preferences.pending.has_fullscreen = true; - g_preferences.pending.fullscreen = value; - g_preferences.dirty |= PREF_FULLSCREEN; -} +void mark_fullscreen(int value) { mark("video.fullscreen", value); } -void mark_master_volume(int value) { - g_preferences.pending.has_master_volume = true; - g_preferences.pending.master_volume = value; - g_preferences.dirty |= PREF_MASTER_VOLUME; -} +void mark_master_volume(int value) { mark("audio.master_volume", value); } -void mark_audio_muted(bool value) { - g_preferences.pending.has_audio_muted = true; - g_preferences.pending.audio_muted = value; - g_preferences.dirty |= PREF_AUDIO_MUTED; -} +void mark_audio_muted(bool value) { mark("audio.muted", value); } -void mark_skip_intro(bool value) { - g_preferences.pending.has_skip_intro = true; - g_preferences.pending.skip_intro = value; - g_preferences.dirty |= PREF_SKIP_INTRO; -} +void mark_skip_intro(bool value) { mark("disruptor.skip_intro", value); } void mark_language_disc(const std::string &path) { g_language_disc = path; g_preferences.pending.has_language_disc = true; g_preferences.pending.language_disc = path; - g_preferences.dirty |= PREF_LANGUAGE_DISC; + g_preferences.language_disc_changed = true; } -void mark_hud_scale(int value) { - g_preferences.pending.has_hud_scale = true; - g_preferences.pending.hud_scale = value; - g_preferences.dirty |= PREF_HUD_SCALE; -} +void mark_hud_scale(int value) { mark("disruptor.hud_scale", value); } -void mark_frame_unlock(bool value) { - g_preferences.pending.has_frame_unlock = true; - g_preferences.pending.frame_unlock = value; - g_preferences.dirty |= PREF_FRAME_UNLOCK; -} +void mark_frame_unlock(bool value) { mark("disruptor.frame_unlock", value); } -void mark_shadow_shape(bool improved) { - g_preferences.pending.has_improved_shadows = true; - g_preferences.pending.improved_shadows = improved; - g_preferences.dirty |= PREF_SHADOW_SHAPE; -} +void mark_shadow_shape(bool improved) { mark("disruptor.improved_shadows", improved); } -void mark_occlusion(bool value) { - g_preferences.pending.has_ambient_occlusion = true; - g_preferences.pending.ambient_occlusion = value; - g_preferences.dirty |= PREF_OCCLUSION; -} +void mark_occlusion(bool value) { mark("disruptor.ambient_occlusion", value); } void mark_occlusion_shape(int reach, int strength) { - g_preferences.pending.has_occlusion_reach = g_preferences.pending.has_occlusion_strength = true; - g_preferences.pending.occlusion_reach = reach; - g_preferences.pending.occlusion_strength = strength; - g_preferences.dirty |= PREF_OCCLUSION_SHAPE; + mark("disruptor.occlusion_reach", reach); + mark("disruptor.occlusion_strength", strength); } -void mark_gap_filling(bool value) { - g_preferences.pending.has_gap_filling = true; - g_preferences.pending.gap_filling = value; - g_preferences.dirty |= PREF_GAP_FILLING; -} +void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); } -void mark_world_depth(bool value) { - g_preferences.pending.has_depth_sorted_world = true; - g_preferences.pending.depth_sorted_world = value; - g_preferences.dirty |= PREF_WORLD_DEPTH; -} +void mark_world_depth(bool value) { mark("disruptor.depth_sorted_world", value); } // Views 1 to 4 are layers of the occlusion pass, 5 is the mesh drawn over in-between frames. void apply_diagnostic_view(int view) { @@ -363,7 +281,7 @@ void apply_diagnostic_view(int view) { void mark_diagnostic_view(int value) { g_preferences.pending.has_diagnostic_view = true; g_preferences.pending.diagnostic_view = value; - g_preferences.dirty |= PREF_DIAGNOSTIC_VIEW; + g_preferences.diagnostic_view_changed = true; } // With the vertical divided too a renderer turn of one step misses the game's next step in a median of 8604 pixels of 307200, without in 24274. @@ -372,146 +290,28 @@ void apply_smooth_turn(bool enabled) { if (!env_override_present("PSX_GEOMETRY_FULL_YAW")) gl_renderer_set_full_yaw(enabled ? 1 : 0); } -void mark_smooth_turn(bool value) { - g_preferences.pending.has_high_precision_camera = true; - g_preferences.pending.high_precision_camera = value; - g_preferences.dirty |= PREF_SMOOTH_TURN; -} +void mark_smooth_turn(bool value) { mark("disruptor.high_precision_camera", value); } -void mark_present_rate(int value) { - g_preferences.pending.has_frame_interpolation_fps = true; - g_preferences.pending.frame_interpolation_fps = value; - g_preferences.dirty |= PREF_PRESENT_RATE; -} +void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); } -void mark_present_enabled(bool value) { - g_preferences.pending.has_frame_interpolation = true; - g_preferences.pending.frame_interpolation = value; - g_preferences.dirty |= PREF_PRESENT_ENABLED; -} +void mark_present_enabled(bool value) { mark("video.frame_interpolation", value); } void merge_dirty_preferences(PSXRecompV4::UserSettings &settings) { const auto &pending = g_preferences.pending; - if (g_preferences.dirty & PREF_MOUSE_AIM) { - settings.has_mouse_aim = true; - settings.mouse_aim = pending.mouse_aim; - } - if (g_preferences.dirty & PREF_MODERN_CONTROLS) { - settings.has_modern_controls = true; - settings.modern_controls = pending.modern_controls; - } - if (g_preferences.dirty & PREF_SENSITIVITY) { - settings.has_horizontal_sensitivity = true; - settings.horizontal_sensitivity = pending.horizontal_sensitivity; - } - if (g_preferences.dirty & PREF_INVERT_X) { - settings.has_invert_horizontal = true; - settings.invert_horizontal = pending.invert_horizontal; - } - if (g_preferences.dirty & PREF_VERTICAL_LOOK) { - settings.has_vertical_look = true; - settings.vertical_look = pending.vertical_look; - } - if (g_preferences.dirty & PREF_VERTICAL_SENS) { - settings.has_vertical_sensitivity = true; - settings.vertical_sensitivity = pending.vertical_sensitivity; - } - if (g_preferences.dirty & PREF_INVERT_Y) { - settings.has_invert_vertical = true; - settings.invert_vertical = pending.invert_vertical; - } - if (g_preferences.dirty & PREF_GEOMETRY) { - settings.has_geometry_correction = true; - settings.geometry_correction = pending.geometry_correction; - } - if (g_preferences.dirty & PREF_TEXTURES) { - settings.has_perspective_textures = true; - settings.perspective_textures = pending.perspective_textures; - } - if (g_preferences.dirty & PREF_VSYNC) { - settings.has_vsync = true; - settings.vsync = pending.vsync; - } - if (g_preferences.dirty & PREF_ASPECT) { - settings.has_aspect_ratio = true; - settings.aspect_num = pending.aspect_num; - settings.aspect_den = pending.aspect_den; - settings.has_adaptive_view = true; - settings.adaptive_view = pending.adaptive_view; - } - if (g_preferences.dirty & PREF_SUPERSAMPLING) { - settings.has_supersampling = true; - settings.supersampling = pending.supersampling; - } - if (g_preferences.dirty & PREF_FULLSCREEN) { - settings.has_fullscreen = true; - settings.fullscreen = pending.fullscreen; - } - if (g_preferences.dirty & PREF_MASTER_VOLUME) { - settings.has_master_volume = true; - settings.master_volume = pending.master_volume; - } - if (g_preferences.dirty & PREF_AUDIO_MUTED) { - settings.has_audio_muted = true; - settings.audio_muted = pending.audio_muted; - } - if (g_preferences.dirty & PREF_SKIP_INTRO) { - settings.has_skip_intro = true; - settings.skip_intro = pending.skip_intro; - } - if (g_preferences.dirty & PREF_LANGUAGE_DISC) { + for (const Option *option : g_preferences.changed) + option->set(settings, option->get(pending)); + if (g_preferences.language_disc_changed) { settings.has_language_disc = true; settings.language_disc = pending.language_disc; } - if (g_preferences.dirty & PREF_HUD_SCALE) { - settings.has_hud_scale = true; - settings.hud_scale = pending.hud_scale; - } - if (g_preferences.dirty & PREF_FRAME_UNLOCK) { - settings.has_frame_unlock = true; - settings.frame_unlock = pending.frame_unlock; - } - if (g_preferences.dirty & PREF_SHADOW_SHAPE) { - settings.has_improved_shadows = true; - settings.improved_shadows = pending.improved_shadows; - } - if (g_preferences.dirty & PREF_OCCLUSION) { - settings.has_ambient_occlusion = true; - settings.ambient_occlusion = pending.ambient_occlusion; - } - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) { - settings.has_occlusion_reach = settings.has_occlusion_strength = true; - settings.occlusion_reach = pending.occlusion_reach; - settings.occlusion_strength = pending.occlusion_strength; - } - if (g_preferences.dirty & PREF_GAP_FILLING) { - settings.has_gap_filling = true; - settings.gap_filling = pending.gap_filling; - } - if (g_preferences.dirty & PREF_WORLD_DEPTH) { - settings.has_depth_sorted_world = true; - settings.depth_sorted_world = pending.depth_sorted_world; - } - if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) { + if (g_preferences.diagnostic_view_changed) { settings.has_diagnostic_view = true; settings.diagnostic_view = pending.diagnostic_view; } - if (g_preferences.dirty & PREF_SMOOTH_TURN) { - settings.has_high_precision_camera = true; - settings.high_precision_camera = pending.high_precision_camera; - } - if (g_preferences.dirty & PREF_PRESENT_RATE) { - settings.has_frame_interpolation_fps = true; - settings.frame_interpolation_fps = pending.frame_interpolation_fps; - } - if (g_preferences.dirty & PREF_PRESENT_ENABLED) { - settings.has_frame_interpolation = true; - settings.frame_interpolation = pending.frame_interpolation; - } } bool flush_preferences() { - if (g_preferences.dirty == 0) return true; + if (!g_preferences.dirty()) return true; if (g_preferences.path.empty()) { g_preferences.save_failed = true; g_preferences.status = "Runtime settings path is unavailable."; @@ -534,7 +334,7 @@ bool flush_preferences() { return false; } - g_preferences.dirty = 0; + g_preferences.clean(); g_preferences.save_failed = false; g_preferences.status = "Settings saved."; std::fprintf(stdout, "disruptor: saved in-game settings to %s\n", @@ -638,74 +438,75 @@ void apply_saved_preferences(const PSXRecompV4::UserSettings &settings) { void apply_pending_preferences() { const auto &pending = g_preferences.pending; - if (g_preferences.dirty & PREF_MOUSE_AIM) + if (changed("disruptor.mouse_aim")) disruptor_mouse_aim_set_enabled(pending.mouse_aim ? 1 : 0); - if (g_preferences.dirty & PREF_MODERN_CONTROLS) + if (changed("disruptor.modern_controls")) set_modern_controls_live(pending.modern_controls); - if (g_preferences.dirty & PREF_SENSITIVITY) + if (changed("disruptor.horizontal_sensitivity")) (void)disruptor_mouse_set_horizontal_sensitivity( pending.horizontal_sensitivity); - if (g_preferences.dirty & PREF_INVERT_X) + if (changed("disruptor.invert_horizontal")) disruptor_mouse_set_invert_horizontal( pending.invert_horizontal ? 1 : 0); - if (g_preferences.dirty & PREF_VERTICAL_LOOK) + if (changed("disruptor.vertical_look")) disruptor_mouse_set_vertical_look_enabled( pending.vertical_look ? 1 : 0); - if (g_preferences.dirty & PREF_VERTICAL_SENS) + if (changed("disruptor.vertical_sensitivity")) (void)disruptor_mouse_set_vertical_sensitivity( pending.vertical_sensitivity); - if (g_preferences.dirty & PREF_INVERT_Y) + if (changed("disruptor.invert_vertical")) disruptor_mouse_set_invert_vertical( pending.invert_vertical ? 1 : 0); - if (g_preferences.dirty & PREF_GEOMETRY) + if (changed("disruptor.geometry_correction")) gpu_geometry_correction_set(pending.geometry_correction ? 1 : 0); - if (g_preferences.dirty & PREF_TEXTURES) + if (changed("disruptor.perspective_textures")) gpu_texture_correction_set( pending.perspective_textures && gpu_geometry_correction_enabled() ? 1 : 0); - if (g_preferences.dirty & PREF_VSYNC) + if (changed("video.vsync")) (void)psx_host_video_set_vsync(pending.vsync); - if (g_preferences.dirty & PREF_ASPECT) { + if (changed("video.aspect_ratio")) { if (pending.adaptive_view) (void)psx_host_video_set_adaptive_view(1); else (void)psx_host_video_set_display_aspect( pending.aspect_num, pending.aspect_den); } - if (g_preferences.dirty & PREF_SUPERSAMPLING) + if (changed("video.supersampling")) (void)psx_host_video_set_internal_scale(pending.supersampling); - if (g_preferences.dirty & PREF_FULLSCREEN) + if (changed("video.fullscreen")) g_menu.fullscreen_apply_failed = psx_host_video_set_fullscreen_mode(pending.fullscreen) == 0; - if (g_preferences.dirty & PREF_MASTER_VOLUME) + if (changed("audio.master_volume")) (void)psx_host_audio_set_master_volume(pending.master_volume); - if (g_preferences.dirty & PREF_AUDIO_MUTED) + if (changed("audio.muted")) (void)psx_host_audio_set_muted(pending.audio_muted ? 1 : 0); - if (g_preferences.dirty & PREF_HUD_SCALE) + if (changed("disruptor.hud_scale")) gpu_ws_set_hud_scale(pending.hud_scale); - if (g_preferences.dirty & PREF_FRAME_UNLOCK) + if (changed("disruptor.frame_unlock")) disruptor_frame_rate_set_unlocked(pending.frame_unlock ? 1 : 0); - if (g_preferences.dirty & PREF_SKIP_INTRO) + if (changed("disruptor.skip_intro")) disruptor_intro_skip_set_enabled(pending.skip_intro ? 1 : 0); - if (g_preferences.dirty & PREF_SHADOW_SHAPE) + if (changed("disruptor.improved_shadows")) gpu_set_shadow_shape(pending.improved_shadows ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION) + if (changed("disruptor.ambient_occlusion")) gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) + if (changed("disruptor.occlusion_reach") || + changed("disruptor.occlusion_strength")) gl_renderer_set_ssao_shape(static_cast(pending.occlusion_reach), pending.occlusion_strength / 100.0f); - if (g_preferences.dirty & PREF_GAP_FILLING) + if (changed("disruptor.gap_filling")) gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); - if (g_preferences.dirty & PREF_WORLD_DEPTH) + if (changed("disruptor.depth_sorted_world")) gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); - if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) + if (g_preferences.diagnostic_view_changed) apply_diagnostic_view(pending.diagnostic_view); - if (g_preferences.dirty & PREF_SMOOTH_TURN) + if (changed("disruptor.high_precision_camera")) apply_smooth_turn(pending.high_precision_camera); #ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (g_preferences.dirty & PREF_PRESENT_ENABLED) + if (changed("video.frame_interpolation")) (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0); - if (g_preferences.dirty & PREF_PRESENT_RATE) + if (changed("video.frame_interpolation_fps")) (void)disruptor_present_rate_apply(pending.frame_interpolation_fps); #endif } @@ -1403,14 +1204,14 @@ void draw_system_tab() { if (g_preferences.save_failed) ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), "Not saved"); - else if (g_preferences.dirty) + else if (g_preferences.dirty()) ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), "Unsaved changes"); else ImGui::TextColored(ImVec4(0.35f, 0.90f, 0.45f, 1.0f), "Saved"); if (!g_preferences.status.empty()) ImGui::TextWrapped("%s", g_preferences.status.c_str()); - if (g_preferences.dirty && ImGui::Button("Save now")) + if (g_preferences.dirty() && ImGui::Button("Save now")) (void)flush_preferences(); ImGui::TextDisabled( "Current vertical pitch, menu layout, " diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index cd732f9..78b9796 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -25,6 +25,17 @@ def between(source: str, opening: str, closing: str) -> str: menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") @@ -96,29 +107,18 @@ def between(source: str, opening: str, closing: str) -> str: ) require( - """void mark_occlusion(bool value) { - g_preferences.pending.has_ambient_occlusion = true; - g_preferences.pending.ambient_occlusion = value; - g_preferences.dirty |= PREF_OCCLUSION; -} + """void mark_occlusion(bool value) { mark("disruptor.ambient_occlusion", value); } void mark_occlusion_shape(int reach, int strength) { - g_preferences.pending.has_occlusion_reach = g_preferences.pending.has_occlusion_strength = true; - g_preferences.pending.occlusion_reach = reach; - g_preferences.pending.occlusion_strength = strength; - g_preferences.dirty |= PREF_OCCLUSION_SHAPE; + mark("disruptor.occlusion_reach", reach); + mark("disruptor.occlusion_strength", strength); } """ in menu - and """ if (g_preferences.dirty & PREF_OCCLUSION) { - settings.has_ambient_occlusion = true; - settings.ambient_occlusion = pending.ambient_occlusion; - } - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) { - settings.has_occlusion_reach = settings.has_occlusion_strength = true; - settings.occlusion_reach = pending.occlusion_reach; - settings.occlusion_strength = pending.occlusion_strength; - } -""" in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>(\n "disruptor.ambient_occlusion",' in options + and "write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key)" in options + and "write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key)" in options and """ if (!env_override_present("PSX_GL_SSAO")) { if (settings.has_ambient_occlusion) gl_renderer_set_ssao(settings.ambient_occlusion ? 1 : 0); """ in menu @@ -128,12 +128,13 @@ def between(source: str, opening: str, closing: str) -> str: settings.has_occlusion_strength && !env_override_present("PSX_GL_SSAO_STRENGTH") ? settings.occlusion_strength / 100.0f : -1.0f); """ in menu - and """ if (g_preferences.dirty & PREF_OCCLUSION) + and """ if (changed("disruptor.ambient_occlusion")) gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) + if (changed("disruptor.occlusion_reach") || + changed("disruptor.occlusion_strength")) gl_renderer_set_ssao_shape(static_cast(pending.occlusion_reach), pending.occlusion_strength / 100.0f); """ in menu, - "the switch and the shape are each staged under a bit of their own, merged into the file's settings, applied at the " + "the switch and the shape are each staged as options of the shared table, merged into the file's settings, applied at the " "next start unless the environment decides that one, and applied when another run saved them", ) diff --git a/tests/contracts/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py index d097fe5..060c45e 100644 --- a/tests/contracts/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -61,10 +61,10 @@ def between(source: str, opening: str, closing: str) -> str: """void mark_diagnostic_view(int value) { g_preferences.pending.has_diagnostic_view = true; g_preferences.pending.diagnostic_view = value; - g_preferences.dirty |= PREF_DIAGNOSTIC_VIEW; + g_preferences.diagnostic_view_changed = true; } """ in menu - and """ if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) { + and """ if (g_preferences.diagnostic_view_changed) { settings.has_diagnostic_view = true; settings.diagnostic_view = pending.diagnostic_view; } @@ -72,7 +72,7 @@ def between(source: str, opening: str, closing: str) -> str: and """ apply_diagnostic_view(settings.has_diagnostic_view && env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY") ? settings.diagnostic_view : 0); """ in menu - and """ if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) + and """ if (g_preferences.diagnostic_view_changed) apply_diagnostic_view(pending.diagnostic_view); """ in menu, "a view is written to the file so that a capsule carries it, and at a start only a capsule's replay takes it from there", diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 87bd76d..3d1f014 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -59,6 +59,18 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: gl = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gte = read("psxrecomp-overlay/runtime/src/gte.cpp") menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) mouse = read("src/game/input/mouse_aim.cpp") cheats = read("src/game/session/cheats.cpp") cheats_h = read("src/game/session/cheats.h") @@ -542,12 +554,13 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: ): require(token in scale_controls, f"live internal-resolution controls are missing {token}") -require("PREF_SUPERSAMPLING" in - function_body(menu, "merge_dirty_preferences") and - "settings.has_supersampling = true" in - function_body(menu, "merge_dirty_preferences"), +require('void mark_supersampling(int value) { mark("video.supersampling", value - 1); }\n' in menu and + MERGED in function_body(menu, "merge_dirty_preferences") and + "void set_scale(Settings& settings, int index) { settings.has_supersampling = true; " + "settings.supersampling = index + 1; }\n" in options and + "saved<&Settings::has_supersampling>, scale, set_scale)" in options, "internal scale must be merged into the user settings") -require("PREF_SUPERSAMPLING" in +require('changed("video.supersampling")' in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_internal_scale" in function_body(menu, "apply_pending_preferences"), @@ -571,14 +584,11 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: ): require(token in fullscreen_controls, f"live fullscreen controls are missing {token}") -require("PREF_FULLSCREEN" in - function_body(menu, "merge_dirty_preferences") and - "settings.has_fullscreen = true" in - function_body(menu, "merge_dirty_preferences") and - "settings.fullscreen = pending.fullscreen" in - function_body(menu, "merge_dirty_preferences"), +require('void mark_fullscreen(int value) { mark("video.fullscreen", value); }\n' in menu and + MERGED in function_body(menu, "merge_dirty_preferences") and + "write<&Settings::has_fullscreen, &Settings::fullscreen>)" in options, "explicit fullscreen selections must be merged into user settings") -require("PREF_FULLSCREEN" in +require('changed("video.fullscreen")' in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_fullscreen_mode" in function_body(menu, "apply_pending_preferences"), @@ -608,17 +618,16 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: require(token in audio_controls, f"live host-audio controls are missing {token}") merge_preferences = function_body(menu, "merge_dirty_preferences") -require("PREF_MASTER_VOLUME" in merge_preferences and - "settings.has_master_volume = true" in merge_preferences and - "settings.master_volume = pending.master_volume" in merge_preferences and - "PREF_AUDIO_MUTED" in merge_preferences and - "settings.has_audio_muted = true" in merge_preferences and - "settings.audio_muted = pending.audio_muted" in merge_preferences, +require('void mark_master_volume(int value) { mark("audio.master_volume", value); }\n' in menu and + 'void mark_audio_muted(bool value) { mark("audio.muted", value); }\n' in menu and + MERGED in merge_preferences and + "write<&Settings::has_master_volume, &Settings::master_volume>)" in options and + ' toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted",' in options, "host-audio selections must be merged into user settings") pending_preferences = function_body(menu, "apply_pending_preferences") -require("PREF_MASTER_VOLUME" in pending_preferences and +require('changed("audio.master_volume")' in pending_preferences and "psx_host_audio_set_master_volume" in pending_preferences and - "PREF_AUDIO_MUTED" in pending_preferences and + 'changed("audio.muted")' in pending_preferences and "psx_host_audio_set_muted" in pending_preferences, "pending host-audio settings must survive a soft runtime session") saved_preferences = function_body(menu, "apply_saved_preferences") @@ -671,15 +680,14 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: require("disruptor_billboard_aspect_all_site_mask" in session_body and "disruptor_billboard_aspect_set_site_mask" in session_body, f"{function_name} must restore every billboard repair path") -require("PREF_ASPECT" in function_body(menu, "merge_dirty_preferences") and - "settings.has_aspect_ratio = true" in - function_body(menu, "merge_dirty_preferences") and - "settings.has_adaptive_view = true" in - function_body(menu, "merge_dirty_preferences") and - "settings.adaptive_view = pending.adaptive_view" in - function_body(menu, "merge_dirty_preferences"), +require('g_preferences.changed.insert(find_option("video.aspect_ratio"));' in + function_body(menu, "mark_aspect") and + MERGED in function_body(menu, "merge_dirty_preferences") and + " settings.has_aspect_ratio = settings.has_adaptive_view = true;\n" + " settings.adaptive_view = index == window_aspect;\n" in options and + "aspect_saved, aspect, set_aspect)" in options, "fixed and adaptive aspect choices must persist atomically") -require("PREF_ASPECT" in function_body(menu, "apply_pending_preferences") and +require('changed("video.aspect_ratio")' in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_display_aspect" in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_adaptive_view" in @@ -729,4 +737,40 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "export PSX_VIDEO_ASPECT=16:9" in run_sh, "explicit widescreen launch switches must override a saved aspect") +shared_keys = set(re.findall(r'\b(?:toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)"', options)) +staged_keys = set(re.findall(r'\bmark\("([\w.]+)"', menu)) | set( + re.findall(r'g_preferences\.changed\.insert\(\s*find_option\("([\w.]+)"\)\)', menu)) +applied_keys = set(re.findall(r'\bchanged\("([\w.]+)"\)', function_body(menu, "apply_pending_preferences"))) +require(len(shared_keys) == 27 and staged_keys == shared_keys and applied_keys == shared_keys and + set(re.findall(r'\bfind_option\("([\w.]+)"', menu)) <= shared_keys, + "the menu must stage every shared option, apply every one it staged and name no key the table lacks") +require('void mark_vsync(int value) { mark("video.vsync", value + 1); }\n' in menu and + " g_preferences.pending.horizontal_sensitivity = value;\n" + " g_preferences.changed.insert(\n" + ' find_option("disruptor.horizontal_sensitivity"));\n' in function_body(menu, "mark_sensitivity") and + " g_preferences.pending.vertical_sensitivity = value;\n" + " g_preferences.changed.insert(\n" + ' find_option("disruptor.vertical_sensitivity"));\n' in function_body(menu, "mark_vertical_sensitivity") and + "(void)disruptor_mouse_set_horizontal_sensitivity(\n pending.horizontal_sensitivity);" in + function_body(menu, "apply_pending_preferences") and + "int vsync(const Settings& settings) { return std::clamp(settings.vsync + 1, 0, 2); }\n" in options and + " return static_cast(std::lround(settings.*Value * 1000.0));\n" in options and + " settings.*Value = value / 1000.0;\n" in options, + "a value the menu holds in its own unit must be staged in the shared table's, and a sensitivity kept whole " + "for the running game and rounded only where it is written") +require(STAGED in menu and + " return g_preferences.changed.contains(find_option(key));\n" in menu and + " std::set changed;\n" in menu and + " return !changed.empty() || language_disc_changed ||\n" + " diagnostic_view_changed;\n" in menu and + " changed.clear();\n" + " language_disc_changed = diagnostic_view_changed = false;\n" in menu and + " if (!g_preferences.dirty()) return true;\n" in function_body(menu, "flush_preferences") and + " g_preferences.clean();\n g_preferences.save_failed = false;\n" in function_body(menu, "flush_preferences") and + "PreferenceDirty" not in menu and "PREF_" not in menu and + ' list(APPEND DISRUPTOR_EXTRA_SOURCES\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/options.cpp"\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp")\n' in cmake, + "a staged change must be an option of the shared table, and a save must write and then forget every one") + print("developer menu contract: PASS") diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index 1d63952..9e2dca3 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -46,6 +46,18 @@ def require(condition: bool, message: str) -> None: source = read("src/game/frame/frame_rate.cpp") header = read("src/game/frame/frame_rate.h") menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) manifest = read("PSXRECOMP_OVERLAY_FILES.txt") cycle_header = read("psxrecomp-overlay/runtime/include/psx_cyc.h") cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") @@ -125,9 +137,10 @@ def require(condition: bool, message: str) -> None: "the settings menu must apply the switch live and stage it for saving", ) require( - "if (g_preferences.dirty & PREF_FRAME_UNLOCK) {\n" - " settings.has_frame_unlock = true;\n" - " settings.frame_unlock = pending.frame_unlock;" in menu, + 'void mark_frame_unlock(bool value) { mark("disruptor.frame_unlock", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_frame_unlock, &Settings::frame_unlock>(\n "disruptor.frame_unlock",' in options, "a changed switch must reach settings.toml", ) require( @@ -185,9 +198,9 @@ def require(condition: bool, message: str) -> None: "#endif\n" in menu and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" - " if (g_preferences.dirty & PREF_PRESENT_ENABLED)\n" + ' if (changed("video.frame_interpolation"))\n' " (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0);\n" - " if (g_preferences.dirty & PREF_PRESENT_RATE)\n" + ' if (changed("video.frame_interpolation_fps"))\n' " (void)disruptor_present_rate_apply(pending.frame_interpolation_fps);\n" "#endif\n" in menu, @@ -221,9 +234,9 @@ def require(condition: bool, message: str) -> None: "the switch must keep the rate and the blend mode and report only a state the host accepted", ) require( - " if (g_preferences.dirty & PREF_PRESENT_ENABLED) {\n" - " settings.has_frame_interpolation = true;\n" - " settings.frame_interpolation = pending.frame_interpolation;\n" in menu, + 'void mark_present_enabled(bool value) { mark("video.frame_interpolation", value); }\n' in menu + and MERGED in menu + and ' toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>(\n "video.frame_interpolation",' in options, "a changed switch must reach settings.toml", ) require( @@ -244,9 +257,9 @@ def require(condition: bool, message: str) -> None: "the host must restore the saved switch, and geometry in-between frames not without exact geometry", ) require( - " if (g_preferences.dirty & PREF_PRESENT_RATE) {\n" - " settings.has_frame_interpolation_fps = true;\n" - " settings.frame_interpolation_fps = pending.frame_interpolation_fps;\n" in menu, + 'void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); }\n' in menu + and MERGED in menu + and "write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key)" in options, "a changed in-between frame rate must reach settings.toml", ) require( diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 7f6c559..f6996d3 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -25,6 +25,17 @@ def between(source: str, opening: str, closing: str) -> str: menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require(" disruptor_contract(gap_filling)\n" in cmake, "this contract is registered") @@ -144,24 +155,17 @@ def between(source: str, opening: str, closing: str) -> str: ) require( - """void mark_gap_filling(bool value) { - g_preferences.pending.has_gap_filling = true; - g_preferences.pending.gap_filling = value; - g_preferences.dirty |= PREF_GAP_FILLING; -} -""" in menu - and """ if (g_preferences.dirty & PREF_GAP_FILLING) { - settings.has_gap_filling = true; - settings.gap_filling = pending.gap_filling; - } -""" in menu + 'void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_gap_filling, &Settings::gap_filling>(\n "disruptor.gap_filling",' in options and """ if (settings.has_gap_filling && !env_override_present("PSX_GL_CRACK_FILL") && !env_override_present("PSX_GL_GROWN_UNDER")) gl_renderer_set_gap_fill(settings.gap_filling ? 1 : 0); """ in menu - and """ if (g_preferences.dirty & PREF_GAP_FILLING) + and """ if (changed("disruptor.gap_filling")) gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); """ in menu, - "a change is staged under a bit of its own, merged into the file's settings, applied at the next start unless the " + "a change is staged as an option of the shared table, merged into the file's settings, applied at the next start unless the " "environment decides either filler, and applied when another run saved it", ) diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index ab2d45c..11a1b12 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -21,6 +21,18 @@ def require(condition: bool, message: str) -> None: gpu = read("psxrecomp-overlay/runtime/src/gpu.c") header = read("psxrecomp-overlay/runtime/include/gpu.h") menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( @@ -104,9 +116,10 @@ def require(condition: bool, message: str) -> None: "the settings menu must apply the size live and stage it for saving", ) require( - " if (g_preferences.dirty & PREF_HUD_SCALE) {\n" - " settings.has_hud_scale = true;\n" - " settings.hud_scale = pending.hud_scale;" in menu, + 'void mark_hud_scale(int value) { mark("disruptor.hud_scale", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and "write<&Settings::has_hud_scale, &Settings::hud_scale>)" in options, "a changed size must reach settings.toml", ) require( diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index d66bca2..58973bd 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -48,6 +48,18 @@ def require(condition: bool, message: str) -> None: source = read("src/game/session/intro_skip.cpp") header = read("src/game/session/intro_skip.h") menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) generator = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") interpreter = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") audit = read("tools/audit_codegen.py") @@ -165,9 +177,10 @@ def require(condition: bool, message: str) -> None: "the settings menu must apply the switch live and stage it for saving", ) require( - "if (g_preferences.dirty & PREF_SKIP_INTRO) {\n" - " settings.has_skip_intro = true;\n" - " settings.skip_intro = pending.skip_intro;" in menu, + 'void mark_skip_intro(bool value) { mark("disruptor.skip_intro", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_skip_intro, &Settings::skip_intro>(\n "disruptor.skip_intro",' in options, "a changed switch must reach settings.toml", ) require( diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index 581ff28..cd55795 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -361,8 +361,8 @@ def require(condition: bool, message: str) -> None: and " if (program.empty() || !CreateProcessW(program.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &startup, &started)) {" in restart and " program.resize(GetModuleFileNameW(nullptr, program.data(), static_cast(program.size())));" in restart and ' if (ImGui::Button("English") && !g_language_disc.empty()) mark_language_disc(std::string());' in menu - and " g_preferences.pending.has_language_disc = true;\n g_preferences.pending.language_disc = path;\n g_preferences.dirty |= PREF_LANGUAGE_DISC;" in menu - and " if (g_preferences.dirty & PREF_LANGUAGE_DISC) {\n settings.has_language_disc = true;\n settings.language_disc = pending.language_disc;" in menu + and " g_preferences.pending.has_language_disc = true;\n g_preferences.pending.language_disc = path;\n g_preferences.language_disc_changed = true;" in menu + and " if (g_preferences.language_disc_changed) {\n settings.has_language_disc = true;\n settings.language_disc = pending.language_disc;" in menu and " if (settings.has_language_disc) g_language_disc = settings.language_disc;" in menu, "the menu must show the saved disc, take a new one from a file dialog that neither stops the game nor moves its " "working directory, or none, stage it for saving, and offer a restart that saves first and is refused under netplay", diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index 9e2a286..2d531bd 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -28,6 +28,17 @@ def between(source: str, opening: str, closing: str) -> str: menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) require( overlay.count("+runtime/include/gpu_turned_perspective.h\n") == 1 @@ -95,26 +106,19 @@ def between(source: str, opening: str, closing: str) -> str: if (!env_override_present("PSX_GEOMETRY_FULL_YAW")) gl_renderer_set_full_yaw(enabled ? 1 : 0); } """ in menu - and """void mark_smooth_turn(bool value) { - g_preferences.pending.has_high_precision_camera = true; - g_preferences.pending.high_precision_camera = value; - g_preferences.dirty |= PREF_SMOOTH_TURN; -} -""" in menu - and """ if (g_preferences.dirty & PREF_SMOOTH_TURN) { - settings.has_high_precision_camera = true; - settings.high_precision_camera = pending.high_precision_camera; - } -""" in menu + and 'void mark_smooth_turn(bool value) { mark("disruptor.high_precision_camera", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>(\n "disruptor.high_precision_camera",' in options and """ if (const char *forced = std::getenv("PSX_DISRUPTOR_HIGH_PRECISION_CAMERA")) apply_smooth_turn(forced[0] != '\\0' && forced[0] != '0'); else if (settings.has_high_precision_camera) apply_smooth_turn(settings.high_precision_camera); """ in menu - and """ if (g_preferences.dirty & PREF_SMOOTH_TURN) + and """ if (changed("disruptor.high_precision_camera")) apply_smooth_turn(pending.high_precision_camera); """ in menu, - "a change switches the mouse module and the renderer's full turn together, is staged under a bit of its own, merged " + "a change switches the mouse module and the renderer's full turn together, is staged as an option of the shared table, merged " "into the file's settings, applied at the next start with the environment first, and applied when another run saved it", ) diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 9cfdec0..99b00c9 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """Source contract for the depth-sorted world: its rule's header, the renderer's two passes and the setting.""" -import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -29,6 +28,17 @@ def between(source: str, opening: str, closing: str) -> str: menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( @@ -123,27 +133,19 @@ def between(source: str, opening: str, closing: str) -> str: "the setting is on the Enhancements tab alone, greyed without geometry correction, applied and staged when changed", ) -bits = re.findall(r"PREF_(\w+)\s*= 1u << (\d+),", menu) require( - len({number for _, number in bits}) == len(bits) - and """void mark_world_depth(bool value) { - g_preferences.pending.has_depth_sorted_world = true; - g_preferences.pending.depth_sorted_world = value; - g_preferences.dirty |= PREF_WORLD_DEPTH; -} -""" in menu - and """ if (g_preferences.dirty & PREF_WORLD_DEPTH) { - settings.has_depth_sorted_world = true; - settings.depth_sorted_world = pending.depth_sorted_world; - } -""" in menu + " std::set changed;\n" in menu + and 'void mark_world_depth(bool value) { mark("disruptor.depth_sorted_world", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>(\n "disruptor.depth_sorted_world",' in options and """ if (settings.has_depth_sorted_world && !env_override_present("PSX_GL_WORLD_DEPTH")) gl_renderer_set_world_depth(settings.depth_sorted_world ? 1 : 0); """ in menu - and """ if (g_preferences.dirty & PREF_WORLD_DEPTH) + and """ if (changed("disruptor.depth_sorted_world")) gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); """ in menu, - "a change is staged under a bit of its own, merged into the file's settings, applied at the next start unless the " + "a change is staged as an option of the shared table, merged into the file's settings, applied at the next start unless the " "environment decides, and applied when another run saved it", ) diff --git a/tests/unit/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp index 9dac975..865e281 100644 --- a/tests/unit/launcher_settings_test.cpp +++ b/tests/unit/launcher_settings_test.cpp @@ -64,6 +64,46 @@ void table() { "a preset sets a value outside its option"); } +// The in-game menu saves a staged value by get and set from one settings to another. +void carried() { + for (const auto& one : options()) { + std::set values{one.initial}; + for (int value = one.lowest; value <= one.highest; ++value) values.insert(value); + for (const int value : values) { + Settings staged{}, file{}; + one.set(staged, value); + one.set(file, one.get(staged)); + require(one.saved(file) && one.get(file) == value, "a staged value is not carried whole"); + } + } + const Option& aspect = option("video.aspect_ratio"); + const int shapes[][3] = {{4, 3, 0}, {16, 9, 0}, {21, 9, 0}, {32, 9, 0}, {32, 9, 1}}; + for (const auto& shape : shapes) { + Settings staged{}, file{}; + staged.has_aspect_ratio = staged.has_adaptive_view = true; + staged.aspect_num = shape[0]; + staged.aspect_den = shape[1]; + staged.adaptive_view = shape[2] != 0; + aspect.set(file, aspect.get(staged)); + require(file.has_aspect_ratio && file.has_adaptive_view && file.aspect_num == shape[0] && + file.aspect_den == shape[1] && file.adaptive_view == staged.adaptive_view, + "an aspect the menu stages is not carried whole"); + } + Settings probe{}; + option("video.vsync").set(probe, 0); + option("video.supersampling").set(probe, 3); + option("disruptor.horizontal_sensitivity").set(probe, 83); + require(probe.vsync == -1 && probe.supersampling == 4 && probe.horizontal_sensitivity == 0.083, + "the menu's own values are not the table's"); + const Option& turn = option("disruptor.horizontal_sensitivity"); + Settings typed{}, written{}; + typed.has_horizontal_sensitivity = true; + typed.horizontal_sensitivity = 0.0834; + turn.set(written, turn.get(typed)); + require(written.has_horizontal_sensitivity && written.horizontal_sensitivity == 0.083, + "a sensitivity is not written in whole thousandths"); +} + void placements() { std::set placed; for (const auto& row : layout()) { @@ -427,6 +467,7 @@ int main() { fs::remove_all(folder); fs::create_directories(folder); table(); + carried(); placements(); absent(folder); one_change(folder); From 7e794c264cd7750f507f5e776146bda2edf52ca6 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 03:10:55 +0300 Subject: [PATCH 11/13] refactor: draw the menu's settings from the shared table --- src/game/frame/present_rate.cpp | 8 +- src/game/frame/present_rate.h | 4 +- src/game/ui/dev_menu.cpp | 101 ++++++++++-------- .../test_ambient_occlusion_contract.py | 11 +- tests/contracts/test_dev_menu_contract.py | 64 ++++++++--- tests/contracts/test_frame_rate_contract.py | 19 +++- tests/contracts/test_gap_filling_contract.py | 4 +- tests/contracts/test_hud_scale_contract.py | 7 +- tests/contracts/test_intro_skip_contract.py | 3 +- tests/contracts/test_launcher_contract.py | 11 +- tests/contracts/test_smooth_turn_contract.py | 4 +- tests/contracts/test_world_depth_contract.py | 4 +- tests/unit/disruptor_present_rate_test.cpp | 43 ++++---- 13 files changed, 177 insertions(+), 106 deletions(-) diff --git a/src/game/frame/present_rate.cpp b/src/game/frame/present_rate.cpp index e5e7740..4a6acf4 100644 --- a/src/game/frame/present_rate.cpp +++ b/src/game/frame/present_rate.cpp @@ -20,13 +20,13 @@ int disruptor_present_rate_enable(int enabled) { return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0; } -int disruptor_present_rate_switch(int available, int *enabled) { +int disruptor_present_rate_switch(const char *label, int available, int *enabled) { int on = 0, changed = 0; psx_host_video_get_interpolation(&on, nullptr, nullptr); bool checked = on != 0; const bool locked = !available && !checked; if (locked) ImGui::BeginDisabled(); - if (ImGui::Checkbox("In-between frames", &checked) && + if (ImGui::Checkbox(label, &checked) && disruptor_present_rate_enable(checked ? 1 : 0)) { if (enabled) *enabled = checked ? 1 : 0; changed = 1; @@ -43,7 +43,7 @@ int disruptor_present_rate_apply(int frames_per_second) { return enabled && psx_host_video_set_interpolation(1, frames_per_second, blend) != 0; } -int disruptor_present_rate_control(int *accepted) { +int disruptor_present_rate_control(const char *label, int *accepted) { int enabled = 0, target = 0, changed = 0; double display_hz = 0.0; psx_host_video_get_interpolation(&enabled, &target, nullptr); @@ -52,7 +52,7 @@ int disruptor_present_rate_control(int *accepted) { int shown = disruptor_present_rate_shown(target, top); if (!enabled) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt("In-between frame rate", &shown, kDisruptorPresentRateLowest, top, "%d FPS", + if (ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS", ImGuiSliderFlags_AlwaysClamp) && disruptor_present_rate_apply(shown)) { if (accepted) *accepted = shown; diff --git a/src/game/frame/present_rate.h b/src/game/frame/present_rate.h index 601811c..2ab5f3c 100644 --- a/src/game/frame/present_rate.h +++ b/src/game/frame/present_rate.h @@ -28,10 +28,10 @@ constexpr int disruptor_present_rate_shown(int target_fps, int top) { /* Switches in-between frames on or off at the rate and blend the host has. Returns 0 when the host refused. */ int disruptor_present_rate_enable(int enabled); /* Draws the switch: frames that are off cannot be switched on while `available` is 0. Returns 1 and the state in `enabled` when the user changed it and the host took it. */ -int disruptor_present_rate_switch(int available, int *enabled); +int disruptor_present_rate_switch(const char *label, int available, int *enabled); /* Sets the rate of in-between frames that are on. Returns 0 when they are off or the host refused. */ int disruptor_present_rate_apply(int frames_per_second); /* Draws the control. Returns 1 and the rate in `accepted` when the user changed it and the host took it. */ -int disruptor_present_rate_control(int *accepted); +int disruptor_present_rate_control(const char *label, int *accepted); #endif diff --git a/src/game/ui/dev_menu.cpp b/src/game/ui/dev_menu.cpp index ed6262c..72e998e 100644 --- a/src/game/ui/dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -86,6 +86,7 @@ DevMenuState g_menu; namespace fs = std::filesystem; using disruptor::settings::find_option; using disruptor::settings::Option; +using disruptor::settings::Unit; struct PreferenceState { fs::path path; @@ -197,6 +198,32 @@ bool changed(const char *key) { return g_preferences.changed.contains(find_option(key)); } +bool option_toggle(const char *key, bool *value) { + return ImGui::Checkbox(find_option(key)->label, value); +} + +bool option_slider(const char *key, int *value) { + const Option &option = *find_option(key); + const char *format = option.unit == Unit::percent ? "%d%%" : "%d"; + return ImGui::SliderInt(option.label, value, option.lowest, + option.highest, format, + ImGuiSliderFlags_AlwaysClamp); +} + +bool option_slider(const char *key, float *value) { + const Option &option = *find_option(key); + return ImGui::SliderFloat(option.label, value, option.lowest / 1000.0f, + option.highest / 1000.0f, "%.3f", + ImGuiSliderFlags_Logarithmic); +} + +bool option_list(const char *key, int *index, size_t skipped = 0) { + const Option &option = *find_option(key); + const auto shown = option.choices.subspan(skipped); + return ImGui::Combo(option.label, index, shown.data(), + static_cast(shown.size())); +} + void mark_mouse_aim(bool value) { mark("disruptor.mouse_aim", value); } void mark_modern_controls(bool value) { mark("disruptor.modern_controls", value); } @@ -680,14 +707,14 @@ void draw_status_badge(const char *label, const ImVec4 &colour) { void draw_controls_tab() { bool mouse_aim = disruptor_mouse_aim_enabled() != 0; - if (ImGui::Checkbox("Horizontal mouse aim", &mouse_aim)) { + if (option_toggle("disruptor.mouse_aim", &mouse_aim)) { disruptor_mouse_aim_set_enabled(mouse_aim ? 1 : 0); mark_mouse_aim(mouse_aim); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool modern = disruptor_modern_controls_enabled() != 0; - if (ImGui::Checkbox("Modern keyboard and mouse controls", &modern)) { + if (option_toggle("disruptor.modern_controls", &modern)) { set_modern_controls_live(modern); mark_modern_controls(modern); } @@ -695,16 +722,14 @@ void draw_controls_tab() { float sensitivity = static_cast(disruptor_mouse_horizontal_sensitivity()); - if (ImGui::SliderFloat("Horizontal sensitivity", &sensitivity, - 0.005f, 2.0f, "%.3f", - ImGuiSliderFlags_Logarithmic)) { + if (option_slider("disruptor.horizontal_sensitivity", &sensitivity)) { const double applied = disruptor_mouse_set_horizontal_sensitivity(sensitivity); mark_sensitivity(applied); } bool invert_x = disruptor_mouse_invert_horizontal() != 0; - if (ImGui::Checkbox("Invert horizontal mouse", &invert_x)) { + if (option_toggle("disruptor.invert_horizontal", &invert_x)) { disruptor_mouse_set_invert_horizontal(invert_x ? 1 : 0); mark_invert_x(invert_x); } @@ -712,7 +737,7 @@ void draw_controls_tab() { const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Smooth mouse turning", &smooth)) { + if (option_toggle("disruptor.high_precision_camera", &smooth)) { apply_smooth_turn(smooth); mark_smooth_turn(smooth); } @@ -725,7 +750,7 @@ void draw_controls_tab() { ImGui::SeparatorText("Vertical look"); bool vertical_look = disruptor_mouse_vertical_look_enabled() != 0; - if (ImGui::Checkbox("Vertical mouse look", &vertical_look)) { + if (option_toggle("disruptor.vertical_look", &vertical_look)) { disruptor_mouse_set_vertical_look_enabled(vertical_look ? 1 : 0); mark_vertical_look(vertical_look); } @@ -733,16 +758,14 @@ void draw_controls_tab() { float vertical_sensitivity = static_cast(disruptor_mouse_vertical_sensitivity()); - if (ImGui::SliderFloat("Vertical sensitivity", &vertical_sensitivity, - 0.005f, 2.0f, "%.3f", - ImGuiSliderFlags_Logarithmic)) { + if (option_slider("disruptor.vertical_sensitivity", &vertical_sensitivity)) { const double applied = disruptor_mouse_set_vertical_sensitivity(vertical_sensitivity); mark_vertical_sensitivity(applied); } bool invert_y = disruptor_mouse_invert_vertical() != 0; - if (ImGui::Checkbox("Invert vertical mouse", &invert_y)) { + if (option_toggle("disruptor.invert_vertical", &invert_y)) { disruptor_mouse_set_invert_vertical(invert_y ? 1 : 0); mark_invert_y(invert_y); } @@ -783,8 +806,6 @@ void apply_geometry_enabled(bool enabled) { void draw_aspect_controls() { static constexpr int kAspectNumerators[] = {16, 21, 32, 32}; static constexpr int kAspectDenominators[] = {9, 9, 9, 9}; - static const char *kAspectLabels[] = { - "16:9", "21:9", "32:9", "Match window (up to 32:9)"}; static constexpr int kFixedWideAspectCount = 3; static constexpr int kAdaptiveAspectIndex = 3; static constexpr int kAspectCount = 4; @@ -831,8 +852,7 @@ void draw_aspect_controls() { draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); int selected = g_menu.selected_wide_aspect; - if (ImGui::Combo("Aspect ratio", &selected, kAspectLabels, - kAspectCount)) { + if (option_list("video.aspect_ratio", &selected, 1)) { g_menu.selected_wide_aspect = selected; if (widescreen) { if (selected == kAdaptiveAspectIndex) { @@ -864,7 +884,7 @@ void draw_aspect_controls() { if (gpu_sprite_placement_available()) { int shadow_shape = gpu_shadow_shape(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::Combo("Shadows", &shadow_shape, "Vanilla\0Improved\0")) { + if (option_list("disruptor.improved_shadows", &shadow_shape)) { gpu_set_shadow_shape(shadow_shape); mark_shadow_shape(shadow_shape != 0); } @@ -876,8 +896,7 @@ void draw_aspect_controls() { int hud_scale = gpu_ws_hud_scale(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt("HUD size", &hud_scale, 50, 100, "%d%%", - ImGuiSliderFlags_AlwaysClamp)) { + if (option_slider("disruptor.hud_scale", &hud_scale)) { gpu_ws_set_hud_scale(hud_scale); mark_hud_scale(hud_scale); } @@ -888,15 +907,12 @@ void draw_aspect_controls() { } void draw_internal_resolution_controls() { - static const char *kScaleLabels[] = { - "1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"}; static constexpr int kScaleCount = 8; int selected = std::clamp( psx_host_video_get_internal_scale(), 1, kScaleCount) - 1; ImGui::SeparatorText("Rendering resolution"); - if (ImGui::Combo("Internal resolution scale", &selected, - kScaleLabels, kScaleCount)) { + if (option_list("video.supersampling", &selected)) { const int requested = selected + 1; g_menu.internal_scale_apply_failed = psx_host_video_set_internal_scale(requested) == 0; @@ -920,13 +936,13 @@ void draw_enhancements_tab() { draw_internal_resolution_controls(); bool geometry = gpu_geometry_correction_enabled() != 0; - if (ImGui::Checkbox("Exact-provenance geometry", &geometry)) + if (option_toggle("disruptor.geometry_correction", &geometry)) apply_geometry_enabled(geometry); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool textures = gpu_texture_correction_enabled() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Perspective-correct world textures", &textures)) { + if (option_toggle("disruptor.perspective_textures", &textures)) { gpu_texture_correction_set(textures ? 1 : 0); mark_textures(textures); } @@ -937,7 +953,7 @@ void draw_enhancements_tab() { bool gaps = gl_renderer_gap_fill() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Gap filling", &gaps)) { + if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } @@ -949,7 +965,7 @@ void draw_enhancements_tab() { bool by_depth = gl_renderer_world_depth() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Depth-sorted world", &by_depth)) { + if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } @@ -962,15 +978,13 @@ void draw_enhancements_tab() { ImGui::SeparatorText("Synchronisation"); int vsync = psx_host_video_get_vsync(); int vsync_index = std::clamp(vsync + 1, 0, 2); - static const char *kVsyncLabels[] = { - "Adaptive", "Immediate", "Synchronised"}; - if (ImGui::Combo("VSync", &vsync_index, kVsyncLabels, 3)) { + if (option_list("video.vsync", &vsync_index)) { const int requested = vsync_index - 1; if (psx_host_video_set_vsync(requested)) mark_vsync(requested); } bool unlocked = disruptor_frame_rate_unlocked() != 0; - if (ImGui::Checkbox("60 FPS gameplay", &unlocked)) { + if (option_toggle("disruptor.frame_unlock", &unlocked)) { disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0); mark_frame_unlock(unlocked); } @@ -988,15 +1002,19 @@ void draw_enhancements_tab() { #ifndef PSX_DISABLE_FRAME_INTERPOLATION int present_enabled = 0; - if (disruptor_present_rate_switch(geometry ? 1 : 0, &present_enabled)) + if (disruptor_present_rate_switch( + find_option("video.frame_interpolation")->label, geometry ? 1 : 0, + &present_enabled)) mark_present_enabled(present_enabled != 0); int present_rate = 0; - if (disruptor_present_rate_control(&present_rate)) mark_present_rate(present_rate); + if (disruptor_present_rate_control( + find_option("video.frame_interpolation_fps")->label, &present_rate)) + mark_present_rate(present_rate); #endif ImGui::SeparatorText("Startup"); bool skip_intro = disruptor_intro_skip_enabled() != 0; - if (ImGui::Checkbox("Skip the logos and the title movie", &skip_intro)) { + if (option_toggle("disruptor.skip_intro", &skip_intro)) { disruptor_intro_skip_set_enabled(skip_intro ? 1 : 0); mark_skip_intro(skip_intro); } @@ -1028,7 +1046,7 @@ void draw_experimental_tab() { const bool geometry = gpu_geometry_correction_enabled() != 0; bool occlusion = gl_renderer_ssao() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Ambient occlusion", &occlusion)) { + if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } @@ -1038,10 +1056,10 @@ void draw_experimental_tab() { int strength = static_cast(std::lround(gl_renderer_ssao_strength() * 100.0f)); if (!geometry) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); - const bool reach_moved = ImGui::SliderInt("Occlusion reach", &reach, 50, 800, "%d", ImGuiSliderFlags_AlwaysClamp); + const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); ImGui::SetNextItemWidth(260.0f); const bool strength_moved = - ImGui::SliderInt("Occlusion strength", &strength, 0, 300, "%d%%", ImGuiSliderFlags_AlwaysClamp); + option_slider("disruptor.occlusion_strength", &strength); if (!geometry) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); @@ -1145,12 +1163,10 @@ void draw_cheats_tab() { } void draw_fullscreen_controls() { - static const char *kFullscreenLabels[] = { - "Windowed", "Borderless fullscreen", "Exclusive fullscreen"}; int mode = std::clamp(psx_host_video_get_fullscreen_mode(), 0, 2); ImGui::SeparatorText("Window presentation"); - if (ImGui::Combo("Display mode", &mode, kFullscreenLabels, 3)) { + if (option_list("video.fullscreen", &mode)) { g_menu.fullscreen_apply_failed = psx_host_video_set_fullscreen_mode(mode) == 0; if (!g_menu.fullscreen_apply_failed) @@ -1172,15 +1188,14 @@ void draw_audio_controls() { ImGui::SeparatorText("Audio output"); int volume = psx_host_audio_get_master_volume(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt("Master volume", &volume, 0, 100, "%d%%", - ImGuiSliderFlags_AlwaysClamp) && + if (option_slider("audio.master_volume", &volume) && psx_host_audio_set_master_volume(volume)) { mark_master_volume(volume); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool muted = psx_host_audio_get_muted() != 0; - if (ImGui::Checkbox("Mute all audio", &muted) && + if (option_toggle("audio.muted", &muted) && psx_host_audio_set_muted(muted ? 1 : 0)) { mark_audio_muted(muted); } diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 78b9796..b2e7acb 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -84,24 +84,25 @@ def between(source: str, opening: str, closing: str) -> str: """ const bool geometry = gpu_geometry_correction_enabled() != 0; bool occlusion = gl_renderer_ssao() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Ambient occlusion", &occlusion)) { + if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } if (!geometry) ImGui::EndDisabled(); """ in experimental - and """ const bool reach_moved = ImGui::SliderInt("Occlusion reach", &reach, 50, 800, "%d", ImGuiSliderFlags_AlwaysClamp); + and """ const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); """ in experimental - and """ ImGui::SliderInt("Occlusion strength", &strength, 0, 300, "%d%%", ImGuiSliderFlags_AlwaysClamp); + and """ option_slider("disruptor.occlusion_strength", &strength); if (!geometry) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); } """ in experimental - and menu.count('"Ambient occlusion"') == 1 + and menu.count('option_toggle("disruptor.ambient_occlusion"') == 1 and experimental.count("ImGui::BeginDisabled()") == experimental.count("ImGui::EndDisabled()") == 2 - and experimental.count("ImGui::Checkbox(") == 1 + and experimental.count("option_toggle(") == 1 + and "ImGui::Checkbox(" not in experimental and ' if (ImGui::BeginTabItem("Experimental")) {\n draw_experimental_tab();\n' in menu, "the Experimental tab holds the occlusion alone: a switch and two bounded sliders, greyed without geometry correction", ) diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 3d1f014..7d664e3 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -462,9 +462,9 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: function_body(menu, "apply_saved_preferences"), "saved or environment-enabled modern controls must install conflict-free binds") for token in ( - "Vertical mouse look", - "Vertical sensitivity", - "Invert vertical mouse", + 'option_toggle("disruptor.vertical_look", &vertical_look)', + 'option_slider("disruptor.vertical_sensitivity", &vertical_sensitivity)', + 'option_toggle("disruptor.invert_vertical", &invert_y)', "Recenter vertical view", "disruptor_mouse_set_vertical_look_enabled", "disruptor_mouse_set_vertical_sensitivity", @@ -528,14 +528,12 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: aspect_controls = function_body(menu, "draw_aspect_controls") for token in ( 'Checkbox("Widescreen"', - 'Combo("Aspect ratio"', - '"16:9", "21:9", "32:9"', + 'option_list("video.aspect_ratio", &selected, 1)', "psx_host_video_get_display_aspect", "psx_host_video_set_display_aspect", "psx_host_video_get_adaptive_view", "psx_host_video_set_adaptive_view", "mark_aspect", - '"Match window (up to 32:9)"', 'draw_status_badge("LIVE"', ): require(token in aspect_controls, @@ -544,8 +542,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: scale_controls = function_body(menu, "draw_internal_resolution_controls") for token in ( 'SeparatorText("Rendering resolution")', - 'Combo("Internal resolution scale"', - '"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"', + 'option_list("video.supersampling", &selected)', "kScaleCount = 8", "psx_host_video_get_internal_scale", "psx_host_video_set_internal_scale", @@ -571,8 +568,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: fullscreen_controls = function_body(menu, "draw_fullscreen_controls") for token in ( 'SeparatorText("Window presentation")', - 'Combo("Display mode"', - '"Windowed", "Borderless fullscreen", "Exclusive fullscreen"', + 'option_list("video.fullscreen", &mode)', "psx_host_video_get_fullscreen_mode", "psx_host_video_set_fullscreen_mode", "mark_fullscreen", @@ -604,8 +600,8 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: audio_controls = function_body(menu, "draw_audio_controls") for token in ( 'SeparatorText("Audio output")', - 'SliderInt("Master volume"', - 'Checkbox("Mute all audio"', + 'option_slider("audio.master_volume", &volume)', + 'option_toggle("audio.muted", &muted)', "psx_host_audio_get_master_volume", "psx_host_audio_set_master_volume", "psx_host_audio_get_muted", @@ -773,4 +769,48 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp")\n' in cmake, "a staged change must be an option of the shared table, and a save must write and then forget every one") +HELPERS = ( + "bool option_toggle(const char *key, bool *value) {\n" + " return ImGui::Checkbox(find_option(key)->label, value);\n" + "}\n" + "\n" + "bool option_slider(const char *key, int *value) {\n" + " const Option &option = *find_option(key);\n" + ' const char *format = option.unit == Unit::percent ? "%d%%" : "%d";\n' + " return ImGui::SliderInt(option.label, value, option.lowest,\n" + " option.highest, format,\n" + " ImGuiSliderFlags_AlwaysClamp);\n" + "}\n" + "\n" + "bool option_slider(const char *key, float *value) {\n" + " const Option &option = *find_option(key);\n" + " return ImGui::SliderFloat(option.label, value, option.lowest / 1000.0f,\n" + ' option.highest / 1000.0f, "%.3f",\n' + " ImGuiSliderFlags_Logarithmic);\n" + "}\n" + "\n" + "bool option_list(const char *key, int *index, size_t skipped = 0) {\n" + " const Option &option = *find_option(key);\n" + " const auto shown = option.choices.subspan(skipped);\n" + " return ImGui::Combo(option.label, index, shown.data(),\n" + " static_cast(shown.size()));\n" + "}\n" +) +shared_labels = re.findall(r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"[\w.]+",\s*"([^"]+)"', options) +shared_choices = re.findall(r'"([^"]+)"', "".join(re.findall(r"constexpr const char\* \w+\[\] = \{([^}]*)\};", options))) +drawn_keys = set(re.findall(r'\boption_(?:toggle|slider|list)\("([\w.]+)"', menu)) | set( + re.findall(r'find_option\("([\w.]+)"\)->label', menu)) +present = read("src/game/frame/present_rate.cpp") +require(HELPERS in menu and len(shared_labels) == 27 and drawn_keys == shared_keys and + not [text for text in [*shared_labels, *shared_choices] if f'"{text}"' in menu + present] and + " static constexpr int kScaleCount = 8;\n" in menu and + '"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"' in options and + '{"4:3", "16:9", "21:9", "32:9", "Match window (up to 32:9)"}' in options and + '{"Windowed", "Borderless fullscreen", "Exclusive fullscreen"}' in options and + '{"Adaptive", "Immediate", "Synchronised"}' in options and '{"Vanilla", "Improved"}' in options and + '"How far the view turns for a movement of the mouse.", 5, 2000, Unit::thousandths,' in options and + '"How far the view tilts for a movement of the mouse.", 5, 2000, Unit::thousandths,' in options and + '"The loudness of everything the game plays.", 0, 100, Unit::percent,' in options, + "the menu must draw every shared option by its key, with the table's label, bounds and choices and none of its own") + print("developer menu contract: PASS") diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index 9e2dca3..0b72189 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -133,7 +133,8 @@ def require(condition: bool, message: str) -> None: require( "disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0);\n mark_frame_unlock(unlocked);" in menu - and '"60 FPS gameplay"' in menu, + and ' if (option_toggle("disruptor.frame_unlock", &unlocked)) {\n' in menu + and '"disruptor.frame_unlock", "60 FPS gameplay", 0,' in options, "the settings menu must apply the switch live and stage it for saving", ) require( @@ -191,10 +192,14 @@ def require(condition: bool, message: str) -> None: in cmake and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" " int present_enabled = 0;\n" - " if (disruptor_present_rate_switch(geometry ? 1 : 0, &present_enabled))\n" + " if (disruptor_present_rate_switch(\n" + ' find_option("video.frame_interpolation")->label, geometry ? 1 : 0,\n' + " &present_enabled))\n" " mark_present_enabled(present_enabled != 0);\n" " int present_rate = 0;\n" - " if (disruptor_present_rate_control(&present_rate)) mark_present_rate(present_rate);\n" + " if (disruptor_present_rate_control(\n" + ' find_option("video.frame_interpolation_fps")->label, &present_rate))\n' + " mark_present_rate(present_rate);\n" "#endif\n" in menu and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" @@ -207,7 +212,7 @@ def require(condition: bool, message: str) -> None: "a build without the frame interpolator must have no rate control, in the menu or in the sources", ) require( - 'if (ImGui::SliderInt("In-between frame rate", &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' + 'if (ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' " ImGuiSliderFlags_AlwaysClamp) &&\n" " disruptor_present_rate_apply(shown)) {\n" " if (accepted) *accepted = shown;\n" @@ -227,7 +232,11 @@ def require(condition: bool, message: str) -> None: ) require( " return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0;\n" in present_control - and 'if (ImGui::Checkbox("In-between frames", &checked) &&\n' + and '"video.frame_interpolation", "In-between frames", 0,' in options + and 'slider("video.frame_interpolation_fps", "In-between frame rate", 0,' in options + and "int disruptor_present_rate_switch(const char *label, int available, int *enabled) {\n" in present_control + and "int disruptor_present_rate_control(const char *label, int *accepted) {\n" in present_control + and "if (ImGui::Checkbox(label, &checked) &&\n" " disruptor_present_rate_enable(checked ? 1 : 0)) {\n" " if (enabled) *enabled = checked ? 1 : 0;\n" in present_control, diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index f6996d3..3e2efe6 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -143,13 +143,13 @@ def between(source: str, opening: str, closing: str) -> str: require( """ bool gaps = gl_renderer_gap_fill() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Gap filling", &gaps)) { + if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } if (!geometry) ImGui::EndDisabled(); """ in enhancements - and menu.count('"Gap filling"') == 1 + and menu.count('option_toggle("disruptor.gap_filling"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), "the setting is on the Enhancements tab alone, greyed without geometry correction, applied and staged when changed", ) diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index 11a1b12..393c175 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -109,10 +109,11 @@ def require(condition: bool, message: str) -> None: ) require( - ' if (ImGui::SliderInt("HUD size", &hud_scale, 50, 100, "%d%%",\n' - " ImGuiSliderFlags_AlwaysClamp)) {\n" + ' if (option_slider("disruptor.hud_scale", &hud_scale)) {\n' " gpu_ws_set_hud_scale(hud_scale);\n" - " mark_hud_scale(hud_scale);" in menu, + " mark_hud_scale(hud_scale);" in menu + and 'slider("disruptor.hud_scale", "HUD size", 100,' in options + and '"The weapon keeps its size.", 50, 100, Unit::percent,' in options, "the settings menu must apply the size live and stage it for saving", ) require( diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index 58973bd..7399fae 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -173,7 +173,8 @@ def require(condition: bool, message: str) -> None: require( "disruptor_intro_skip_set_enabled(skip_intro ? 1 : 0);\n mark_skip_intro(skip_intro);" in menu - and '"Skip the logos and the title movie"' in menu, + and ' if (option_toggle("disruptor.skip_intro", &skip_intro)) {\n' in menu + and '"disruptor.skip_intro", "Skip the logos and the title movie", 0,' in options, "the settings menu must apply the switch live and stage it for saving", ) require( diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index 9c61bd7..a5bbcda 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -76,14 +76,11 @@ def plain_values(text: str) -> dict[str, str]: f"keys listed here are no longer saved: {sorted((NOT_OFFERED | COVERED) - saved)}", ) +drawn = set(re.findall(r'\boption_(?:toggle|slider|list)\("([\w.]+)"', menu)) | set( + re.findall(r'find_option\("([\w.]+)"\)->label', menu)) +require(drawn == set(keys), f"the in-game menu must draw every row by its key: {sorted(set(keys) ^ drawn)}") for kind, key, label, _ in rows: - require(f'"{label}"' in menu, f"the row {key} must be named as the in-game menu names it: {label}") -for label in ( - "Windowed", "Borderless fullscreen", "Exclusive fullscreen", "16:9", "21:9", "32:9", "Match window (up to 32:9)", - "1x (native)", "8x", "Adaptive", "Immediate", "Synchronised", -): - require(f'"{label}"' in options and f'"{label}"' in menu, f"the choice {label} must read as in the in-game menu") -require('"Vanilla", "Improved"' in options and '"Vanilla\\0Improved\\0"' in menu, "the shadow choices must match") + require(f'"{label}"' not in menu, f"the in-game menu must take the name of {key} from the shared table") shipped = plain_values(read("release/windows/settings.toml")) require(not set(shipped) - set(keys), f"the shipped settings.toml sets keys the launcher does not show: {sorted(set(shipped) - set(keys))}") diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index 2d531bd..f6339bd 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -89,13 +89,13 @@ def between(source: str, opening: str, closing: str) -> str: """ const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Smooth mouse turning", &smooth)) { + if (option_toggle("disruptor.high_precision_camera", &smooth)) { apply_smooth_turn(smooth); mark_smooth_turn(smooth); } if (!turn_applies) ImGui::EndDisabled(); """ in controls - and menu.count('"Smooth mouse turning"') == 1 + and menu.count('option_toggle("disruptor.high_precision_camera"') == 1 and controls.count("ImGui::BeginDisabled()") == controls.count("ImGui::EndDisabled()"), "the setting is on the Controls tab alone, greyed without mouse aim or geometry correction, applied and staged when changed", ) diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 99b00c9..4226f36 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -122,13 +122,13 @@ def between(source: str, opening: str, closing: str) -> str: require( """ bool by_depth = gl_renderer_world_depth() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Depth-sorted world", &by_depth)) { + if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } if (!geometry) ImGui::EndDisabled(); """ in enhancements - and menu.count('"Depth-sorted world"') == 1 + and menu.count('option_toggle("disruptor.depth_sorted_world"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), "the setting is on the Enhancements tab alone, greyed without geometry correction, applied and staged when changed", ) diff --git a/tests/unit/disruptor_present_rate_test.cpp b/tests/unit/disruptor_present_rate_test.cpp index 58540ba..95cee36 100644 --- a/tests/unit/disruptor_present_rate_test.cpp +++ b/tests/unit/disruptor_present_rate_test.cpp @@ -28,6 +28,7 @@ struct Host { double display_hz = 143.86; }; struct Slider { + std::string label; bool moved = false; int moved_to = 0; int shown = -1; @@ -38,6 +39,7 @@ struct Slider { std::string text; }; struct Switch { + std::string label; bool clicked = false; bool shown = false; int disabled_when_drawn = -1; @@ -77,7 +79,8 @@ extern "C" void gl_renderer_interpolation_diag(int *, int *, int *, double *host namespace ImGui { void BeginDisabled() { ++g_slider.disabled; } -bool Checkbox(const char *, bool *value) { +bool Checkbox(const char *label, bool *value) { + g_switch.label = label; g_switch.shown = *value; g_switch.disabled_when_drawn = g_slider.disabled; const bool taken = g_switch.clicked && g_slider.disabled == 0; @@ -86,7 +89,8 @@ bool Checkbox(const char *, bool *value) { } void EndDisabled() { --g_slider.disabled; } void SetNextItemWidth(float) {} -bool SliderInt(const char *, int *value, int lowest, int highest, const char *, int) { +bool SliderInt(const char *label, int *value, int lowest, int highest, const char *, int) { + g_slider.label = label; g_slider.shown = *value; g_slider.disabled_when_drawn = g_slider.disabled; g_slider.lowest = lowest; @@ -119,8 +123,9 @@ void test_the_control_hands_over_what_the_user_chose() { launcher_override(""); g_host = Host{}; g_slider = Slider{}; - expect(disruptor_present_rate_control(&accepted) == 0 && accepted == -7 && g_host.sets == 0, + expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 0, "a control nobody touched changes nothing"); + expect(g_slider.label == "Rate", "the control is drawn under the label it was given"); expect(g_slider.shown == 60 && g_slider.lowest == 30 && g_slider.highest == 144 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 0, "it shows the host's target between 30 and the display's rounded rate"); @@ -128,19 +133,19 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.text.find("launcher") == std::string::npos, "and says where its top comes from"); launcher_override("90"); - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", &accepted); expect(g_slider.text.find("PSX_FRAME_INTERPOLATION_FPS of the launcher replaces it") != std::string::npos && g_slider.text.find("not known") == std::string::npos, "with the launcher's override set it says the override wins at the next start"); for (const char *refused : {"29", "1001", "-5"}) { launcher_override(refused); - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", &accepted); expect(g_slider.text.find("launcher") == std::string::npos, "a launcher rate the start refuses is not announced as replacing the saved one"); } launcher_override(""); g_host.display_hz = 1000.6; - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", &accepted); expect(g_slider.highest == 120 && g_slider.text.find("outside the host's range") != std::string::npos, "a refresh rate past the host's range gives 120, as the sentence says"); g_host.display_hz = 143.86; @@ -148,21 +153,21 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider = Slider{}; g_slider.moved = true; g_slider.moved_to = 30; - expect(disruptor_present_rate_control(&accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, + expect(disruptor_present_rate_control("Rate", &accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, "a moved control sets the chosen rate and reports it"); g_host = Host{}; g_host.accepts = 0; accepted = -7; - expect(disruptor_present_rate_control(&accepted) == 0 && accepted == -7 && g_host.sets == 1, + expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 1, "a rate the host refuses is not reported as chosen"); g_host = Host{}; - expect(disruptor_present_rate_control(nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); + expect(disruptor_present_rate_control("Rate", nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); g_host = Host{}; g_host.display_hz = 59.94; g_host.target = 0; g_slider = Slider{}; - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", &accepted); expect(g_slider.highest == 120 && g_slider.shown == 120, "on a 60 Hz display the top is 120 and a target of 0 is shown as the top"); g_host = Host{}; @@ -171,7 +176,7 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.moved = true; g_slider.moved_to = 45; accepted = -7; - expect(disruptor_present_rate_control(&accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 1, "with in-between frames off the control is drawn disabled and sets nothing"); expect(g_slider.text.find("In-between frames are off") != std::string::npos, "and says why"); @@ -196,38 +201,40 @@ void test_the_switch_turns_the_frames_on_and_off() { g_host.enabled = 0; g_slider = Slider{}; g_switch = Switch{}; - expect(disruptor_present_rate_switch(1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && + expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && g_switch.label == "Switch", + "the switch is drawn under the label it was given"); + expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && g_switch.disabled_when_drawn == 0, "a switch nobody touched changes nothing and shows the host's state"); g_switch.clicked = true; - expect(disruptor_present_rate_switch(1, &state) == 1 && state == 1 && g_host.set_enabled == 1, + expect(disruptor_present_rate_switch("Switch", 1, &state) == 1 && state == 1 && g_host.set_enabled == 1, "a click on a switch that is off turns the frames on and reports it"); g_host = Host{}; state = -7; - expect(disruptor_present_rate_switch(1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, + expect(disruptor_present_rate_switch("Switch", 1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, "a click on a switch that is on turns them off and reports it"); g_host = Host{}; g_host.enabled = 0; g_host.accepts = 0; state = -7; - expect(disruptor_present_rate_switch(1, &state) == 0 && state == -7 && g_host.sets == 1, + expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && state == -7 && g_host.sets == 1, "a switch the host refuses is not reported as changed"); g_host = Host{}; g_host.enabled = 0; - expect(disruptor_present_rate_switch(1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); + expect(disruptor_present_rate_switch("Switch", 1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); g_host = Host{}; g_host.enabled = 0; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch(0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_switch("Switch", 0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_switch.disabled_when_drawn == 1, "without exact geometry the switch is drawn disabled and sets nothing"); expect(g_slider.text.find("Enable exact geometry") != std::string::npos, "and says what it waits for"); g_host = Host{}; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch(0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && + expect(disruptor_present_rate_switch("Switch", 0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.disabled_when_drawn == 0 && g_slider.text.empty(), "frames that are on can be switched off without exact geometry too"); } From 728d059a3a2fca1151b991f0cfe258a340390f61 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 03:18:13 +0300 Subject: [PATCH 12/13] fix: grey a setting by one rule of what it needs, in the launcher and the menu --- src/game/settings/options.cpp | 35 ++++++---- src/game/settings/options.h | 5 +- src/game/ui/dev_menu.cpp | 47 +++++++++---- src/launcher/launcher_settings.cpp | 6 +- .../test_ambient_occlusion_contract.py | 29 ++++---- tests/contracts/test_dev_menu_contract.py | 68 +++++++++++++++++++ tests/contracts/test_frame_rate_contract.py | 5 +- tests/contracts/test_gap_filling_contract.py | 8 ++- tests/contracts/test_launcher_contract.py | 5 ++ tests/contracts/test_smooth_turn_contract.py | 7 +- tests/contracts/test_world_depth_contract.py | 8 ++- tests/unit/launcher_settings_test.cpp | 34 ++++++++-- 12 files changed, 199 insertions(+), 58 deletions(-) diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp index 046131c..59d9788 100644 --- a/src/game/settings/options.cpp +++ b/src/game/settings/options.cpp @@ -65,10 +65,14 @@ constexpr const char* frames_key = "video.frame_interpolation"; constexpr const char* rate_key = "video.frame_interpolation_fps"; constexpr const char* occlusion_key = "disruptor.ambient_occlusion"; constexpr const char* aim_key = "disruptor.mouse_aim"; +constexpr const char* needs_geometry[] = {geometry_key}; +constexpr const char* needs_frames[] = {frames_key}; +constexpr const char* needs_occlusion[] = {occlusion_key}; +constexpr const char* needs_aim_and_geometry[] = {aim_key, geometry_key}; template constexpr Option toggle(const char* key, const char* label, int initial, const char* hint, - const char* needs = nullptr) { + std::span needs = {}) { return {key, Kind::toggle, label, saved, read, write, initial, 0, 1, {}, needs, Unit::none, hint}; } @@ -76,12 +80,12 @@ constexpr Option toggle(const char* key, const char* label, int initial, const c constexpr Option choice(const char* key, const char* label, int initial, const char* hint, std::span choices, Saved saved, Get get, Set set) { return {key, Kind::choice, label, saved, get, set, initial, 0, static_cast(choices.size()) - 1, - choices, nullptr, Unit::none, hint}; + choices, {}, Unit::none, hint}; } constexpr Option slider(const char* key, const char* label, int initial, const char* hint, int lowest, int highest, Unit unit, Saved saved, Get get, Set set, - const char* needs = nullptr) { + std::span needs = {}) { return {key, Kind::slider, label, saved, get, set, initial, lowest, highest, {}, needs, unit, hint}; } @@ -116,14 +120,14 @@ constexpr Option option_list[] = { toggle<&Settings::has_perspective_textures, &Settings::perspective_textures>( "disruptor.perspective_textures", "Perspective-correct world textures", 1, "Draws wall and floor textures in true perspective. The PlayStation's own way bends them near " - "the viewer.", geometry_key), + "the viewer.", needs_geometry), toggle<&Settings::has_gap_filling, &Settings::gap_filling>( "disruptor.gap_filling", "Gap filling", 1, - "Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", geometry_key), + "Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", needs_geometry), toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>( "disruptor.depth_sorted_world", "Depth-sorted world", 0, "Draws world polygons by their depth where the game's own order puts a far one over a near " - "one.", geometry_key), + "one.", needs_geometry), choice("disruptor.improved_shadows", "Shadows", 0, "Vanilla is the game's own shape. Improved lays a shadow flat on the floor: as wide as the " "game makes it and as tall as the floor's perspective gives. Improved needs exact " @@ -136,12 +140,12 @@ constexpr Option option_list[] = { toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>( "video.frame_interpolation", "In-between frames", 0, "Shows extra pictures between the game's frames, built from the game's own geometry, for " - "smoother motion.", geometry_key), + "smoother motion.", needs_geometry), slider("video.frame_interpolation_fps", "In-between frame rate", 0, "Pictures shown per second while in-between frames are on.", kDisruptorPresentRateLowest, kDisruptorPresentRateHighest, Unit::frames, saved<&Settings::has_frame_interpolation_fps>, read<&Settings::frame_interpolation_fps>, - write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key), + write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, needs_frames), toggle<&Settings::has_skip_intro, &Settings::skip_intro>( "disruptor.skip_intro", "Skip the logos and the title movie", 0, "Starts at the main menu without the logos and the title movie. Escape skips a logo or a " @@ -165,7 +169,7 @@ constexpr Option option_list[] = { toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( "disruptor.high_precision_camera", "Smooth mouse turning", 0, "The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " - "a step in between. Needs exact geometry.", aim_key), + "a step in between. Needs exact geometry.", needs_aim_and_geometry), toggle<&Settings::has_vertical_look, &Settings::vertical_look>( "disruptor.vertical_look", "Vertical mouse look", 0, "Looks up and down with the mouse, to about 31 degrees each way. The original game has no " @@ -187,15 +191,15 @@ constexpr Option option_list[] = { toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>( "disruptor.ambient_occlusion", "Ambient occlusion", 0, - "Shades corners and the floor under things. Unfinished.", geometry_key), + "Shades corners and the floor under things. Unfinished.", needs_geometry), slider("disruptor.occlusion_reach", "Occlusion reach", 160, "How far from a corner or an object the shade spreads.", 50, 800, Unit::none, saved<&Settings::has_occlusion_reach>, read<&Settings::occlusion_reach>, - write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key), + write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, needs_occlusion), slider("disruptor.occlusion_strength", "Occlusion strength", 80, "How dark the shade is.", 0, 300, Unit::percent, saved<&Settings::has_occlusion_strength>, read<&Settings::occlusion_strength>, - write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key), + write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, needs_occlusion), }; } // namespace @@ -207,4 +211,11 @@ const Option* find_option(const char* key) { return nullptr; } +bool applies(const Option& option, const std::function& on) { + return std::all_of(option.needs.begin(), option.needs.end(), [&](const char* key) { + const Option* needed = find_option(key); + return needed && on(*needed) && applies(*needed, on); + }); +} + } // namespace disruptor::settings diff --git a/src/game/settings/options.h b/src/game/settings/options.h index b6e5dd9..c939a5a 100644 --- a/src/game/settings/options.h +++ b/src/game/settings/options.h @@ -2,6 +2,7 @@ #include "config_loader.h" +#include #include // The settings a player can change, said once for the launcher and for the in-game menu. @@ -24,12 +25,14 @@ struct Option { int lowest; int highest; std::span choices; - const char* needs; // the toggle that must be on for this option to apply + std::span needs; // the toggles that must be on for this option to apply Unit unit; const char* hint; }; std::span options(); const Option* find_option(const char* key); +// Whether every toggle the option needs is on, as `on` tells, and every toggle those need in turn. +bool applies(const Option& option, const std::function& on); } // namespace disruptor::settings diff --git a/src/game/ui/dev_menu.cpp b/src/game/ui/dev_menu.cpp index 72e998e..e568fa7 100644 --- a/src/game/ui/dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -198,6 +198,19 @@ bool changed(const char *key) { return g_preferences.changed.contains(find_option(key)); } +bool prerequisite_on(const Option &option) { + const std::string_view key(option.key); + if (key == "disruptor.geometry_correction") + return gpu_geometry_correction_enabled() != 0; + if (key == "disruptor.mouse_aim") return disruptor_mouse_aim_enabled() != 0; + if (key == "disruptor.ambient_occlusion") return gl_renderer_ssao() != 0; + return false; +} + +bool applies(const char *key) { + return disruptor::settings::applies(*find_option(key), prerequisite_on); +} + bool option_toggle(const char *key, bool *value) { return ImGui::Checkbox(find_option(key)->label, value); } @@ -734,7 +747,7 @@ void draw_controls_tab() { mark_invert_x(invert_x); } - const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; + const bool turn_applies = applies("disruptor.high_precision_camera"); bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.high_precision_camera", &smooth)) { @@ -941,35 +954,38 @@ void draw_enhancements_tab() { draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool textures = gpu_texture_correction_enabled() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool textures_apply = applies("disruptor.perspective_textures"); + if (!textures_apply) ImGui::BeginDisabled(); if (option_toggle("disruptor.perspective_textures", &textures)) { gpu_texture_correction_set(textures ? 1 : 0); mark_textures(textures); } - if (!geometry) ImGui::EndDisabled(); + if (!textures_apply) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - if (!geometry) + if (!textures_apply) ImGui::TextDisabled("Enable exact geometry before perspective textures."); bool gaps = gl_renderer_gap_fill() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool gaps_apply = applies("disruptor.gap_filling"); + if (!gaps_apply) ImGui::BeginDisabled(); if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } - if (!geometry) ImGui::EndDisabled(); + if (!gaps_apply) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); ImGui::TextDisabled( "Closes hairline gaps between polygons and keeps the game's own " "patches off their neighbours."); bool by_depth = gl_renderer_world_depth() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool depth_applies = applies("disruptor.depth_sorted_world"); + if (!depth_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } - if (!geometry) ImGui::EndDisabled(); + if (!depth_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); ImGui::TextDisabled( "Draws world polygons by their depth where the game's own order puts a " @@ -1003,7 +1019,8 @@ void draw_enhancements_tab() { #ifndef PSX_DISABLE_FRAME_INTERPOLATION int present_enabled = 0; if (disruptor_present_rate_switch( - find_option("video.frame_interpolation")->label, geometry ? 1 : 0, + find_option("video.frame_interpolation")->label, + applies("video.frame_interpolation") ? 1 : 0, &present_enabled)) mark_present_enabled(present_enabled != 0); int present_rate = 0; @@ -1043,24 +1060,26 @@ void draw_experimental_tab() { "correction on."); ImGui::Spacing(); - const bool geometry = gpu_geometry_correction_enabled() != 0; bool occlusion = gl_renderer_ssao() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool occlusion_applies = applies("disruptor.ambient_occlusion"); + if (!occlusion_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } - if (!geometry) ImGui::EndDisabled(); + if (!occlusion_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); int reach = static_cast(std::lround(gl_renderer_ssao_radius())); int strength = static_cast(std::lround(gl_renderer_ssao_strength() * 100.0f)); - if (!geometry) ImGui::BeginDisabled(); + const bool shape_applies = applies("disruptor.occlusion_reach") && + applies("disruptor.occlusion_strength"); + if (!shape_applies) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); ImGui::SetNextItemWidth(260.0f); const bool strength_moved = option_slider("disruptor.occlusion_strength", &strength); - if (!geometry) ImGui::EndDisabled(); + if (!shape_applies) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); diff --git a/src/launcher/launcher_settings.cpp b/src/launcher/launcher_settings.cpp index 98a4e72..9b84723 100644 --- a/src/launcher/launcher_settings.cpp +++ b/src/launcher/launcher_settings.cpp @@ -121,15 +121,15 @@ int SettingsFile::shown(const Option& option) const { } bool SettingsFile::applies(const Option& option) const { - const Option* needed = option.needs ? find_option(option.needs) : nullptr; - return !needed || (shown(*needed) != 0 && applies(*needed)); + return settings::applies(option, [this](const Option& needed) { return shown(needed) != 0; }); } void SettingsFile::put(Settings& settings, const Option& option, int value) { option.set(settings, value); if (option.kind != Kind::toggle || value != 0) return; + const auto named = [&](const char* key) { return std::string_view(key) == option.key; }; for (const auto& other : options()) { - if (other.kind == Kind::toggle && other.needs && std::string_view(other.needs) == option.key && + if (other.kind == Kind::toggle && std::any_of(other.needs.begin(), other.needs.end(), named) && stored(settings, other) != 0) put(settings, other, 0); } diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index b2e7acb..95fdf3b 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -81,19 +81,23 @@ def between(source: str, opening: str, closing: str) -> str: experimental = between(menu, "void draw_experimental_tab() {", "\n}\n") require( - """ const bool geometry = gpu_geometry_correction_enabled() != 0; - bool occlusion = gl_renderer_ssao() != 0; - if (!geometry) ImGui::BeginDisabled(); + """ bool occlusion = gl_renderer_ssao() != 0; + const bool occlusion_applies = applies("disruptor.ambient_occlusion"); + if (!occlusion_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } - if (!geometry) ImGui::EndDisabled(); + if (!occlusion_applies) ImGui::EndDisabled(); """ in experimental - and """ const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); + and """ const bool shape_applies = applies("disruptor.occlusion_reach") && + applies("disruptor.occlusion_strength"); + if (!shape_applies) ImGui::BeginDisabled(); + ImGui::SetNextItemWidth(260.0f); + const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); """ in experimental and """ option_slider("disruptor.occlusion_strength", &strength); - if (!geometry) ImGui::EndDisabled(); + if (!shape_applies) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); @@ -104,7 +108,7 @@ def between(source: str, opening: str, closing: str) -> str: and experimental.count("option_toggle(") == 1 and "ImGui::Checkbox(" not in experimental and ' if (ImGui::BeginTabItem("Experimental")) {\n draw_experimental_tab();\n' in menu, - "the Experimental tab holds the occlusion alone: a switch and two bounded sliders, greyed without geometry correction", + "the Experimental tab holds the occlusion alone: a switch greyed without geometry correction and two bounded sliders greyed without the switch", ) require( @@ -118,8 +122,8 @@ def between(source: str, opening: str, closing: str) -> str: and STAGED in menu and MERGED in menu and ' toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>(\n "disruptor.ambient_occlusion",' in options - and "write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key)" in options - and "write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key)" in options + and "write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, needs_occlusion)" in options + and "write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, needs_occlusion)" in options and """ if (!env_override_present("PSX_GL_SSAO")) { if (settings.has_ambient_occlusion) gl_renderer_set_ssao(settings.ambient_occlusion ? 1 : 0); """ in menu @@ -162,11 +166,12 @@ def between(source: str, opening: str, closing: str) -> str: reach = between(options, 'slider("disruptor.occlusion_reach", "Occlusion reach", 160,', "),\n") strength = between(options, 'slider("disruptor.occlusion_strength", "Occlusion strength", 80,', "),\n") require( - switch.endswith("geometry_key") + switch.endswith("needs_geometry") and "50, 800, Unit::none," in reach - and reach.endswith("occlusion_key") + and reach.endswith("needs_occlusion") and "0, 300, Unit::percent," in strength - and strength.endswith("occlusion_key") + and strength.endswith("needs_occlusion") + and "constexpr const char* needs_occlusion[] = {occlusion_key};\n" in options and 'constexpr const char* occlusion_key = "disruptor.ambient_occlusion";\n' in options and ' {"disruptor.ambient_occlusion", Page::experimental},\n' ' {"disruptor.occlusion_reach", Page::experimental},\n' diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 7d664e3..4924809 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -813,4 +813,72 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: '"The loudness of everything the game plays.", 0, 100, Unit::percent,' in options, "the menu must draw every shared option by its key, with the table's label, bounds and choices and none of its own") +RULE = ( + "bool applies(const Option& option, const std::function& on) {\n" + " return std::all_of(option.needs.begin(), option.needs.end(), [&](const char* key) {\n" + " const Option* needed = find_option(key);\n" + " return needed && on(*needed) && applies(*needed, on);\n" + " });\n" + "}\n" +) +LIVE = ( + "bool prerequisite_on(const Option &option) {\n" + " const std::string_view key(option.key);\n" + ' if (key == "disruptor.geometry_correction")\n' + " return gpu_geometry_correction_enabled() != 0;\n" + ' if (key == "disruptor.mouse_aim") return disruptor_mouse_aim_enabled() != 0;\n' + ' if (key == "disruptor.ambient_occlusion") return gl_renderer_ssao() != 0;\n' + " return false;\n" + "}\n" + "\n" + "bool applies(const char *key) {\n" + " return disruptor::settings::applies(*find_option(key), prerequisite_on);\n" + "}\n" +) +key_names = dict(re.findall(r'constexpr const char\* (\w+_key) = "([\w.]+)";', options)) +needs_lists = { + name: [key_names[word.strip()] for word in words.split(",")] + for name, words in re.findall(r"constexpr const char\* (needs_\w+)\[\] = \{([^}]*)\};", options) +} +table_rows = re.split(r"\n (?=toggle<|choice\(|slider\()", options[options.index("constexpr Option option_list[]") :])[1:] +needing = {} +for row in table_rows: + row = row.split("\n};")[0] + ending = re.search(r"\b(needs_\w+)\),?\s*$", row) + if ending: + needing[re.search(r'"([\w.]+)"', row)[1]] = needs_lists[ending[1]] +asked = set(re.findall(r'\bapplies\("([\w.]+)"\)', menu)) +told = set(re.findall(r'key == "([\w.]+)"', function_body(menu, "prerequisite_on"))) +require(RULE in options and LIVE in menu and len(needing) == 9 and + needing["disruptor.high_precision_camera"] == ["disruptor.mouse_aim", "disruptor.geometry_correction"] and + asked == set(needing) - {"video.frame_interpolation_fps"} and + told == {key for keys in needing.values() for key in keys} - {"video.frame_interpolation"} and + " if (!enabled) ImGui::BeginDisabled();\n" in function_body(present, "disruptor_present_rate_control") and + "if (!geometry)" not in menu and "turn_applies = mouse_aim" not in menu, + "the menu must grey a control by the shared table's rule alone, asked of the running game, and the " + "rate control must grey itself while in-between frames are off") + + +def greyed_by_own_rule(source: str, key: str) -> bool: + rule = re.search(r'const bool (\w+) =[^;]*\bapplies\("' + re.escape(key) + r'"\)[^;]*;\n', source) + if rule is None: + return False + begin, end = f" if (!{rule[1]}) ImGui::BeginDisabled();\n", f" if (!{rule[1]}) ImGui::EndDisabled();\n" + control = re.search(r'\boption_(?:toggle|slider|list)\("' + re.escape(key) + '"', source) + if control is None or source.count(begin) != 1 or source.count(end) != 1: + return False + opened, closed = source.index(begin), source.index(end) + return (rule.end() <= opened < control.start() < closed and + "Disabled()" not in source[opened + len(begin):control.start()] and + "BeginDisabled()" not in source[control.start():closed]) + + +greyed = asked - {"video.frame_interpolation"} +require(len(greyed) == 7 and all(greyed_by_own_rule(menu, key) for key in greyed) and + not greyed_by_own_rule(menu.replace(" if (!textures_apply) ImGui::BeginDisabled();\n", + " if (false) ImGui::BeginDisabled();\n"), "disruptor.perspective_textures") and + not greyed_by_own_rule(menu.replace('applies("disruptor.gap_filling")', 'applies("disruptor.vsync")'), + "disruptor.gap_filling"), + "each control that needs something must be drawn inside a disabled block opened by its own rule") + print("developer menu contract: PASS") diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index 0b72189..1f54eb8 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -193,7 +193,8 @@ def require(condition: bool, message: str) -> None: and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" " int present_enabled = 0;\n" " if (disruptor_present_rate_switch(\n" - ' find_option("video.frame_interpolation")->label, geometry ? 1 : 0,\n' + ' find_option("video.frame_interpolation")->label,\n' + ' applies("video.frame_interpolation") ? 1 : 0,\n' " &present_enabled))\n" " mark_present_enabled(present_enabled != 0);\n" " int present_rate = 0;\n" @@ -268,7 +269,7 @@ def require(condition: bool, message: str) -> None: require( 'void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); }\n' in menu and MERGED in menu - and "write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key)" in options, + and "write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, needs_frames)" in options, "a changed in-between frame rate must reach settings.toml", ) require( diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 3e2efe6..292cb94 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -142,12 +142,13 @@ def between(source: str, opening: str, closing: str) -> str: enhancements = between(menu, "void draw_enhancements_tab() {", "const char *cheat_result_message(") require( """ bool gaps = gl_renderer_gap_fill() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool gaps_apply = applies("disruptor.gap_filling"); + if (!gaps_apply) ImGui::BeginDisabled(); if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } - if (!geometry) ImGui::EndDisabled(); + if (!gaps_apply) ImGui::EndDisabled(); """ in enhancements and menu.count('option_toggle("disruptor.gap_filling"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), @@ -184,7 +185,8 @@ def between(source: str, opening: str, closing: str) -> str: row = between(options, '"disruptor.gap_filling", "Gap filling", 1,', "),\n") require( - row.endswith("geometry_key") + row.endswith("needs_geometry") + and "constexpr const char* needs_geometry[] = {geometry_key};\n" in options and options.count('"disruptor.gap_filling"') == launcher.count('"disruptor.gap_filling"') == 1 and ' {"disruptor.gap_filling", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, on at first and only with geometry correction", diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index a5bbcda..0108a2d 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -60,6 +60,11 @@ def plain_values(text: str) -> dict[str, str]: and " for (const auto& placement : layout()) {\n const Option& option = *find_option(placement.key);\n" in window, "the launcher puts each shared option on one page and describes none itself", ) +require( + " return settings::applies(option, [this](const Option& needed) { return shown(needed) != 0; });\n" in table + and "std::any_of(other.needs.begin(), other.needs.end(), named)" in table, + "the launcher greys a row by the shared rule asked of its file, and a toggle switched off takes with it the toggles that need it", +) writer = loader[loader.index("bool save_user_settings(") :] saved, section = set(), "" diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index f6339bd..f182d04 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -86,7 +86,7 @@ def between(source: str, opening: str, closing: str) -> str: controls = between(menu, "void draw_controls_tab() {", "void apply_geometry_enabled(") require( - """ const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; + """ const bool turn_applies = applies("disruptor.high_precision_camera"); bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.high_precision_camera", &smooth)) { @@ -124,11 +124,12 @@ def between(source: str, opening: str, closing: str) -> str: row = between(options, '"disruptor.high_precision_camera", "Smooth mouse turning", 0,', "),\n") require( - row.endswith("aim_key") + row.endswith("needs_aim_and_geometry") + and "constexpr const char* needs_aim_and_geometry[] = {aim_key, geometry_key};\n" in options and 'constexpr const char* aim_key = "disruptor.mouse_aim";\n' in options and options.count('"disruptor.high_precision_camera"') == launcher.count('"disruptor.high_precision_camera"') == 1 and ' {"disruptor.high_precision_camera", Page::controls},\n' in launcher, - "the launcher offers it on the Controls page, off at first and only with mouse aim", + "the launcher offers it on the Controls page, off at first and only with mouse aim and geometry correction", ) print("Disruptor smooth mouse turning source contract: PASS") diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 4226f36..58adbfc 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -121,12 +121,13 @@ def between(source: str, opening: str, closing: str) -> str: enhancements = between(menu, "void draw_enhancements_tab() {", "const char *cheat_result_message(") require( """ bool by_depth = gl_renderer_world_depth() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool depth_applies = applies("disruptor.depth_sorted_world"); + if (!depth_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } - if (!geometry) ImGui::EndDisabled(); + if (!depth_applies) ImGui::EndDisabled(); """ in enhancements and menu.count('option_toggle("disruptor.depth_sorted_world"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), @@ -165,7 +166,8 @@ def between(source: str, opening: str, closing: str) -> str: row = between(options, '"disruptor.depth_sorted_world", "Depth-sorted world", 0,', "),\n") require( - row.endswith("geometry_key") + row.endswith("needs_geometry") + and "constexpr const char* needs_geometry[] = {geometry_key};\n" in options and options.count('"disruptor.depth_sorted_world"') == launcher.count('"disruptor.depth_sorted_world"') == 1 and ' {"disruptor.depth_sorted_world", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, off at first and only with geometry correction", diff --git a/tests/unit/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp index 865e281..36ba231 100644 --- a/tests/unit/launcher_settings_test.cpp +++ b/tests/unit/launcher_settings_test.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace fs = std::filesystem; using namespace disruptor::launcher; @@ -54,7 +55,8 @@ void table() { require(!one.choices.empty() && one.initial >= 0 && one.initial < static_cast(one.choices.size()) && one.highest == static_cast(one.choices.size()) - 1, "a choice starts outside its list"); if (one.kind == Kind::toggle) require(one.initial == 0 || one.initial == 1, "a toggle starts as neither"); - if (one.needs) require(option(one.needs).kind == Kind::toggle && &option(one.needs) != &one, "a row needs no toggle"); + for (const char* need : one.needs) + require(option(need).kind == Kind::toggle && &option(need) != &one, "a row needs no toggle"); require(one.hint && std::strlen(one.hint) >= 20 && std::strlen(one.hint) <= 240, "a row has no hint to show"); } require(presets().size() == 2, "the two presets are expected"); @@ -104,6 +106,24 @@ void carried() { "a sensitivity is not written in whole thousandths"); } +// The rule both front ends ask: every toggle an option needs is on, and so is what those need. +void needed() { + const auto on = [](std::set keys) { + return [keys = std::move(keys)](const Option& toggle) { return keys.contains(toggle.key); }; + }; + const Option& smooth = option("disruptor.high_precision_camera"); + const Option& reach = option("disruptor.occlusion_reach"); + require(disruptor::settings::applies(option("video.vsync"), on({})), "an option that needs nothing always applies"); + require(disruptor::settings::applies(smooth, on({"disruptor.mouse_aim", "disruptor.geometry_correction"})) && + !disruptor::settings::applies(smooth, on({"disruptor.mouse_aim"})) && + !disruptor::settings::applies(smooth, on({"disruptor.geometry_correction"})), + "an option applies only with every toggle it needs"); + require(disruptor::settings::applies(reach, on({"disruptor.ambient_occlusion", "disruptor.geometry_correction"})) && + !disruptor::settings::applies(reach, on({"disruptor.ambient_occlusion"})) && + !disruptor::settings::applies(reach, on({"disruptor.geometry_correction"})), + "and only when what it needs applies itself"); +} + void placements() { std::set placed; for (const auto& row : layout()) { @@ -218,10 +238,13 @@ void dependants(const fs::path& folder) { const Option& by_depth = option("disruptor.depth_sorted_world"); const Option& occlusion = option("disruptor.ambient_occlusion"); const Option& reach = option("disruptor.occlusion_reach"); - require(settings.set(aim, 1) && settings.set(smooth, 1) && settings.applies(smooth) && settings.set(geometry, 0) && - settings.shown(smooth) == 1 && settings.applies(smooth), "smooth turning needs mouse aim, not geometry"); - require(settings.set(aim, 0) && settings.shown(smooth) == 0 && !settings.applies(smooth) && - has(file, "high_precision_camera = false"), "and goes off with mouse aim"); + require(settings.set(aim, 1) && settings.set(smooth, 1) && settings.applies(smooth) && settings.set(aim, 0) && + settings.shown(smooth) == 0 && !settings.applies(smooth) && has(file, "high_precision_camera = false"), + "smooth turning needs mouse aim and goes off with it"); + require(settings.set(aim, 1) && settings.applies(smooth) && settings.set(smooth, 1) && + has(file, "high_precision_camera = true") && settings.set(geometry, 0) && settings.shown(smooth) == 0 && + !settings.applies(smooth) && has(file, "high_precision_camera = false") && settings.set(aim, 0), + "and geometry, and goes off with that too"); require(settings.shown(gaps) == 0 && has(file, "gap_filling = false"), "gap filling, on in a fresh installation, went off with geometry above and was written so"); require(settings.set(geometry, 1) && settings.set(gaps, 1) && settings.set(by_depth, 1) && settings.set(occlusion, 1) && @@ -468,6 +491,7 @@ int main() { fs::create_directories(folder); table(); carried(); + needed(); placements(); absent(folder); one_change(folder); From 3f17794b1a2805baa697207c81a43e26ee504cff Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 03:24:18 +0300 Subject: [PATCH 13/13] feat: show a setting's description under the pointer in the in-game menu --- docs/PLAYING.md | 5 +- src/game/frame/present_rate.cpp | 18 ++++--- src/game/frame/present_rate.h | 4 +- src/game/settings/options.cpp | 2 +- src/game/ui/dev_menu.cpp | 50 ++++++++---------- src/game/ui/hint.h | 16 ++++++ .../test_ambient_occlusion_contract.py | 2 +- tests/contracts/test_dev_menu_contract.py | 52 +++++++++++++++---- tests/contracts/test_frame_rate_contract.py | 21 +++++--- tests/support/present_rate_fakes/imgui.h | 6 +++ tests/unit/disruptor_present_rate_test.cpp | 47 ++++++++++------- 11 files changed, 147 insertions(+), 76 deletions(-) create mode 100644 src/game/ui/hint.h diff --git a/docs/PLAYING.md b/docs/PLAYING.md index ec45571..857f1d2 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -72,7 +72,10 @@ folder; these backups can be removed manually once the repaired copy works. Modern mouse/keyboard controls, geometry correction, and perspective textures are enabled initially. Vertical mouse look is experimental and starts off; -enable it in **Settings → Controls** if desired. +enable it in **Settings → Controls** if desired. A setting shows its +description under the pointer, the same one as in the launcher. A setting that +needs another one stays greyed until that one is on. In-between frames that +are on can still be switched off. **Settings → Enhancements** offers 1x–8x resolution, widescreen options, HUD size for widescreen, and 60 FPS gameplay and in-between frames. Both diff --git a/src/game/frame/present_rate.cpp b/src/game/frame/present_rate.cpp index 4a6acf4..1b7051b 100644 --- a/src/game/frame/present_rate.cpp +++ b/src/game/frame/present_rate.cpp @@ -5,6 +5,8 @@ #include "game/frame/present_rate.h" +#include "game/ui/hint.h" + #include "host_ui.h" #include "imgui.h" @@ -20,14 +22,15 @@ int disruptor_present_rate_enable(int enabled) { return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0; } -int disruptor_present_rate_switch(const char *label, int available, int *enabled) { +int disruptor_present_rate_switch(const char *label, const char *hint, int available, int *enabled) { int on = 0, changed = 0; psx_host_video_get_interpolation(&on, nullptr, nullptr); bool checked = on != 0; const bool locked = !available && !checked; if (locked) ImGui::BeginDisabled(); - if (ImGui::Checkbox(label, &checked) && - disruptor_present_rate_enable(checked ? 1 : 0)) { + const bool clicked = ImGui::Checkbox(label, &checked); + disruptor::ui::hint(hint); + if (clicked && disruptor_present_rate_enable(checked ? 1 : 0)) { if (enabled) *enabled = checked ? 1 : 0; changed = 1; } @@ -43,7 +46,7 @@ int disruptor_present_rate_apply(int frames_per_second) { return enabled && psx_host_video_set_interpolation(1, frames_per_second, blend) != 0; } -int disruptor_present_rate_control(const char *label, int *accepted) { +int disruptor_present_rate_control(const char *label, const char *hint, int *accepted) { int enabled = 0, target = 0, changed = 0; double display_hz = 0.0; psx_host_video_get_interpolation(&enabled, &target, nullptr); @@ -52,9 +55,10 @@ int disruptor_present_rate_control(const char *label, int *accepted) { int shown = disruptor_present_rate_shown(target, top); if (!enabled) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS", - ImGuiSliderFlags_AlwaysClamp) && - disruptor_present_rate_apply(shown)) { + const bool moved = ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS", + ImGuiSliderFlags_AlwaysClamp); + disruptor::ui::hint(hint); + if (moved && disruptor_present_rate_apply(shown)) { if (accepted) *accepted = shown; changed = 1; } diff --git a/src/game/frame/present_rate.h b/src/game/frame/present_rate.h index 2ab5f3c..9832f5f 100644 --- a/src/game/frame/present_rate.h +++ b/src/game/frame/present_rate.h @@ -28,10 +28,10 @@ constexpr int disruptor_present_rate_shown(int target_fps, int top) { /* Switches in-between frames on or off at the rate and blend the host has. Returns 0 when the host refused. */ int disruptor_present_rate_enable(int enabled); /* Draws the switch: frames that are off cannot be switched on while `available` is 0. Returns 1 and the state in `enabled` when the user changed it and the host took it. */ -int disruptor_present_rate_switch(const char *label, int available, int *enabled); +int disruptor_present_rate_switch(const char *label, const char *hint, int available, int *enabled); /* Sets the rate of in-between frames that are on. Returns 0 when they are off or the host refused. */ int disruptor_present_rate_apply(int frames_per_second); /* Draws the control. Returns 1 and the rate in `accepted` when the user changed it and the host took it. */ -int disruptor_present_rate_control(const char *label, int *accepted); +int disruptor_present_rate_control(const char *label, const char *hint, int *accepted); #endif diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp index 59d9788..15325e4 100644 --- a/src/game/settings/options.cpp +++ b/src/game/settings/options.cpp @@ -169,7 +169,7 @@ constexpr Option option_list[] = { toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( "disruptor.high_precision_camera", "Smooth mouse turning", 0, "The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " - "a step in between. Needs exact geometry.", needs_aim_and_geometry), + "a step in between. Needs mouse aim and exact geometry.", needs_aim_and_geometry), toggle<&Settings::has_vertical_look, &Settings::vertical_look>( "disruptor.vertical_look", "Vertical mouse look", 0, "Looks up and down with the mouse, to about 31 degrees each way. The original game has no " diff --git a/src/game/ui/dev_menu.cpp b/src/game/ui/dev_menu.cpp index e568fa7..df034e0 100644 --- a/src/game/ui/dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -17,6 +17,7 @@ #include "game/frame/present_rate.h" #include "game/session/restart.h" #include "game/settings/options.h" +#include "game/ui/hint.h" #include "config_loader.h" #include "gpu.h" #include "host_ui.h" @@ -212,29 +213,38 @@ bool applies(const char *key) { } bool option_toggle(const char *key, bool *value) { - return ImGui::Checkbox(find_option(key)->label, value); + const Option &option = *find_option(key); + const bool moved = ImGui::Checkbox(option.label, value); + disruptor::ui::hint(option.hint); + return moved; } bool option_slider(const char *key, int *value) { const Option &option = *find_option(key); const char *format = option.unit == Unit::percent ? "%d%%" : "%d"; - return ImGui::SliderInt(option.label, value, option.lowest, - option.highest, format, - ImGuiSliderFlags_AlwaysClamp); + const bool moved = + ImGui::SliderInt(option.label, value, option.lowest, option.highest, + format, ImGuiSliderFlags_AlwaysClamp); + disruptor::ui::hint(option.hint); + return moved; } bool option_slider(const char *key, float *value) { const Option &option = *find_option(key); - return ImGui::SliderFloat(option.label, value, option.lowest / 1000.0f, - option.highest / 1000.0f, "%.3f", - ImGuiSliderFlags_Logarithmic); + const bool moved = ImGui::SliderFloat( + option.label, value, option.lowest / 1000.0f, + option.highest / 1000.0f, "%.3f", ImGuiSliderFlags_Logarithmic); + disruptor::ui::hint(option.hint); + return moved; } bool option_list(const char *key, int *index, size_t skipped = 0) { const Option &option = *find_option(key); const auto shown = option.choices.subspan(skipped); - return ImGui::Combo(option.label, index, shown.data(), - static_cast(shown.size())); + const bool moved = ImGui::Combo(option.label, index, shown.data(), + static_cast(shown.size())); + disruptor::ui::hint(option.hint); + return moved; } void mark_mouse_aim(bool value) { mark("disruptor.mouse_aim", value); } @@ -756,10 +766,6 @@ void draw_controls_tab() { } if (!turn_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "The game turns in 256 steps of a circle. While the mouse is captured " - "the picture is turned by the part of a step in between. Needs mouse aim " - "and exact geometry."); ImGui::SeparatorText("Vertical look"); bool vertical_look = disruptor_mouse_vertical_look_enabled() != 0; @@ -902,9 +908,6 @@ void draw_aspect_controls() { mark_shadow_shape(shadow_shape != 0); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Improved lays a shadow flat on the floor: as wide as the game makes it and as tall " - "as the floor's perspective gives. Works with geometry correction on."); } int hud_scale = gpu_ws_hud_scale(); @@ -914,9 +917,6 @@ void draw_aspect_controls() { mark_hud_scale(hud_scale); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Widescreen only. Shrinks the health and ammunition displays toward " - "their screen edges. The weapon keeps its size."); } void draw_internal_resolution_controls() { @@ -974,9 +974,6 @@ void draw_enhancements_tab() { } if (!gaps_apply) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Closes hairline gaps between polygons and keeps the game's own " - "patches off their neighbours."); bool by_depth = gl_renderer_world_depth() != 0; const bool depth_applies = applies("disruptor.depth_sorted_world"); @@ -987,9 +984,6 @@ void draw_enhancements_tab() { } if (!depth_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Draws world polygons by their depth where the game's own order puts a " - "far one over a near one."); ImGui::SeparatorText("Synchronisation"); int vsync = psx_host_video_get_vsync(); @@ -1020,12 +1014,14 @@ void draw_enhancements_tab() { int present_enabled = 0; if (disruptor_present_rate_switch( find_option("video.frame_interpolation")->label, + find_option("video.frame_interpolation")->hint, applies("video.frame_interpolation") ? 1 : 0, &present_enabled)) mark_present_enabled(present_enabled != 0); int present_rate = 0; if (disruptor_present_rate_control( - find_option("video.frame_interpolation_fps")->label, &present_rate)) + find_option("video.frame_interpolation_fps")->label, + find_option("video.frame_interpolation_fps")->hint, &present_rate)) mark_present_rate(present_rate); #endif @@ -1036,7 +1032,6 @@ void draw_enhancements_tab() { mark_skip_intro(skip_intro); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled("Escape skips the logo or movie on screen."); } const char *cheat_result_message(int result, const char *success) { @@ -1084,7 +1079,6 @@ void draw_experimental_tab() { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); } - ImGui::TextDisabled("Shades corners and the floor under things."); } void draw_debug_tab() { diff --git a/src/game/ui/hint.h b/src/game/ui/hint.h new file mode 100644 index 0000000..267ccb8 --- /dev/null +++ b/src/game/ui/hint.h @@ -0,0 +1,16 @@ +#pragma once + +#include "imgui.h" + +namespace disruptor::ui { + +// Shows `text` while the pointer rests on the control drawn last. +inline void hint(const char *text) { + if (!ImGui::BeginItemTooltip()) return; + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 30.0f); + ImGui::TextUnformatted(text); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); +} + +} // namespace disruptor::ui diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 95fdf3b..3710a88 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -79,7 +79,7 @@ def between(source: str, opening: str, closing: str) -> str: "or a strength that is not one changes nothing", ) -experimental = between(menu, "void draw_experimental_tab() {", "\n}\n") +experimental = between(menu, "void draw_experimental_tab() {", "\n}\n") + "\n" require( """ bool occlusion = gl_renderer_ssao() != 0; const bool occlusion_applies = applies("disruptor.ambient_occlusion"); diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 4924809..49a3a9a 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -771,29 +771,38 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: HELPERS = ( "bool option_toggle(const char *key, bool *value) {\n" - " return ImGui::Checkbox(find_option(key)->label, value);\n" + " const Option &option = *find_option(key);\n" + " const bool moved = ImGui::Checkbox(option.label, value);\n" + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" "}\n" "\n" "bool option_slider(const char *key, int *value) {\n" " const Option &option = *find_option(key);\n" ' const char *format = option.unit == Unit::percent ? "%d%%" : "%d";\n' - " return ImGui::SliderInt(option.label, value, option.lowest,\n" - " option.highest, format,\n" - " ImGuiSliderFlags_AlwaysClamp);\n" + " const bool moved =\n" + " ImGui::SliderInt(option.label, value, option.lowest, option.highest,\n" + " format, ImGuiSliderFlags_AlwaysClamp);\n" + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" "}\n" "\n" "bool option_slider(const char *key, float *value) {\n" " const Option &option = *find_option(key);\n" - " return ImGui::SliderFloat(option.label, value, option.lowest / 1000.0f,\n" - ' option.highest / 1000.0f, "%.3f",\n' - " ImGuiSliderFlags_Logarithmic);\n" + " const bool moved = ImGui::SliderFloat(\n" + " option.label, value, option.lowest / 1000.0f,\n" + ' option.highest / 1000.0f, "%.3f", ImGuiSliderFlags_Logarithmic);\n' + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" "}\n" "\n" "bool option_list(const char *key, int *index, size_t skipped = 0) {\n" " const Option &option = *find_option(key);\n" " const auto shown = option.choices.subspan(skipped);\n" - " return ImGui::Combo(option.label, index, shown.data(),\n" - " static_cast(shown.size()));\n" + " const bool moved = ImGui::Combo(option.label, index, shown.data(),\n" + " static_cast(shown.size()));\n" + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" "}\n" ) shared_labels = re.findall(r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"[\w.]+",\s*"([^"]+)"', options) @@ -881,4 +890,29 @@ def greyed_by_own_rule(source: str, key: str) -> bool: "disruptor.gap_filling"), "each control that needs something must be drawn inside a disabled block opened by its own rule") +HINT = ( + "inline void hint(const char *text) {\n" + " if (!ImGui::BeginItemTooltip()) return;\n" + " ImGui::PushTextWrapPos(ImGui::GetFontSize() * 30.0f);\n" + " ImGui::TextUnformatted(text);\n" + " ImGui::PopTextWrapPos();\n" + " ImGui::EndTooltip();\n" + "}\n" +) +LITERAL = r'"((?:[^"\\]|\\.)*)"' +shared_hints = [ + "".join(re.findall(LITERAL, pieces)) + for pieces in re.findall( + r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"[\w.]+",\s*"[^"]+",\s*-?\d+,\s*((?:"(?:[^"\\]|\\.)*"\s*)+)', options) +] +written = "".join(re.findall(LITERAL, menu + present)) +require(HINT in read("src/game/ui/hint.h") and len(shared_hints) == 27 and + all(20 <= len(text) <= 240 for text in shared_hints) and + not [text for text in shared_hints if text[:32] in written] and + "Needs mouse aim and exact geometry." in shared_hints[ + [key for key in re.findall(r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"([\w.]+)"', options)].index( + "disruptor.high_precision_camera")], + "the menu must show an option's description from the shared table while the pointer rests on its control, " + "and write none of the descriptions itself") + print("developer menu contract: PASS") diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index 1f54eb8..f975773 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -194,12 +194,14 @@ def require(condition: bool, message: str) -> None: " int present_enabled = 0;\n" " if (disruptor_present_rate_switch(\n" ' find_option("video.frame_interpolation")->label,\n' + ' find_option("video.frame_interpolation")->hint,\n' ' applies("video.frame_interpolation") ? 1 : 0,\n' " &present_enabled))\n" " mark_present_enabled(present_enabled != 0);\n" " int present_rate = 0;\n" " if (disruptor_present_rate_control(\n" - ' find_option("video.frame_interpolation_fps")->label, &present_rate))\n' + ' find_option("video.frame_interpolation_fps")->label,\n' + ' find_option("video.frame_interpolation_fps")->hint, &present_rate))\n' " mark_present_rate(present_rate);\n" "#endif\n" in menu @@ -213,9 +215,10 @@ def require(condition: bool, message: str) -> None: "a build without the frame interpolator must have no rate control, in the menu or in the sources", ) require( - 'if (ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' - " ImGuiSliderFlags_AlwaysClamp) &&\n" - " disruptor_present_rate_apply(shown)) {\n" + ' const bool moved = ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' + " ImGuiSliderFlags_AlwaysClamp);\n" + " disruptor::ui::hint(hint);\n" + " if (moved && disruptor_present_rate_apply(shown)) {\n" " if (accepted) *accepted = shown;\n" " changed = 1;\n" in present_control @@ -235,10 +238,12 @@ def require(condition: bool, message: str) -> None: " return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0;\n" in present_control and '"video.frame_interpolation", "In-between frames", 0,' in options and 'slider("video.frame_interpolation_fps", "In-between frame rate", 0,' in options - and "int disruptor_present_rate_switch(const char *label, int available, int *enabled) {\n" in present_control - and "int disruptor_present_rate_control(const char *label, int *accepted) {\n" in present_control - and "if (ImGui::Checkbox(label, &checked) &&\n" - " disruptor_present_rate_enable(checked ? 1 : 0)) {\n" + and "int disruptor_present_rate_switch(const char *label, const char *hint, int available, int *enabled) {\n" + in present_control + and "int disruptor_present_rate_control(const char *label, const char *hint, int *accepted) {\n" in present_control + and " const bool clicked = ImGui::Checkbox(label, &checked);\n" + " disruptor::ui::hint(hint);\n" + " if (clicked && disruptor_present_rate_enable(checked ? 1 : 0)) {\n" " if (enabled) *enabled = checked ? 1 : 0;\n" in present_control, "the switch must keep the rate and the blend mode and report only a state the host accepted", diff --git a/tests/support/present_rate_fakes/imgui.h b/tests/support/present_rate_fakes/imgui.h index 7c3725b..364c2dc 100644 --- a/tests/support/present_rate_fakes/imgui.h +++ b/tests/support/present_rate_fakes/imgui.h @@ -3,10 +3,16 @@ enum { ImGuiSliderFlags_AlwaysClamp = 16 }; namespace ImGui { void BeginDisabled(); +bool BeginItemTooltip(); bool Checkbox(const char *label, bool *value); void EndDisabled(); +void EndTooltip(); +float GetFontSize(); +void PopTextWrapPos(); +void PushTextWrapPos(float position); void SetNextItemWidth(float width); bool SliderInt(const char *label, int *value, int lowest, int highest, const char *format, int flags); void TextDisabled(const char *text, ...); +void TextUnformatted(const char *text); } // namespace ImGui #endif diff --git a/tests/unit/disruptor_present_rate_test.cpp b/tests/unit/disruptor_present_rate_test.cpp index 95cee36..44782c3 100644 --- a/tests/unit/disruptor_present_rate_test.cpp +++ b/tests/unit/disruptor_present_rate_test.cpp @@ -77,8 +77,11 @@ extern "C" void gl_renderer_interpolation_diag(int *, int *, int *, double *host if (host_hz) *host_hz = g_host.display_hz; } +std::string g_hint; + namespace ImGui { void BeginDisabled() { ++g_slider.disabled; } +bool BeginItemTooltip() { return true; } bool Checkbox(const char *label, bool *value) { g_switch.label = label; g_switch.shown = *value; @@ -88,6 +91,10 @@ bool Checkbox(const char *label, bool *value) { return taken; } void EndDisabled() { --g_slider.disabled; } +void EndTooltip() {} +float GetFontSize() { return 13.0f; } +void PopTextWrapPos() {} +void PushTextWrapPos(float) {} void SetNextItemWidth(float) {} bool SliderInt(const char *label, int *value, int lowest, int highest, const char *, int) { g_slider.label = label; @@ -99,6 +106,7 @@ bool SliderInt(const char *label, int *value, int lowest, int highest, const cha return g_slider.moved && g_slider.disabled == 0; } void TextDisabled(const char *text, ...) { g_slider.text = text; } +void TextUnformatted(const char *text) { g_hint = text; } } // namespace ImGui #include "../../src/game/frame/present_rate.cpp" @@ -123,9 +131,9 @@ void test_the_control_hands_over_what_the_user_chose() { launcher_override(""); g_host = Host{}; g_slider = Slider{}; - expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 0, + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 0 && accepted == -7 && g_host.sets == 0, "a control nobody touched changes nothing"); - expect(g_slider.label == "Rate", "the control is drawn under the label it was given"); + expect(g_slider.label == "Rate" && g_hint == "Rate hint", "the control is drawn under the label and with the hint it was given"); expect(g_slider.shown == 60 && g_slider.lowest == 30 && g_slider.highest == 144 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 0, "it shows the host's target between 30 and the display's rounded rate"); @@ -133,19 +141,19 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.text.find("launcher") == std::string::npos, "and says where its top comes from"); launcher_override("90"); - (void)disruptor_present_rate_control("Rate", &accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.text.find("PSX_FRAME_INTERPOLATION_FPS of the launcher replaces it") != std::string::npos && g_slider.text.find("not known") == std::string::npos, "with the launcher's override set it says the override wins at the next start"); for (const char *refused : {"29", "1001", "-5"}) { launcher_override(refused); - (void)disruptor_present_rate_control("Rate", &accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.text.find("launcher") == std::string::npos, "a launcher rate the start refuses is not announced as replacing the saved one"); } launcher_override(""); g_host.display_hz = 1000.6; - (void)disruptor_present_rate_control("Rate", &accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.highest == 120 && g_slider.text.find("outside the host's range") != std::string::npos, "a refresh rate past the host's range gives 120, as the sentence says"); g_host.display_hz = 143.86; @@ -153,21 +161,21 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider = Slider{}; g_slider.moved = true; g_slider.moved_to = 30; - expect(disruptor_present_rate_control("Rate", &accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, "a moved control sets the chosen rate and reports it"); g_host = Host{}; g_host.accepts = 0; accepted = -7; - expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 1, + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 0 && accepted == -7 && g_host.sets == 1, "a rate the host refuses is not reported as chosen"); g_host = Host{}; - expect(disruptor_present_rate_control("Rate", nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); + expect(disruptor_present_rate_control("Rate", "Rate hint", nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); g_host = Host{}; g_host.display_hz = 59.94; g_host.target = 0; g_slider = Slider{}; - (void)disruptor_present_rate_control("Rate", &accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.highest == 120 && g_slider.shown == 120, "on a 60 Hz display the top is 120 and a target of 0 is shown as the top"); g_host = Host{}; @@ -176,7 +184,7 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.moved = true; g_slider.moved_to = 45; accepted = -7; - expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 1, "with in-between frames off the control is drawn disabled and sets nothing"); expect(g_slider.text.find("In-between frames are off") != std::string::npos, "and says why"); @@ -201,40 +209,41 @@ void test_the_switch_turns_the_frames_on_and_off() { g_host.enabled = 0; g_slider = Slider{}; g_switch = Switch{}; - expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && g_switch.label == "Switch", - "the switch is drawn under the label it was given"); - expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 0 && g_switch.label == "Switch" && + g_hint == "Switch hint", + "the switch is drawn under the label and with the hint it was given"); + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && g_switch.disabled_when_drawn == 0, "a switch nobody touched changes nothing and shows the host's state"); g_switch.clicked = true; - expect(disruptor_present_rate_switch("Switch", 1, &state) == 1 && state == 1 && g_host.set_enabled == 1, + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 1 && state == 1 && g_host.set_enabled == 1, "a click on a switch that is off turns the frames on and reports it"); g_host = Host{}; state = -7; - expect(disruptor_present_rate_switch("Switch", 1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, "a click on a switch that is on turns them off and reports it"); g_host = Host{}; g_host.enabled = 0; g_host.accepts = 0; state = -7; - expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && state == -7 && g_host.sets == 1, + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 0 && state == -7 && g_host.sets == 1, "a switch the host refuses is not reported as changed"); g_host = Host{}; g_host.enabled = 0; - expect(disruptor_present_rate_switch("Switch", 1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); g_host = Host{}; g_host.enabled = 0; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch("Switch", 0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_switch("Switch", "Switch hint", 0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_switch.disabled_when_drawn == 1, "without exact geometry the switch is drawn disabled and sets nothing"); expect(g_slider.text.find("Enable exact geometry") != std::string::npos, "and says what it waits for"); g_host = Host{}; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch("Switch", 0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && + expect(disruptor_present_rate_switch("Switch", "Switch hint", 0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.disabled_when_drawn == 0 && g_slider.text.empty(), "frames that are on can be switched off without exact geometry too"); }