Commit Graph
24828 Commits
Author SHA1 Message Date
Brian Degenhardt bb9df22fc9 Tests: stop the DXSTG write-back check skipping on 16K-page hosts
MapAt's candidate addresses are 4K-aligned and none is 16K-aligned, so on
a 16K-page kernel -- Asahi, Apple Silicon, some Android, and one of our own
CI jobs -- the kernel rejects every one of them and the mapping fails. The
write-back check treated that as a precondition and skipped outright, which
took its guest-side assertions with it: the ones that actually pin where a
DXSTG-steered eviction lands, none of which need anything from the host.

The mapping is only the negative control, there to show the write-back did
not ALSO reach the host page carrying the same number. Make it optional.
The guest-side half now runs everywhere and only the control drops out.

DxstgDirtyStaysInsideGuestMemory still skips, and should: it is entirely
about the host page. That leaves one skip here on a 16K-page host instead
of two, and none at all on a 4K one.
2026-08-10 15:58:14 -07:00
Brian Degenhardt 85a88d75da Tests: point the DXSTG unresolvable-page check at a page that is unresolvable
The check named 0x1FFFF000, described as "BIOS/unmapped territory at the
top of the physical map". That page is the last one of the 4 MB BIOS ROM
mapped at 0x1FC00000, so it is real backing memory: the test took the
backed branch every time, wrote 64 bytes into the loaded BIOS image, and
asserted only that nothing faulted. The branch it was named for -- the one
carrying the safety property -- had no coverage at all.

Name 0x60129000 instead. It is past the end of the physical map, and it is
the page with teeth, because the old 29-bit fold sent it to 0x00129000 in
main RAM. A witness there turns "we did not fault" into "we did not write
somewhere the guest never named", which is the property worth holding.

An SCPH-30001 agrees with that much: an eviction steered above the end of
RAM puts nothing into RAM. Nothing beyond it is asserted -- where a tag
naming one of our main-RAM mirrors resolves is emulator-specific, so it
stays unpinned, with a comment saying so and why.
2026-08-10 15:58:14 -07:00
Brian Degenhardt 5f50eab28e EE: the D-cache store-tag lookup dropped the top three bits of the tag
DXSTG takes a guest physical page from TagLo and has to turn it into the
host pointer our tags carry. It did that by routing the page through its
KSEG0 alias, which meant masking the tag to 29 bits first -- and KSEG0 is
only 512 MB wide, so the mask was not a formality. Every physical page at
or above 0x20000000 folded into the low half of the map and resolved to
whatever happened to live at the folded address.

The consequence that matters is that a page past the end of the physical
map folded onto real memory: 0x60129000 resolved to 0x00129000, and the
eviction wrote 64 bytes of cache line into guest RAM the tag never named.

Use vtlb_GetPhyPtr instead, which is what the debugger and PSM already use
to ask this question. It covers the whole 1 GB physical map and answers
null both for a handler page and for an address off the end of the map, so
the unbacked case is now decided by the same lookup that produces the
pointer rather than by a truncation.

Where a tag naming one of our main-RAM mirrors resolves changes as a side
effect of that, and is deliberately left unpinned. Those mirrors are our
physical map's, not a console's: an SCPH-30001 has no RAM at those physical
addresses, and an eviction steered at one reached nothing at all. There is
no hardware answer to hold us to, so nothing asserts one.
2026-08-10 15:57:42 -07:00
Brian Degenhardt e509b17e7a Merge pull request #565 from pstef/tests
Assorted improvements
2026-08-09 19:48:45 -07:00
jpolo1224 dfb4263926 Android: regenerate the PGO profile against this tree, and let generate mode run
The committed profile was generated from ARMSX2-mono-recovered and last refreshed
on 2026-07-13 -- its own function paths name that tree. Building against it costs
7.7% of .text (15,262,600 -> 16,433,556 bytes) versus a matched profile, because
every function the profile does not cover falls back to static inlining
heuristics. Size is the visible symptom; the risk is speed, and this is the same
class of defect as the #165 VU slam, where a profile predating recompiler churn
made LTO optimise the hot VU paths the wrong way.

This one is captured from armsx2-push-staging at e9f8f8366 -- the first profile
ever taken from the tree it is used to build. 38,219 functions against the old
profile's 36,807, with microVU (292 entries), recExecuteBlock, the recompiler
dispatchers, GSRendererHW::Draw and the VIF/GIF transfer loops all covered.
Rebuilding with it lands .text at 15,173,960, 0.6% BELOW the last known-good
build despite carrying more code -- a matched profile inlines selectively where
an unmatched one inlines blindly.

