The VU's largest value is 0x7FFFFFFF, one binade above FLT_MAX, the same
range the EE FPU has. So an exponent-255 word is an ordinary number on
the way in and on the way out, and "overflow" starts above it rather
than above FLT_MAX.
Both engines put the boundary a binade lower. vuDouble() rewrites an
exponent-255 operand as 0x7F7FFFFF and VU_MAC_UPDATE() calls every
exponent-255 result an overflow; the arm64 COP2 macro emitters clamp the
result to +/-FLT_MAX and raise neither O nor U. microVU's per-op operand
clamps approximate the same thing from a list of games rather than a
rule.
68 rows off an SCPH-90000 through VU0 macro mode, scored per engine and
per column, with what each engine cannot yet reproduce recorded per case
so a fix trips the test as loudly as a regression. Nothing is fixed
here.
Two of the rows are structural rather than about range. An overflowed
product does not become 0x7FFFFFFF before the accumulate: an addend of
-0x7FFFFFFF cannot cancel it. An underflowed product does become zero
before it. And the multiplier is the EE's, with the same one-ULP deficit
decided by ft's mantissa alone.
The harness grows two things the rows need: a VADDA encoder, and an
opt-out from Run()'s VU0 JIT-vs-interp auto-diff for tests that score
each engine against a hardware capture instead of against the other
engine.
The per-second LSFG branch counters and the FSR gate line were added to find two
specific bugs and both did their job — the counters proved generated frames were
reaching the screen uncounted (VK_SUBOPTIMAL_KHR treated as failure), and the
gate proved all three FSR conditions passed while a misplaced log made the pass
look dead. Neither belongs in a release: one printed every second, the other on
every state change.
What stays is event-driven and diagnostic in the ordinary sense: LSFG's
initialise line, shader-cache hits and misses, load and ABI failures, and one
FSR line per output-size change.
The desktop and Big Picture pickers landed the same tier as Exact, and
GameIndex.md documents it under that name. Android was the only frontend
calling it something else, which made the same setting look like two
different ones depending on which screen the user was on.
The translation key moves with the label rather than keeping the old name
for a value it no longer matches, so the map stays alphabetical and there
is nothing left to mislead the next reader.
The tier existed in the core but nowhere in the UI, on any frontend, so
reaching it meant hand-editing the settings file. Both Android pickers —
the Performance tab and the in-game pause menu — now carry a fifth
option, and the settings layer packs it.
The packing is the part that matters. emucore validates the four clamp
booleans as a cascade and silently resets an inconsistent set to the
defaults rather than rejecting it, so writing fpuExactMode without its
three predecessors would not fail loudly, it would quietly land the user
back on Normal. applyTo therefore writes all four cumulatively, and
readFromIni reads them back highest-first.
readFromIni treats a missing fpuExactMode as an older core rather than as
mode 3: a build without the key never wrote it, and inferring 3 there
would demote a Ludicrous setting every time the settings were reloaded
under a mixed pair of builds.
The chip row already scrolls horizontally, so a fifth option needs no
layout change.
⚠️ Not addressed here, and worth a decision: the GameDB overwrites the
whole tier for any title carrying an eeClampMode entry, and an entry
below 4 clears the exact bit outright. On those ~115 titles the new
option is inert unless game fixes are off — which is most of the titles
whose users would want it. The setting description says so; whether the
core should let a user's choice raise the database's is a separate call.
Two bugs of mine, both found on-device with everything else working.
★ The zero-timeout acquire disabled frame generation entirely.
Forcing FIFO fixed the MAILBOX discard, and the display rate still equalled the
real rate. Under FIFO the presentation engine returns an image at a vblank, so
at steady state nothing is ever free INSTANTLY — vkAcquireNextImageKHR with a
zero timeout returns VK_NOT_READY every frame, the loop breaks, and every
interpolated frame is dropped. Silently, because a dropped generated frame is a
legitimate outcome and nothing logs it.
The reasoning behind the zero was that an interpolated frame is a bonus not
worth stalling for. That is backwards: presenting two frames per rendered frame
MEANS waiting for the second display slot. Waiting is the mechanism, not the
cost. Now a 50ms bound — six vblanks at 120Hz, so it expires only when something
is genuinely wrong, while still keeping a lost surface from wedging the GS
thread the way an unbounded wait would.
★ The in-game FSR row was gated on renderer == "vulkan", and the default is
"auto".
"auto" resolves to Vulkan on Android, so the row was hidden from anyone who had
not explicitly pinned the renderer — which is nearly everyone, and was the
reporter. Gated on the two backends that genuinely cannot run it instead.
★ Frame generation produced nothing on a MAILBOX swapchain, silently.
Reported on an Adreno 740: LSFG logged 'active: 1920x1080 x2 frames, 3.1p',
cached its 52 shaders, never logged a single failure — and both the FPS and the
LSFG display counters read 59. The interpolator was working perfectly and its
output was being thrown away.
MAILBOX keeps only the most recent image queued for a given refresh. Presenting
an interpolated frame and then the real frame immediately after replaces the
interpolated one, so it is generated, costs its full GPU time, and is never
displayed. IMMEDIATE discards the same way. Nothing errors anywhere along that
path, which is why the only symptom is a display rate identical to the real one.
The device landed on MAILBOX because vsync was off — 'Immediate not supported
for vsync-disabled, using mailbox'. SelectPresentMode now forces FIFO while
frame generation is enabled. Eden reached the same conclusion; their setting
text reads 'Forces FIFO presentation while enabled'.
Gated on the setting rather than GSLsfg::IsAvailable(), which cannot answer at
swapchain-creation time: the DLL path only reaches GSLsfg from EndPresent.
Also adds the FSR rows to the in-game GraphicsPane. In full settings FSR sits
under Display Effects beside CAS, which is the right shelf for a post-effect and
the wrong one for finding it — it is an upscaler, so in the quick menu it goes
with the internal-resolution controls, which is where you reach while watching
the framerate. Vulkan-only, so it is never a dead toggle on OpenGL.
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.
DJbox 2
Ratchet & Clank 2 13
Tourist Trophy 6
Monster Hunter 12
The Taxi 2 1
Pride FC 4
Jak and Daxter - The Precursor Legacy 14
Jak II 10
Jak 3 6
Final Fantasy X 15
Driv3r 6
Shadow of the Colossus 5
Gran Turismo 4 19
Tales of the Abyss 4
Disney/Pixar Up 5
Need for Speed - Undercover 9
Mortal Kombat - Shaolin Monks 3
Tokyo Xtreme Racer Zero 5
Yu-Gi-Oh! - The Duelists of the Roses 3
Devil May Cry 3 Special Edition 5
Constantine 3
Sega Ages 2500 Virtual On 1
Tales of Destiny - Director's Cut 4
Colin McRae Rally 3 2
Stuntman 5
Deadly Strike 1
Virtua Fighter 4 4
R:Racing Evolution 5
Xenosaga Episode III 7
Steambot Chronicles 11
Fatal Frame 10
Playmobil - Hype - The Time Quest 3
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.
Exponent-0xFF operands through VMADDw and VMSUB at every dest mask, one
case per half of MADDw's set. MADDx/y/z runs the same shape with its
broadcast lanes in range as a scope control.
arm64 hand-rolls these ops instead of going through the mVU emitter, so
microVU's per-op clamp argument is retyped in iCOP2-arm64.cpp. The two
rows conditional on isCOP2, MADDw's cACC|cFt|cFs and MSUB's cFs, were
missing.
Shadow of Destiny moved its player character a fraction of a step per
frame under the recompiler, at every eeClampMode, on the MADDw row.
Clamping ACC also changes results against the interpreter for values
above FLT_MAX, but is what the x86 COP2 path already does.
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.
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.
Exact was reachable through the GameDB and the INI only: the desktop and
Big Picture pickers stopped at Full, and cleared fpuExactMode behind
themselves. Both now read and write it with the other three bits, so the
mode can be picked and a config already on it displays as itself.
The Big Picture choice list is sized from its own array. It had taken
the EE array's length for the VU pickers too, which held only while the
two arrays were the same length.
eeFprSyncSlotFormat keys on CHECK_FPU_FULL, so the relocated file is
what both of iFPUd's rungs compute over, not mode 3 alone. The comments
that name a mode where they mean the tier are renamed to the tier.
DIV.S, SQRT.S and RSQRT.S ran on host doubles on every recompiler rung,
which makes them correctly rounded; the EE's divide/square-root unit is
a digit recurrence with no rounding step and is not. The interpreter has
run the recurrence since the model landed, so mode 4 now calls the same
two functions out of line rather than reproducing them. eeDivide and
eeSqrtBits stop being static for it.
RSQRT.S composes the two with an ordinary single in between, as silicon
does, and its intermediate crosses the square root's call through the
island's scratch. Only the value moves: the negative-operand and
zero-divisor arms, and every flag they set, are the code they were.
Modes 1 to 3 keep the host instruction and the FPUDivFPCR swap.
The rsqrt sign table gains a mode-4 leg, which owes every row; the
filter for the twenty rows mode 3 misses by one ULP stays where it is,
now as that mode's own pin. The divide unit's console table gains a
mode-4 leg beside its interpreter one.
The multiply array's out-of-line call carries its own spill of the
allocator's live caller-saved homes and its own pin flush/reload pair.
The divide unit needs the same frame around calls of its own, so the two
halves become emitIslandEnter/emitIslandLeave and the multiply is
rewritten on top of them.
Enter takes a byte count for scratch above the saved registers, which
nothing needs yet at zero and RSQRT.S will: its dividend has to outlive
the square root's call.