Written while testing the activity lifecycle work on a Galaxy S8 and a Quest 2.
Mostly about ways the tooling answers a question you didn't ask.
The expensive one: versionCode is the git commit count, so installing a feature
branch over a longer branch fails with INSTALL_FAILED_VERSION_DOWNGRADE, which
-r does not override. The device keeps running the previous build, and if the
install output went through a pipe the failure is never seen. An A/B where both
installs failed that way agrees with itself perfectly and means nothing.
Also: dumpsys meminfo counts activities that are garbage but not yet collected,
so the obvious reading looks like a leak whether or not there is one - trim and
re-read until it settles; how to force an activity recreate over adb when the
device won't rotate on command; why screencap is black on a Quest and what to
read instead; and how to tell whether a device exercises the content-URI path at
all before concluding a run covered it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01152igCdjTNhT29qvphPyPC
Stage 5 of the JavaGL removal. Now that the GL path owns its own EGL context and
render thread, Android's two lifecycle branches and the Win32 one in
MainThreadFunc were the same code three times over, so this moves the branch into
one place - RunGraphicsLoop() in Core/EmuThread.cpp. It picks the thread
arrangement the context asks for: either this thread becomes the render thread
and spawns the emu thread (OpenGL), or it just runs the main loop because the
backend spawns its own render thread (Vulkan, D3D11). Android, Windows and
headless all go through it now, which is what the "TODO: Merge with the Win32
EmuThread and so on" in app-android.cpp was asking for.
Falls out of having one implementation instead of three:
- MainThreadFunc called graphicsContext->ThreadStart() right after
EmuThread_Start(), which already calls it, and ThreadEnd() right after
EmuThread_Join(), which already calls that too. So Windows and headless GL ran
CreateDeviceObjects()/DestroyDeviceObjects() twice per context, leaking a VAO
on the way in and double-deleting it on the way out. Dates back to 16206d18ab,
which moved those calls into EmuThread_Start/Join without removing the
originals.
- RECREATE_VIEWS is gone. Only the GL path ever posted it; Vulkan goes through
the same destroy/recreate without it and always has. Verified on device: views
survive pause/resume, and both a real rotation and a density change - each one
an activity destroy and recreate - lay them out again by themselves.
- The non-separate path is now named EmuThread rather than MainThread on
Windows. It's the thread that runs NativeFrame(), which is what
DebuggerThreading.md calls the emu thread, and the real main thread there is
the GUI message pump.
The loop keeps an explicit exit flag, as a shouldExit callback, rather than
relying on ThreadFrame() returning false. In VR we skip ThreadFrame() entirely
while the XR session is idle, and the session going idle is exactly what
backgrounding the app does - so the exit would never be observed, the render
thread would sit in that sleep forever, and the UI thread joining it would hang.
That is not hypothetical: it deadlocked a Quest 2 on the first try.
Also drops a dead EmuThreadState enum and an unused JNIEnv local in
app-android.cpp, both orphaned when the JavaGL JNI entry points went away.
Tested on a nubia Z2464N (Mali-G57, Android 15) on both backends: cold start,
booting a homebrew, pause/resume in the menu, in a settings screen and in-game,
rotation back and forth, a density-change activity recreate with the game live,
the pause menu, and exit to menu. ThreadStart/ThreadEnd log exactly once per
context, and every one of those cycles tears the EGL context down and builds a
new one.
Also tested on a Quest 2 with the vr flavor, which is what shook out the deadlock
above: cold start into a focused XR session at 72fps, backgrounding (render
thread joins in 27ms where it used to never return), resuming back into VR with
a fresh EGL context, and exiting from in-app. xrCreateSession runs on the render
loop thread, which is where the EGL context is current - VRBase.cpp builds its
graphics binding from eglGetCurrentDisplay/eglGetCurrentContext, so that ordering
matters.
Windows x64 debug and the Android gradle builds are clean.
Not converged, and not a small change: NeedsSeparateEmuThread() itself. GL needs
its API calls on the thread its context is current on, so somebody has to pump
ThreadFrame() there, whereas VulkanRenderManager owns its thread outright.
Getting rid of the virtual means teaching GLRenderManager to spawn and own its
own render thread, for every GL platform at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01152igCdjTNhT29qvphPyPC
A pixel counts as different only if it's outside the range of the other
image's 3x3 neighborhood (widened by a tolerance) and enough of its neighbors
are too, so edges a pixel off, filtering rounding and speckle drop out.
8x8 block averages catch broad shifts separately, and heatmaps show where.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
--depth-save converts their float depth (z / 65536) to the PSP's 16-bit
values, or writes the unrounded values when the path ends in .f32.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
headless_bench.py compares the speed of headless binaries on savestates
or frame dumps. It alternates the binaries between rounds and reports
medians of wall time, CPU time, and instructions and cycles where the OS
counts them. Instructions vary by well under 0.1% between identical runs,
where wall time swings by several percent. It checks for mains power
first, and stops on a run that failed or didn't get where it should.
dump_compare.py replays every frame dump in a directory with two binaries
and reports the dumps that render differently, or differently from run
to run.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nine tests of GE probe jobs that the software renderer reproduces bit for
bit (transform, planes, coverage, lines, texture, lighting, curves, nearclip,
pixel), recorded on a PSP.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bounded PSP liveness checks and the usbhostfs restart, stuck utility dialogs,
debugging replay hangs and recording a dump through wsdbg, syntax-checking the
RISC-V/LoongArch JITs and the no-SIMD build without a cross toolchain,
non-deterministic games in screenshot comparisons, and --dump-file.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lets a dump replay stopped with --replay-end be compared against the PSP on
an offscreen buffer. The framebuffer commands after the draw limit are
dropped too, so it shows the buffer the last drawn primitive went to.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Document pspautotests' ppdmp-playback and its new run.py, which replays a
frame dump over PSPLink and compares it with PPSSPPHeadless.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Under --sync, input.buttons.press while stepping waited out the whole timeout,
and so did a cpu.resume issued to get it going. With :nowait the press is sent
and the next line (a cpu.runUntilTime) runs the CPU. Unanswered tickets are
now tracked in one place, so a reply that arrives during a later wait still
counts for :quit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PreferredToolArchitecture has no effect; the MSBuild.exe that runs decides
the compiler host. The earlier note claimed otherwise, from a build that
passed by luck.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
:screenshot saves gpu.buffer.screenshot as a PNG without dumping the data
URI into the output. The docs claimed nested parameters need a raw JSON
line, which gets no ticket; a single-quoted JSON value in key=value form
works and keeps it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rebuilding one test with the current SDK while its neighbours keep old
binaries hides toolchain differences until someone happens to touch
them, like the tests/intr module manager stubs that did nothing. So the
policy is now to rebuild every .prx in the directory and re-record them
all, diffing each .expected against the old one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When sceAudioOutputBlocking has to wait and the wait fails at once
(interrupts or dispatch disabled, inside an interrupt), the firmware
returns the error but leaves the channel's waiting flag set, and the
channel can never be used or released again. Keep the error, drop the
rest: whether the channel was busy at that moment is timing, and a
small difference in ours could lose a channel for the rest of a game
where hardware wouldn't. No game can depend on losing one.
Savestates made while this was emulated have the flag cleared on load.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From pspautotests intr/waits:
- sceAudioOutputBlocking sets the channel's waiting flag before its
event flag wait, and when that wait fails at once (interrupts or
dispatch disabled, or inside an interrupt) it returns the error
without clearing the flag. The channel stays busy from then on, and
can't be released.
- The SRC blocking output fails the same way even when a completion is
already there, leaving the buffer armed.
- After a block that had samples in it, the mixer DMA is still playing
it out, so a buffer arriving then isn't read early or restarts it.
- sceKernelVolatileMemLock only writes the fake address and size
through pointers that are there, instead of faulting on NULL.
intr/waits now runs to the end; one scheduling marker still differs, from
async IO timing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
How to keep lldb and gdb from stopping on the faults the handler is meant
to catch, and the crash_*.prx tests as a quick check that it works.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Since 551e4cd0ab, headless without --graphics silently used the OpenGL
backend, since bSoftwareRendering defaulted to false. Under Mesa llvmpipe
on Linux/WSL, that hangs games early in boot (and test.py, which passes no
--graphics). The README already documents software as the default.
Also documents the traps hit while chasing this in docs/debugging.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
--disable-hle had no counterpart, which made "is this our fault or the game's?"
awkward to answer: the only ways to put our HLE back were a per-game config or
hiding the firmware, and neither works from a script - the setting is per-game
and the firmware gets found anyway. --force-hle takes the same bitmask and runs
our HLE for those libraries even where the real module is now the default, so
the same repro can be run both ways and the logs diffed.
Used it on the warnings left over in Tekken 6 under the real mpeg.prx. Three of
them appear identically with --force-hle=16, so they are the game's own and
match what the hardware answers: sceKernelChangeThreadPriority(-1) eight times
in a row (pspautotests/threads/threads/change says hardware returns
UNKNOWN_THID for -1 too), sceAtracAddStreamData on a released id with a zero
byte count, and a sceKernelDeleteMutex on a garbage id.
The fourth only happens under the real module and is ours. mpeg.prx sizes its
allocation through a scratch context in its own bss and calls
sceAudiocodecReleaseEDRAM on that one, while decoding through a different
context that never gets released - so the next movie's sceAudiocodecInit finds
a live decoder and replaces it. That is once per video on every game running
the real module, and it was a WARN_LOG_REPORT, so it would have reported from
everyone's machine. It is bounded - removeDecoder deletes the old one and Init
makes exactly one more - so it is an INFO_LOG now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also why gpu/signals/continue, jumps, simple and handlercalls fail, and the
one thing not to try on a real PSP.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What SIGNAL and FINISH interrupts do and in which order, what enqueueing
checks, the pause window, what the sync calls wait for, and how
ProcessDLQueue() keeps the order right while executing lists in one go.
Also a note on gentest.py keeping a pipe open through usbhostfs_pc.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test didn't compile: VERTS is captured by reference into testFormat, and
MSVC won't use a captured constexpr as an array bound. Make it static.
On arm64 it then failed on the UV prescale steps, by one ULP. The arm64 JIT and
the NEON handwritten decoders fuse the multiply-add, and the steps only match
that when the compiler contracts a * b + c - which clang does and MSVC doesn't,
in Debug or Release. Spell out which one happens instead of relying on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When the firmware module a --disable-hle bit asks for is neither installed nor
on the disc, that library silently runs our HLE instead. For the emulator that
is the right thing; for a test tool it means the run measures something other
than what was asked for and says so only as one INFO line, which is easy to
grep past and easy to never see. It cost a round of wrong results here, where
the memory stick headless defaults to (beside the exe, not the app's) had no
firmware, so a comparison against the real mpeg.prx was quietly a comparison
against the HLE it was supposed to be measured against.
g_unavailableDisableFlags already records exactly which flags fell back, so it
just needed an accessor. Headless now names each one, prints the flash0:/kd and
memory stick it looked in, and fails the run.
Only an explicit --disable-hle binds. sceMpeg and sceMp4 are LLE by default, and
falling back is the correct and expected behaviour wherever no firmware is
installed - making that fatal would fail every run on such a machine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running a commercial game through headless to measure something has four ways to
report a clean pass for a run that proved nothing, and they compound: --log is
needed before anything is printed (and it goes to stderr, so grepping forces the
streams together), the memory stick defaults to one beside the exe rather than
the app's, which means no installed firmware and a silent LLE-to-HLE fallback,
an unrecognised parameter then hides in the merged output, and a count of zero
errors reads identically whether the code ran or never got there.
All four of these cost a round of wrong results while investigating ME memory.
Also records that the line-ending check has to be done on the bytes, since Git
Bash's grep normalises them: the obvious `grep -c $'\r$'` for a stray LF reports
every file clean, which is how a handful of LF lines got into two CRLF files
here without the usual whole-file-rewrite tell in git diff --stat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The legacy Android build has a unit test executable of its own, so a new file in
unittest/ goes in three build files rather than the two the docs named. Missing
the Android one builds fine everywhere it is convenient to try and fails only on
Android CI, which is what happened here - so both AGENTS.md and building.md now
say three, and which one is easy to forget.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Ant build hasn't been runnable since the SDK dropped tools/ant/, so build.xml,
custom_rules.xml, ab-ant.sh, ant-build.bat, project.properties and
proguard-project.txt all go. Plus some orphans: buildassets.sh (nothing called it),
build.sh (still referred to "phoenix"), symx86.cmd (x86 isn't in APP_ABI) and
README.TXT (Eclipse import instructions).
ab.sh and ab.cmd no longer copy assets into android/assets - only the Ant/Eclipse
packaging ever read that directory. Gradle packages the repo-root assets/ directly
and ndk-build doesn't look at assets at all, so the stray copy only made it unclear
where the APK's assets come from. Noted how that actually works in docs/building.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The MSBuild examples passed /p:Platform=x64 and the run lines pointed at
Windows/x64/..., so following them on an ARM64 machine produced an x64 build -
which then runs anyway under emulation, so nothing looks wrong. It is slower
than the native build, it isn't the code ARM users get, and a benchmark taken
from it measures the emulator: the colour conversion benchmark this was noticed
on reads 200 MPix/s emulated against 300 native.
The examples now say <platform> rather than either value, so there is no default
to follow and the machine has to be looked up. Also note that
$PROCESSOR_ARCHITECTURE describes the shell, not the host, and says AMD64 from
an emulated shell.
test.py searched only Windows\x64 for the headless binary, so on Windows-on-ARM
it would silently test an emulated build; it now looks for the native one first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--timeout was wall-clock seconds, which is what CI wants but not what you want
when the question is whether the game has had long enough to get somewhere: a
heavy scene runs many times slower than real time and a near-idle one much
faster, so the same budget means very different amounts of game time. Booting a
firmware VSH is a good example - 10 emulated seconds is about 25 real ones on
6.61 and about 7 on 2.00, and judging those two by the same wall-clock number
makes a working shell look stuck.
Both limits can be set at once and whichever is reached first ends the run,
which also says which one it was. --timeout still works as the old name for
--timeout-wall. The IsDebuggerPresent() exemption stays on the wall-clock check
only; the emulated one doesn't need it, since sitting at a native breakpoint
burns no emulated time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Another pass looking for gaps, all of it now recorded by audio/blocking/channels
and audio/blocking/oneshot.
sceAudioOneshotOutput was the last unimplemented entry in the module, returning
"library not linked" to anyone who called it. It plays one buffer on a channel it
never reserves, so the channel frees itself when the buffer runs out, and its
argument checks are their own set: any positive sample count, aligned or not, no
upper bound, a negative volume rejected rather than skipped, and no busy check at
all. No game is known to use it; it is implemented because tracing it turned out
to be cheap, not because anything needed it.
The channels test confirms four behaviors: sceAudioChReserve(-1) skips a released
channel that is still playing while a reserve of it succeeds, a second channel
joining a running mixer doesn't lose a block unlike a first, mono counts down in
the same 64-sample steps over the same time as stereo, and the panned blocking
output has no extra delay on the high channels.
Also: the SRC resampler now interpolates into the next buffer at a buffer join
instead of holding the last sample, since the codec reads the two descriptors as
one stream. That only shows up at non-native rates and no test can see it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nine channels were one array of one struct, but only index 8 ever used the
two DMA descriptors, the played/fraction position and the waiting-thread vector,
and only 0-7 ever used the buffer slot - so eight copies of a std::vector sat
there dead. They are two different pieces of hardware and now they are two
structs: AudioChannel for the eight the mixer walks, and a single AudioSRCChannel
for the one the codec reads directly.
Found while rereading it:
- __AudioUpdate can now run from inside an output call, and it reschedules when
it wakes a thread. A context switch there lands before the syscall writes its
return value, so the caller loses it and the woken thread gets it instead.
Deferred with hleReSchedule when a syscall is in flight. The same fix applies
to the release path, which had the problem before this branch existed.
- A finished SRC buffer whose waiting thread had given up dropped the completion
entirely, so the next caller waited a buffer too long. Now it walks past dead
waiters and banks the completion if nobody is left.
- An output with a null pointer changed the channel volume, where the hardware
returns before touching it.
- A busy channel came back as an error from the Output2 path and as debug from
the mixer path. It is an ordinary answer a game polls on, so both are debug
now; the old behavior filled the log with 18k error lines in a minute of F1
2009.
- AudioChannel::reset had no callers left: releasing a channel with a thread
parked on it is refused, so there is nobody to wake.
- The two routing modes are globals and were written once per channel. They get
their own small savestate block.
Savestate: the sceAudio section goes to 3, and the SRC channel gets a section of
its own. States from released builds carry it as a ninth channel record, which is
read and discarded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I deduced that this was the case, and attempted implementing this path long ago,
but I could never quite get it to work in all games. Set Claude on a quest to
research and implement it, and lo and behold, it works. A bit sobering.
Fixes#12888 and likely more. Additionally, audio latency is likely slightly
improved overall, and memory usage is down by 4.6MB.
Claude says:
The blocking output calls are not a queue that callers line up behind. Each
mixer channel holds exactly one buffer and at most one parked thread; a second
thread arriving while the first is waiting is told the channel is busy and is
expected to skip its turn. The Output2/SRC channel holds two DMA descriptors and
refuses a third caller outright, without waiting at all.
We blocked everyone instead, so a game running a movie thread and a sound-effect
thread over one output made the two alternate - a frame of movie audio, a frame
of effects silence - and the movie played at half rate. That is #12888, seen in
F1 2009 and Colin McRae: DiRT 2. With this, the movie thread keeps the channel
for the whole cutscene and the effects thread is refused, which is what the
hardware trace shows.
The driver also never copies a buffer on the way in: it stores the pointer and
its mixer walks it forward 64 samples at a time out of the game's own memory.
Modelling that fixes#20095 as a side effect, and drops the 4.6MB of per-channel
sample rings we were carrying. The mix event is re-phased to the moment a DMA
starts, since the mixer thread outranks its caller and gets a block in before the
output call returns.
Along the way: the two rest-length calls differ after a null-pointer output,
sceAudioChRelease reports not-reserved rather than not-init,
sceAudioChangeChannelConfig validates the format,
sceAudioChangeChannelVolume validates nothing, and sceAudioOutput2ChangeLength
takes a range of 17..4111. Details in docs/sceAudio.md.
Savestates: AudioChannel goes to version 4. Older ones stored mixed samples that
can't become a pointer and a position again, so they load with the pending audio
dropped and any parked threads released.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Record the fifth cause (the rejected impose parameter), correct the counts now
that 3.80/3.90 and 2.6x-2.8x are covered, and note that 1.50 through 2.50 never
touch the encrypted XMB index at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old claim of "all of them" measured that the shell renders, which several
versions do while showing only the wallpaper or a full-screen error. Record what
each version actually does, the four causes that were in the way, and how to
find the next one of each kind - including that --timeout is wall-clock, so a
slow-drawing shell can look stuck when it isn't.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backwards from 5.01, one release at a time, against all 39 versions that ship
on a disc plus the download-only 6.61. Nothing here is an offset - it's almost
entirely Sony renumbering the kernel *_driver NIDs, which sends an import we
mean to HLE into the real firmware module instead.
Four more NIDs each for sceRtc_driver/sceRtcSetAlarmTick and
sceHprm_driver/sceHprmReadLatch, covering 1.50 up. The rtc one is what parked
every thread on a SceSysconSync semaphore; the hprm one runs once a frame, so
unresolved it was most of the boot log. Also sceImposeGetParam/sceImposeChanges
(1.50 - 2.xx) and sceKernelLoadModuleVSH (1.x, which is how the shell loads its
own plugins - unresolved it got module id 0 and StartModule failed).
sceRtcIsAlarmed had to be implemented too; it returns 0, as in JPCSP. As a null
entry it returned LIBRARY_NOT_YET_LINKED, and the 3.0x-3.5x VSH read that as
"ask the hardware instead" and went back to blocking on syscon.
Two structural findings:
- Up to 4.05, scePaf's heap allocator is a separate heaparea1.prx that paf
imports as scePafHeaparea. Load it when it's there. Its pool pointer needs
the same pre-fill paf's does, at gp - 0x7FCC rather than gp - 0x7E88.
- 1.50's vshmain.prx declares no module attributes at all - PSP_MODULE_VSH_MODE
only appears from 1.52 - so the whole VSH bootstrap was being skipped. Accept
the module name too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blocker below 6.60 wasn't offsets, it was that Sony renumbered the kernel
*_driver NIDs between versions. A function we HLE under its 6.6x NID is a
stranger on an older build, so the import lands in the real firmware module
instead - and that's where it goes wrong:
- sceRtc_driver sceRtcSetAlarmTick. Without the HLE the VSH's alarm call ran
the real rtc.prx, which called on into syscon.prx and blocked forever on a
SceSysconSync semaphore. That was the whole "stalls with every thread parked"
symptom; the tell was a fourth SceSysconSync waiter a healthy boot lacks.
- sceHprm_driver sceHprmReadLatch, called once a frame - so before this an
older firmware's 12-second boot logged ~20000 lines of one unresolved import.
Three extra NIDs each, found by disassembling the module from both firmwares
and matching on the address of the user-mode export whose NID never changed
(sceRtc/0x7D1FBED3, sceHprm/0x40D2F9F0).
5.55 additionally needed two PRX decryption keys we didn't have (0x4C941AF0
and 0x4C941BF0) - without them none of flash0:/kd decrypted and the shell came
up with no drivers behind it at all.
Checked one release at a time against every version that ships on a disc, plus
6.61. 4.05 and below still die on a null write inside vsh_module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.60 ships byte-identical paf.prx and vshmain.prx to 6.61 - all 6338 + 669
functions disassemble the same - and boots to an interactive XMB, so let
FirmwareVersionSupportsVSH accept it. That matters because no UMD carries 6.61
(it was download-only), so 6.60 is the best a disc-installed firmware can be.
The two module patches were hardcoded offsets from the module base applied to
any module of the right name, which is quietly wrong on any other build:
- The scePaf heap arena slot moves with every build (0x18CCD8 on 6.00 through
0x18D728 on 6.60/6.61) but sits at gp - 0x7E88 in all of them, so find it
that way. On its own this turns an immediate SIGSEGV inside scePaf into a
clean stall on 6.00 through 6.39 - they still don't reach an XMB, they get
stuck in sceVshBridge_Driver instead.
- The vsh_module alarm-category offset has no such anchor, so check the word
there is the one the patch was derived from. On 6.20 and 6.00 it's ASCII
string data - the unconditional write was corrupting a string table.
Also resolve the per-model kernel drivers (memlmd, loadexec, wlanfirm) to the
model being emulated. They were asked for as _01g, which a firmware unpacked
for a single model doesn't have - and our own updater unpack defaults to 02g.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>