Commit Graph
24923 Commits
Author SHA1 Message Date
refractionpcsx2 bc268a7e83 GameDB: Add Prioritize Lower depth fix to Steambot Chronicles
(cherry picked from commit cde2448a6cd0d27f6651c59b43102d27dcb45e48)
2026-08-16 12:46:34 -04:00
refractionpcsx2 34333fe6bd GS/HW: Avoid detecting shuffles as source of truth for target format
(cherry picked from commit ded263f98aa2862b56439ea3c0b552748e4e64f0)
2026-08-16 12:46:34 -04:00
refractionpcsx2 0d558ce69f GS/TC: Improve single pixel overlap detection in RT in RT
(cherry picked from commit ffa569065638312d9c01a47f760130a2109ce7cc)
2026-08-16 12:46:34 -04:00
Ziemas ae4da6a633 EE/IOP: Fix loading elf's through relative path
(cherry picked from commit ca03806a4c86e8fbfc948fcb110f3961be10e5b2)
2026-08-16 12:46:34 -04:00
jpolo1224 eeb3affb13 GS: FidelityFX Super Resolution 1 as an output-scaling mode
Adds FSR1 (EASU upscale + RCAS sharpen, two compute passes) to the Vulkan
backend, so a game rendered below display size can be upscaled properly instead
of bilinear-stretched at present. Slots in beside the existing MetalFX branch in
GSRenderer rather than introducing a parallel abstraction: GSUpscaler and the
non-pure DoXxx virtuals already occupy that design space, and OpenGL and Metal
inherit a false return and need no change.

FSR1 is MIT (AMD, 2021) and the tree already ships ffx_a.h and ffx_cas.h under
the identical grant, so the headers are vendored verbatim with their licence
blocks intact.

★ ffx_a.h is NOT replaced. FSR1 wants the 2021 header, ours is 2019, and the
2019 one has been locally patched for Metal Shading Language (A16, A_MSL,
A_MAYBE_UNUSED) with ffx_cas.h depending on those. Swapping it would break the
Metal backend. The 2021 copy ships alongside as ffx_a_fsr1.h, used only for
GPU-side string substitution. The CPU-side FsrEasuConOffset/FsrRcasCon compile
against the existing 2019 header — verified by compiling a probe, not by
grepping, because AU1_AF1 and AU1_AH2_AF2 are functions and a grep for a #define
reports a false negative.

Two shader modules, not two specializations. FSR_EASU_F and FSR_RCAS_F are
preprocessor gates deciding which function bodies ffx_fsr1.h emits at all, and
specialization constants resolve after preprocessing, so CAS's constant_id trick
would produce a shader calling undefined functions. Confirmed distinct:
disassembly shows EASU with three OpImageGather and RCAS with none.

Both passes push the full 80-byte constant block. With all five uvec4 declared
so one layout serves both, Sample decorates to byte offset 64 — pushing the 32
bytes RCAS nominally needs would leave it undefined, and Sample gates a
gamma-squaring branch, so garbage there squares the image.

Binding 0 is a combined image sampler, unlike CAS's plain sampled image, because
EASU uses textureGather.

The EASU intermediate stays in GENERAL with explicit compute-to-compute
barriers. Layout::ShaderReadOnly targets the FRAGMENT stage and
TransitionToLayout early-outs when the layout already matches, so neither of the
usual tools makes a compute write visible to a compute read. The barrier also
covers frame N+1's EASU write against frame N's RCAS read, since the image is
parked across frames.

FSR and CAS are alternatives, not a chain: RCAS is itself a sharpener. Selecting
FSR hides the CAS rows. Pipeline compilation failure is non-fatal and leaves
Features().fsr1 false, matching the CAS path that exists because of an Adreno
650 crash.

GSUpscaler::FSR1 is appended, not inserted, since the enum is persisted as an
integer. Android clamps to the enum's own maximum rather than the count of
options its picker shows — clamping to the picker would have rewritten FSR1 back
to Off on every save, because MetalFX occupies value 1 and is never displayed.

Verified: build clean, no C++ or Kotlin errors; all three resource files
packaged into the APK; FSR code present in the core. NOT verified: anything on a
GPU. No visual check, no perf numbers, and in particular no confirmation that
textureGather in a compute shader works on the Adreno drivers this targets.
2026-08-16 12:11:33 -04:00
jpolo1224 dfd92a4f31 LSFG: persist settings and shaders, report status, add flow scale and 3.1p
Five changes, one of which is a plain bug in what shipped.