build-release-apk.sh required PROF unconditionally, which made regenerating
impossible: PGO_MODE=generate builds the instrumented APK you play in order to
CREATE a profile, so demanding one up front failed instantly with a FATAL naming
a file that run never reads. Require it only in optimize mode.
2.6.6.5
2026-08-09 21:53:50 -04:00
Brian Degenhardt e9f8f83669 GS/HW: carry the blend-mix factor in the output alpha without dual-source blend
A blend mix hands the blend unit exactly one number - the alpha factor, on the
PS2's 0..2 scale where 128 is opaque. A second fragment output is the usual way
to carry a value on that scale, but it is not the only one: fixed-function
SRC_ALPHA reads the first output's alpha, and the shader can put the factor
there instead. Two cases make that free.

When the target holds its alpha double-scaled, the alpha the shader would write
IS the factor - tfx computes both as C.a/128 under RTA correction - so scaling
the target is the whole change. Otherwise the substitution is free whenever the
pass writes no alpha at all, because the output alpha is discarded on the way to
the target: a draw whose alpha is masked outright, or one whose alpha write has
moved into a second pass under SPLIT_RGB_ONLY.

Only the plain mix1 shape qualifies. The other mix cases rewrite the second
output's RGB independently of its alpha, so there the two outputs really do
carry different values and no substitution exists.

Without this, a GPU with no dual-source blend emulates the equation in the
shader, which needs a fresh destination read per primitive. With neither a
texture barrier nor a multidraw framebuffer copy available, all it gets is one
snapshot taken before the draw, so every primitive after the first composites
against stale pixels. That is what hollowed out God of War II's menu glyphs on
Mali r44p1, where the whole text is a single draw whose drop-shadow and bright
quads overlap each other 200 times.

Measured against a dual-source GPU rendering the same dump: over the text the
mean per-pixel error falls from 3.351 to 0.109 and the worst pixel from 163 to
25, with the lit-pixel count landing on 3896 against the reference's 3898.
Frame-wide it removes 25k of the 49k differing pixels and introduces 22. It
needs no barriers and no target copies at all, where matching this by refreshing
the snapshot per primitive group cost ~1000 render-pass breaks a frame and two
thirds of the frame rate on device.

No effect where dual-source blending exists: 33 frames across 11 dumps are byte
identical.
2026-08-09 17:49:51 -07:00
Brian Degenhardt 89e51d93a1 GS/HW: split RGB_ONLY alpha test by channel without dual-source blend
AFAIL=RGB_ONLY means every fragment writes RGB and only the ones passing the
alpha test write A and Z. The accurate single-pass form of that carries the
pass/fail decision in the second blend source, so it needs a hardware
dual-source blend unit. Mali Vulkan stacks routinely report dualSrcBlend=false,
and there the draw fell back to pass/fail: one pass for the passing fragments,
another for the failing ones.

Pass/fail splits the draw by *fragment*, which puts RGB in both passes. Where
the primitives overlap each other, the two passes then composite out of order -
every failing fragment of the whole draw lands after every passing one, rather
than each primitive completing before the next begins.

Splitting by *channel* instead is exact and costs the same two passes: run one
pass with the alpha test off writing RGB, then one with the test on writing A
and Z. Both passes see the primitives in order, so overlap stops mattering.

Forced on over a dual-source GPU it reproduces the single-pass path byte for
byte - 33 frames across 11 dumps, no differing pixels. Against that reference
on a no-dual-source configuration it takes God of War II's pause menu from
2.064 to 0.893 mean per-pixel error.
2026-08-09 17:49:51 -07:00
Brian Degenhardt 9d7f8c2376 Translations: restore the pt-BR plural forms for the save-state delete count
The Brazilian Portuguese update flattened "%n save states deleted." into a
single string, but the message is declared numerus="yes", so its translation
may only hold <numerusform> children — one per plural form of the language.
Bare text there is a hard lrelease error, which stopped ninja and took down
every Qt desktop build (Linux 4k/16k, macOS, Windows); Android and iOS pass
only because they never run lrelease.

