Commit Graph
19973 Commits
Author SHA1 Message Date
jpolo1224 8bc7ca307c Update README.md 0.9.1 2026-08-18 16:30:45 -04:00
jpolo1224 93110e899f Release: 0.9 (versionCode 15) 0.9 2026-08-18 15:09:55 -04:00
jpolo1224 4b27b585f5 Input: let analog triggers bind in the pad-button capture
On a pad that reports L2/R2 as AXES rather than buttons they arrive in
dispatchGenericMotionEvent and never as a key, so the binder -- which lives in Compose's
onPreviewKeyEvent -- could not see them, and L2/R2 would not bind while every other button on
the same pad did. That depends on the controller MODEL, not the port, which is what makes it
present as a Player 2 problem: a second pad of a different make fails where the first worked.
Reported against Player 2 with everything else binding.

handleCaptureMotion already existed for exactly this shape -- the D-pad HAT arrives as an axis
on several handhelds and is synthesised into a key during capture -- so the triggers just join
its  set and inherit its press/release tracking, which debounces the motion stream for
free.

Uses the same axis pairs as the gameplay path (sendTrigger), including the per-device third axis
some pads put the right trigger on, so a trigger that works in game can also be bound.
Threshold is a deliberate half-pull so resting drift on a worn trigger cannot self-bind.
2026-08-18 14:31:22 -04:00
jpolo1224 bbf44c684c Input: pace digital transitions so a short press cannot fall between guest polls
The guest polls cellPad at its own rate -- 33ms at 30fps -- and a press plus its release are
two separate snapshot pushes with nothing between them. A press shorter than one poll interval
lands entirely between polls and the game never sees it. Steady presses always span a poll,
which is why a button works everywhere except during a rapid mash, and why a held button
(crouch) keeps working while everything tapped alongside it does not.

GestureLayer already knew this and worked around it locally with a 40ms hold in pulse();
ordinary touch taps and physical controller presses had no equivalent. Reported on Iron Man's
quick-time event, where circle must be pressed repeatedly and does not register, and again as
'only the crouch button works' with both on-screen controls and a PS5 pad.

Delaying a too-short release would NOT fix it and would look identical: in a mash, press N's
deferred release collides with press N+1 and the game sees one long press instead of several,
while a QTE counts presses. Each transition therefore gets its own slot -- press visible for
40ms, release visible for 40ms, then the next press -- so a mash arrives as distinct presses
rather than a hold.

A press with nothing queued ahead of it still goes through immediately, so normal input takes
no added latency; only the release of a tap shorter than 40ms is deferred, and only up to 40ms.
Queue depth is capped so a mash cannot accumulate while the guest is not consuming.

40ms matches the gesture path and clears one 60Hz sample. NEEDS FIGHTING-GAME TESTING: it also
paces the d-pad, and 40ms is about 2.4 frames at 60fps, so frame-tight directional input is the
case most likely to feel different.
2026-08-18 14:23:15 -04:00
jpolo1224 f823f6fd66 Touch: list the keyboard button in the default layout so the editor offers it
Adding the TouchButtonId was not enough. The editor offers what the LAYOUT contains, not what
the enum declares, so the button existed and did nothing visible -- reported straight away.

Listed with enabled = false alongside the save/load/screenshot buttons, which is the same
opt-in shape. Existing layouts pick it up without being disturbed: TouchLayout.fromJson
splices in any default button a saved layout lacks, and defaultPortrait splices from this
same table, so one entry covers both orientations and everyone's current layouts.
2026-08-18 13:56:34 -04:00
jpolo1224 9a12b5e958 Pads 2-7 at startup, and an on-screen keyboard button
Ports 2-7 were left as Null pad handlers unless a USB device happened to be plugged in. The
loop that claims them for the virtual handler existed, but only inside _rpcsx_usbDeviceEvent,
so it ran on a USB plug/unplug and nowhere else. A second controller on a phone is normally
BLUETOOTH, which never produces that event, so those ports stayed Null and a second pad did
not exist in the core at all. Per-player button mapping therefore looked correct -- the UI
stores those bindings regardless -- while the second controller did nothing in game. Reported
against Tekken 6. Now claimed at startup, next to player 1.

