Erasing the tracked state whenever cpu_flag::wait was set is what stopped
this firing. The loop dips into a syscall regularly -- almost certainly
sys_ppu_thread_yield, seen at ~100 million -- and every dip cleared the
accumulated seconds, so the counter never got past 2.
The device reported it precisely once the range was instrumented:
widest_range=0x0, meaning an identical cia on every single sample. As tight a
spin as can exist, and invisible purely because of that erase.
A wait sample is now skipped rather than treated as evidence against a spin.
A thread genuinely parked in a syscall never accumulates running samples at
all, so idle threads still cannot trip it.
The 1 KiB base+offset window measured nothing. On the hung device it reported
tracked=1 longest=0s: one running thread, whose cia left the window every
second, so the window reset on every tick and the counter never advanced past
zero. The loop is wider than a handful of instructions -- it polls and calls
helpers -- which the previous shape could not represent at all.
Track the RANGE cia has covered instead, and allow 64 KiB of it. A polling
loop that calls helpers stays within tens of KiB; ordinary execution covers
megabytes in a second.
The state line now reports the widest range being tracked. If this still does
not fire, that number is itself the answer -- it says how large the loop
really is, and therefore what the threshold must be, instead of costing
another reproduction to find out.
Five earlier attempts at catching this hang keyed on something STOPPING --
frames, then lock traffic -- and every one missed it, because nothing stops.
Measured on the hung device: PPU[0x1000000] burning 4.36s of CPU across 4s of
wall clock, more than a full core, while rsx::thread spun on
NV406E_SEMAPHORE_ACQUIRE. Tales of Xillia 2 white-screens when its Bandai logo
is skipped, and the guest's main thread is not blocked at all -- it sits in a
tight guest-side wait loop, making no syscalls, taking no locks and writing no
log lines. A frame-based detector saw frames still flipping, a lock-based one
saw a peak of 10 locks/sec to fall from, and neither was wrong about what it
measured. They were measuring the wrong thing.
So look for the opposite of a stall: a thread that is RUNNING -- no
cpu_flag::wait, meaning not parked in a syscall -- whose cia has not left a
1 KiB window for 30 seconds. A spin loop is a few instructions branching to
themselves; ordinary execution walks cia across the binary many times a
second. Threads waiting in a syscall are skipped, so an idle game cannot trip
it.
Dumps twice, 15s apart, so the second shows whether cia moved at all between
them, and reports its own state every 10s: threads tracked, longest spin,
dumps taken.
Two corrections to the previous attempt, both measured rather than reasoned.
Including _sys_lwmutex_lock broke it. That counter keeps climbing straight
through the hang at a flat ~375 per 10s -- the idle loops take lightweight
mutexes -- so the 'unchanged' test re-armed on every tick and the detector
never fired. sys_mutex_lock alone froze outright, at 22.
And 'exactly zero' only fits Xillia 2. Kane & Lynch collapsed from ~200,000
per 10s to ~300, which is just as dead and never reaches zero. So the test is
now relative: remember the busiest rate this game has reached, decay it
slowly, and call it a hang when the current rate stays under a fiftieth of
that for 30 seconds. A title that has never been busy has no peak to fall from
and cannot trip it; the 5000/s floor sits far below every busy rate measured
(20,000+) and far above anything idle.
The syscall code is resolved by name from g_ppu_syscall_table once, rather
than hardcoded, so it cannot silently come to mean a different syscall.
Also logs its own state every 10s -- rate, peak, quiet seconds, dumps taken.
Five attempts at detecting this hang have now failed, every one of them
silently, and each cost a reproduction to discover. The detector reporting
what it sees is worth more than the detector being clever.
The frame-based check cannot see this class of hang at all. Tales of Xillia 2
white-screens with its RENDER loop still running: it submits real, non-forced
flips every ~10ms forever, so 'no frame presented' is never true while the
game logic behind them is dead. Measured on device -- g_last_frame_time was
9-12ms old on every sample taken across the hang. Four fixes to the
frame-based detector were all fixing the wrong instrument.
What actually stops is lock traffic. Both hangs seen so far -- Xillia 2's
white screen and Kane & Lynch's freeze -- show mutex acquisition at exactly
zero for minutes while sys_timer_usleep and sys_event_queue_receive continue
at flat, identical rates, which is idle service loops and nothing else. Both
games were taking 100k+ locks per 10s until the moment they stopped.
Polled from the PPU syscall usage thread, which already holds the counters and
is independent of both the RSX thread and the guest. Bounded the same way as
the other path: two dumps, the second 15s after the first so a cia that has
not moved between them is distinguishable from slow progress, re-armed only
when lock traffic resumes.
The watchdog was called under !Emu.IsPaused() && !Emu.IsStopped(). The
default IsStopped() overload is m_state <= system_state::stopping, and the
enum orders stopped, loading, stopping, running -- so it reports true for a
game that is LOADING.
A hang during a load is exactly what this watches for. Tales of Xillia 2
white-screens mid-load once its logos are skipped, so the guard skipped the
watchdog on every tick of the precise case it exists for, and skipped it
silently: not a declined decision anyone could read, just no call at all.
Nine minutes of held white screen produced no output whatsoever.
Use IsStopped(true), which is the fully-stopped test.
Also log the watchdog's decision once every 10s -- progress flag, last frame
timestamp, its age, dumps taken. Three attempts at this detector have failed
silently on a reproducible hang; the only evidence each time was an absence,
which cannot say which branch won. One line per ten seconds makes the next
failure a fact rather than another guess.
poll_frame_stall_watchdog returned early when g_last_frame_time was zero,
where the RSX-side check seeds it -- and seeding is what starts the clock.
Since the whole reason the watchdog exists is an RSX thread too stuck to run
that check, nothing ever seeded it: the value stayed zero and the watchdog
bailed on every tick forever, blocked in exactly the scenario it was written
for.
Reproduced on Tales of Xillia 2: white screen held for nearly nine minutes,
guest mutex traffic zero throughout, and not one dump.
check_frame_stall() runs from do_local_task, on the RSX thread's own FIFO
loop. That works for a guest-side hang with the RSX idle -- every hang chased
so far -- and is useless for the opposite case, where the RSX thread is the
one stuck. It never returns to do_local_task, so the detector that would
report the hang is starved by the hang.
Tales of Xillia 2 (BLUS31397) is exactly that. Reproduced on device: guest
mutex traffic at zero for minutes while sys_event_queue_receive and
sys_timer_usleep tick at flat identical rates, rsx::thread accumulating 4.99s
of CPU per 5s of wall clock, and NV406E_SEMAPHORE_ACQUIRE its costliest method
at 1.59ms a call. The RSX is spinning on a guest semaphore the stopped guest
will never write, and nothing reported any of it.
The same condition is now polled once a second from the PPU syscall usage
thread, which is independent and keeps running. That half only dumps; the
on-screen message and the native-UI flip stay on the RSX side, because the
overlay is not safe to drive from another thread. Both share one dump budget
so they cannot produce four dumps between them.
check_frame_stall() arms native-UI flipping when it reports a stall, so the
guest has something on screen while it is hung. Those synthetic flips reach
flip(), which finds nothing queued and calls on_frame_end(buffer, true), and
on_frame_end refreshed g_last_frame_time unconditionally. So the first hang of
a session switched on a flip source that then refreshed the timestamp forever,
and no later hang in that session could be detected at all.
Only a frame the guest actually produced counts as guest progress now.
Found on Tales of Xillia 2 (BLUS31397), which reproduces the same white screen
as Xillia 1: a stall was reported at 0:29:06, that game was closed, another was
booted, and when it hung 90 seconds later nothing fired. Guest mutex traffic
sat at exactly zero for over two minutes -- sys_event_queue_receive and
sys_timer_usleep continuing at flat, identical rates every interval, which is
idle service loops and nothing else -- while VKGSRender::flip kept running and
the detector kept believing frames were landing.
That is the one case this was written for: a hang where something still flips
looks perfectly healthy to a detector that only watches frames.
A callback the backend cannot fill completely is a hole in the output: the
device asked for N frames, the emulator did not have them, and the gap is
filled by repeating the last sample. That is what crackling IS, and nothing
counted it. The buffer-level report samples every ten seconds while a starved
callback lasts milliseconds, so every transient underrun passed between
samples unseen -- a Call of Duty: World at War capture shows a perfectly
healthy buffer (queued 22.7-48ms against a 36.7ms target, never near dry)
through a session where the audio was audibly breaking up.
Counted in AudioBackend rather than per backend so Cubeb and Oboe report the
same number the same way, one count per starved callback rather than per
padded frame -- the audible event is the gap, and its length is already
implied by how much of the callback had to be invented.
Reported on the existing audio line as a delta since the previous one, not a
running total: what matters is whether the output is breaking up now, and a
total from a rough patch minutes ago hides that.
There was exactly one previous log: RPCSX.log became RPCSX.old.log and the
previous old was deleted. That loses the capture reliably, because of how
people send it -- play, stop, relaunch the app to reach the file, and the
relaunch rotates the wanted session into .old; relaunch once more, to find it
or to share it or because the launcher restored the app, and it is gone.
Three captures have been lost this way (#87, #91, and the Call of Duty audio
one). In two of them what arrived was a 47-line log ending before the game had
booted, which is the replacement session rather than the one played.
Two defences, because generations alone would not have saved those:
1. A session that produced almost nothing does not get a slot. The logs that
did the damage were boot-only -- a few KB, stopping within a second of
launch -- and pushing one of those down the chain is what evicted the real
capture. Below the threshold the file is discarded instead. A session with
'Silence All Logs' on still writes far more than this (~190 KB for a
29-minute one), so that setting does not trip it.
2. Three generations instead of one, so an ordinary mistake costs nothing.
Ported from ARMSX2. Android has no supported API for SoC temperatures --
HardwarePropertiesManager is gated behind the signature-level DEVICE_POWER --
so the only route is the thermal sysfs, which is readable without permission
on essentially every device but is not a contract: zone count, order, naming
and even the unit are all vendor-specific. So the app discovers zones once by
name, tolerates every failure by having no reading, and never displays a value
it could not actually read. A device that exposes nothing shows nothing, which
is a normal outcome rather than an error.
The core cannot read these and should not learn how, so the app pushes them in
through _rpcsx_setThermals into atomics beside the overlay. The overlay line is
appended after the detail-level switch, the same way the frame generation line
is, so all four levels get it without their format strings and positional
arguments having to agree.
The sentinel is -1000.0f on both sides. ARMSX2 spells the Kotlin half
Float.MIN_VALUE, which in Kotlin is the smallest POSITIVE float (1.4e-45) and
so arrives as a real temperature of 0 degrees rather than as absent; worth
correcting there too.
Default on, poll interval configurable 1-5s (default 2s) since sensor overhead
was the concern raised when this was asked for. No realtime option: a
temperature that moves slower than a second is not worth the syscalls.
It stays a feature -- the hotkey, the touch button and the OSD indicator are
untouched -- it just no longer occupies a tile in either panel.
Removing it also repairs the Session grid's controller dispatch, which was
broken. EmulationMenuViewModel indexes three things by the same number: the
grid in SessionPane, actionCount(), and activateSelection(). Fast-forward sat
at index 1 in the grid and had no entry in activateSelection, so every index
from 1 up dispatched to its neighbour -- a pad press on Fast Forward restarted
the game, Restart swapped the disc, Swap Disc closed the game, and Close could
not be activated at all. actionCount had been corrected to 5 to let the pad
reach the last tile, which made the misalignment reachable rather than fixing
it. With the tile gone the grid is resume/restart/swap/close, exactly what
activateSelection already dispatched, and actionCount goes to 4.
Touch users are unaffected: the grid is tapped by index through onSelect, so
it was always correct there. This only ever misfired for a controller.
Upstream bc22df8ba skips waking waiters after a SUCCESSFUL conditional store
when the SPU sits at pc 0x11e4 with the SPURS control block reserved, unless
byte 0x73 of that block shows this thread going running->idle. It is a
throughput optimisation: SPURS kernels store to that block constantly and
waking every waiter each time is a thundering herd.
Both constants are assumptions about one specific SPURS kernel build. 0x11e4
is a guest code address and 0x73 an offset inside the guest's control block,
and SPURS ships in many versions across titles. On a kernel whose layout
differs, the running->idle test reads the wrong byte, answers no forever, and
the store succeeds while every waiter stays asleep -- reported by nothing.
Tales of Xillia (BLUS31006) hangs with all five graphics SPUs reserving that
block, each suppressing ~100,000 notifications, ~4.85M conditional stores at
a 16.5% failure rate. Its SPURS kernel then executes its own HALT at
pc=0x00f00: r16 & r17 = 0x40000000, two workload masks that must be disjoint
both claiming workload bit 30. The group never joins, no frame is ever
presented again, and every PPU thread parks.
Whether the missed wakeups cause that inconsistency or merely accompany it is
NOT established. What is established is that notifying is correct and
suppressing is the optimisation, so the optimisation goes. Expect a
throughput cost on SPURS-heavy titles; it is measurable and revertable.
The counter is kept, now recording how often the heuristic would have
suppressed, so that cost shows up in a log instead of being guessed at.
The stall report picked its detailed SPU by raddr == spurs_addr -- a kernel
waiting on its own control block, i.e. an IDLE one -- on the assumption that
every kernel parks at the same pc so any of them would do. Tales of Xillia
is the counter-example: one graphics kernel executed a guest HALT and sits
stopped at pc=0x00f00 while the other four idle normally at 0x011a8, so the
rule picked an idle SPU. The only thread in the process with anything to say
printed no registers at all.
A stopped, halted or exited SPU now wins outright, chosen in a pass before
the walk. Nothing else in a SPURS group stops on its own, so if one has, it
is why the group never joined and the rest are only waiting on it.
Also dump all 128 GPRs rather than the first 16. Xillia's assertion is a
validity check over r12/r16/r17/r19/r33/r34, so the sixteen that were printed
did not include a single operand of the test the log had just disassembled.
average_playtime_ratio divided m_average_playtime -- a rolling average of
get_enqueued_playtime(), in microseconds -- by audio_buffer_length, which is
a sample count (AUDIO_BUFFER_SAMPLES * channels, 512 for stereo). The result
was microseconds per sample: roughly 78 on a healthy stereo buffer and never
below 1, so the 'not as full as desired' branch it gates has not executed
once since it was written. The buffer target has therefore always been the
fixed desired_buffer_duration + half a block, with the adaptive widening
silently inert.
Wrong from introduction rather than drifted: m_average_playtime has been a
duration since before that line existed. audio_buffer_length had no other
reader anywhere in the tree, which is why nothing caught it; it is deleted
here so it cannot be picked up again by mistake.
The denominator is now desired_buffer_duration, which is what the comment
above it names and the only choice that makes the branch meaningful --
audio_block_period would leave the ratio near 7 and the branch just as dead.
Guarded against a zero denominator, and the existing max(ratio, 0.25) clamp
still bounds the widening at 4x.
This arms a path that has effectively never run, so it changes behaviour for
every game: a consistently under-filled buffer now raises the target instead
of being ignored, trading latency for fewer dropouts. That is what it was
written to do. The audio buffer report added alongside prints the ratio, so
the effect is visible in a log rather than inferred.
Progressive audio delay keeps being reported (#87: Guitar Hero titles, where
audio starts synchronised and falls further behind the notes the longer a
song runs, and pausing resets it). It reproduces on both Cubeb and Oboe, so
it is not the backend -- it is this ring, and how full this ring is IS the
delay the player hears. It was recorded nowhere, so every theory about it is
unfalsifiable from the logs we receive.
One line per 10s: queued depth, the target the algorithm is aiming for, and
the dynamic period as a percentage of nominal, which says how hard it is
correcting. Enough to see the curve across a song; few enough that the log
volume cannot become a stall in its own right.
Noted while reading the algorithm, deliberately NOT changed here:
average_playtime_ratio divides m_average_playtime (microseconds) by
audio_buffer_length (samples times channels, 512 for stereo), so it reads
about 78 when healthy and its 'not as full as desired' branch has never once
executed. The intended denominator is a duration. Correcting it would arm a
widening path that has effectively never run, which raises latency in exactly
the under-buffered case -- the opposite of the symptom being chased. It wants
its own change, with the numbers this report will provide.
check_frame_stall() already detects a hang reliably and always-on: it
reports "No frame presented in 30s" and puts a message on screen. But the
one thing that says WHERE the guest is parked -- dump_guest_threads_stalled(),
which prints every PPU thread's registers, guest call stack and the
instructions around its cia, plus SPU state -- was only ever reached from the
RSX profiler's poll_stall(), and that returns false immediately unless the
profiler is switched on. Testers do not switch it on.
So every freeze report arrives with the detection line and nothing behind
it. Confirmed against a Kane & Lynch (BLUS30102) capture: guest execution
collapsed at 0:04:15 -- sys_mutex_lock fell from ~200,000 per 10s to ~300,
CPU from 43% to 5.5% -- and this fired at 0:04:45 having recorded none of
it. Three such reports across two different SoCs, none reproducible locally,
all equally undiagnosable.
Call it from the detector instead. Twice, ~15s apart, because one sample
cannot tell a thread spinning from one making very slow progress -- a cia
that has not moved between two samples is itself the finding. Twice and no
more, and re-armed only when a frame actually lands: the detail runs to
hundreds of lines per thread, and log volume alone is enough to stall the
emulator on Android. The profiler path is unchanged.
port_new opened with "if (g_started) return 1". That was defensible while
gl1's state was whatever the previous saver left behind, but nativeInit now
calls gl1_lost() before every create, so gl1 is guaranteed DOWN on entry.
Reporting success there would hand the caller a saver with no shim under it.
So the gl1_lost() added alongside the leak fix did not just close the leak,
it turned that early return from redundant into wrong -- its safety rested
on a call-graph property that same change removed. All six ports now tear
down a stale run through port_free and always run gl1_init(). Each needs a
forward declaration of port_free, which is defined below port_new.
No behaviour change on any path reachable today: port_free always clears
g_started, so the guard never fires. The point is that a saver added later,
or an upstream cleanup that returns early, should fail in its own saver
rather than poison the next one -- the same reason port_free tears gl1 down
unconditionally.
Adapted from the ARMSX2 change (be674a64c5).
Ported from the ARMSX2 fix; ARMSX3 had the same bug, plus a sixth saver.
A saver that dies natively made the app unlaunchable. The choice is a
persisted pref read on the library screen -- the first screen -- so the
crash repeated on every launch and Settings was never reachable to turn it
off. The only escape was clearing app data, which takes memory cards and
save states with it.
Cause: gl1's state is a file-scope global holding GL object names, and
gl1_init() early-returns on g.ready. Skyrocket and Lattice defer initSaver()
to port_resize, so a create-then-teardown with no surface size left
g_started false and their port_free returned BEFORE gl1_shutdown(); flux,
plasma and solarwinds leaked it the other way, since returning 0 when
initSaver() leaves readyToDraw clear means port_free is never called at all.
Either way g.ready stayed set with names from a destroyed EGL context, and
the next saver -- new view, new context -- drew against them. Drivers answer
that with anything from a black screen to a segfault.
Every port now gives gl1 back on every exit, unconditionally, so the rule
holds by construction rather than by arguing about which paths are
reachable. gl1_lost() already existed here but was never called; nativeInit
calls it now as the standing invariant, so a new context cannot inherit old
GL names even if a saver added later forgets.
Containment, independent of the fix: native GL can always find a new way to
die, so the setting arms itself with a synchronous commit() before the
render thread starts and disarms when that thread exits in an orderly way.
Still armed at startup means the last run died with a saver up -- the
background switches off and a toast names it. A crash or a kill cannot reach
the disarm; that is the entire signal.
Also guards Thread.start(): it asks for a 16MB stack (Skyrocket declares a
3MB starmap as a local) and an OutOfMemoryError there is an uncaught throw
on the main thread -- the same lockout with no native crash involved.
The backend asked for the guest's rate with sample rate conversion
disabled, then recorded the rate it ASKED for rather than the one the
stream actually opened at. A device that cannot run at the guest rate
opens at its own, and with conversion off nothing resamples -- so the
emulator feeds samples at one rate into a device consuming them at
another. That is a permanent rate mismatch, not a one-off pitch error:
the gap between what is heard and what is on screen grows for as long as
playback continues, and nothing downstream can see it, because every
later calculation (including the buffering algorithm's idea of how much
audio is queued) uses the rate we requested.
Check the opened rate and, on a mismatch, reopen letting Oboe convert.
Converting is a worse signal path than matching rates outright and a far
better one than not converting at all; Cubeb resamples for the same
reason. If the rate is still wrong after that, say so in the log rather
than drifting in silence.
Refs #87
load_iso() cannot report failure: it mounts the virtual device whether or
not the archive actually opened. An image that is unreadable, truncated,
or encrypted without a usable disc key therefore sailed past the mount and
died much further down as a generic 'invalid file or folder', with nothing
in the log distinguishing it from a dozen other causes. A bug report of
that failure carries no information at all.
Check the mount where the reason is still knowable. An image whose
filesystem did not parse now reports whether a disc key was missing or
rejected -- returning decryption_error, and naming both the key locations
searched -- or, failing that, says the image looks truncated. When the
archive is readable but has no EBOOT.BIN at the expected path, log what the
disc root did contain; install discs legitimately lack one, so that stays a
warning rather than a failure.
Also realign BootResult with game_boot_result. It was missing
firmware_version and database_config_missing, so every code from ordinal 10
down was reported as its neighbour -- still_running surfaced as
'AlreadyAdded' -- and the last two had no entry at all, making fromInt
throw rather than return. fromInt is now total.
Refs #88
Rumble always went to the phone's own motor: the vibrator lookup asked
the system service and never considered the connected pad. On a handheld
that is the wrong motor outright, and on a phone-plus-controller setup it
buzzes the device sitting in a dock while the pad in hand stays still.
Prefer the first connected gamepad or joystick reporting a working motor,
falling back to the phone. The target is resolved per state change rather
than cached for the pump's lifetime, so connecting or disconnecting a pad
mid-session moves rumble with it; the motor that was last started is
tracked separately so unplugging mid-rumble cannot leave one buzzing.
Add a 'Vibrate the phone' toggle gating only that fallback, so playing on
a pad need not mean the phone rumbles along with it. Touch haptics stay
on the phone deliberately -- the finger is on the phone's screen.
Closes#89
present_generated_frame submitted its blit and presented immediately.
command_buffer::submit goes through queue_submit, which DEFERS to the offloader
thread when multithreaded RSX is on -- so the present waited on a semaphore whose
signal operation had not been submitted, which the spec forbids and which hangs
the present without returning the acquired image. The real present path flushes
first for exactly this reason; this now does the same.
Frame generation also refuses to run at all with multithreaded RSX enabled.
generate() submits inline on the RSX thread while the frame's own command buffer
is merely enqueued to the offloader, so the interpolation can reach the queue
before the frame it reads -- and two threads then submit to one VkQueue without
the external synchronisation vkQueueSubmit requires, which is what g_submit_mutex
exists for and what generate() bypasses by calling vkQueueSubmit directly.
Refusing is honest. Supporting it means routing through vk::queue_submit and
reasoning about deferred submission, which is not a change to make untested.
MTRSX defaults off, so this affects only users who enabled it -- and it is the
profile that produces intermittent DEVICE_LOST under load.
capture_presented_frame() was handed target_layout -- COLOR_ATTACHMENT_OPTIMAL or
TRANSFER_DST_OPTIMAL -- and stored it for generate() to use. But generate()
records into its own command buffer, submitted after the frame's, and the frame's
own transition to present_layout runs in between. So by the time the barrier
executed, its oldLayout named a layout the image had already left, and the
matching barrier on the way out restored that same wrong layout -- leaving the
image outside PRESENT_SRC_KHR when it was presented.
Both are violations: oldLayout must be the current layout or UNDEFINED
(VUID-VkImageMemoryBarrier-oldLayout-01197), and a presented image must be in
PRESENT_SRC_KHR (VUID-VkPresentInfoKHR-pImageIndices-01430). It also desynced the
renderer's own layout bookkeeping for that swapchain image on every subsequent
frame.
Passing present_layout instead describes what the image will actually be in, so
the barrier is a no-op transition that reads it where it lies and leaves it there.
Found by a Vulkan lifetime and layout audit. Not verified on device.
Two findings from the Vulkan audit, both of which let the interpolation read an
image it had no valid dependency on.
The barrier before Process used TOP_OF_PIPE with MEMORY_READ. TOP_OF_PIPE in
srcStageMask specifies no stage of execution, so the first synchronization scope
was empty and no availability operation happened for the colour-attachment and
transfer writes the frame made into that image in the PREVIOUS submission.
Submission order on a queue orders execution; it is not a memory dependency. So
the interpolation could read a partially-flushed frame -- which presents as
smearing during motion and does not respond to any interpolation setting, since
the interpolation is not what is wrong. Source scope is now
COLOR_ATTACHMENT_OUTPUT|TRANSFER with the matching write access, and the mirror
barrier's empty BOTTOM_OF_PIPE second scope becomes ALL_COMMANDS.
Separately, the swapchain was created COLOR_ATTACHMENT|TRANSFER_DST, but
FrameGen::Process copies FROM the presented image as TRANSFER_SRC_OPTIMAL. Both
that layout and vkCmdCopyImage require TRANSFER_SRC usage. Nothing else in the
renderer reads a WSI image, so it had never been needed. Requested only where the
surface reports it, with a warning otherwise, since it is not guaranteed on
Android.
Frame generation's state is file-scope global and had no teardown path.
shutdown() only finalised the old dlopen'd library, and release_shared_images()
was never called at all, so at emulation stop the fences, command pool, command
buffers, allocator, queue and the whole pass chain stayed live as children of a
destroyed VkDevice.
The reachable consequence is worse than the leak. On the NEXT boot in the same
process g_native.valid() was still true, so build_native_stack() was skipped and
generate() waited on a fence from the dead device, reset freed command buffers
and submitted to its queue. g_shared_w/h still matched too, so the output images
were not recreated either.
That is boot -> stop -> boot, and it is deterministic rather than intermittent.
It is the likeliest explanation for frame generation working in one game and then
failing immediately in the next one launched without restarting the app, which is
exactly how it was reported.
release_device_resources() destroys the stack and the images and resets the state
that gates rebuilding, including g_disabled -- a new device may not fail where the
last one did. Called from ~VKGSRender after vkDeviceWaitIdle, while the device is
still alive.
Found by a Vulkan lifetime audit. Not verified on device; the hardware was
unavailable.
shader_count() read the ENTIRE SPIR-V cache off disk and rebuilt every module,
then threw the result away -- and it ran twice per frame on the RSX thread, from
capture_presented_frame() and generated_frame_count(). At ~322KB that is roughly
644KB of file IO, four stats, two opens and hundreds of allocations per frame, on
the thread driving the whole present path.
That is CPU cost, which is why halving the GPU shader cost with fp16 changed
nothing, and why ~3ms of measured interpolation was costing ~20ms of frame time.
It arrived with the fix for shaders not loading at all: making the probe ask the
cache was right, doing it per frame was not. The verdict can only change on
import, so it is memoised and cleared there, as ARMSX2 does for the same reason
(GSLsfg.cpp:53-58, whose comment names this exact hazard).
Also: GenerateInto writes through a storage image view, which requires
VK_IMAGE_LAYOUT_GENERAL, and the blit that follows declares TRANSFER_SRC. Nothing
transitioned those images either way -- a storage write outside GENERAL is
undefined, and on Adreno a mismatched layout on a UBWC image forces a
conservative full-surface decompress. Both transitions added, with the layout
tracked across frames.
And the generated-frame blit is source-size to destination-size, so LINEAR
filtering bought nothing and cost a filtered sampling pass instead of the fast
copy path. NEAREST.
Found by a comparative audit against the ARMSX2 driver. Not yet verified on
device -- the hardware was unavailable when this was written.
The fence wait at the head of generate() blocked for up to a second on the
previous slot's work. When it actually waited, the long frame interval it
produced went straight into the pacer -- whose clock is sampled after this point
-- and any interval over 100ms trips MINIMUM_BASE_RATE and stops generation for a
full second (FrameGenPacer.cpp:29/40/97-100).
So a stall here made the pacer stand down, and it was measuring a rate we had
slowed ourselves. Measured against that: the pacer asked for a generated frame on
only ~40% of real frames, which matches one >100ms interval every couple of
seconds costing a second of generation each time.
A zero timeout asks whether the previous frame's work is done and skips this
frame if it is not. Skipping costs one generated frame; stalling cost a second of
them. With three rotating slots the work has three frames to finish, and it
measures ~3ms, so skips should be rare.
VK_TIMEOUT is now a normal outcome rather than an error, so only real failures --
VK_ERROR_DEVICE_LOST being the one actually suspected here -- reach the counter
that disables the feature.
Every non-success result from vkWaitForFences was reported as a timeout, which
hid the one detail that mattered: 60 of them arrived in 3 seconds against a
1-second timeout, so the wait was failing immediately rather than expiring.
VK_ERROR_DEVICE_LOST is the obvious candidate and means something entirely
different from a slow frame -- the device is gone and frame generation is the
messenger, not the cause.
The failure is intermittent, so this logs the result code once per stall rather
than trying to fix a cause that has not been identified.
The single-submit path broke two games -- one reported "failed", another stopped
progressing past its logo -- and reverting it restores both. It was chasing ~8 ms
of measured per-submit overhead, and the idea may still be right, but it changed
swapchain image ownership incrementally and got it wrong four separate ways:
acquiring before the count was known (leaving semaphores signalled and every
later acquire failing), sharing semaphores across in-flight frames, leaking
acquired images that were never presented, and writing storage images outside
GENERAL. That is a design that needs settling before it is written, not patched
into place against a device.
Kept, all measured:
- The acquire semaphore. Generated frames were acquired with VK_NULL_HANDLE and
written immediately -- a swapchain image used before the presentation engine
had finished with it. That was the judder: invariant to every interpolation
setting because the interpolation was never at fault, present at any frame
rate because a race has no cadence, and smearing rather than stutter because a
torn image is parts of two frames at once. Gone entirely at x2.
- The guest extent. The passes size their optical flow from the ratio between
what the game rendered and what is presented; passing the presented size for
both made that ratio 1.0 and ran the flow at full 1080p for a title that
rendered 720p. 4.8 ms -> 3.0 ms of GPU time.
- fp16 shaders, where the device reports and enables them.
- Fence timeouts are transient. disable() is permanent for the session, so one
stall during a load spike left frame generation reading "failed" until the
game was restarted with nothing wrong.
Frame generation costs ~3 ms of GPU time per frame. On a title already at the
refresh rate that is enough to cross a vblank boundary, so it trades real frames
for interpolated ones and gains nothing; it is worth having where a game has
headroom below the panel rate, not where it is already at the cliff edge.
Measured, not guessed: a GPU timer around the passes says they cost 4.1 ms per
frame, steady, while frame time went from ~17 ms to ~30 ms with generation on.
Roughly 8 ms per frame was therefore not interpolation -- it was the cost of
submitting it.
ARMSX2 records Process, every GenerateInto, and every copy into the acquired
swapchain images into ONE command buffer and issues ONE vkQueueSubmit that waits
on each acquire and signals each present. ARMSX3 submitted framegen's work and
then a separate command buffer and submit per generated frame, each its own GPU
batch on the same queue with its own scheduling gap.
generate_into_targets() takes the acquired images and does the whole frame in one
buffer and one submit; the present path acquires them up front and issues the
presents afterwards. present_generated_frame is left in place for the pipelined
path, which is still disabled, and no longer runs on this one.
Follows the acquire-semaphore fix in the previous commit -- that was the judder
(a swapchain image written before the presentation engine had finished with it),
this is the cost.
present_generated_frame acquired its swapchain image with VK_NULL_HANDLE -- no
semaphore, no fence -- and then blitted into it and presented immediately.
Vulkan requires waiting on the acquire before touching the image, so this was
writing into one the presentation engine could still be scanning out.
That accounts for what nothing else did. The artifact was invariant to flow
scale, shader family, fp16 and multiplier, because all of those change how a
pair is interpolated and the interpolation was never at fault. It appeared at
every frame rate, because a race has no cadence. It looked like smearing rather
than stutter, because a torn image is parts of two frames at once, and it only
showed during motion, because that is when consecutive frames differ.
The real frame has always had an acquire/present semaphore pair in
frame_context_t. Only the generated path went without, which is why nothing
inside frame generation could reveal it.
ARMSX2 acquires with a fence and blocks on it before submitting, and says so.
This waits on the GPU instead: the blit waits on the acquire, the present waits
on the blit, one pair per swapchain image so none is reused while in flight.
Also restores the fp16 probe consistency in shader_count(), which a git checkout
during this session reverted -- three callers ask which shader variant to use and
any two disagreeing makes a freshly imported cache read as "no shaders".
Frame generation was running the fp32 shader family on hardware that supports
fp16. Measured on an Adreno 740: real frame rate fell from ~60 to ~25 with
generation on, so the output was worse than not using it at all.
The cause was copying a hardcode without its reason. ARMSX2 sets allow_fp16 and
prefer_fp16 false and says why: PCSX2's Vulkan backend never asks for
shaderFloat16 when it creates its logical device, so a module declaring the
Float16 capability would be invalid usage there -- and its own comment ends
"restore Eden's two lines if PCSX2 ever enables the feature". Eden does gate on
the device, and Eden is the one that performs. RPCS3 enables shaderFloat16 where
the driver is trusted, and clears its own flag on the Adreno drivers whose
compiler rejects native float16, so asking the device is both possible and safe.
The device in question logs "GPU/driver supports float16 data types natively".
The compat Device answers a third query now, as Eden's does. BuildShaderCache
takes the flags rather than choosing its own, because the cache header stores
them and rejects an entry whose flags differ -- the same mismatch that made a
freshly written cache read as "no shaders".
Found by measuring instead of reasoning: the CPU side of the passes costs
0.000-0.061 ms/frame, which said the cost was GPU-side and not in how the work
was sequenced, after four attempts at the sequencing.
One command buffer and one fence meant every frame waited for the immediately
previous interpolation to finish before it could reset the buffer. The GPU
pipeline drained once per frame and the CPU could never get ahead, which is a
stall no amount of moving the work around removes -- it is the depth that is
wrong, not the placement.
ARMSX2 keeps one slot per swapchain image, each with its own command buffer and
fence, and waits on the fence belonging to the slot it is about to reuse. That
fence is several frames old, so in the normal case it is already signalled and
the wait costs nothing. Three slots here, rotated per frame.
This is the last of the differences found by reading the ARMSX2 driver rather
than reasoning about my own: the copy, then the pacer's clock, then the frame's
own command buffer, and now the depth.
Discord: the emulator logo now appears beside the cover art, as it does in
ARMSX2. By URL rather than asset key -- a key that is not in the portal makes
Discord reject the entire presence update, which is what left the card blank
before. It is the launcher icon out of this repository, so it cannot drift from
the app. With no cover the logo takes the large slot instead of sending no assets
at all.
The previous commit removed the capture copy by recording Process() into the
frame's command buffer. That traded one problem for a worse one: the present path
waits on that buffer, so the interpolation's GPU cost came straight off the real
frame rate. Measured on device, 1920x1080 content ran at 24-28 real fps with
generation on, while the CPU side of the same call cost 0.013-0.061 ms -- the
work was not expensive to record, it was expensive to be waited on.
ARMSX2 runs Process in framegen's OWN command buffer, after the frame, against
the same presented image. That costs neither the copy nor the frame, and it is
what this now does: the capture side only remembers the image and lets the pacer
plan, and generate() transitions it, runs Process, runs GenerateInto per planned
frame, and submits -- one buffer, one submission, off the critical path.
Defaults were checked against ARMSX2 first and are identical (flow scale 100,
performance shaders on, target rate 0, multiplier 2), so this was never a settings
difference.
Two defects, both from grafting the passes onto the existing shape instead of
adopting the one they were written for.
A copy per frame that ARMSX2 never makes. Process() copies the source into its
chain itself (FrameGen.cpp, CopyPresentedFrame), and ARMSX2 hands it the real
swapchain image. This handed it a capture copy made by a separate full-frame blit,
so every frame paid for two 1920x1080 copies instead of one. The capture existed
only because generation happened later, from another submission, by which time the
swapchain image was gone -- so the copy was paying for a problem the ordering
created.
And the pacer planned against a clock that jittered. Plan() derives the base frame
rate from the interval between its own calls; it sat inside generate(), behind
three early returns and a conditional call site, so it measured an interval the
game never had and planned against it. That is judder by construction, and no
amount of tuning the multiplier reaches it.
Process now runs in the frame's OWN command buffer, against the presented image,
where the frame is finished and the image is still ours -- which removes the copy
and the reason for it together. The pacer plans there too, once per frame,
unconditionally. generate() keeps only the generation half: record GenerateInto
per planned frame, submit, and let queue order carry it into the blits.
The input capture images are gone entirely.
Reported as judder and lower performance against ARMSX2 and Eden running the same
passes, which was the right comparison to make.
The cache was written with one flag set and read with another. BuildShaderCache
asked for allow_fp16 = true while LsfgShaders, the only consumer, asks for
(false, false), and the header stores those flags and is rejected when they
differ -- so the cache written at import could never satisfy the read after it.
On a desktop that is invisible: the source DLL is still there, LoadShaderModules
falls through to re-parsing the executable, and the cache is dead weight rewritten
every launch. Android has nothing to fall through to, because the picked file is
a copy in app cache that the system may clear, so it surfaced as "no shaders"
with a valid cache sitting next to it. Worth telling Camille: Eden and ARMSX2 are
both re-parsing Lossless.dll every launch rather than using their cache.
The cache also survives a missing source now. It is validated against the source's
size and mtime, which is right where an install stays put and wrong here; passing
0 skips those checks, matching what source_hash and variant already do. A replaced
Lossless Scaling is no longer noticed automatically, which is the trade -- against
losing the cache to a routine cache sweep, and re-importing is explicit.
Performance: the fence wait moved from after the submit to before the next one.
Waiting at the end put the whole interpolation on the critical path -- the thread
sat idle until the GPU finished, every frame -- when nothing required it: frame
generation and the present blits share one queue, so submission order already
orders them. Waiting at the START only blocks when the previous frame's passes
have not finished in time. The queue is now taken from the device rather than a
second vkGetDeviceQueue, so that sharing is explicit rather than incidental.
Settings: motion detail is a slider rather than three stops, and performance
shaders is a switch rather than a pair of buttons.
Three defects from the switchover, all mine.
vmaCreateAllocator crashed at pc 0 the first time frame generation was switched
on. The allocator was created with physicalDevice and device but no instance and
no vulkanApiVersion; Android builds with VK_NO_PROTOTYPES, so VMA resolves its
table dynamically through vkGetInstanceProcAddr, which answers only for global
functions when the instance is null. It stored nulls and called one. memory.cpp
documents this exact failure at its own vmaCreateAllocator -- the two getters were
supplied and the instance beside them was not.
shader_count() then answered for the wrong cache, twice. First it still counted
g_shaders, which import_shaders stopped filling when it moved to the ported
extractor: generated_frame_count() multiplies by it, so generate() returned on its
first line and frame generation sat on "starting" with nothing logged, because it
never got far enough to fail. Replacing that with GetInstalledLosslessStatus() was
also wrong -- that validates the DLL PATH, and the UI copies the picked file into
app cache, which Android may purge. A re-import wrote a good 322KB shader cache and
it still said "no shaders". It now asks whether the cache LOADS, which is the
actual question: the shaders are extracted once and the source is not needed again.
The dead initialize() call in generate() is gone with it. It built the dlopen'd
library's context, nothing uses it, and a failure there could still disable the
feature.
Settings: the in-game menu offered the multiplier alone. Someone opening it
mid-game is there to answer "it is generating but the picture is unsteady" or
"it is generating but it is too expensive", and neither is the multiplier. Target
rate, flow scale and performance mode now sit beside it. Target rate is new --
0 keeps the fixed multiplier, non-zero is Camille's adaptive pacing.
Wires up the previous commit. Frame generation now runs on OUR VkDevice, in our
own present path, instead of inside a dlopen'd library with a VkDevice of its own.
What that removes: every frame used to cross between devices as an
AHardwareBuffer -- allocated, imported as a VkImage on our device so the renderer
could blit into it, handed over as a raw buffer, imported again on theirs. The
capture images are plain device-local VkImages now, because the passes read the
very images the renderer already wrote. The AHardwareBuffer external-memory
extension was also the narrowest gate on the whole feature and is no longer
required.
Kept deliberately:
- The capture itself. The swapchain image is presented and reused, so the passes
still need a stable copy; only its backing changed.
- commit_capture(), now near-vestigial. It existed because a second VkDevice had
no semaphore joining it to ours. One device and one queue means submission
order already says this, but the call site is where the capture becomes
readable and that is worth keeping named.
- The fence wait in generate(). The present path blits the generated images
immediately after, so the old contract -- return only when they are ready --
still holds. A semaphore would do it without stalling the thread and is the
obvious next step, on a path that has broken three times from partial fixes.
Frame generation records into its OWN command buffer, as the ARMSX2 driver does:
the passes have to run after the captured frame is complete and before the
generated images are blitted, and the renderer's buffer is already closed by then.
import_shaders now drives the ported extractor rather than the library. Both kept
their own copy of the shaders, and importing through the library would have looked
like it worked -- a count comes back, the settings screen agrees -- while the cache
the passes actually read stayed empty. Its failures are now phrased for the person
who chose the file.
VKPresent.cpp is untouched: generate(), generated_frame_count(), generated_image(),
capture_presented_frame() and commit_capture() all kept their signatures and their
meaning, which is the whole reason the switchover is one commit and not five.
Builds clean. NOT yet run on hardware -- neither this nor the device-feature change
under it has drawn a frame.
Compiles; not yet wired. VKFrameGen still drives the old dlopen path and nothing
calls these passes -- that is the next commit. Landing it here keeps the port and
the switchover separable, because the present path is the part that has broken
before.
The implementation is Camille LaVey's, from eden-emu PR #4263, by way of ARMSX2
2.6.6.8. She licensed it GPL-2.0-or-later for use here: ARMSX3 derives from
RPCS3, which is GPL-2.0-ONLY with no "or later" clause, so the original
GPL-3.0-or-later terms could not be carried across. Every file records that.
Eden is unaffected -- "or later" leaves its own use exactly as it was.
Why it is worth having: the current implementation runs behind a dlopen'd .so
with its OWN VkDevice, so every frame crosses devices as an AHardwareBuffer --
allocated, imported on our device, handed over, imported again on theirs. These
passes run on our device in our own present path and that round-trip disappears.
It also brings adaptive pacing: instead of a fixed x2, generate as many frames as
it takes to hold a target refresh rate, which is the answer to a game oscillating
between 60 and 30 on a 60Hz panel.
Porting notes, all of it concentrated in three seams rather than spread:
- LsfgVkCompat's Device adapter wrapped GSDeviceVK and now wraps vk::render_device.
28 of the 31 files never mention either and were not touched.
- GSConfig lives in FrameGenConfig.h, mapping RPCS3's mode enum and cfg fields to
the names the passes read, so a later merge from Eden touches passes not wiring.
- LosslessDll's platform calls (FileSystem/Path/GSXXH) became fs:: and an inline
FNV-1a; the hash only has to notice the DLL changed since the cache was written.
Two changes outside the tree were prerequisites, not cleanups:
- vkCreateDevice now ENABLES vulkanMemoryModel and robustness2's nullDescriptor
when the device reports them. Both were already probed and never enabled, and a
shader that declares the memory model on a device where it was not enabled is
invalid usage rather than a soft fallback -- desktop drivers shrug it off while
Adreno takes the device down mid-frame, so it would have looked fine until it
was not. Only nullDescriptor is turned on, not robustness2's expensive half.
- The Android Vulkan loader gained vkFreeDescriptorSets, which it never loaded.
New settings: Frame Generation Target Rate (0 = fixed multiplier) and Frame
Generation Lossless Path. Nothing ships the shaders; they are read from the
user's own legitimately purchased copy, exactly as before.
A game that is visible but unreadable is the failure mode that costs the most
time, because nothing about it looks like a permissions problem. The core opens
games with ordinary file IO. Without All files access the scanner falls back to
SAF document trees, and a title found that way has no filesystem path to open --
but it is still listed, still launches, and then sits on its first loading
screen when the reads never arrive. The same shape appears when the grant is
revoked after a folder was added, or when a disc sits on storage the app was
never given. Moving the same files into the emulator's own games directory fixes
all of it at once, which is what makes the cause so hard to see: the game works
or does not depending only on where it lives.
Boot now proves the content is readable first, at two offsets. Size alone proves
nothing -- an entry can be listed with its real size and still refuse to deliver
bytes -- so it reads byte 0 and byte 32769, the ISO descriptor, the first place
any disc is read for real. Failure returns invalid_file_or_folder with a message
naming the two fixes, rather than handing the core content it cannot read.
The scanner says the same thing at the point it falls back, distinguishing "All
files access is not granted" from "this tree has no filesystem path", because
they need different fixes.
Read-only probe, and directory-installed titles skip it: fs::is_file is false for
them and they were never affected.
Reported by a tester whose whole library started working after moving the ISOs
into ARMSX3/config/games -- cover art, launching, and Prototype no longer locking
up, all from the move alone.
Reverts upstream 8a6c96745, taken in the ROP remap merge, back to the 0.8
behaviour. The check is sound on a desktop filesystem, but on Android the disc is
read through SAF/content URIs and this predicate decides how a title gets
mounted: System.cpp:1572, 1589, 1764 and 1878 all branch on it, as does
rpcsx-android.cpp:3935. A short read there does not fail the boot outright, it
silently routes the title down a different mount path -- which is what a game
that reaches its first loading screen and never leaves it looks like.
Not identical to 0.8: magic[] is zero-initialised. The original compared it
without checking the read at all, so a failed read compared uninitialised stack.
Zeroing restores "do not reject on a short read" without the undefined behaviour,
and a genuinely non-ISO file still fails the CD001 test.
iso_archive::is_valid() stays. It comes from a different upstream commit
(d6d5c6082), System.cpp calls it, and it is not part of this predicate.
Reported against NASCAR 2011, which reached the menus before the merge and now
sticks on the first loading screen. NOT confirmed as the cause -- the reporter's
build predates the local commits, and the other public candidates in that window
are the Adreno compute group size and the driver pipeline cache work.
The PS3 has two PPU hardware threads and upstream's own config comment says this
must be 2. With 1, SPURS does not get the concurrent PPU progress it expects and
its task modules fail validation: the SPU executes its own HALT instruction and
the graphics pipeline stops. It presents as an intermittent freeze after 10-30
minutes, on hardware where the same build with the default is fine.
There is no UI for this value -- it is reachable only through a raw core
override, so it is set deliberately and then forgotten. Nothing reported it,
which meant an affected log was indistinguishable from a healthy one and the
setting had to be found by eye in the config dump, after a diagnosis that went
through the SPU halt, the reservation path and an instruction-level SPU test run
before arriving at a configuration difference.
Six of Terry Welsh's screensavers (GPL-2.0-or-later) run as animated library
backgrounds alongside Flurry: Flux, Plasma, SolarWinds, Hyperspace, Lattice and
Skyrocket.
They are GL 1.x -- immediate mode, the fixed-function matrix stack, display
lists, texgen -- so savers/gl1.c answers all of that on GLES2 rather than the
renderers being rewritten. Display lists are not an optimisation to skip here:
each saver compiles its particle shape once and replays it per particle with a
different matrix, so glCallList IS how they draw.
Flux, Plasma, SolarWinds and Hyperspace are byte-identical to upstream. They
build with RS_XSCREENSAVER, their own platform-neutral path, and everything it
expects from an X11 host is answered by savers/compat. Lattice and Skyrocket
ship Windows-only and were hand-ported; each carries a header listing exactly
what was removed. rss-glx has neutral versions of those two but is GPL-2.0-only,
which ARMSX2 (GPL-3.0) could not take.
Every saver is compiled inside its own namespace by a *_unit.cpp. They all
declare the same globals -- draw, idleProc, cleanUp, readyToDraw -- because each
was built as a separate executable, and two in one .so collide at link time.
Notes on the three bugs that were not obvious:
- The render thread gets a 16MB stack. Skyrocket's World constructor declares a
1024x1024x3 starmap as a LOCAL, 3MB, then a 768KB sunsetmap. On a Windows main
thread that was fine; here it was a SIGSEGV in memset before the first frame.
- gl1_frame_begin clears the first frames and then masks alpha off. These savers
fade the previous frame instead of clearing, and that fade writes destination
alpha, dragging a composited surface toward transparent -- which reaches the
screen as TV static. Same fix Flurry needed.
- The saver lifecycle is serialised behind a mutex and a generation token. Each
view owns a GL thread, so switching preset let the outgoing thread's teardown
land after the incoming thread's init and wipe it; gl1 keeps its state in one
global. A view may now only free the run it started.
Also renames the settings mirror armsx2-settings.json to armsx3-settings.json
(issue #82). The old name stays readable: after a reinstall it is the only copy
of a user's settings, so it is still restored from, still collected into new
backups, and now deleted alongside the new one -- leaving it behind would
silently re-seed settings that had just been purged. Old backup archives restore
unchanged.
The library background rendered as full-screen TV static with the smoke
faintly visible behind it.
Flurry draws its trails by fading the PREVIOUS frame rather than clearing,
so it needs the back buffer to still hold what it drew last time. EGL
defaults EGL_SWAP_BEHAVIOR to EGL_BUFFER_DESTROYED, which leaves the buffer
undefined after every eglSwapBuffers, so every frame started from
uninitialised GPU memory -- which is what reached the screen as static.
Measured: a single fade darkened the buffer by 3.17%, but sixty consecutive
fades only achieved 6.5% in total, where sixty compounding 3.17% fades
should have taken a corner reading 123 down to 17. The fade was working
every frame; its result was not surviving the swap. A 64x64 readback of that
corner went from mean=128 ndiff=41 to mean=0 ndiff=0 with preservation on.
Preservation must be requested on the config and on the surface, and is then
queried back rather than assumed, since a driver may refuse it.
Also set the surface alpha once and mask further alpha writes off. Flurry
fades with GL_SRC_ALPHA/GL_ONE_MINUS_SRC_ALPHA, which writes destination
alpha as well as colour and drags it toward the fade's own value -- harmless
for a screensaver that owns the display, wrong for a surface the window
compositor blends. RGB is untouched, so the trails are unaffected.
Calum Robinson's Flurry screensaver (2002), ported from the xscreensaver tree and
offered alongside the XMB wave. Asked for by a tester who has wanted it on a
handheld since the Mac original.
The renderer is his, unmodified. It draws the way 2002 did -- client-side vertex
arrays, a fixed-function ortho, GL_QUADS -- none of which GLES2 has, so
gl_compat.c answers those calls with a shader, a matrix uniform and an index
buffer instead. That keeps the diff against upstream to four lines: gluBuild2DMipmaps
becomes glTexImage2D plus glGenerateMipmap, GETTIMEOFDAY_TWO_ARGS is set by hand
rather than by xscreensaver's configure, DrawSpark is guarded because it is
immediate-mode and upstream never defines DRAW_SPARKS, and the xlockmore shell is
replaced by four entry points. Its own target rather than part of the JNI glue:
BSD-3-clause next to GPL, and plain C that wants none of the C++20 around it.
FlurryGlView is the same TextureView and EGL shell XmbGlView already uses, so it
reports through onGlStatus the same way and a device that cannot bring GL up
falls back to the 2D backdrop rather than showing a hole.
Off by default, and the description says why. This is a live particle simulation,
and an animated library background has been walked back here once before --
ARMSX2 shipped a looping video behind this same screen and removed it in 2.5.9
when the continuous decode turned out to cost real performance. The loop is
capped at 60fps for the same reason XmbGlView caps itself at 30: Flurry already
refuses to advance faster, but without a cap the thread still spins at panel rate.
Also adds @JvmStatic to Rpcs3Bridge.deleteState, which NativeApp calls from Java.
Kotlin-only compilation does not catch that.
Deleting a slot could never work. There was no delete entry point, so the picker
called java.io.File(getGamePathSlot(slot)).delete() -- and getGamePathSlot answers
OCCUPANCY, not a path. Its own declaration two lines below says so: "Not
getGamePathSlot, which answers a title id". So the call was
File("SHVH06660").delete(), which removes nothing and returns false. Every delete
failed, and the UI correctly reported that it had. Issue #80.
Added _rpcsx_deleteStateFromSlot, which resolves the file through
armsx3_slot_find, the same function load uses. That matters: the extension
depends on which build wrote the state -- .zst today, .gz and bare historically --
and building the path by hand is how this went wrong to begin with. The thumbnail
beside it goes too, best effort, so a removed state cannot leave a slot still
showing as occupied.
Saving arms after_kill_callback with Emu.Restart and then kills the VM. The
restart runs BootGame, whose restore_on_no_boot does ensure(IsStopped()) -- and
that overload accepts only stopped, loading or stopping. A Resume landing
anywhere in that window puts the state at running and turns the assert into a
process abort.
Issue #81, from the log: "Emulation has been resumed!" at 0:03:23.275950 and
"{Savestate Prepare Thread} Verification failed (object: 0x0)" at 0:03:23.275981.
Thirty one microseconds apart, on different threads.
Android makes the window far easier to hit than desktop. CallFromMainThread runs
its callback INLINE on the calling thread here rather than deferring it, so the
whole kill-and-restart chain executes on the savestate thread while the UI thread
stays free to resume underneath it.
Guarded on m_emu_state_close_pending, which is the emulator's own marker for that
window and is cleared on every failure path, so this cannot leave a game stuck
unresumable. Placed in Resume() rather than in the Android entry point so it also
covers the resume that surface-loss recovery issues.
discover_ether_address() reads the MAC with SIOCGIFHWADDR. Android has not let an
app do that since Android 6 and enforces it hard from 10 -- the ioctl returns
EPERM and /sys/class/net/*/address is unreadable -- so on every modern Android
device that branch failed, np_handler logged
NPHandler: Failed to discover ethernet or ip address!
and gave up. That leaves the network stack unidentified and blocks RPCN and any
game that asks it who it is. Reported as issue #79 against two unrelated titles,
with network settings that looked correct and could not have helped, because
nothing exposed in them reaches this.
The IP half was never the problem: it learns the local address from a UDP connect
to 8.8.8.8, which is a routing lookup and sends nothing.
Derive the address from Console PSID, exactly as the derive_mac_from_psid path at
the top of the same function already does. Nothing validates it against hardware
-- it identifies the console to the network stack and to peers -- and a locally
administered address is the correct thing to present when the platform refuses
the real one. Console PSID defaults to a per-install random value, so two users
do not end up sharing one.
Tried after the ioctl rather than in place of it, so a device or ROM that does
answer still uses its own.
"Is this submission finished" costs 1.9ms per call and returns not-ready 0.0% of
the time on Adreno 740 -- about 5ms a frame in Minecraft, a quarter of the RSX
thread. Measured with the profiler's own counters (fence polls 2.6/frame,
1903712 ns each).
That is the same signature this code already carries a comment about:
vkGetFenceStatus measured 19.7ms per call and never once returned VK_NOT_READY,
"a wait wearing a query's name". The zero-timeout vkWaitForFences that replaced
it is ten times better and still, evidently, a wait -- the driver appears not to
honour a zero timeout either way. That is driver behaviour, so which call is best
is a per-device question and not something to decide here.
ARMSX3_FENCE_POLL in driver_env.txt selects between them:
wait0 default, today's zero-timeout wait
status vkGetFenceStatus, what this used before
defer speculative callers do not ask the driver at all
The third is the interesting one. check_present_status walks the queued frames
looking for ones that have already finished and gives up on the first that has
not, and poke_all discards the result entirely -- neither wants to wait, so
neither should be the thing that does. They now pass allow_block=false. next()
and reset() keep the blocking form, so the command buffer ring cannot be starved
by this and "CB chain has run out of free entries" cannot fire because of it.
Default behaviour is unchanged. This ships as a lever rather than a fix on
purpose: the last renderer change justified by one game on one device was
reverted in 0.7.1 after wider testing, and the note on that revert asked for
pieces that come back one at a time with testing behind each. The profiler
already prints per-call timing, so each mode can be judged on the device in front
of whoever is testing.
89c6d08ed made untouched_expected a high-water mark to stop a silent port
resetting it every period. That fixed a measured, severe bug -- H.A.W.X. 2 ran
its audio clock at 55% of real time with the ring buffer permanently empty -- and
its own commit message flagged what was left unverified: whether anything depends
on the baseline falling again within a stable port configuration.
It does, and the mark latches. The fast path runs whenever untouched ==
active_ports, and stores min(max(untouched, expected), active_ports), so the first
fully silent period a title has pins the baseline at its maximum. From then on
`untouched > untouched_expected` can never be true and the loop stops waiting for
late ports for the rest of the session. Every game has a silent period.
Going back to upstream's instantaneous store is not the fix: that is exactly what
produced the H.A.W.X. stall. A port a game leaves started while writing only
zeros still flips the -0.0f tags, so it reads as touched on the few periods a
write lands in and untouched on the rest; following that dip drops the baseline
and the next period waits out the whole timeout, every flicker.
Hysteresis covers both, because the two differ in duration rather than shape. The
flicker is one period wide; a game that genuinely starts filling its ports stays
filled. Raise at once, lower only after the lower count has held for eight
periods -- far longer than any flicker, still a small fraction of the untouched
timeouts this feeds. For the H.A.W.X. case the behaviour is unchanged.
Bounded either way: the in_progress gate below is untouched, so no half-written
buffer was ever mixed by this. What the latch cost was the wait for a port that
had not started yet.
Issue #73: F1 Championship Edition silent since v0.8 except during videos, on
every audio backend. The reporter also notes the races run at 10fps and the
videos are "a bit choppy" -- so audio dies when the machine is saturated and
survives when it is not. That is a load correlation, not a code-path split, and
it rules out the theory that cellAudio is never reached.
cellAudio.cpp has no commits at all in the v0.6..v0.8 window. Across every audio
setting the Android UI writes, the only delta is the default backend moving from
Cubeb to Oboe, with a migration that moved existing users too.
Cubeb starts the OS stream in Open() and never stops it while open; its Play and
Pause only flip a bool. Oboe's call requestStart and requestPause for real, and
this backend asked for an Exclusive low-latency stream with a two-burst buffer --
4 to 10ms. An Exclusive AAudio stream holds the app to the callback deadline and
the platform disconnects it when that is missed for long enough, which at 10fps
is continuous. ARMSX2's own Oboe backend, same team and same device class, uses
Shared with a 4096-frame capacity.
Three fixes here:
Open() asks for Shared, and sizes the buffer from the emulator's own Desired
Audio Buffer Duration rather than a burst multiple. cellAudio fills ahead by that
much, so a stream that cannot hold it underruns however the trigger is set.
Play() started the stream AFTER committing m_playing, and never rolled it back on
failure. onAudioReady requires m_playing but a stream that never started does not
call it, so cellAudio's backend_active never armed, every block it enqueued was
discarded by the !backend_active check in enqueue(), and the early return at the
top of Play() made that permanent for the session. Start first, claim second.
Operational() answered `m_stream && !m_reset_req`, which is true for a stream that
failed to start or was disconnected -- so cellAudio's "backend stopped
unexpectedly" recovery never ran. It now reports the stream state.
Also: audio::configure_audio() is what turns a changed audio node into a rebuilt
backend, and its only caller is main_application.cpp, which is Qt and not part of
this build. Writing Audio Renderer changed the YAML and nothing else until the
next boot, so "try another backend" was a null experiment -- which is how that
issue collected three backends' worth of identical results. Call it from the
Android settings path, alongside the overlay resets that had the same gap.
And the "Audio Backend (advanced)" row only affects Cubeb, since cubeb_backend is
read once in CubebBackend's constructor. Under Oboe it did nothing while staying
interactive and showing a selection. Hidden unless the renderer is Cubeb.
The core is built for armv8.1-a and that is a floor, not a preference:
util/simd.hpp emits SQRDMLAH and util/asm.hpp carries inline LSE atomics. On
ARMv8.0 silicon -- Cortex-A53/A57/A73, so Exynos 9610, Snapdragon 660 and the
like -- those are illegal opcodes.
What the user saw was a bare crash. The fault is a SIGILL inside a static
constructor while the linker is still running libarmsx3-core.so's initialisers,
so it happens before any of our code can report anything, and the top frame names
a log-channel registration rather than a CPU problem. Because the core is
dlopen'd lazily, it surfaced at whatever first needed it -- issue #15 filed it as
"crashes when selecting the firmware .pup file", which is where the crash appears
and not what it is about.
Check /proc/cpuinfo for atomics and asimdrdm before that dlopen and report it.
Checked across every core listed, not just the first, since emulator threads are
scheduled on all of them.
Fails OPEN by design: an unreadable or unfamiliar /proc/cpuinfo returns null and
loads as before. Blocking a device that would have worked is worse than the crash
this replaces. Verified on a Snapdragon 8 Gen 2 that all eight cores report both
flags, so nothing changes there.
The reason is also held in unsupportedCpuMessage so a screen can show it later; a
toast is easy to miss for something this final.
The RPCS3 config database is maintained against desktop, so a fix specific to
this port has nowhere upstream to live. Add a small local map layered on top of
whatever the database says.
First entry: Tales of Symphonia Chronicles (BLUS31213, BLES01935) gets Frame
limit "PS3 Native". That is the only mode which honours the game's own
cellGcmSetFlipMode(CELL_GCM_DISPLAY_VSYNC) request -- every other mode flips
immediately, so a title pacing itself by flipping on alternate vblanks free-runs
to the 60 cap, which is issue #77: 60fps in a 30fps game and battles at double
speed.
Applied in three places because none of them alone is enough: after refresh(),
which clears the directory before writing; after setEnabled(), which moves the
whole directory aside; and at startup, so it works for users who never downloaded
the database at all. Merged rather than overwritten, and skipped entirely if the
database already sets a Frame limit for that title.
UNVERIFIED against the reporter's device. The mechanism is read from the code,
not measured, and the failure mode matters: PS3 Native applies NO limit when a
game does not request vsync, so if this title turns out not to, it would run
unbounded rather than capped at 60. Drop the serials from the map if the reporter
says it is worse.
PS3 Native is the only Frame limit mode that honours the game's own
cellGcmSetFlipMode(CELL_GCM_DISPLAY_VSYNC) request: in handle_emu_flip every
other mode falls through and flips immediately, while that one defers the flip to
a real vblank. A title that paces itself to 30fps by flipping on alternate
vblanks therefore free-runs to the 60 cap under Auto, our default -- reported in
issue #77 as Tales of Symphonia running at 60 and battles playing too fast.
The mode already existed and Rpcs3Settings could already write it, but nothing
reachable from the UI ever did: the only control is the Display FPS Cap row,
which maps to numeric presets or Second Frame Limit. It was reachable solely as a
raw core override. That also explains the reporter's follow-up -- capping by hand
does slow the game, but through the wall-clock limiter, which pushes flips off
the vblank boundary and brings tearing and audio glitches with it.
Added as -1 in that row rather than as a new setting, since it is mutually
exclusive with a numeric cap. Both clamps on the way to the core floored at 0 and
would have quietly turned it into "no cap".
setFrameLimitEnabled needed the same widening. It runs last, so its `cap > 0`
test read -1 as "no explicit rate" and wrote Auto back over PS3 Native -- the
collision its own comment describes, one value along.
Deliberately NOT the default: with no vsync request from the game this mode
applies no limit at all, so titles that never ask for vsync would run unbounded.
Both sat inside set_transform_program, the handler upstream rewrote for
ROP_OUTPUT_REMAP. Resolving that conflict took upstream's version as the base and
re-applied our profiler scopes, which put the scopes back and quietly dropped
these.
The first is the one that matters. e13fc184f made the redundant vertex program
check actually compare: the destination holds each word byte-swapped individually
by copy_data_swap_u32, while be_t<u64> swaps all eight bytes and so also exchanges
the two words. Without the rotl the check compares (w0,w1) against (w1,w0), which
can only match when w0 == w1 -- so it never fires, and every single upload marks
the ucode dirty. That forces a vertex program re-analysis, a program cache hint
drop and a full transform constant re-upload on every draw.
That is a per-draw cost on the RSX thread, restored to 0.9.4 by the merge after
being fixed, and it fits issue #78: Wipeout HD Fury reported as much higher CPU
and far worse performance on 0.9.4 than 0.9.3, with no other change in the window
that adds per-draw work.
The second is g_xform_program_words, which the profiler divides by
g_xform_program_calls to report average batch size. Losing the increment prints 0
rather than printing nothing -- exactly the failure mode I checked for on the
transform CONSTANT counter during the same merge, and missed on this one.
ISO.cpp, the other conflicted file, was checked the same way and lost nothing.
ARMSX3_PIPELINE_CACHE=0, read through driver_env.txt like the compute group size
override, so a suspected regression can be A/B'd on a single build.
Issue #78 reports Wipeout HD Fury running far worse on 0.9.4 than 0.9.3 with much
higher CPU. That window holds 28 commits, and most of them only remove work, so
the plausible suspects are this cache, the upstream ROP output remap, and the ISO
reader revert. Guessing between them is not worth a release each.
This one is worth being able to eliminate first because it is the only change that
introduces a structure shared between threads that previously shared none: up to
eight shader cache loader workers at boot, plus the async pipeline compiler
workers during play, all now insert into one VkPipelineCache. Concurrent use is
permitted by the spec, but the driver still has to serialise its own inserts.
Not evidence that it IS the cause -- it is the cheapest candidate to rule out.
Both landed as our own commits and both owe him more than they said.
The on-disk pipeline cache header -- length, version, vendorID, deviceID and
pipelineCacheUUID -- is his design from EmuCoreC 47220b153, used as-is, including
using the UUID as the invalidation key so a driver swap rebuilds rather than
feeding a driver a blob it cannot read. What was missing there was the wiring:
nothing passed the cache to vkCreate*Pipelines, so it saved an empty file. That
part, and sharing it with the shader interpreter, are ours.
Splitting Adreno and Turnip out to a 64-wide group size is his too (b9f0f3631).
Our comment had held all of mobile at 32 on the claim that Mali was also 64-wide,
which is wrong -- Valhall warps are 16 lanes and Bifrost 4-8. His split was the
better call and the reason ours changed.
Tales of Xillia 2 (BLUS31397) polls this syscall in a loop: 20,664 calls in a 19
minute session, 5,123 of them inside a single second. It was logged at warning, so
each one wrote a line to external storage on Android -- the same shape as the SPU
recompiler diagnostics, and enough on its own to stop frames landing.
Reported as issue #76, an intermittent hang every few minutes that ends with
"Game has stopped responding - it is no longer drawing frames". That message comes
from the 30 second frame-stall watchdog, which is what a saturated log writer
looks like from the RSX thread.
Moved to trace. Nothing is lost: a container's size is fixed at creation, and
sys_memory_container_create and _destroy both still log at warning, so the events
that carry information are still recorded. sys_memory_get_user_memory_size in the
same file already has the equivalent treatment upstream -- it only logs when the
values it reports actually change.
Not confirmed as the cause of #76 yet; the reporter has not attached a log, and 19
minutes of play here did not reproduce the hang. It is the largest log source this
title produces by a wide margin.
Booting a game while another was still tearing down left the app frozen on the
last frame of the previous game.
The boot gate asked Emu.IsStopped(), whose default overload is
m_state <= system_state::stopping
so it answers true while the previous VM is still stopping -- and while it is
loading. Kill() only signals the threads and hands the joining to a detached
"Emulation Join Thread", so the state reaches stopping at once and the guard read
that as stopped. The entire wait block was skipped precisely when it was needed,
which is why no "previous VM still running" line was ever logged.
On device: Stopping emulator at 0:10:14, BootGame at 0:10:17, and at 0:10:31 the
join thread was still waiting on PPU[0x1000004] "SPU Interrupt Thread0" -- 17.4
seconds -- with seven SPUs parked in EXIT|w|G-PAUSE and one PPU thread spinning at
100%.
Use the IsStopped(true) overload, which requires system_state::stopped and is
reached only once that join thread has finished. The ten second bound and the
boot-anyway fallback are unchanged, so a teardown that genuinely hangs still gets
reported rather than freezing the UI.
Only the three checks in _rpcsx_boot are changed. Other IsStopped() callers here
want "not running" and the loose overload is right for them.
This does not fix why that interrupt thread fails to exit, which is still open. It
stops a slow teardown from becoming a boot into a half-destroyed VM.
cpu_on_stop() is a teardown hook and nothing enforces that it runs a single time.
A PPU thread that re-enters the stop path without exiting reports again on every
pass: one thread ("SPU Interrupt Thread2") was producing "PPU thread perf stats
are not available." roughly every 10 microseconds, near 100,000 lines a second.
That is survivable on a desktop. On Android the log goes to external storage, so
it pins the log writer and drags down the shutdown it is describing -- the
emulator logged "Stopping emulator..." and never reached "All threads have been
stopped", leaving the next boot stuck on the last frame of the previous game.
Guard the reporting with a flag so it happens once per thread. The flag is
deliberately left out of serialization: it describes this run's reporting, not
guest state.
This does not address why the thread re-enters the stop path, which is a separate
question -- it stops that from being an emulator-wide stall while it is open.
The client thread's inner loop breaks out to the outer sem_rpcn.acquire() for
every state except one: connected and authentified with no game running. The
only blocking wait sits inside the `authentified && !Emu.IsStopped()` branch, so
that case fell through to `while (true)` with nothing to wait on and span a full
core for as long as the user stayed signed in outside a game -- at the menu,
between titles, and throughout shutdown.
Measured on a Retroid Pocket 6 sitting at the library: `RPCN Client` at 100%,
utime 21964 against stime 58, so a userspace spin rather than a syscall storm.
Wait in the fall-through case instead. Breaking out to the outer semaphore would
also stop the spin but nothing releases it when a game starts, so the pings would
never resume.
Only reachable once RPCN actually authenticates, which is why it survived
upstream: signing in is new on Android.
A 15 minute Prototype session wrote 56,881 log lines, and 49,644 of them came from
the SPU recompiler -- peaking at 2,473 lines in a single second, written to
/sdcard. The bursts land exactly when a game is already stalling to compile new
blocks, which is the worst possible moment to add synchronous file writes, and
they stop when compilation finishes. That matches the reported symptom: seconds of
lockup that recover on their own.
Every one of these is per-block or per-instruction:
8963 New SPU block compiled successfully was success
7833 Precompiling fallthrough was notice
4337 Precompiling filler space was notice
3449 SPU block is a loop was notice
2418 MFC_EAH / MFC_Cmd not constant was warning, per INSTRUCTION
1516 Trampoline simplified was error, and is routine
843 SPU Block Dump was notice, and is multi-line
696 GETLLAR pattern entry point was notice
~1400 PUTLLC16 / pattern breakage family was notice and success
Upstream can afford these: a desktop has a fast disk and nobody is writing to
external storage. Demoted to trace, so they stay available by raising the SPU
channel and cost nothing during normal play.
The genuine faults keep their level -- MFC_Cmd invalid size and unknown command
are still errors, and they are rare.
This does not claim compilation is free. It removes the logging so what remains
can be measured, which is not possible while the instrument is this loud.
Adreno waves are 64 lanes, and a workgroup narrower than the wave does not pack
together with its neighbours -- it occupies a whole wave and masks the surplus
lanes off. At 32 that idled half of every wave on every dispatch, and these
kernels run on every texture upload of a kilobyte or more, plus every deswizzle
and detile.
Nothing argues the other way here. The kernels carry no shared memory and no
barriers, so group size is a scheduling hint with no LDS or synchronisation
cost to trade against.
Adreno and Turnip only. The comment this replaces claimed Mali was also 64-wide
and used that to justify holding everything at 32; that is wrong -- Valhall
warps are 16 lanes and Bifrost 4-8, so 32 already spans several of them and
there is no half-empty wave to reclaim. Xclipse is RDNA-derived and prefers
wave32 for compute. Both stay where they were.
Group size is baked into the generated GLSL, so this invalidates shader and
pipeline caches once on first launch after the update.
The mobile branch of the per-vendor group size table picks 32 by falling
through to the NVIDIA case. Adreno and Mali both run 64-wide waves, so 32
plausibly leaves half of each wave idle -- but that is reasoning, not a
measurement, and guessing wrong here costs performance silently.
Read ARMSX3_CS_GROUP_SIZE, which driver_env.txt already plumbs through
setenv(), so both candidates can be compared on one build without a settings
field or a second APK. Powers of two only, clamped to maxComputeWorkGroupSize
and maxComputeWorkGroupInvocations, because an over-large local_size_x fails
shader compilation rather than validation. The default is unchanged; this only
makes the question answerable.
Every vkCreate*Pipelines call passed VK_NULL_HANDLE for the pipeline cache, so
the driver re-did the whole of its own compilation work for every pipeline, in
every run. On mobile that work is a visible stall the first time each pipeline
is seen -- and it was being thrown away at every shutdown.
Give render_device a VkPipelineCache seeded from <cache>/vk_pipeline_cache.bin
and written back at teardown, and hand it to both pipeline creation calls. The
file is keyed on vendorID, deviceID and pipelineCacheUUID, so a driver update
or an adrenotools driver swap rejects the old blob and rebuilds rather than
feeding a driver a cache it cannot read. Oversized blobs are dropped instead of
being allowed to grow without bound -- the cache is shared by every title, so
one cold run is the cheaper failure.
This is orthogonal to the RSX shader cache: that one remembers WHICH pipelines
a title needs, this one makes each one cheap to create.
The shader interpreter was opening a private cache and destroying it on the way
out, which discarded exactly the expensive ubershader compiles. It now borrows
the device's. Teardown order already guarantees the pipe compiler threads are
joined before the device is destroyed, so the readback needs no extra locking.
vkGetPipelineCacheData was missing from the generated Android dispatch table;
regenerated with it, no other entry point changed.
Seventeen commits. The substantial one is kd-11's ROP_OUTPUT_REMAP series
across rsx/fp, glsl and both backends, which ARMSX3 did not have at all.
Two conflicts.
nv4097.cpp: upstream added the ROP remap plumbing to the format-change checks,
we have profiler instrumentation and an ARM64 observe() on the two hot FIFO
reads. Different parts of the same file, so upstream's version is the base and
ours is re-applied on top. The g_xform_const_words increment is included
deliberately: the profiler reports average batch size as words/calls, so
dropping it would have printed 0 rather than nothing, which is worse than an
absent stat.
ISO.cpp: took upstream's magic-read check. It is a real fix --
`!file.read_at(...) == 5` parses as `(!x) == 5`, which is false for every x, so
the guard never fired and a short read left `magic` uninitialised. Our reverted
reader has no check there at all, and read_at returns a byte count in this
version too, so the corrected form applies cleanly.
This does NOT undo the ISO reader revert. The refactor that broke reading for
some users is still reverted; only the one-line magic check comes across.
"Server error 1" is ErrorType::Malformed, and it was our fault rather than the
server's: two buttons posted queries with a required field blank, and the
server rejects the whole query rather than naming the field.
Reset password sends the account's email so the server can mail a token, but
the email box was only rendered while creating an account. Outside that mode it
sent an empty address every time. The box is always shown now.
Resend token sends the password, and Save deliberately clears that box, so
anyone who saved before pressing it sent an empty one. Having typed it a moment
ago is not the same as it being in the field.
Both are checked before sending now, along with account creation, and the
message names the field to fill in rather than failing at the server.
The error text is better too. Malformed, Invalid and the unknown case were
surfacing as a number with no way to act on it. Malformed now says outright
that it is a bug and asks which button was pressed, because if it appears again
the guards above have missed a path.
Not fixed here: sign-in reporting Invalid Password for credentials that work on
desktop. That is a distinct server code rather than a catch-all, and the cause
is not yet known -- desktop stores the password exactly as this does, so it is
not a hashing difference.
The ISO reader revert is confirmed working by the reporter, so this ships it.
Upload signing is now requested explicitly with -Parmsx3.uploadSigning rather
than used whenever keystore.properties happens to exist. Once that file was
created, every release build silently started signing with the upload key, and
an APK signed differently from the one already installed cannot be installed
over it -- so a sideload build becomes something testers cannot install, and
Android's error does not mention signatures.
It was being worked around by hiding keystore.properties by hand before each
build, which is the kind of step that gets forgotten exactly once and then
wastes a tester's evening. build-play-aab.sh passes the flag; nothing else
does, and asking for it without the keystore present is now an error rather
than a silent fallback.
The upstream merge brought a six-commit ISO series that regressed reading on
some images. A tester's ISOs that loaded on 0.9.2 now fail at the very first
header read:
ISO: init: Failed to read region information (region_count=0)
ISO: iso_archive: Corrupt ISO file: Decryption failed
Both of his games fail identically, so it is not one bad dump, and other
people's ISOs still load, so it depends on the image format -- most likely the
3k3y path, which has its own watermark detection and key extraction at 0xF70
and 0xF80, separate from the plain one.
The reader is reverted to its 0.9.2 state. The change implicated is the region
count read moving from char_arr_BE_to_uint(sec0_sec1.data()) to
read_from_ptr<be_t<u32>>(sec0_sec1), alongside decrypt_data moving from raw
pointers to std::span, but this is not a targeted fix: it restores code that
demonstrably worked rather than guessing which line of the refactor is wrong.
is_valid() is kept, because System.cpp calls it and it is one line that has
nothing to do with the failure.
What this gives up is real: the series contained out-of-bounds write and
overflow hardening for ISO parsing. A reader that cannot open a user's games
is the worse of the two, and the hardening should come back with a version of
the refactor that works. The path validation from the same series that lives
outside these two files is untouched.
Unverified against the actual failure -- the ISOs here all load either way, so
this could not be reproduced locally. It is verified not to break what already
worked.
Builds were reporting whichever commit cmake last configured against, not the
one being built. rpcs3/git-version.h is generated at CONFIGURE time, and
build-variants.sh deliberately skips reconfiguring an already-correct build
dir because re-running cmake regenerates LLVM's headers and costs a full
rebuild. So the stamp froze, and every build after that lied about itself.
The gap was wide. HEAD is 20120-da26a455; the header on disk still said
19985-91952ae4, a commit from before 0.9.2.
This is not cosmetic. A tester running 0.9.3 reported that older commit in
their log, which sent an investigation hunting for a regression among upstream
ISO changes their build did not contain, and very nearly had a fix built and
sent for a version they were not running. A build that misreports itself makes
every report from it ambiguous.
stamp-git-version.sh writes the header from HEAD and is called by both build
entry points before anything compiles. It only writes when the contents differ,
so an unchanged HEAD does not force a rebuild, and ninja rebuilds just the
translation units that include it.
The script bundled whatever core happened to be sitting in jniLibs, and
build-variants.sh overwrites that path once per variant, so the file left there
is simply whichever ran last. Running the variants and then rebuilding the
bundle would have shipped an armv8.2 core inside a minSdk 30 bundle:
installable on devices that cannot execute it, failing at dlopen, with nothing
in the failure to point at the cause.
Play serves one bundle to every device, so the ISA floor has to be the lowest
ARMSX3 supports. The legacy core is now stripped into place by this script
every time, and it refuses to run if that core has not been built.
The bundle already shipped was correct -- the legacy core was staged by hand
before it was built -- but only by hand, which is the part worth removing.
The bundle verifier reported libarmsx3-core.so missing from a bundle that
plainly contained it, at base/lib/arm64-v8a/libarmsx3-core.so.
Under `set -o pipefail`, `unzip -l "$AAB" | grep -q x` fails whenever x IS
present: grep exits at the first match, closes the pipe, and unzip takes
SIGPIPE, so the pipeline reports failure. Every check for something that must
be there was inverted, and every check for something that must be absent passed
for the wrong reason -- grep found nothing, read to the end, and no signal was
raised.
Capturing the listing first and piping printf into grep instead only moved
which process took the signal. The listing is matched with `case` now, which
has no subprocess to kill.
Worth stating plainly: this was a verifier that would have reported a clean
bundle whatever went wrong, which is the failure mode the script exists to
prevent. It only surfaced because the one check that should have passed was the
one that failed.
Checking that keystore.properties exists was not enough. A template with the
placeholders still in it passed the check and went on to build, failing much
later inside signing with an error that says nothing about the cause. It now
rejects the placeholders too, so the message arrives before the fifteen minutes
rather than after them.
Also locate a JDK. The script assumed the calling shell had one on PATH, and
without it gradle fails with "Unable to locate a Java Runtime" several steps in,
which points at Java rather than at the environment. Android Studio's bundled
JDK is used when JAVA_HOME is unset, with /usr/libexec/java_home as a fallback
and a clear failure if neither exists.
Two things stood between the flavor split and an uploadable bundle.
The release build type was debug-signed, with a comment saying to swap it
before any public build. It now uses an "upload" signing config when
keystore.properties exists and falls back to the debug key when it does not, so
GitHub alphas stay sideloadable without the key present while Play gets a
properly signed bundle. The keystore and the properties file are both already
gitignored, and nothing reads or echoes them outside the build.
build-play-aab.sh refuses to start without that file and prints the keytool
command to create one, rather than spending fifteen minutes producing a bundle
Play will reject.
Minify is off for the Play bundle and on everywhere else. This is not a
preference: AGP 9.2.1's R8 writes its mapping as mapping.prt, a compressed
per-class archive, while packageBundle still requires a plain mapping.txt, so
an AAB cannot be built with R8 enabled at all. ARMSX2 ships its Play build with
minify off entirely, so this matches existing practice rather than introducing
a compromise, and a 94 MB native core dominates a 76 MB APK -- shrinking the
Kotlin was never where the size is.
Driven by a gradle property, matching how armsx3.minSdk is already threaded
through by build-variants.sh, rather than by the variant API.
Verified: the github release APK still builds at 76.5 MB with R8 running.
An AAB is a ZIP and its entries are compressed, so grepping the archive finds
nothing and reports a clean bundle whatever is inside it. The first version of
this script did exactly that. It passed all five exclusion checks and then
failed to find the applicationId it was supposed to find, which is the only
reason the method got questioned at all -- every check that mattered was a
false pass.
It extracts the bundle now and reads base/manifest/AndroidManifest.xml for the
permissions, and the archive listing for the native libraries. Verified against
a real bundle: com.armsx3.play present, REQUEST_INSTALL_PACKAGES,
MANAGE_EXTERNAL_STORAGE, RECORD_AUDIO and the update provider all absent, and
libarmsx3_lsfg.so not among the shipped .so files.
The manifest is protobuf rather than text, so LC_ALL=C and grep -a stay: the
NUL bytes in it would otherwise make grep declare the file binary and print
nothing, which reads the same as a pass.
Play will not take the build ARMSX3 ships today, and the two runtime flags that
were already here could never have made it acceptable. The manifest said so:
it is the declared PERMISSION in the bundle that gets rejected, not the code
path behind a boolean. So the split is by source set, which is the only thing
that actually removes anything.
github keeps what a sideloaded build should have: the in-app updater, all-files
storage for an arbitrary data folder, and frame generation. play has none of
them, and its applicationId is com.armsx3.play so the two install side by side
instead of over each other.
Moved rather than flagged: com.armsx2.update and its FileProvider resource into
src/github, with no-op composables in src/play so that source set still
compiles. REQUEST_INSTALL_PACKAGES and MANAGE_EXTERNAL_STORAGE now live in the
github manifest, so the play bundle never declares them.
Frame generation is excluded by moving libarmsx3_lsfg.so into
src/github/jniLibs. A packaging block inside a productFlavor is NOT honoured --
it applied to both and dropped the library from the github build too, which the
build caught. Excluding the file is the whole exclusion: the shim is dlopen'd
by name and the core already reports the feature unavailable when it is absent,
which is the same path a device that cannot run it takes.
build-play-aab.sh builds the bundle and refuses to emit one that fails any of
five checks, because a flavor split is only worth as much as the thing that
notices when it stops working. It greps the bundle with LC_ALL=C and -a on
purpose: a NUL byte makes plain grep declare the file binary and say nothing,
which reads exactly like a pass.
build-variants.sh follows the rename to assembleGithubRelease; there is no
flavorless release variant any more.
Both failures in the savedata fixup were logged as fatal while the code
carried straight on -- the loop continues, the emulator starts, and one save
is left as it was. On Android a fatal line goes to logcat at ANDROID_LOG_FATAL,
so it reads like a crash, and it was reported as one.
It is also permanent rather than transient when it happens. Android storage can
refuse a directory rename outright with EPERM, and nothing about launching
again changes that, so it fired on every single launch and looked like a fault
that was getting worse. A user hitting it has no way to tell that their games
are unaffected.
Both are errors now, and both say what the consequence is and what to delete if
it keeps happening, rather than repeating an alarm about something the emulator
has already decided to continue past.
Continuing with the modules that did compile is correct -- the dispatcher
entry for an uncompiled function interprets, nothing runs garbage -- but it is
per-instruction dispatch and it is slower than the interpreter outright. Saint
Seiya measured 6fps against 23 with most of its modules missing.
A game at that speed looks broken, and it gets reported as broken, when the
real answer is one restart away. An honest stop is better than a degraded run
that invites the wrong bug report.
So this ends the boot the way running out of memory always did. What is
different from before is the reason: the message names it and says what to do,
and it stays on screen because the overlay is drawn by the RSX thread rather
than the one this stops.
The fallback itself stays in place for the ordinary case of a single module
failing for some other reason, which is upstream's design and is worth keeping
-- one bad module costing its own functions is a fair trade. Running out of
memory is not that case: it takes most of the executable with it.
Both changes that were brought back are out again. Neither recovered the frame
rate after switching frame generation off, and both broke rendering.
That is every attempt at this reverted. What remains is the code as it stood
before any of it: frame generation is slow, and switching it off does not
release what it allocated.
The GLES DSA shim is kept, because it belongs to the upstream merge and not to
this work -- it landed in the wrong commit and reverting that commit took it
with it, which broke the build for a reason unrelated to frame generation.
The findings from the three audits are still correct as descriptions of what
the code does. Acting on them, one at a time and with the reasoning written
down each time, still made the result worse on every attempt. So the fault is
not in the individual fixes but in something about this subsystem that reading
it has not revealed, and the next attempt should start from a measurement on
the device rather than from another reading of the source.
This is the change that stopped turning frame generation off leaving the
frame rate tanked -- confirmed by which build that was reported on, and by the
SUBOPTIMAL acquire fix not reproducing it on its own.
It replaces the per-frame vkDeviceWaitIdle on framegen's device with a poll on
a sync fd exported from a fence framegen already creates. Reasons it is safe
to bring back while the other three stay reverted: it only runs when frame
generation is producing frames, every failure path falls back to the device
wait, and it changes no layout, no ownership and no barrier -- which is where
the rendering damage came from.
Still out, and staying out: the cross-device ownership barrier, the
generated-frame acquire semaphore with its teardown, and the deferred-present
guard in frame_context_cleanup.