Give the message back its singular and plural forms. All translation files
now release clean.
2026-08-09 16:11:32 -07:00
Brian Degenhardt ebc4ee75f3 Merge pull request #563 from johnpetersa19/master
Complete Brazilian Portuguese graphics translations
2026-08-09 15:38:38 -07:00
Brian Degenhardt 0daaf5a6f7 GameDB overlay: stop erasing upstream fixes the overlay never meant to drop
The mobile overlay layers onto bin/resources/GameIndex.yaml, and the loader
clears-then-replaces each map rather than merging: an entry that lists one
gsHWFix erases every other fix upstream sets for that serial. The file header
states the invariant - each entry must carry the complete block - but nothing
enforces it and nothing warns when it is broken. 115 serials were silently
dropping at least one upstream fix.

The bulk of it is one generation defect, not sync drift. Android used to carry
a forked copy of the GameDB; 54f0f8ba91 generated this overlay by diffing that
stale copy against bin and treating every difference as an intentional
override. Where the stale copy merely lacked a fix, the generator promoted the
absence into a deliberate-looking one, and the replace semantics then erased
the upstream value at runtime. Nothing upstream added after generation is
involved: every fix upstream sets today it already set on 2026-07-22.

Three changes here.

drawBuffering restored on 63 serials. All of them are entries the stale copy
also lacked, so the class has a single cause and no residue; before this,
exactly one overlay entry carried the key at all. It is a pure performance fix
lost on the tier that needs it most. Measured on NFS Underground 2 (SLUS-21065),
GS-dump replay on the SD865 at 2x, fan pinned, 3 interleaved reps per arm with
disjoint ranges:

               draws/frame  passes  RT copies  frame ms
  as shipped          6250    2640       2630      29.1
  + drawBuffering     3559    1633       1623      18.9

-35% frame time, 1.55x, and visually free: deterministic in both arms with
0.12% of pixels differing by 2/255 or less.

Delta Force: Black Hawk Down (SLUS-21124, SLES-53299) restated complete. That
entry listed hwDownloadMode alone and thereby erased upstream's autoFlush,
halfPixelOffset, textureInsideRT and nativeScaling - the bloom, sky-bloom and
post-processing fixes. Both commit messages behind it describe only an
addition, and the entry is hand-appended above the sorted body, so this was an
accident rather than a decision. It keeps its out-of-sorted position; moving it
risks a future regeneration adding a second SLUS-21124 in the sorted slot.

Valkyrie Profile 2 (11 serials) keeps its configuration and regains the comment
explaining it. Upstream's halfPixelOffset:4 with nativeScaling:2 blows out the
render target when upscaling on Adreno and Mali; only nativeScaling:1 with
roundSprite:1 renders cleanly, so the drop is the point. The original entry
said so in an 8-line comment that 54f0f8ba91 stripped when it re-sorted the
file, which is why the entry has read as unexplained collateral since. The
intent was never lost, only the record of it - so the rationale now lives next
to the entry, where a regeneration cannot separate them, and it warns that an
audit will flag it.

57 entries still drop some other upstream fix and are deliberately untouched.
That residue is a mix of causes and needs per-fix judgement: the 7
preloadFrameData removals are the Rogue Galaxy see-through-wall fix and must
stay dropped, 10 more are advisory-only keys that change no setting, and the
cpuSpriteRender and minimumBlendingLevel drops would cost performance on this
exact tier if restored. drawBuffering was the one class safe to restore
wholesale.

Audit re-run clean: no serial drops drawBuffering, none sets it where upstream
does not, and no other fix class was touched.
2026-08-09 15:13:46 -07:00
Brian Degenhardt ce3eac044e GS: stop taking a voluntary RT feedback read where it costs a render pass
An Ad blend with alpha writes masked can be substituted (Ad -> As) and run in
hardware if the draw reads the render target. The draw did not otherwise need
that read, so the substitution is only worth taking where reading is free.

The gate for "free" was !texture_barrier, written to mean D3D11, where the
fallback is a plain copy on an API with no render passes. It is equally true of
every driver carrying UseRenderTargetCopyForFeedback, where the fallback is a
per-draw copy bracketed by a render-pass break - the most expensive feedback
read we have. Widening that workaround to all of Adreno therefore handed those
drivers the whole optimization in its worst form, on thousands of draws that
never needed to read anything. This is the same regression fixed for the
framebuffer-fetch path in ec57f7f1c6, arriving by the other term.

Replayed on the same dumps and binaries, draws whose shader reads the render
target, per frame:

                barriers on   barriers off   with this change
  NFS U'ground           14            610                  1
  FlatOut 2             n/a            448                 17