Also adds a KEYBOARD touch button (Kind.STATEACTION, so it emits no pad code and calls
MainActivityRuntime.toggleSoftKeyboard) for the same reason the hotkey exists: to reach the
keyboard without pausing. The hotkey needs a spare pad button, which a touch-only player does
not have. Opt-in, absent from the default layout, like the save/load/screenshot buttons.

Appending to TouchButtonId is safe: touch layouts serialise the id by NAME
(TouchButtonId.valueOf), unlike SysHotkey which is persisted by ordinal.
2026-08-18 13:43:32 -04:00
jpolo1224 d094ecd491 Settings: put Emulate USB Keyboard in the Network tab
It existed only in the in-game pause menu, which made the On-Screen Keyboard hotkey's own
message a dead end: it tells you to turn this on in Network settings, and there was nothing
in Network settings to turn on. Reported from Discord after exactly that.

Same Settings.usbKeyboard field as the in-game row, so the two stay in sync, and indexed for
settings search.
2026-08-18 13:29:42 -04:00
jpolo1224 fda4cc3b50 Revert the added keyboard work: it duplicated an existing feature and shifted hotkey ordinals
Reverts 422d831ef and 00ce69a31.

ARMSX3 already had all of this. Settings.usbKeyboard writes USB1/Type = hidkbd and
NativeApp.usbSetKeyboardEnabled, the TOGGLE_KEYBOARD hotkey raises the Android IME through
SoftKeyboard.toggle, and dispatchKeyEvent already forwards keys via forwardKeyToUsbKeyboard.
The toast users see -- "Turn on Emulate USB Keyboard (Network settings) first" -- is that
feature correctly reporting that its setting is off, not a missing capability. What I added
was a second, parallel path through cellKb with its own setting and its own hotkey.

The revert is not only for redundancy. SysHotkey is persisted BY ORDINAL, as the comments
around TOGGLE_KEYBOARD and GYRO_RECENTER say in as many words, and both are appended last
for exactly that reason. KEYBOARD_TOGGLE was inserted mid-enum, ahead of GYRO_TOGGLE, which
re-points every binding after it for every existing user.
2026-08-18 13:21:57 -04:00
jpolo1224 00ce69a315 Android: on-screen keyboard over the running game, and a setting to enable it
Uses the Android system IME rather than a drawn key grid, so layouts, languages, prediction
and emoji come for free and it is the keyboard users already know. Bound to a new
KEYBOARD_TOGGLE hotkey, so it can be raised and dismissed mid-game without opening
settings.

The IME only opens for a focused view that accepts input, so a zero-size transparent
EditText owns focus on demand. Its InputConnection does the real work, because an IME
reports typing in two different ways and only one of them is a key event:

  sendKeyEvent            backspace, enter, arrows -- forward the keycode as-is
  commitText              ordinary characters, with NO key event behind them
  deleteSurroundingText   some IMEs delete by range instead of sending backspace

commitText is synthesised with KeyCharacterMap.getEvents, which produces the shift presses
capitals and symbols need rather than guessing a keycode per character. Characters no
keycode can produce -- emoji, CJK picked from a candidate list -- are still delivered with
their unicode and KEYCODE_UNKNOWN, since the guest reads the unicode field and that is the
honest keycode for a character with no key behind it.

Also adds the Emulated Keyboard setting, without which all of this was inert: the core
defaults to keyboard_handler::null, so cellKb told games no keyboard was attached no matter
what was typed. Off by default, matching the core, because a game that sees a keyboard can
behave differently. The hotkey says so in its toast when the guest cannot receive keys,
rather than silently showing an IME that goes nowhere.
2026-08-18 13:09:51 -04:00
jpolo1224 422d831eff Android: give the guest a real keyboard
cellKb reported no keyboard at all, so games that need one were unreachable: NFS Most
Wanted's beta debug menu, and native keyboard support in games like Counter-Strike. The
only handler upstream ships, basic_keyboard_handler, derives from QObject and filters
QKeyEvent off a QWindow, and android/CMakeLists.txt excludes it with the rest of the Qt
input layer -- init_kb_handler was hardcoded to NullKeyboardHandler as a result.