★ LSFG settings were never persisted. lsfgEnabled, lsfgMultiplier and
lsfgDllPath were absent from toJson/fromJson, and that pair IS the persistence
format — ConfigStore stores toJson().toString(). So every choice, including the
Lossless.dll the user went and found, was discarded on restart. Added to the
round-trip, the per-game override diff/merge, and gsDiffersFrom.

Translated SPIR-V is now cached to disk. Extraction used to keep raw DXBC and
translate inside the shader callback, so all 26 translations re-ran on the GS
thread inside EndPresent after every enable, resize or multiplier change. Now
ExtractShaders translates eagerly, drops the DXBC, and writes
<cache>/lsfg_shaders.bin. The DLL's size and mtime go in the header and a
mismatch re-extracts — ARMSX3's equivalent has no invalidation at all.

Frame generation can no longer fail invisibly. GetStatusText() feeds one line
to the performance overlay, empty ONLY when the user has not enabled it:
unavailable / failed / no shaders / starting / a display rate. That rate counts
frames actually PRESENTED, real plus generated, because the acquire loop can
break early and assuming the multiplier would overstate it. FPS alone cannot
show this — frame generation deliberately does not change the emulator's frame
rate, so without a separate line 'working', 'broken' and 'unsupported' are all
the same absent line.

Flow scale and the 3.1p pipeline are exposed. flowScale is a DIVISOR — framegen
computes flowExtent = inputExtent / flowScale — so the UI percentage is passed
as clamp(100/percent, 1, 4). ARMSX3 passes percent/100, where only the default
is right because 1.0 is its own reciprocal and every lower position makes it
slower; that inversion is not copied here. 3.1p is a separate shader family with
separate device state, so the shim fixes the choice at initialise and dispatches
every entry point on it, and the name table gains the p_* resource IDs.

Frames the game did not draw are no longer interpolated. PresentWithGeneration
captured unconditionally, so pause menus and boot screens got interpolated at
full GPU cost. It now takes frame_has_new_content, sourced from the condition
GSRenderer already computes (current && !blank_frame) rather than a new
heuristic, and consumed with std::exchange because RenderBlankFrame presents
without going through BeginPresent. A false also resets the frame history, so
the pair either side of a gap is never stitched into one bogus in-between frame.

Verified in the built APK: four status states and lsfg_shaders.bin in the core,
26 p_* names in its table, 3.1p linked into the shim. Build clean, no C++ or
Kotlin errors. NOT verified: any of it at runtime — no Adreno 7xx here, so the
flow-scale direction, the cache round-trip and the capture gate are reasoned,
not observed.
2026-08-16 11:13:54 -04:00
jpolo1224 7fde8ce980 Merge remote-tracking branch 'origin/master' into jit-android-catchup-gv7 2026-08-16 10:52:15 -04:00
jpolo1224 c66f28a395 LSFG: fix binary semaphore misuse and the unbounded acquire
Three defects in the frame-generation present path, found comparing it against
ARMSX3's.

1. Binary semaphores signalled without being waited, and waited twice.

   The loop reassigned the real present's wait to the last post-copy semaphore.
   That left s_pre_copy_sem signalled and never waited, so the next frame
   signalled an already-signalled binary semaphore; and it made the last
   post-copy semaphore the target of two waits, its own present and the real
   one, when a binary semaphore's signal can be consumed exactly once. Both are
   spec violations, and the kind that work until a driver decides otherwise.

   The fix is to delete the reassignment, not to add semaphores. The real
   present must wait on s_pre_copy_sem specifically, because the pre-copy reads
   the real image as TRANSFER_SRC and returns it to PRESENT_SRC — presenting
   before that lands would present an image still being read. The generated
   presents are independent: different swapchain images, each gated by its own
   post-copy. Presents issued on one queue are processed in call order, which is
   what keeps them on screen ahead of the real frame. Every semaphore is now
   signalled once and waited once.

2. vkAcquireNextImageKHR with UINT64_MAX, on the present path.

   Two problems at once. It bypassed the bounded ACQUIRE_TIMEOUT_NS that
   VKSwapChain::AcquireNextImage deliberately adopted so a surface destroyed
   under the GS thread — background, rotate, fold — does not leave every thread
   asleep at 0% CPU with nothing in any log. And the swapchain is 2 or 3 images
   while this loop holds the real one and asks for multiplier-1 more, so at
   x3/x4 it serialised on a vblank per generated frame, spending exactly the
   time the feature exists to save.

   Now a zero timeout: an interpolated frame is a bonus, so if nothing is free
   the right move is to drop it and get the real frame out. VK_NOT_READY leaves
   the semaphore unsignalled, so the slot stays clean for the next frame.