Ask for the property being asserted instead. cheap_rt_feedback_read is set by
D3D11, and by Metal when programmable blending is available - a feedback draw
there binds the target and stays in the same render pass. Vulkan's
ordered-attachment-access spelling does not qualify: the loop is declared
through the pass configuration, so toggling it ends the pass.

Cost on the SD865 (Adreno 650, turnip, fan and governors pinned, median frame
time over 3 runs of 20 loops, 3x upscale):

                shipped   OverrideTextureBarriers=1   this change
  NFS U'ground  17.15 ms      12.86 ms (1.33x)     12.00 ms (1.43x)
  FlatOut 2     22.84 ms      21.93 ms (1.04x)     17.91 ms (1.28x)
  Katamari       1.42 ms       1.29 ms (1.10x)      1.40 ms (1.02x)

Katamari is the control: it has no Ad-masked draws, its population is unchanged
(49 -> 50 copies per frame) and so is its frame time. Render passes per frame on
NFSU go 390 -> 47 and copies 347 -> 4.

Correctness is unchanged, and specifically the workaround still applies wherever
it did. Scored per-pixel against the software rasteriser over frames verified
stable across runs, this change renders Tales of the Abyss and God of War II
byte-for-byte identically to the texture-barrier path - same tiers, same
worst-case pixel - and leaves OutRun 2006 and Katamari untouched. The Abyss
title screen text, the defect the workaround exists for, is unaffected. Ad
blends that genuinely need software blending are still forced into it by
blend_requires_barrier.
2026-08-09 14:59:55 -07:00
Brian Degenhardt b5415c8105 GS: stop a screenshot ending a GS dump that is already recording
A snapshot request and a running recording shared one frame counter. The
screenshot hotkey asks for zero dump frames, so pressing it mid-recording
zeroed the budget of the dump in progress and the next VSync closed it as
though the user had asked it to stop. A single-frame dump request did the
same thing one frame later. Both were silent; the file simply ended early.

Two fields now, so a request cannot reach into a recording at all: one for
what the queued request asked for, one for what the open dump still owes,
written only when that dump is created.

The two branches were also alternatives rather than independent, so the
frame a screenshot landed on never reached the dump and two guest frames
merged into one on replay. A recording now takes every frame it is open
for -- except the one it was opened on, whose state went into the dump's
header and whose replay therefore starts from the frame after.

A dump request arriving while one records still cannot open a second dump,
but it says so on the OSD instead of quietly writing only the screenshot.

The decision is extracted to a header-only policy with the usual
static_asserts, pinned by eight cases riding the GS test target. The
truncation is reachable only from the hotkeys and the Big Picture button --
PINE's dump opcode was written to refuse rather than trip over it -- so the
policy suite is the regression gate. Its refusal comment is updated: it now
rests on not handing back a path for a file that will never appear, which
was always the better half of the argument.
2026-08-09 14:39:29 -07:00
Brian Degenhardt 727ffd7c7d Android: add a PINE toggle to Advanced settings
EnablePINE and PINESlot were already INI-backed and VMManager::ReloadPINE
already starts and stops the server when they change, but the Android frontend
never surfaced them, so there was no way to switch PINE on from the device.

It sits beside the recompiler switches because it is the same class of control:
a developer tool a player has no reason to find, next to the other things you
turn on to diagnose rather than to play. The row states the address and, once
enabled, the adb forward line -- the listener is on loopback, so it does nothing
until a workstation bridges the port, and a port nobody tells you about cannot
be bridged.

The port itself gets no editing widget. The only reason to move it is running
two emulators on one machine, which does not happen on a handheld, and a
free-entry port field is a support burden for a knob nobody turns; it stays
readable from the INI. It is still carried in the settings model so the row can
state the real port rather than assume the default.

Note the per-game merge is a full constructor, so a field omitted there resets
to its default instead of inheriting: PINE is a process-wide server and cannot
be per-game, so it is absent from the diff (no game file ever acquires the key)
but explicitly carried through the merge.
2026-08-09 13:02:50 -07:00
Brian Degenhardt 912b1d8f95 PINE: listen on loopback TCP on Android
PINE has never worked on Android. Every non-Windows platform binds an AF_UNIX
socket under XDG_RUNTIME_DIR, falling back to /tmp; Android sets neither and has
no /tmp, so Initialize() failed at bind() and the server simply never started.

