The load-time repairs in rcntFreeze/psxRcntFreeze exist only to heal .p2s
files saved by builds that predate the trigger fix (9f6288531d, 2026-08-09).
No new state can carry the scar, so once old states have aged out the loops
can simply be deleted; the sync-time guards stay.
The baseline-ahead guards added in 4e34e65b84 silently skipped the sync.
Post-fix, that condition is unreachable for ungated counters unless the
CPU clock itself moved backwards — which is exactly the signature of the
cross-thread nextEventCycle race that poisoned God of War II savestates
(fixed in the previous commit), and of any future clock-regression bug.
A silent skip would hide the next one; a console warning is what let the
exit-storm repro pinpoint this one.
rcntSyncCounter computed (cpuRegs.cycle - startCycle) / rate into a u32.
With 64-bit cycle counts, a baseline even one cycle AHEAD of now (a
transient state around savestate thaw and vsync-retime seams) underflows
the subtraction, and the truncated quotient becomes change=0xFFFFFFFF:
count += 0xFFFFFFFF and startCycle += 2^32 - rate, zero-extended into the
u64. The counter is then dead until cycle crosses the bogus baseline
(14.6s at EE clock), and the blown-out count drains at one overflow lap
per pass for minutes afterwards - and the scar rides along in every
savestate taken meanwhile. psxRcntSync had the identical pattern, where
one epoch is 116.5s at IOP clock.
This is the God of War II poisoned-savestate bug: the area-title banner
stays stuck and gorgon-eye chest pickups freeze for ~6 minutes after
loading an affected state, on every host that loads it. A poisoned state
carries EXACTLY startCycle = (cycle & ~(rate-1)) + 2^32 on EE timer 0,
byte-for-byte the arithmetic above. A/B from that state: 1200 frames on
the old code still shows the stuck banner; with this change it clears.
Guard the negative case (skip the sync; the counter resumes within one
tick), widen change to u64, and repair poisoned baselines/counts when
thawing a savestate so existing affected saves heal on load.
Aspect ratios: added 20:9, 19.5:9 and a user-entered Custom ratio
(GSOptions::CustomAspectRatio, clamped 0.5..5.0). All APPENDED, never inserted —
these values are persisted as raw ints in the ini and in the Android prefs, so
slotting one in mid-enum would silently repoint every saved config at a different
ratio. Also filled in the two ultrawide cases RequestDisplaySize was missing.
Interlace/presentation: ported sashkinbro's EmuCoreX 30799e4. SelectGSInterlaceMode
centralises the mode choice and keeps shader_mode -1 for automatic full-frame output
(a deinterlace pass must not run over progressive output during a video-mode
transition); our formula already agreed, so this is centralisation plus
static_asserts rather than a behaviour change. ShouldSkipAndroidBlankFrame is new
behaviour: Vulkan now suppresses only the startup blank, so a mid-game fade reaches
the normal present path and its recorded command buffer is submitted.
Vulkan: declare the attachment feedback loops on the PIPELINE, not just on the image
layout and render pass. We put attachments into FEEDBACK_LOOP_OPTIMAL without ever
setting VK_PIPELINE_CREATE_{COLOR,DEPTH_STENCIL}_ATTACHMENT_FEEDBACK_LOOP_BIT_EXT,
which the spec requires — undefined behaviour rather than a missed optimisation, and
strict mobile drivers are where undefined shows up as stale attachment reads.
Requested by David (SSR), who noted no PS2 emulator offers one: without it the
only way to use an ultrawide patch was Stretch, which distorts. Useful on folds,
tablets, DeX and anything driving a 21:9 panel.
Added to the generic aspect AND the FMV override, since a game that wants
ultrawide gameplay usually wants it during cutscenes too. Adding it to the FMV
enum also shifted MaxCount, which the name array is sized from -- that array had
to grow with it or the last entry would have been a hole.
The Kotlin side needed SIX edits for one new enum value, and getting five of them
right still left the feature completely dead:
RendererTab options list + clamp
RendererTab FMV options list + clamp
EmulationMenu setAspectRatio clamp
EmulationMenuScreen the pause menu's own options list
Settings NativeApp.setAspectRatio(coerceIn(0, 4)) <-- the killer
Settings INI name<->index, both directions
That fifth one clamped on the way to the core, so the picker highlighted 21:9
while the emulator was told 10:7 -- UI correct, nothing happens, no error. The
sixth meant the choice would not have survived a reload even once it applied.
The NATIVE clamp needed no change at all, because it derives its bound from
AspectRatioType::MaxCount instead of hard-coding it. That is the pattern the
Kotlin side should follow; four literal 4s in four files is why this was a
six-site change instead of a one-site one.
Rendering
- Auto renderer now resolves to Vulkan HW on Adreno (OpenGL elsewhere).
- Mobile hardware ROV (Phase 0): tile-native depth feedback behind the ROV toggle.
Performance & input
- Low Latency frame pacing is the default on capable devices, with a one-time
migration for existing installs; low-end devices keep the queued pacing.
- Reduce Android input latency and improve input handling (PR #403, Splaser).
- Experimental CPU clock hint (ADPF) toggle in Performance settings (default off).
Audio & UI
- Pop-up open/close sound cues (info, hardcore confirm, patches & cheats).
- Alternating controller navigation / slider tick sounds.
RetroAchievements
- Inject the RA client version from a build-time secret kept out of public source,
with a stock-PCSX2 fallback (no hardcore) for secret-less builds. Applies to the
iOS client token too. Prevents third parties from copying our User-Agent.
Game compatibility
- Delta Force: Black Hawk Down (SLUS-21124 / SLES-53299) GameDB fixes
(PR #401, XDarkFallenX).
NFL 2K5 (SLUS-20919) hangs at the boot logo in a 64-bit divide-by-repeated-
subtraction with a huge unsigned dividend. Its 64-bit clock is an overflow-
ISR-maintained wrap accumulator plus a live T0_COUNT read (bus/16, OVFE),
reconciled lock-free with double reads — airtight on hardware, where the
count wrap and the overflow interrupt are the same edge and the ISR preempts
before any later read.
Under the JIT the guest can observe the wrap while the ISR's effects are
still pending, in two phases: (1) the count (derived from the live
cpuRegs.cycle) crosses the boundary before the scheduled rcntUpdate event
runs; (2) rcntUpdate has wrapped the count and raised the INTC, but the
exception waits for the next event test — which our static-linked /
short-block tails defer past the reader's entire load sequence (traced live:
the wrap event fires at the reader's own block-entry event test, and
delivery lands at its jr-ra exit, 30 cycles too late). Either way the game
reads stale-accumulator + wrapped-count, time goes backwards one wrap
period, and the divide runs ~2^48 iterations.
Clamp the read to just-before-the-boundary until the interrupt has actually
been delivered. The deliverability guard (INTC pending & unmasked & Status
EIE/IE, no EXL/ERL) makes this exact: inside the handler or with the source
masked (e.g. the game's DisableIntc reader, which reconciles the raw wrap
itself) the wrapped count stays observable, as on hardware.
Pinned by EeTimerCountReadRace.* in recompiler_tests. Verified live: cold
fastboot reaches attract; previously parked at the divide loop within ~20s.