Almost none of that handler is actually Qt-bound. KeyboardHandlerBase::HandleKey already
takes plain u32 codes and keyboard_consumer::ConsumeKey resolves them through
m_keys.find(code), so the code space only has to agree between whatever registers the
buttons and whatever injects them. android_keyboard_handler therefore registers ANDROID
KeyEvent keycodes directly rather than impersonating Qt. The PS3 side uses USB HID usage
IDs and Android's letters and digits are contiguous too, so those map arithmetically and
only the remainder needs a table. Android also distinguishes left from right modifiers,
which Qt cannot, so all eight are wired rather than four.

init_kb_handler now honours the Keyboard setting instead of always reporting none, and
_rpcsx_keyboardKey delivers one key through the usual dlsym bridge, returning false when
no keyboard is active so a caller can tell the difference.

A physical keyboard reaches the guest through dispatchKeyEvent. The test there is
KEYBOARD_TYPE_ALPHABETIC, not the event source: gamepads also report SOURCE_KEYBOARD for
their buttons, so filtering on source alone would send every controller press to the guest
keyboard as well as the pad. The event is consumed only when the native side reports the
key landed, which keeps a physical keyboard usable for UI navigation everywhere else.

Not yet done: the on-screen keyboard overlay, and a UI setting for the handler. The core
default is still keyboard_handler::null, so this is inert until Keyboard is set to Basic.
2026-08-18 12:57:34 -04:00
jpolo1224 55a54c924e SPU/Android: stop generated SPU code running off the end of the thread stack
The ARM64 SPU gateway reserved a shared 8192-byte stack scratchpad. Compiled SPU
functions build no frames of their own on ARM64 -- GHC_frame_preservation_pass runs with
use_stack_frames = false -- so every one of them spills into that single reservation, and
a function needing more simply writes past it. Borderlands 2's 2401-instruction function
at LS 0x25da8 wants ~21 KB: the fault landed at sp+21760, exactly the top of the thread's
stack mapping, on the PROT_NONE guard page above it. x86 reserves 0xc8 in the same place
because LLVM emits ordinary per-function frames there, so this arrangement and this
failure are ARM64-only. Raised to 256 KB.

That is still a fixed bound rather than a scaling fix; a larger function could overflow
it the same way. use_stack_frames = true would scale, at a cost the pass comments call
out and which is not measured here.

Android threads also ran on an eighth of the stack they get elsewhere: the pthread path
passed null attributes, so bionic's 1 MB default applied where glibc gives 8 MB, measured
as a 0xfc000 stack mapping. Not the cause of this bug -- the overrun is off the TOP of the
stack, so size does not affect it, and 1 MB to 64 MB changed nothing -- but a real
discrepancy worth closing.

Both were invisible because of how the fault died. A guard page is not emulator memory,
so is_emulator_fault() correctly declines it, the handler forwards to libsigchain, and
ART's FaultManager reads the guest registers as an ArtMethod* and takes the process down.
No tombstone is produced, the async emulator log never reaches disk, and Android records
only 'SIGNALED status=11'.

Verified with the function compiled and no forced interpretation: zero stalls, zero
guard-page faults, 47 presented frames where the previous best was 18.
2026-08-18 12:19:07 -04:00
jpolo1224 19d23eb691 SPU LLVM: fix ARM64 SHUFB byteswap fold and accurate-xfloat CFLTS
SHUFB: a7fc31f32 made two semantic changes to the ARM64 path, and BOTH have to go. It
widened the byteswap fold from splat-only constants to any constant, byte-reversing
non-splat ones in get_swap_from_const, and it added idx_selects_single, which treats a
mask whose bit 4 is known-constant across all lanes as single-source.

Borderlands 2's SPURS function at LS 0x25da8 is 1446 shufb whose data operand is usually
a non-splat constant -- 0xbf800000 built by ilhu/iohl, or a mask straight out of cbd/cwd.
Compiled, that function spins forever inside a single block: block_counter, loop count
and retreat count are byte-identical across six thread dumps spanning the hang, at 96%
CPU, so it never reaches a block boundary. Interpreted, the game boots.