Nor is there anywhere better to put the socket. The writable directories on
Android are app-private, and every client that would want to connect -- adb, a
shell, another process -- runs under a different uid, so a socket placed there
binds successfully and then admits nobody.

Loopback TCP is the transport Android does support reaching into: adb forward
bridges a device port to a workstation. PINE already speaks TCP because Windows
has always needed it, and the wire format is identical, so this is a matter of
selecting the existing branch rather than writing a new one -- hence
PINE_TCP_TRANSPORT, which separates "which socket family" from the two Windows
API questions (the SOCKET handle type, winsock startup) that _WIN32 still owns.

SO_REUSEADDR comes along on the POSIX side: relaunching the app is the normal
debugging loop on a handheld, and without it a killed process leaves the port in
TIME_WAIT and PINE looks broken for a minute with nothing explaining why.
2026-08-09 13:02:40 -07:00
Brian Degenhardt 7af3929992 GS: move the Mali r44p1 self-read gates into the driver-bug database
The r44p1 blob cannot survive reading the render target in-tile, in any
spelling: on Vulkan it loses the device outright, on GL the same silicon
corrupts the frame instead. Three hand-rolled substring searches encoded that
one fact -- one in the GL backend testing GL_VERSION, two in the Vulkan backend
testing driverInfo -- while the database that exists precisely for this already
modelled it as UseRenderTargetCopyForFeedback, described in its own definition
as being for "drivers where no form of attachment self-read works".

So express it as two rules, one per API, and read them:

  - GL takes the workaround bit in place of its GL_VERSION search.
  - The Vulkan texture_barrier gate is deleted outright. It was setting
    m_features.texture_barrier = false sixteen lines below a table-driven block
    that now sets exactly the same thing for the same driver -- pure duplication
    once the rule exists.

One deliberate behaviour difference: the table-driven path respects
OverrideTextureBarriers, which the hand-rolled test ignored. The comment above
it documents forcing barriers on as the way back to the in-tile path for A/B
work, so honouring that is the intent rather than a regression.

Rules match a PARSED driver revision, which is what lets them say "exactly
r44p1" instead of "contains r44p1" and what makes the next bad blob a table row.
It is also the risk: a rule that matches nothing looks perfectly healthy and
puts the device back on the faulting path with no diagnostic. Hence the new
tests, which drive the resolver with the exact strings the RG 477V reports and
assert the outcome -- plus the neighbouring revisions r44p0, r44p2, r45p1, r38
and r52, which must keep the fast in-tile read.

Desktop GL is unaffected (the profile only resolves on Android, so the
workaround bit is never set there); verified through gsrunner that framebuffer
fetch is still selected. 53/53 GS tests.

Two r44p1 gates are deliberately left alone for now: both run during device
creation, before the Vulkan profile is resolved in CheckFeatures, so they need
that resolution moved earlier first.
2026-08-09 12:06:59 -07:00
Brian Degenhardt 4db909d0ad GS: say what died when the host GPU device is lost
A lost device is almost always the driver refusing something we asked it to
do, and the ask lives in the feature set rather than in the crash. The Mali
r44p1 blob is the worked example: it loses the Vulkan device under
attachment-feedback-loop and mishandles in-tile framebuffer fetch on GL, both
of which are the accurate-blending destination read. Neither is deducible from
"host GPU lost", and nothing else in the log restates which blend path the
device picked -- that is decided from driver strings at startup and never
mentioned again.

The second-loss-within-15s guard makes this worse than it looks. Recovery
rebuilds the identical device, so a configuration the driver cannot survive
reaches the guard deterministically: the second loss follows the first within
a frame or two. The guard aborts before the OSD warning is raised, so from the
user's side it is an unexplained crash, and the abort message named neither the
GPU nor the driver version nor anything about the blend path.

So log the driver identity, the renderer, the destination-read path in words,
the features behind it, and the settings that steer them -- once at the loss,
and again in both abort messages. Captured before the recovery path destroys
the device, which is the last point at which any of it can still be read.