3. The fetch was not actually pinned.

   LSFG_PIN was "release" — a branch — under a comment explaining that an
   unpinned fetch would let a remote push change what our core links. Now the
   commit we have actually built and verified against, with GIT_SHALLOW off
   because a shallow clone carries only branch tips and cannot resolve a SHA.

Also caches the structural PE check. GetUnavailableReason() runs once per frame
from EndPresent while the feature is on, and it was doing a full
fopen/fread/fseek/fread/fclose on the GS thread every frame; the verdict can
only change when the path does, which is where it is now invalidated.
2026-08-16 10:40:50 -04:00
jpolo1224 cafb5e758b Android: make the legacy tier actually run on ARMv8.0 cores
The legacy APK claimed Android 8 (minSdk 26) while being compiled
-march=armv8.1-a, which lets clang emit LSE atomics inline. Android 8 means
Cortex-A53/A72/A73 — ARMv8.0, no LSE — so the one tier whose entire purpose is
reach did not reach them.

This is not a theoretical concern. BuildParameters.cmake:145 already records it:
'proven by a casal SIGILL on a real A53 device'. The guard written in response
only applies the safe default when nobody passes an -march, and this script
always passes one, so the tier defeated the protection added for it.

Legacy now builds -march=armv8-a -moutline-atomics, which is exactly what that
comment prescribes. Outline atomics keep LSE on cores that have it via a
runtime HWCAP dispatch, so a modern phone loses nothing.