Reverting the byteswap widening alone is NOT enough -- measured, and the hang came back
with seven stall dumps at 0x25da8. Disabling the whole ARM64 shufb block in favour of the
generic path also fixes it, which is what identifies the fold and the single-source
trigger rather than the tbl/tbx paths themselves. Kept narrow so ARM64 keeps its fast
paths.

CFLTS accurate xfloat: only the high side was guarded. The f32 path is a single saturating
fcvtzs.4s, but this one converts f64[4], and AArch64 has no v4f64->v4i32 form, so it
lowers to fcvtzs.2d twice plus uzp1 -- saturation happens at int64 range and uzp1 then
keeps the low 32 bits. Negative overflow therefore did not produce 0x80000000: -3e9 came
back as +1295786496.
2026-08-18 12:19:06 -04:00
jpolo1224 884cb47dde SPU: fix ARM64 float-to-int conversions in the interpreter
CFLTS and CFLTU both carried x86 corrections that are wrong on AArch64, and the
SSE templates they live in are what spu_interpreter_rt is built from, so they are
live on ARM64 through spu_run_interp_fallback.

CFLTS applied the cvttps2dq fixup: x86 returns the integer-indefinite value
0x80000000 for anything unrepresentable, positive overflow included, so the result
was XORed back. _mm_cvttps_epi32 is sse2neon's vcvtq_s32_f32 (FCVTZS), which
already saturates, so the correction inverted a correct result. Measured: +3e9 gave
0x80000000 instead of 0x7fffffff, and NaN gave 0 instead of 0x80000000.

CFLTU went further and relied on the 0x80000000 return, ORing the remainder back in
to rebuild the u32. On ARM64 the conversion yields 0x7fffffff, and 0x7fffffff | v is
0x7fffffff for every v below 2^31, so the entire upper half of the range collapsed to
one value: 3e9 read back as 0x7fffffff rather than 0xb2d05e00.

This also matters for diagnosis, not just correctness: forcing a block to the
interpreter is the standard test for whether the recompiler emits wrong code, and
until now that test could introduce a fault the recompiler did not have.
2026-08-18 10:40:00 -04:00
jpolo1224 9b33316982 SPU: copy the reservation line 16 bytes at a time on ARM64, and add SPURS dispatch diagnostics
mov_rdata and mov_rdata_nt move the 128-byte reservation line -- the GETLLAR
snapshot, and the fill back into live guest local store. On x86 that is four
16-byte vector moves, so each quarter lands whole and a racing reader sees either
the old or the new 16 bytes. On ARM64 both fell through to std::memcpy, whose
granularity is a libc implementation detail; AArch64 implementations mix transfer
sizes freely, so a reader can observe a line stitched from both versions. Use
eight vld1q_u8/vst1q_u8 pairs to match what x86 gets for free.

This does NOT fix the Borderlands 2 hang -- measured, no change to any observable:
same 4807 SPU blocks, same 0x29b48 ceiling, same stall state. It is committed as a
latent correctness fix rather than a behavioural one: the copy exists to produce a
coherent snapshot and had no atomicity guarantee here at all.

The diagnostics are the instrumentation that traced that hang from symptom to a
single missing DMA: guest thread and thread-group state at an RSX stall, per-SPU
conditional-store counters, the local-store and reservation-vs-memory dumps, code
GET destinations, the SPURS control-block fields, and the register dump at the last
transfer both hosts issue in common. They hang off the existing rate-limited stall
report or are capped by distinct key, because every earlier attempt at this was
capped by volume and got eaten by whichever event happened most often.
2026-08-18 08:10:43 -04:00
jpolo1224 55a35b5e1d Diagnostics: guest-thread stall reporting, SPU reservation counters, autotest harness
Hangs where the RSX idles were only ever visible from the RSX side, so a stall
report now names every guest thread, its state, PC and function, and for SPUs adds
the reservation counters -- conditional store calls, failures, notifications, and
the SPURS heuristic's deliberate non-notifications -- plus where the host thread
last was in cpu_task. block_counter alone cannot separate a thread livelocked
retrying PUTLLC from one that is genuinely idle; both report zero blocks a second.