No behaviour change: recovery still rebuilds the same device. Automatic
demotion was considered and rejected -- falling back silently is what stops the
bug report reaching us, and these reports are the only signal we get from
hardware we do not own.
2026-08-09 11:25:59 -07:00
Brian Degenhardt 393cb544e0 GS/OpenGL: fall back per draw, not per primitive, when GLES has no barrier
A GLES device has no ARB or NV texture barrier, so CheckFeatures sets
multidraw_fb_copy and the backend substitutes a render-target copy taken once
per primitive group inside a full-barrier draw. That is the right substitute on
an immediate-mode GPU, where a blit is a blit and the per-primitive copy buys
real blend ordering. On a tiler it is not a copy at all: reading the render
target back forces the tile to flush and resolve to main memory, so a draw with
a few hundred primitive groups pays a few hundred full-screen flushes.

Nothing noticed because the flag is inert while there is a barrier, and on GLES
framebuffer fetch supplies one. Where fetch is off it becomes the whole blend
path -- and fetch is off on exactly the devices least able to afford it: the
Mali r44p1 blocklist, a user who disabled fetch, or a GLES stack without the
extension. Metal Gear Solid 3 on an Anbernic RG 477V (Mali-G615 MC6, r44p1) ran
at 0.33 fps. The same game on the same device runs at ~30 fps on Vulkan, which
reaches the identical copy-based concept only without this flag -- Vulkan,
D3D12 and Metal all clear it unconditionally.

So clear it on GLES too when the barrier does not materialise. GSRendererHW
then sees no feedback loop, drops require_full_barrier, and the backend takes
one render-target copy per draw. Measured on device: 0.33 fps to 23 fps, and
the frame is clean.

The accuracy cost is real and worth stating. Against the software rasteriser on
a 640x480 MGS3 frame, the fetch path is 0.245% of pixels off by >=8 and the
per-draw copy is 2.399%. Losing fetch itself accounts for none of that (0.247%
with fetch off and barriers intact) -- it is entirely the dropped per-primitive
ordering. That is the same trade every barrier-less backend already ships, and
it is not really a trade against 0.33 fps.

Only the auto path decides this; both OverrideTextureBarriers branches keep
clearing the flag themselves, so Force Disabled still means no copies rather
than a different kind of copy. Desktop GL is untouched -- verified unchanged at
753/759 differing pixels either way.

gs_vertex_tests 48/48, including four new cases pinning the fallback shape.
2026-08-09 10:38:26 -07:00
Brian Degenhardt 84f7c33822 GS: derive fetch-orders-overlap where it cannot go stale
The Vulkan backend derived framebuffer_fetch_orders_overlap from
framebuffer_fetch immediately after the first assignment, but three later
statements still write framebuffer_fetch -- the RT-copy workaround's
texture_barrier mask among them. On Adreno that mask clears fetch, and the
derived bit kept the value it had beforehand, so the device came out
advertising no framebuffer fetch and "fetch orders overlapping primitives"
at the same time.

Nothing reads the stale value today: DetermineBarriers is the only consumer
and it sits inside an `if (features.framebuffer_fetch)` gate, so the
contradiction is unreachable. That is a property of the current single call
site, not of the bit, and it is the kind of guarantee a second reader removes
without noticing. Derive it after the last write instead.

Record what the Turnip source says about the contract while the bit is being
explained, because the file already carries a measurement that reads like a
counter-example and is not one. Turnip does request the ordering when tiled
(SINGLE_PRIM_MODE = FLUSH_PER_OVERLAP under rasterization-order access, which
the a6xx docs define as waiting for overlapping primitives); what it only sets
untiled is the stronger mode that additionally keeps UCHE and CCU in sync when
fetching the current pixel's previous value. So the Adreno failure recorded
above is read visibility while tiled, not primitive ordering, and it does not
generalise to a tiler whose fetch is a genuine tile-local read.

OpenGL never assigned the bit at all and took false from the FeatureSupport
memset, which is the value it wants -- GL fetch does not order overlapping
primitives, which is why the flag exists. Say so explicitly: Vulkan and Metal
both assign it, and the one backend that stays silent reads as an omission
rather than as an answer.

No behaviour change on any backend. gs_vertex_tests 48/48.
2026-08-09 10:37:51 -07:00
Brian Degenhardt 7668167aa1 Counters: mark the savestate poison-repair blocks DELETEME after 2026-12-01
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.
2026-08-09 10:03:57 -07:00
Brian Degenhardt c77ed879a3 eerunner: add EERUNNER_EXITSTORM and EE cycle-hack knobs to liverun
EERUNNER_EXITSTORM=<period_us> spawns a thread that fires
Cpu->ExitExecution() at randomized intervals during a liverun, mimicking
the Android JNI pause/suspend churn (native-lib calls it cross-thread
against a running EE). This is what reproduced the God of War II
poisoned-timer trigger on a desktop within 1500 frames, and what verified
the fix clean over a denser 3000-frame storm.