Verified on the built core rather than assumed. The flags reach 3990 and 2036
compile lines respectively, and disassembling an LSE site shows the dispatch:

    bti  c
    adrp x16, ...                 ; __aarch64_have_lse_atomics
    ldrb w16, [x16, #0xc10]
    cbz  w16, 0x10b7128           ; no LSE -> fall through to LL/SC
    cas  w0, w1, [x2]
  0x10b7128:
    ldxr w0, [x2] / cmp / stxr / cbnz

An A53 takes the branch and never reaches the cas. APK minSdk confirmed 26.

Also moves a11/a13/a15 onto ARMSX3's SDK/NDK pairs and pins all four to NDK 29.
The NDK is not a device-compatibility knob — API level and -march gate devices,
and nothing on the device can tell which toolchain built the binary — so one
toolchain across the matrix is what makes a cross-tier comparison mean anything,
and there is no reason to withhold the measured gain from the weakest tier.

Needs a new armsx2.marchExtra gradle property: -moutline-atomics has to be its
own token, and BuildParameters.cmake's escape hatch keys on CMAKE_CXX_FLAGS
matching '-march='.

Artifact renamed to ARMSX2-<VN>-legacy-armv8.0-sdk26.apk. The updater keys on
the -sdkNN suffix, which is unchanged, so no updater change is needed. The Play
AAB is untouched: build.gradle.kts defaults still say minSdk 26 / NDK 28, and
only the APK script ever passes the tier properties.
2026-08-16 10:38:25 -04:00
Brian Degenhardt aeabe3e0e5 EE cache: fix the DXSTG tag lookup's 29-bit fold, and the tests around it (#568)
* 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.

* 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.

* 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.
nightly-20260815
2026-08-14 21:50:24 -07:00
Brian Degenhardt bbda693a7f Android: let analog triggers be bound, and read the left one everywhere (#584)
Reported on an Xbox One controller: every control on the pad binds except
the triggers, which do nothing at the "press a button" prompt.

The binding model is keyed on Android keycodes, and most pads — an Xbox
controller among them — report their triggers ONLY as analog axes, never as
KEYCODE_BUTTON_L2/R2. The capture path already bridges motion to key for the
HAT and for stick deflection; triggers were simply never added, and since
that path consumes the motion event the press vanished without a trace. Pads
whose triggers do send key events were unaffected, which is why this only
surfaced now.

A pulled trigger now stands in for the keycode a key-emitting pad would
send, so it is an ordinary button to everything downstream: bindable to any
PS2 control, stealable by another row, assignable as a hotkey or a macro,
usable as a combo member. Gameplay resolves that same keycode back through
the binding table, so what the capture records is what gets honoured — the
two now share one axis resolver rather than each knowing its own list.

That makes trigger-bound hotkeys and macros reachable from the Hotkeys and
Pad tabs, so the gameplay side has to be able to fire them, or binding one
would be a dead end. Both act on the press and the release, which lets a
trigger drive the hold-type hotkeys (fast-forward, pressure modifier, gyro
hold) that a stick edge cannot.

Second fix, same area: the right trigger has a per-device fallback axis for
pads that report it on AXIS_RZ, and the left had none. A pad Android has no
vendor key layout for passes raw HID through, putting the triggers on plain
Z and RZ — so on those devices the right trigger worked and the LEFT ONE WAS
READ BY NOTHING, dead in gameplay rather than merely unbindable. Both sides
now take the fallback, gated on a 0..1 range so a stick axis (-1..1) can
never be mistaken for a trigger.
2026-08-14 21:50:01 -07:00
Brian Degenhardt c8b51438cc IPU: dither a whole row per deinterleaving load
ipu_dither has had an SSE2 path and a scalar reference since forever, and
arm64 took the reference. The compiler closes half of that gap on its own —
with dithering off the loop is simple enough that clang vectorises it, and
measured here the scalar and NEON versions come out cycle-identical. With
dithering on it closes none of it: the clamp is written as std::max/std::min
around a table lookup, the destination is a 5/5/5/1 bitfield, and between
them the vectoriser gives up entirely. That arm ran at about 36 instructions
per pixel.

The NEON version is not a transliteration of the SSE2 one. x86 needs six
unpacks to split a row into channels because it has no deinterleaving load;
NEON has VLD4, so a whole 16-pixel row arrives already split one register per
channel and the shuffle chain simply does not exist. The dither tables are
the reference's coefficients with the sign folded into the choice of
operation, which lets saturating byte arithmetic supply the clamp for free —
the same trick the SSE2 path uses, and the reason both agree with the
reference bit for bit.

Measured on an M2 Max P-core, 2M macroblocks, two runs each:

  dither on   reference  18.76G instructions / 3.372G cycles
              NEON        1.29G instructions / 0.293G cycles   (11.5x)
  dither off  reference   1.08G instructions / 0.247G cycles
              NEON        1.13G instructions / 0.247G cycles   (even)

Function size drops from 476 to 208 bytes.

The tests are the point of the commit as much as the code is. Three
implementations of one function existed and nothing had ever compared them,
which is a bad shape here: a wrong result does not crash, it tints an FMV,
and nobody reports that. The transform depends on nothing but a pixel's four
bytes and its position modulo four in each axis, so the suite sweeps every
byte value through every one of the sixteen dither cells rather than
sampling. It holds whichever path the host selected to the reference, so it
gates the SSE2 arm on x86 exactly as it gates NEON here.

Proven to discriminate by mutation: transposing the r and b channels fails
three of four cases (correctly not the sweep that holds the channels equal),
perturbing one dither cell by one fails two, and dropping saturation fails
all four.

ipu_dither_reference loses its __ri so that a symbol survives into Release
for the tests to call.
2026-08-14 21:25:30 -07:00
Brian Degenhardt a3bf73bf7a GS: AArch64 has no slow unaligned load to compile around
FAST_UNALIGNED was defined only inside the ARCH_X86 arm, where it records
that AVX-and-later cores stopped punishing unaligned vector loads. On ARM64
the macro was therefore undefined, which the preprocessor reads as zero, so
every arm64 build compiled the texture-upload path as though the punishment
existed.

It never did. LDR Q and LD1 take any address, and GSVector4i's load template
ignores its own `aligned` parameter and emits the same instruction either
way. So the callers were paying for a distinction with no machine behind it:
WriteImage tests the source address and the pitch on every call to choose
between three template instantiations of WriteImageBlock and
WriteImageColumn that, for 8- and 4-bit columns, compile to identical code.
For 32- and 16-bit columns the unaligned arm is not identical, but it is the
worse one — eight combining 64-bit loads instead of four 128-bit loads and a
swizzle.

Defining it collapses all of that. GSLocalMemoryMultiISA.cpp.o goes from
80,368 to 62,184 bytes of .text and from 58 emitted functions to 32, which
is what an I-cache on a handheld cares about. Only GSBlock.h and
GSLocalMemoryMultiISA.cpp read the macro, so nothing else moves.

The retained load strategy is not new code: whenever an upload happened to
land 32-byte aligned, arm64 already ran exactly this sequence. What goes
away is the arm that only ever ran when it did not.
2026-08-14 21:25:14 -07:00
Brian Degenhardt 6aa2fd79d9 Android: a Game-scope save must still write the process-wide fields
Toggling PINE from the in-game menu wrote it nowhere, so the setting was gone
at the next launch while the switch still read as enabled -- saveSettings had
already updated the in-memory Settings, and only a process restart exposed that
the store never agreed.

PINE is one server for the whole process, so "this game runs with PINE on" is
not a thing that can be true. Settings.merge therefore pins pineEnabled and
pineSlot to the global value, and Settings.diff never emits either key, so a
per-game file can never acquire them. Both are deliberate and both are right.

What was missing is the other half: a Game-scope save writes ONLY the override
file. So for these two fields the write had no destination at all -- the
override file structurally refuses them, and global was never touched. Every
other field is fine, because every other field is one the override file accepts.
The in-game menu saves in Game scope whenever a game is running, which is
exactly when someone reaches for PINE, so the toggle looked simply broken.

So promote those fields to global on a Game-scope save. Copied onto the loaded
global rather than saving `updated` wholesale: `updated` is the game's RESOLVED
settings, so writing all of it to global would push every per-game value into
the global layer. The diff below is unaffected -- it reads the pre-promotion
`global`, and the keys involved are precisely the ones it never emits.

Pairs with the core fix that makes a commit act on the value; without this the
value never survived to be acted on a second time.
2026-08-14 11:45:40 -07:00
Brian Degenhardt 58b6dd983c Core: apply a PINE toggle when it is toggled, not at the next game change
ReloadPINE() had two callers -- CPUThreadInitialize() and UpdateDiscDetails().
Neither is on the settings path, so turning PINE on did nothing until the next
app start or the next game boot. The switch stays on in the UI, because that
part persists correctly; it is only the server that never appears. Reported on
Android, where it is worst -- a handheld has no second window to restart into,
so there is nothing to reveal that the setting did land and simply was not acted
on -- but nothing about the gap is Android-specific. The desktop Big Picture
toggle in FullscreenUI_Settings has the same two-call-site problem behind it.

Discord presence is the same shape of feature: an optional external service
whose whole lifecycle is one bool, toggled from the same settings pages. It is
handled in CheckForMiscConfigChanges, three lines from where PINE was missing.
So put PINE beside it, which is also what the Android settings layer already
documents as the contract ("a commit is enough -- no game restart").

Called unconditionally rather than gated on old_config, because ReloadPINE()
already compares the request against the LIVE server -- whether one is
initialized and on which slot -- and that is strictly stronger than a config
diff. It early-returns when the two agree, so the common commit costs one
comparison, and it recovers a server whose bind failed earlier rather than
trusting a config value that never changed. The port sitting in TIME_WAIT after
a fast restart is the usual way a bind is lost, and it is exactly the case a
config diff cannot see.

CheckForMiscConfigChanges runs from ApplySettings/ApplyCoreSettings, which
assert the CPU thread -- the same thread CPUThreadInitialize already reloads
PINE from, so this adds no new threading contract. The iOS emulation-only path
is unaffected: ReleaseNonEssentialRuntimeResources runs after
CheckForConfigChanges and calls PINEServer::Deinitialize() itself, so a release
still ends with the server down.
2026-08-14 11:45:39 -07:00
jpolo1224 318588f7a6 Android: LSFG frame generation (github flavour only)
Drives Lossless Scaling's interpolation from our own Vulkan present path.
Upstream's consumer app captures the screen with MediaProjection and
composites over the target process, because Android 12+ forbids injecting
code into a non-debuggable app. That constraint is not ours: ARMSX2 owns its
swapchain, so it hands the library its own images through the AHardwareBuffer
entry points. No screen capture, no overlay, no accessibility service.

NOTHING PROPRIETARY SHIPS. The interpolation shaders are read at runtime out
of the user's own Lossless.dll, supplied through SAF exactly as a PS2 BIOS is.
The requirements dialog says so before the toggle commits, not after it
silently fails. Only the MIT-licensed lsfg-vk-android framegen library is
fetched; its sibling app carries a no-commercial-use licence and is not.

framegen is ISOLATED IN ITS OWN .so BEHIND A C ABI. It links volk, which
defines 759 globals named vkCreateImage, vkQueueSubmit and so on -- precisely
the names VKLoader.cpp defines. In one library that is a duplicate-symbol
error at best; at worst the linker merges them and framegen's volkLoadDevice()
call, made against its OWN VkDevice, silently repoints every entry point the
GS renderer uses, which would present as a driver crash with nothing pointing
back at frame generation. libarmsx2_lsfg.so gives volk its own copies, and
nm confirms only the eight armsx2_lsfg_* entry points are exported. The
interface is C because the CMake project builds ANDROID_STL=c++_static, so an
std::vector crossing that boundary would be two unrelated types sharing a
name; errors come back as codes, never exceptions.

The shader chain (pe-parse over the PE resources, then upstream's DXBC to
SPIR-V translator) stays in the core -- neither half touches Vulkan symbols.
GSLsfg.cpp is the one PCSX2 translation unit built with exceptions, because
that translator throws and the alternative is std::terminate on exactly the
paths a wrong DLL takes.

Present path mirrors upstream's Android sequence: copy the rendered frame
into shared storage, idle, interpolate, idle, present each generated frame,
then the real one. The idles are not laziness -- Turnip rejects OPAQUE_FD on
AHB-imported memory, so there is no cross-device semaphore and a device idle
is the only barrier that exists. Every failure degrades to an ordinary
present rather than taking the GS thread down.

Gated on Vulkan + Adreno 7xx and newer, asked of the resolved driver profile
rather than a GL_RENDERER substring. The UI reports WHY it is unavailable,
since 'needs an Adreno 7xx' and 'you have not picked a DLL yet' are the same
greyed row otherwise and only one is actionable.

Rows live in All Settings > Performance and the in-game performance tab, from
one shared section, wired to each host's own settings tier the same way
ShaderChainSection is. Play builds compile the whole thing out -- gradle sets
ARMSX2_ENABLE_LSFG=OFF, BuildConfig.LSFG is false, and build-play-aab.sh now
fails closed if libarmsx2_lsfg.so ever appears in a bundle.

Verified: github APK carries libarmsx2_lsfg.so and 13 live @@ANDROID_LSFG@@
strings in the core; the play variant configures with zero references to
either. NOT verified: the present path itself, which needs an Adreno 7xx
device, a real Lossless.dll and a running game.
2026-08-14 12:52:39 -04:00
jpolo1224 146bce27de Android: four release targets, keyed by minSdk suffix
Splits the ARMv8.2 build into three platform tiers instead of two, so the
Android 11 floor gets FP16 + DotProd as well:

  legacy  minSdk 26  NDK 28  armv8.1-a
  a11     minSdk 30  NDK 28  armv8.2-a+fp16+dotprod
  a13     minSdk 33  NDK 28  armv8.2-a+fp16+dotprod
  a15     minSdk 35  NDK 29  armv8.2-a+fp16+dotprod

Artifacts are now ARMSX2-<VN>-{legacy-armv8.1-sdk26,a11-armv8.2-sdk30,
a13-armv8.2-sdk33,a15-armv8.2-sdk35}.apk, and the updater classifies on the
-sdkNN suffix alone. The old markers were -v82 and -v82-sdk35, where one was a
substring of the other and only a carefully ordered when-branch kept Android 15
devices off the standard build; that hazard grows with every tier. The four sdk
suffixes cannot overlap.

An asset with no recognised marker still counts as legacy, so releases published
before tiering keep resolving.

The release-shape check now requires all four and verifies each name carries
exactly one, distinct sdk marker, and prints the upload-order warning: every
updater up to 2.6.6.6 takes the first .apk asset in a release regardless of
name, so the legacy build has to go up first or those installs are handed an
APK that SIGILLs on its first hot path.
2026-08-14 12:21:01 -04:00
jpolo1224 5ddbf8188f Android: ship three CPU targets, and teach the updater to pick between them
A release now carries three sideload APKs instead of one:

  ARMSX2-<VN>.apk            legacy    minSdk 26, NDK 28.2, armv8.1-a
  ARMSX2-<VN>-v82.apk        standard  minSdk 33, NDK 28.2, armv8.2-a+fp16+dotprod
  ARMSX2-<VN>-v82-sdk35.apk  modern    minSdk 35, NDK 29,   armv8.2-a+fp16+dotprod

build-release-targets.sh owns the matrix and the naming; build-release-apk.sh keeps
the whole recipe (dual page-size cores, PGO, rotation signing) and gains a
GRADLE_EXTRA_ARGS seam. minSdk, ndkVersion and march become gradle properties whose
DEFAULTS are the legacy build, so an unqualified invocation still produces exactly
what it did before. ndkVersion is now pinned rather than left to AGP: three targets
are only comparable if the toolchain moves when we say it moves.

march is passed through CMAKE_CXX_FLAGS rather than set in CMake, because
BuildParameters.cmake applies its armv8.1-a default only when CMAKE_CXX_FLAGS does
not already carry a -march. That escape hatch exists because LSE atomics SIGILL on
in-order ARMv8.0 cores, and it is the seam this needs; add_compile_options would land
after these flags and win.

The updater can no longer take the first .apk it sees. It classifies assets by the
filename markers above and gates on TWO independent things: the CPU must carry
FEAT_FP16 and FEAT_DotProd (read as asimdhp/asimddp off /proc/cpuinfo — both are
OPTIONAL at ARMv8.2, so the architecture level is not a usable proxy), and the OS must
meet the build's minSdk. It walks down from the best qualifying tier, so a release
missing one degrades instead of offering nothing.

Detection fails closed, deliberately. The failure modes are not symmetric: a capable
device given the legacy build is merely slower, while an incapable device given a v8.2
build takes a SIGILL on the first hot path — and a user whose emulator will not launch
cannot reach the updater to escape it. Anything unreadable, unparseable or absent means
legacy. The release-shape check refuses to publish a set without the legacy artifact
for the same reason: without it, every older device silently stops receiving updates.

Measured, so the next person does not have to: enabling armv8.2-a+fp16+dotprod changes
this codebase's codegen by 295 instructions in 3.8 million (0.008%), emits ZERO
sdot/udot, and leaves the half-precision count identical. -march is permission, not a
transformation, and the hot paths here are JIT-emitted at runtime where it has no say.
The tiers are in place for code that will use them; today they carry the same work.
2026-08-14 10:16:34 -04:00
Brian Degenhardt 0b9e9cdfcb GS/SW: the C++ rasteriser packs a colour gradient like the generators do
The per-lane colour offsets were packed with the signed saturating pack while
both code generators used the unsigned one. The mask above the pack has already
put every lane in 0..65535, which makes the unsigned pack the identity and makes
the signed pack flatten everything from 32768 up to 32767. A descending gouraud
gradient is how a lane gets there: its offset is negative, the mask turns it
into a large positive, and the pack saturates it. Every pixel of the group then
carries that instead of its own colour, for the whole scanline.

The mask and the unsigned pack were introduced together to fix exactly this, in
"GS/SW: Mask color gradients to prevent incorrect clamping"; a later refactor
that rewrote the same lines to change how the shift table is loaded retyped the
tail back to the signed pack. The generators were not part of that refactor,
which is why only the C++ path regressed and why nothing noticed.

Where the path is reachable, measured rather than argued: with the rasteriser
JIT on, a probe at the top of the C++ setup never fires across corpus replays
that generate tens of kilobytes of scanline code apiece. It is entered only when
there is no code memory to compile into at all, and that same condition turns off
the EE, IOP and VU recompilers, so it is not a configuration anyone plays in.

What it is, is the path a measurement runs under -- the only way to ask what the
renderer computes without a JIT in the way, and so the arbiter of a
generated-code question. It was about to arbitrate one, and would have lied: the
gs-shade console capture re-run under it differed from the generated arm in
42,240 bytes, concentrated in exactly the gouraud colour it was to be asked
about. It is now byte-identical, and the generated arm is byte-identical to
before the change, so nothing a shipping build renders moves.

The new suite runs both paths over the same spans and compares the setup state
and the stored pixels, so the next divergence anywhere in the scanline fails
loudly instead of waiting for a capture to find it.
2026-08-14 06:33:13 -07:00
Brian Degenhardt 02e93048d4 IOP: let an immediate jump to zero reach the handler we already wrote
The recompiler already has a policy for arriving at address zero. A fetch
at PC=0 raises an Address Error and the BIOS handler takes over (AX-11),
because PS1 mode drives the IOP there through a register jump often
enough to be worth modelling rather than asserting on.

The immediate form of the same event never got there. Emitting a jump
whose target is zero asserted instead, so the two ways of reaching the
same address behaved differently: through a register it is emulated and
the guest carries on, through `j 0` it aborts a Devel build one
instruction before the handler would have seen it. Dropping the assert
routes the immediate form into the existing path — the tail stores pc,
links the block at zero, and the dispatcher hands it to psxRecompile,
which raises the Address Error.

Unlike the EE, nothing here compiles a jump the guest does not take: the
IOP scanner ends every block at the first branch, so an unresolved weak
symbol's guarded `jal 0` is never emitted. Reaching this needs the guest
to genuinely jump to zero — an unguarded weak call, a branch target that
computes to zero in low RAM, or a corrupted code word.

The test runs the JIT arm alone, which is what the new harness mode is
for: the interpreter has no PC=0 model at all, so the arms are meant to
disagree here and the differential harness has nothing to say.
2026-08-13 23:01:02 -07:00
Brian Degenhardt cb56a72b26 EE: a jump to address zero is a target, not an impossibility
A call to an unresolved weak symbol links as `jal 0`, guarded by a null
test on the symbol's address that always skips it. PS2SDK's libc glue
ships four such sites, so every homebrew ELF built against it carries the
shape, and the recompiler asserted the moment it met one.

It meets one because SL-03 continuation compiles the skipped path: the
guard branch becomes a continuation site, the scan runs on through the
dead call, and the emitter is handed a zero target for code that never
executes. The assert (inherited from the x86 recompiler, which aborts on
the same ELF) then takes down any Devel build before the program starts.

Nothing needs to happen at that target. If something did jump there,
address zero resolves like every other address — a block in RAM page 0,
or the unmapped-page handler — so the three tails just emit it.

The shape only reaches the emitter when the guard cannot be resolved at
compile time; a constant address folds the branch and the dead call is
never emitted, which is why an ELF carrying it can run clean until one
block boundary lands between the address materialization and the test.
The tests pin the reachable half.
2026-08-13 22:23:14 -07:00
John Peter Sa 3929e78dc2 Complete Android Brazilian Portuguese translation Android (#578)
* Complete Android Brazilian Portuguese translation
2026-08-12 14:53:44 -07:00
Brian Degenhardt 85adcfe6df Merge branch 'upstream-sync-2026-08' 2026-08-12 12:22:45 -07:00
Brian Degenhardt 2d73c39f03 GS: lift the r44p1 GL fetch blocklist -- the field chose the fast path
Delete gl-arm-r44p1-attachment-self-read from the driver-bug database, so
r44p1 Mali takes GL_ARM_shader_framebuffer_fetch again on GLES and -- because
GSUtil::AndroidAutoPrefersVulkan asks the same table -- Auto resolves back to
OpenGL on those devices.

The rule was correct about the defect and wrong about the trade. Through
2.6.6.4 the gate it formalised was inert: the Mali profile block re-enabled
the ARM backend moments after the gate disabled it, so every r44p1 device
shipped on GL + fetch. 2.6.6.5 made the gate actually engage, and on GLES --
where fetch and the texture barrier are one capability -- every
self-referential draw became an RT copy plus a tile flush. Shadow of the
Colossus fell 30 -> 7 fps on the Anbernic RG 477V and users mass-downgraded
to 2.6.6.4. Offline replay of that scene under the device's feature shape
shows why no smaller fix could win the speed back: 890 render-target copies
and 938 render-pass breaks a frame against 1664 draws -- and a 2.6.6.4
replay under the same shape produces the same ledger (901/948/1664), so the
old build's speed WAS the in-tile read, not better GS decisions.

The known cost is unchanged from 2.6.6.4: r44p1's fetch corrupts some
content (MGS3 observed; most likely the driver grants the tile-read slot per
attachment format and silently degrades denied reads to memory fetches
inside a live feedback loop). Vulkan stays available as the
correct-rendering choice for those games, and its own r44p1 rule is
untouched -- there the in-tile read is a device loss, and the RT copy is an
ordinary image copy rather than a tile flush.

Unlike 2.6.6.4, the restored path is ordering-correct: db41082150 taught the
barrier-drop logic that ARM's fetch orders overlapping primitives by spec.

gs_vertex_tests 64/64, with the driver-profile pins flipped to assert the
restoration on GL and the copy path on Vulkan.
2.6.6.6
2026-08-12 10:57:40 -07:00
Brian Degenhardt b2e22efc45 Android: send Auto to Vulkan where GL cannot read the target in-tile
The Auto renderer resolution picked Vulkan on Adreno and OpenGL everywhere
else, on the reasoning that Mali runs GL_ARM_shader_framebuffer_fetch and so
has the in-tile fast path on GL. That holds for a healthy Mali. It does not
hold for a driver on the fetch blocklist, and the two decisions were made in
different places, so nothing noticed when they disagreed.

On GLES framebuffer fetch and the texture barrier are one capability -- there
is no ARB or NV barrier extension -- so a blocklisted driver loses both. That
is not a mild fallback on a tiler: it is not only accurate blending that starts
reading the render target from a copy, it is every self-referential draw, and
each copy forces the tile to flush and resolve to main memory. Measured on an
Anbernic RG 477V (Mali-G615, r44p1) with Shadow of the Colossus: 7 fps on
OpenGL against ~30 on Vulkan, same device, same settings. Vulkan reaches the
same copy-based concept with an ordinary image copy and no tile flush.

So Auto now also prefers Vulkan when the device's OpenGL driver profile carries
UseRenderTargetCopyForFeedback. Both halves of the question are asked of the
driver database rather than of substrings, which also retires the case-sensitive
search for "Adreno" in GL_RENDERER in favour of the resolved runtime profile.

The decision has to be native, because the database is: rules match a PARSED
driver revision, which is what lets one say "exactly r44p1". The app cannot do
that, so it now hands over the GL strings it already probes -- GL_VERSION is
where the driver revision lives, and the probe was reading GL_RENDERER and
throwing the rest away -- and GSUtil::AndroidAutoPrefersVulkan answers.
setPreferVulkan(boolean) is replaced by setAutoRendererGpuStrings(3 strings)
rather than kept alongside it; there was one call site.

An explicit Vulkan/OpenGL/SW pick still wins, as before. The only devices this
moves are the ones whose GL is degraded: currently r44p1 Mali and nothing else.
2026-08-12 00:09:08 -07:00