The SPU code window prints once per process. Unguarded it re-emitted a whole
function on every stall dump, measured at 538 lines a second over 31 dumps with a
690 MiB log left behind, which on Android is itself a stall -- it was degrading the
hang it was meant to describe, and it buried the state lines that answered the
question.

do_local_task counters cover the case the profiler cannot: it reports the thread is
in Local task and has been for 0.00s, which together mean it is not stuck there at
all and the FIFO loop is calling it repeatedly. Which FIFO state, and whether guest
GET equals PUT, separates a starved RSX from a stuck one.

tools/ps3autotests drives ps3autotests on a device over adb and diffs per
instruction against real-hardware output; compare-platforms.py does the three-way
ARM/x86/hardware split that separates shared upstream failures from ARM-only ones.
This is what found the CFLTS and FMS divergences.
2026-08-17 20:50:56 -04:00
jpolo1224 8caacc8231 Android: correct persisted off-spec settings, file:// launches, and RSS reporting
Accurate SPU Reservations was persisted false in the global config, left over from
earlier debugging, where upstream and our own defaults are both true. Turning the
default back on reached nobody who had already run the app, so this migrates the
stored value -- correcting the curated field and forgetting the raw override at
global scope only, since a per-title exception exists on purpose and
forgetEverywhere() would take it with it. Save LLVM logs had the same problem and
needed the value recorded, not just the override un-pinned.

A file:// launch never booted: the intent path was passed through as a URI string
and the loader wants a filesystem path, so only content:// ever worked.

get_memory_usage() reports system-wide totals -- MemTotal minus MemAvailable, every
process on the machine plus page cache -- and was being read as if it were ours.
Add get_process_memory_usage() for this process's resident set, which is the number
Android's low-memory killer actually decides on, and report that instead.
2026-08-17 20:50:43 -04:00
jpolo1224 0ded153216 Settings: add the console System settings, and route PS3/System to the core
Console Language, Keyboard Type, Console Region, Date Format, Time Format and
Enter Button Assignment had no UI, so anything the core read from PS3/System was
whatever the default happened to be.

Enter Button Assignment in particular was already a field but never reached the
core: Rpcs3Bridge.setSetting has no fallthrough, it translates a fixed set of
(section, key) pairs and silently drops the rest, and PS3/System was not among
them. Add the branch, add the five missing fields through the eight sites a
Settings field needs, and give them setters that map an index to the enum name --
these serialise by name, and the enum is neither contiguous nor in formatter order,
so an index cannot be written straight through.
2026-08-17 20:50:26 -04:00
jpolo1224 4baefed106 Android: install fault handlers ahead of the ART runtime, and handle SIGBUS
Calling sigaction() on Android does not make you the first handler for SIGSEGV.
libsigchain intercepts it and runs ART's FaultManager first, which reads the
faulting thread's registers as an ArtMethod* and dies on guest data -- so every
recoverable guest fault in JIT'd code killed the process before our handler ran,
with nothing in the app log to say why. Resolve sigaction from libc directly and
install through that, keeping the runtime's previous action so non-emulator faults
are forwarded on rather than swallowed. Registering through both paths recurses,
so this registers once and guards re-entry.

SIGBUS was only handled on Apple platforms; Android raises it for the same
unmapped-guest-page cases, so it needs the same treatment.

Also make the fault report survive a fault taken while reporting: emit an
allocation-free breadcrumb with the signal, address, PC and the GPRs before the
formatted dump, and chain to the previous handler first so debuggerd still records
a tombstone. The breadcrumb goes after the recovery attempts, not before -- emitting
it on entry logged over a thousand recovered faults per second and was itself a
stall.
2026-08-17 20:50:17 -04:00
jpolo1224 424514fde6 SPU: fix two ARM64 float divergences and make the object cache key cover codegen
CFLTS applied an x86 saturation correction on every host. cvttps2dq returns the
integer-indefinite value 0x80000000 for anything it cannot represent, positive
overflow included, so XOR-ing all the bits when the input is >= 2^31 produces the
0x7fffffff CFLTS wants. AArch64's FCVTZS already saturates that way, so the same
XOR turned a correct saturated-high result into saturated-low, and its NaN-to-0
conversion became 0xffffffff where x86 lands on 0x7fffffff. Same shape as the
FCTIW/FCTIWZ/FCTID split already guarded in PPUTranslator; the SPU one was missed.