EERUNNER_EECYCLESKIP / EERUNNER_EECYCLERATE apply the EE cycle speedhacks
so a handheld's clock shape (the Android Low-End preset ships cycle skip 1)
is reproducible on the desk.
2026-08-09 10:01:45 -07:00
Brian Degenhardt abe076fb2c Counters: warn loudly when a counter baseline sits ahead of the clock
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.
2026-08-09 10:01:37 -07:00
Brian Degenhardt 9f6288531d EE/arm64: make recSafeExitExecution safe to call cross-thread
The Android pause/stop JNI calls Cpu->ExitExecution() from the UI thread
against a running EE. recSafeExitExecution carried two accelerants inherited
from the x86 recompiler alongside its exit flag: it zeroed
cpuRegs.nextEventCycle when the EE was outside the event test, and folded
psxRegs.iopCycleEE into iopBreak when inside it.

Both are data races from a foreign thread, and the first one is how God of
War II savestates got their poisoned timers. The arm64 JIT pins the cycle
counter as a delta (RECCYCLE = cycle - nextEventCycle) for the whole life of
a block chain, reconstructing the absolute clock as delta + nextEventCycle
at C-call seams. A cross-thread zero landing mid-chain makes the next flush
reconstruct cycle = delta + 0, warping the EE clock back to near VM birth —
observed as a 142-billion-cycle rollback in a live repro. Counter baselines
are then "ahead" of the clock, which the old u32 rcntSyncCounter arithmetic
turned into the +2^32 startCycle scar and blown count that rode along in
every savestate taken afterwards (see the Counters fix in 4e34e65b84).

Desktop hosts only call ExitExecution on the CPU thread, which is why the
poisoning was Android-only. Reproduced on Linux with eerunner's new
EERUNNER_EXITSTORM knob: cross-thread ExitExecution every ~2-3 ms poisons
timer 0 within 1500 frames of God of War II on the unfixed code, and a
denser storm over 3000 frames stays clean on this fix.

The accelerants never bought arm64 anything even on the CPU thread: x86
block tails compare the clock against nextEventCycle in memory, so zeroing
it forced the very next tail into the event test — arm64 tails test the
pinned delta's sign and never reread memory mid-chain. The flag alone is the
mechanism, consumed at most one scheduler horizon (~an hblank) later.
2026-08-09 10:01:29 -07:00
Brian Degenhardt 8e4aa15918 Android: run autosave save and load state on the CPU thread
435f8bd9fd marshalled the numbered-slot save/load JNI entry points onto
the CPU thread but left the autosave pair running directly on the JNI
thread with only the park. The park stops the EE, but it does not confer
thread identity: the freeze pushes to the single-producer MTGS ring, and
the load additionally pushes micro/data memory into the MTVU ring and
resets the recompiler caches, all owned by the CPU thread. The autosave
pair is reachable from Save State And Exit, auto-load-on-boot, and the
load picker's autosave tile, so those flows kept the unpoliced races the
slot paths were cured of.

Same treatment as the slot paths: marshal via Host::RunOnCPUThread with
the park retained, and run the loadAutosaveState present in the same
task so it cannot race the resume in the pause guard's destructor.
2026-08-09 08:35:07 -07:00
Brian Degenhardt 4c57fbf49d eerunner: add --statereport, a field-level savestate timebase decoder
Loads a savestate through the emulator's own thaw path and prints every
serialized timebase and transfer-engine state by name: EE cycle and the
COP0 Count/lastCOP0Cycle pair, the four rcnts with baselines and derived
game-visible counts, vsync/hsync phase, EE<->IOP skew, IOP counters,
CDVD RTC, GIF path buffers, VIF, DMA channel registers, and the MTVU
frozen atomics. Stuck-timer bugs live in the relationship between clocks
that normally advance in lockstep; diffing two reports makes a broken
pair legible where a byte-level diff of the .p2s cannot.

First use found the GoW II poisoned-state scar on its first run: every
field identical between a poisoned and a clean state except EE timer 0,
whose baseline sat exactly one 2^32 epoch in the future.
2026-08-09 08:26:28 -07:00
Brian Degenhardt 4e34e65b84 Counters: fix u32 blowup when a counter baseline sits ahead of cycle
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.
2026-08-09 08:26:17 -07:00