FMS expressed a * b - c as fma(a, b, -c). x86 folds that into vfmsub and never
materialises -c, so a NaN addend propagates its own bits; AArch64 cannot take that
shape -- FMLS is Zd - Zn*Zm -- so it emits the FNEG and propagated the negated NaN.
0x7fffffff is not a NaN on a real SPU, just a large number, so the two hosts
disagreed about the sign of a huge result. Negate the addend only when it is not a
NaN pattern, with a known-never-NaN early out to keep it off the common path.

Measured with ps3autotests cpu/spu_fpu against x86 output from an otherwise
identical build: cflts 16 -> 0 differing lines, fms 484 -> 0, and spu_fpu as a
whole 984 -> 0 against a non-AVX512 x86 host. The 484 fma lines that remain
against an AVX-512 host are that host's vfixupimmps path and reproduce on any x86
without AVX-512, so they are not ARM-specific.

The cache key hashed the build stamp of SPUCommonRecompiler.cpp while the code
generator lives in SPULLVMRecompiler.cpp, so editing codegen alone did not move the
key and a rebuilt emulator silently reused objects from the previous binary -- the
first attempt at the CFLTS fix looked like it did nothing for exactly that reason.
Export a stamp from the codegen TU and hash that in as well, and prune stale
spuobj-* siblings so a version bump does not strand old directories.
2026-08-17 20:50:07 -04:00
jpolo1224 37a5d118be Merge PR #64: Library: a filename-derived PS3 serial gets a hyphen and loses its cover 2026-08-17 13:14:01 -04:00
jpolo1224 301f45a2cb Merge PR #63: cellAudio: don't let a silent port reset the untouched baseline every period 2026-08-17 13:14:00 -04:00
jpolo1224 aa25da4ce2 Merge PR #62: Stop two guest polling loops from flooding the log 2026-08-17 13:13:59 -04:00
jpolo1224 e35bd463cf Merge PR #60: Bundle the H.A.W.X. 2 Bink overlay patch and enable it 2026-08-17 13:13:57 -04:00
Zulux91 968b892e29 Library: a filename-derived PS3 serial gets a hyphen and loses its cover
FilenameParser reconstructs every serial in the PS2 dump shape -- four letters,
a hyphen, five digits (SLUS-20312) -- because that is the convention its regex
was written for. A PS3 title ID has no separator, so a game whose serial comes
off the filename rather than the disc is recorded as BLUS-30917.

That serial matches nothing. Cover art is fetched as COV/<TITLE_ID>.JPG, keyed
by exactly the id PARAM.SFO gives us, and the extracted-icon fallback is
disc-icons/<TITLE_ID>.png:

  COV/BLUS30917.JPG  -> HTTP 200
  COV/BLUS-30917.JPG -> HTTP 404

so the card shows a text placeholder. The filename path is taken whenever the
disc was not probed -- probeDiscInfo answers "{}" while a game is loaded. Once
that has happened the entry cannot recover: the cached serial is re-seeded into
discInfoCache at the start of every scan and comes back as disc.titleId, which
has top priority.

Normalise the resolved serial rather than the parser, which is what repairs the
already-cached entries since they arrive through the same expression.

Three things this has to get right beyond the cover itself.

NOT EVERY 4+5 TOKEN IS A TITLE ID. FilenameParser takes the first four-letter
plus five-digit token it finds anywhere in the name, so what arrives may be a
release tag or an id belonging to a different game. Left hyphenated a bad guess
matches nothing and the card shows a placeholder -- visibly wrong, and safe.
Stripped, it would become a WELL-FORMED id and quietly resolve whatever is filed
under it: another game's cover, its curated name, and its config_db entry, which
the core applies at boot. So normalise only what carries a real PS3 prefix, B
for disc releases and N for PSN. Deliberately not gated on GamePlatform: that
enum comes from the same probe that produced the serial, so in the one case this
exists for -- probe failed, name came off the filename -- it is always null and
the guard would be constant-true.

THE SERIAL IS NOT ONLY THE COVER KEY. It also keys config.game.<serial>, per-game
core overrides, touch layouts and profiles, pad bindings, play time, the pinned
name and the custom cover file. Renaming the game without moving those resets
every one of them silently, and nothing prunes the old keys, so they become
unreachable rather than merely unused -- the custom cover worst of all, since
CustomCovers.remove resolves through the same name and cannot delete the orphan.
migrateSerialKeys moves them, and CustomCovers.renameSerial follows the file.

THE REPAIR HAS TO REACH EXISTING INSTALLS. cacheKey embeds ScanSchemaVersion, and
HomeViewModel only schedules a scan when that key changes. Without a bump an
upgraded install keeps serving the cached hyphenated ids and never rescans, so
the covers stay broken until the user finds the refresh button. Bumped 7 -> 8;
the constant's own contract asks for this whenever a stored field changes, and a
changed VALUE has the same staleness signature as a new field.

Verified on device, 14-game library with three affected ISOs. Seeded the broken
state (hyphenated serial in the cache, a pinned name and play time under the old
id, cached key at v7), then launched WITHOUT touching the UI:

  load(first): cachedKey=v7|...  newKey=v8|...  pending=true
  scan start: 1 dir(s), rawStorage=true
  serial 'BLUS-30917' -> 'BLUS30917' (3 pref key(s) moved)

pending=true is the field that read false before the bump. Afterwards no
hyphenated key or serial remained anywhere in the preferences, the pinned name
was live on the card under the new id, and Lollipop Chainsaw, Ratchet & Clank:
Full Frontal Assault and Virtua Tennis 4 all render their covers.

Not fixed here: those discs still have no extracted ICON0.PNG, so their offline
fallback stays missing, and the re-probe that would create one is folder-only --
re-probing an ISO needs a vfs::mount, which the seeding loop deliberately avoids.
Two library entries that resolve to the same id can also cross-write each other's
per-serial data; that is the intended merge for a genuine duplicate, but nothing
models it.
2026-08-17 07:51:59 -05:00
Zulux91 89c6d08ed3 cellAudio: don't let a silent port reset the untouched baseline every period
A game can leave an audio port started and write nothing but zeros into it.
Those writes still land on the tag slots, overwriting the -0.0f tag with
+0.0f, and count_port_buffer_tags() detects that sign flip as "the buffer was
touched" -- correctly, since it cannot tell silence from data.

The result is a port that reports untouched on most periods and touched on the
few that a write happens to land in. Storing untouched_expected as the
instantaneous count then drops it to 0 on exactly those periods, so on the
next period the same silent port looks like a newly untouched buffer, and the
loop waits out the whole untouched timeout for it. Every time it flickers.

untouched_expected is now a high-water mark, clamped to active_ports so a port
going away lowers it again.

Measured on device, Tom Clancy's H.A.W.X. 2 (BLES00928), main menu, stock
audio settings (time stretching off, buffer 34), with a temporary probe in the
period loop counting branch hits per second. Same scene, same build, only this
change differing:

                       before      after
  wait_untouched         669          0     hits/s (1000us each)
  MIX                     65        188     hits/s
  advance (forced)        37          0     hits/s
  enqueued_buffers         0        5-7
  untouched > expected   743          0     per second
  untouched_expected     0 in 799   1 in 376  of the second's samples

The port itself is unchanged by this: it is still started, still counted as
active, still mixed. A full-block scan of it reads 0 non-zero floats out of
512 on every one of 875 consecutive periods, which is what makes it silent,
and it is the tag flicker rather than the silence that caused the stall.

Audible effect: the audio clock ran at ~55% of real time (103 vs 189 periods
per second) with the ring buffer permanently empty, which is why the whole
title sounded slowed down and stuttering. Note this happens with time
stretching disabled -- the frequency ratio stayed at 1.000 throughout, so the
slowdown is the period rate itself and not resampling.

Not verified: whether any title depends on untouched_expected falling back to
a lower value within a stable port configuration. Nothing in the tree tests
this loop.
2026-08-17 04:26:09 -05:00