Opening an official updater (a PSP/GAME/UPDATE EBOOT.PBP, identified by the
MSTKUPDATE disc ID) from the main screen now brings up a confirmation dialog
that unpacks the firmware into the NAND directory, where the emulated
flash0/flash1 live. Running the updater itself doesn't work, so there was
nothing useful to do with one before.
Unpacks the file list for the model we claim to be (iPSPModel), on a worker
thread, with a progress bar - for which PSARUnpackOptions gets an optional
progress callback.
AGENTS.md: translate UI strings last, in a separate commit
The English string is what all ~47 languages get derived from, so rewording it
after the sweep means redoing the sweep. Check the wording first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds GameInfoFlags::BUNDLED_UPDATE_INFO, holding the version, title, size
and timestamp of the updater in PSP_GAME/SYSDIR/UPDATE. It comes from the
PARAM.SFO and the directory entry next to the archive, so it's a couple of
small reads on the ISOFileSystem the worker already has open - no
decryption, and DATA.BIN itself is only sniffed for its magic. Only
computed for ISOs; everything else is marked complete with an empty struct.
ISOFileSystem now parses the date out of the ISO9660 directory record,
stored as Unix UTC seconds and reported as the PSP's atime/ctime/mtime.
Those used to always read back as zero, so games calling sceIoGetstat on a
UMD file saw 1900 where hardware gives the mastering date.
Shown on GameScreen as e.g. "Firmware update on disc: 6.60 (2011-10-05),
25.6 MB".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149QcTVgZEXKXbgHyvXF4ZY
Two small things I'd noticed as counters in a survey and written off as quirks
of the oldest firmwares. Both were bugs here.
The last entry of every 1.x and 2.x archive was dropped. Decoding a record hands
the decrypter 16 bytes of slack past the block, which was read out of the
archive - and the final record ends flush with the end, so there was nothing
there to read and the entry was refused. It copies what's there and zero-fills
only the remainder now. Worth noting the slack isn't decoration: filling it with
zeros unconditionally, which is what I tried first, breaks every archive that
does have those bytes, so the decrypter reads and uses them.
And 2.x names one entry "ipl:/psp_nandipl.bin", where the check for "is this
already a real path" only knew flash0: and flash1:. The file came out fine, but
it was counted unresolved, and - the part that matters - an entry that isn't a
real path is skipped whenever a prefix filter is set. Rather than collect device
names, the test is now for a "<dev>:/" shape, which 3.x's grouped short names
("com:00123") don't have.
Seven sources, firmware 1.50 through 6.61, now unpack with nothing unresolved
and nothing failed: 1.50 and 1.52 gain their missing last file, 2.81 loses its
unresolved count, and 3.95, 6.00, 6.20 and 6.61 are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
The font extraction this is for happens while a game is running, when disc0: is
already mounted - so opening the image a second time to read one file out of it
is the wrong shape, and doesn't work at all for the cases that aren't an image,
like a folder-based disc. UnpackUpdaterFromMountedDisc() goes through
pspFileSystem instead, with MountedDiscHasUpdater() to ask cheaply first and
ReadMountedDiscUpdaterVersion() for the version out of the PARAM.SFO.
Testing it turned up that the prefix filter didn't work on 3.x archives at all:
their file lists write paths as "flash0/font/x.pgf" where 6.x writes
"flash0:/font/x.pgf", so a filter of "flash0:/font/" matched nothing and the
unpack quietly produced no files. Both forms are normalized to the 6.x one now,
so a caller only has to know one. The unfiltered output was already identical
either way, which is why the earlier disc tests looked fine.
Verified by booting Crisis Core and pulling flash0:/font out of its 3.95 updater
through the mounted disc: 21 files, nothing else, no failures. Booting homebrew
with no disc0: at all reports no updater rather than failing.
pspautotests 314/314 with --graphics=software, UnitTest 55/55, and the three
file-based sources (6.61 PBP, 3.95 and 6.20 discs) still unpack unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Most UMDs carry a firmware updater in PSP_GAME/SYSDIR/UPDATE, so the fonts can
come from a game the user already has instead of a separate download. Its
DATA.BIN turns out to be exactly the same archive as a downloaded updater's
DATA.PSAR, just without the PBP around it.
UnpackUpdater() replaces UnpackUpdaterPBP() and takes any of the three shapes: a
downloaded EBOOT.PBP, a bare DATA.BIN/PSAR, or a disc image, which it opens with
the block device and ISO filesystem we already have and looks in SYSDIR/UPDATE.
ReadUpdaterVersion() answers the version from the PARAM.SFO next to the archive
without decrypting anything, which is cheap enough to check every disc with.
Testing across eras turned up three things the 6.61 updater alone never showed:
Old archives name entries differently. 3.x groups them by model - "com:00123",
"01g:00005" - with "<group>:00000" as that group's file list, keyed on just the
number, separated by '|' rather than ',' and with paths written "flash0/font/x"
rather than "flash0:/font/x". 1.x skips the indirection and stores real paths.
Both are handled now.
Which numbers are file lists isn't fixed either. 6.61 uses 1-11, but 6.00 has
real files at 00010-00012, which were being taken for corrupt lists and dropped.
A list always decrypts, since the PRX layer under it validates a hash, so a
failure there now just means "this is a file" - which recovered 3 files each on
6.00 and 6.20.
And the walk ran one record past the end. The archive header says how long the
records really are, and both archives have a few bytes of padding after that.
Read from the discs of Coded Arms (1.50), Ace Combat X (2.81), Crisis Core
(3.95), Assassin's Creed Bloodlines (6.00) and BlazBlue (6.20), plus the
downloaded 6.61. Every one gives up its fonts - 17 of them on 1.50, 19 on 2.81,
21 from 3.95 on. All but two are clean: 1.50 has one .rco whose block won't
decrypt, and 2.81 has one name no list claims.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
An updater carries one file list per hardware revision, and which one you
resolve names against decides both what a file is called and whether it's part
of that model's firmware at all. That was hardcoded to "first list that names
it", which is right for extracting everything but wrong for reproducing what a
particular console would have installed.
PSARUnpackOptions::model takes a PSPModelGeneration now, and the lists are kept
per model rather than merged. Any (the default) keeps the old behaviour;
anything else uses only that model's list and skips what it doesn't name.
--unpack-updater-model on headless takes "01g".."12g" or "any".
On the 6.61 updater: any gives 411 files, 03g gives 330 with 81 belonging to
other models, 01g gives 313 with 98. The difference is what it should be - 03g
has the _03g.prx variants and arib.pgf, 01g has neither.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Every entry in an updater is named with a five-digit token. The paths live in
entries 00001-00012 of the same archive, one per PSP model, each a list of
"shortname,realpath" lines - so with those decrypted the walk can name
everything, and the prefix filter this was written for becomes usable.
The tables looked like they needed an unknown cipher, but the tables in the
reference implementations are DES's: a 56-entry PC-1, a 48-entry PC-2, 8x64
4-bit S-boxes and a 32-entry P, with the constants of a textbook IP/FP in the
bit-shuffling. It's DES-CBC decrypt with the key assembled from two words and
the IV alongside it, both per firmware series, and then an ordinary PRX blob
underneath. So this is a plain DES, one bit per byte, since the tables are a few
tens of KB in total and readable permutations matter more than speed here.
Confirmed by decrypting a table and finding a known PRX tag at 0xD0, which a
wrong key would not have produced.
On the 6.61 updater all 411 files now come out under their real paths -
flash0/font (21 files, ltn0-15, jpn0, kr0, arib, gb3s1518, imagefont),
flash0/kd (195), flash0/vsh/module (100), flash0/vsh/resource (67), and the
rest - with nothing unresolved. Checked the prefix filter separately with
"flash0:/font/": 21 files written and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
An updater's DATA.PSAR is a flat sequence of records - each is 0x150 bytes of
PRX-style encryption header, a 0x110 byte entry describing one file, and then
its compressed contents. So to get the files, you don't actually have to run it
and let it self-unpack - we can just do it.
Two steps per record. First "demangle": the 0x130 bytes at +0x20 are AES-CBC
encrypted on top of everything else and hide the PRX tag at +0xD0, so a KIRK
CMD7 pass with keyseed 0x55 comes first. Then the record is an ordinary PRX blob
for the decrypter we already have, once it knows the tag - 0x0E000000, which is
new here. Its key needs the kirk7 scramble applied, unlike every other key in
that table, which are stored already scrambled; hence the flag on TAG_INFO.
UnpackPSAR() takes a prefix filter, since the planned main use for this is pulling
flash0:/font out of an updater the user supplies (or from an ISO) rather than
extracting whole firmwares, although that can also be interesting for running
the VSH.
Tested on a 6.61 updater: 436 entries, all 418 files decrypt and decompress,
nothing fails. The contents are what they should be - 295 ~PSP modules, 61 PRF
files, 18 PGF fonts, and the encrypted XMB indices.
Two things it doesn't do yet. Every entry in that archive is named with a
five-digit token rather than a path; the real names live in tables 00001-00012
inside the archive itself, under their own separate encryption, so files come
out under the short name for now and the prefix filter can't match them.
And only the zlib compression format is currently supported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
BlockAllocator backs sceKernelAllocPartitionMemory and the various partition
allocators, and had no coverage at all. It's pure address bookkeeping - no real
memory - so it can be checked hard: after any sequence of operations the blocks
must still tile the range exactly, with no gaps, overlaps or strays, and
GetTotalFreeBytes/GetLargestFreeBlockSize must agree with what's really in the
list. ValidateAllocator() rebuilds the list through the public accessors and
asserts all of that, and it runs after every step of the randomised sections.
Covers bottom-up and top-down allocation, grain rounding, rejection of zero and
oversized requests, splitting and re-merging on free, double free and free of an
address that was never allocated, AllocAt including unaligned positions and
collisions, AllocAligned with alignments coarser than the allocator's grain,
filling the range completely and draining it, a range whose size isn't a
multiple of the grain, and two randomised churn loops - one plain alloc/free,
one mixing in aligned allocations and AllocAt to reach block layouts the simple
loop never produces. Fixed seeds, so a failure reproduces.
No bugs found - the allocator holds up. Verified the tests aren't vacuous by
injecting two plausible bugs: dropping the forward merge in MergeFreeBlocks, and
an off-by-one in the bottom-up fit check. Both are caught, the second pinpointed
to a specific churn iteration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
TouchInput::id was used directly to index the global primaryButton[]
array (MultiTouchButton::Touch) and to shift pointer bitmasks
(PSPDpad/PSPStick/PSPCustomStick/GestureGamepad::Touch), guarded only
by a debug-only assert in one of the five call sites - a no-op in
release builds. input.id isn't always a small sequential slot in
[0, TOUCH_MAX_POINTERS): SDL assigns SDL_FingerID values directly,
Android pointer IDs can go up to 31, and UWP's TouchMapper allocates
one more slot (11) than TOUCH_MAX_POINTERS (10) and can also return -1
when it runs out of slots - all reachable through ordinary multi-touch
use, no malicious input required.
Also apply bounds check to the PER_GAME gesture config ints
(iDoubleTapGesture/iSwipeUp/Down/Left/Right) before indexing
GestureKey::keyList[] with them.
Additionally, minor cleanup on Android and moves the TouchMapper helper
out from UWP to InputState.h.
LoadIfNeeded() indexed lines[0] unconditionally after splitting the
bundled CSV into lines, crashing if the file were empty. It also used
GetColumnIndex()'s result directly as an index into each row's fields
without checking for its (size_t)-1 "not found" sentinel, which would
have produced a huge out-of-bounds index on every row if any of the
expected column headers were missing. Both require a corrupted or
replaced redump.csv asset to trigger, but are simple, cheap checks to
add.
GetBlockTag() dereferenced GetBlockFromAddress()'s result without a
null check, unlike every other accessor in this file. Callers
(NetAdhocCommon.cpp, sceNet.cpp, sceNetAdhocMatching.cpp) pass the
result straight into strcmp() while recovering from a stale address
left over from an old/corrupted savestate - precisely the situation
where the address may no longer resolve to a block. Return "" instead
of dereferencing a null block, so strcmp() simply reports a mismatch
(correctly triggering those callers' recovery path) instead of
crashing.
DoState() read the saved block count directly from the savestate with
no validation before looping that many times allocating Blocks - a
corrupt/malicious savestate claiming an enormous count would drive an
effectively unbounded allocation loop. Clamp it to how many block
records could plausibly still fit in the remaining stream data.
zip_get_name() returns NULL on a corrupted central directory entry;
several call sites passed its result straight into endsWith(),
std::string construction, or the fileAllowed() lambda (which calls
HasParentDirComponent()/strchr()/strrchr() on it) without checking -
undefined behavior on a malformed/malicious zip (game/homebrew/texture
pack installs, or a downloaded ISO zip).
zip_stat_index() can likewise fail, leaving its output zip_stat
uninitialized; ZipReadFileByIndex, DetectTexturePackDest, and
ExtractFile all used the result (zstat.size, to size a buffer or check
a texture-pack size limit) without checking the call succeeded first,
using an initialized zip_stat via zip_stat_init() so the failure case
reads a known-zero size instead of stack garbage that could drive a
huge buffer.resize() or bypass the texture-pack size limit check.
ZipReadFileByIndex also didn't check zip_fopen_index()'s return before
passing it to zip_fread().
These parsers run on fully game-controlled buffers (reachable via the
various sceAtracSetData*/sceAtracSetHalfwayBuffer* HLE calls), so a
malicious/malformed game can supply arbitrary bytes here:
- AnalyzeAtracTrack's RIFF chunk-walking loop computed `offset +=
chunk + (chunk & 1)` (all in 32-bit) and only bounds-checked
afterwards. A crafted chunk size could wrap `offset` (and the
`offset + 12` check itself) around, bypassing the bounds check
entirely - Read32(), whose offset parameter is a plain `int`, would
then read from a wild pointer far outside the buffer. Do the
validation in 64-bit before mutating offset, mirroring the pattern
already used by the newer ParseWaveAT3 parser.
- AnalyzeAA3Track validated `size >= tagSize + 36` but then read up to
relative index 35 after rebasing by 10+tagSize - i.e. absolute index
tagSize+45, 10 bytes past what was actually checked.
- The SMPL chunk's loop count (checkNumLoops) was only checked for
being negative, not bounded against the chunk's actual size, so a
crafted value near INT_MAX would drive an unbounded (up to ~2
billion entry) vector::resize() - an easy crash/OOM. The same
unclamped value also let the fill loop below run past the end of
the chunk, since its bound compares the loop counter to chunkSize
rather than the byte offset actually being advanced (24 bytes/loop).
Clamping checkNumLoops to what the chunk can actually hold fixes
both.
- ParseAA3Headers checked for at least 9 bytes but the "ea3"/"id3"
branch it guards reads up through byte index 9, needing 10.
ExtractZipContents wrote every entry's declared size with no ceiling, so
a small high-ratio zip could decompress to fill the storage device.
- Add a maxTotalSize parameter to ExtractZipContents (default 4GB) and
ExtractFile.
- Bail out in the size-summation pass when the total declared size
exceeds the limit, and again per write chunk in case declared sizes
are inaccurate.
AnalyzeAtracTrack used max(fileSize, size) as the chunk-parse bound with
fileSize taken from the file's RIFF header, so a crafted inflated RIFF
size could push reads past the end of the buffer. Keep the real-library
behavior of tolerating a too-low size, but clamp the parse bound to the
actual mapped guest memory at the buffer.
Also guard ParseWaveAT3's RIFF scan against a blockSize < 4 underflow
that could make the offset negative and bypass the loop bounds check, and
clamp readSize to the mapped region in Atrac2::SetData before parsing.
pmf_init reported stream dimensions without a cap, and PMFView::Draw
allocated width * height * 4 with 32-bit int arithmetic, so a crafted
ICON1.PMF could overflow the allocation to a small buffer while
sws_scale wrote the full frame.
- Reject videos with dimensions outside 1..720x480 in pmf_init (PMFs on
the PSP never exceed 720x480).
- Use size_t arithmetic for the frame buffer allocation.
A crafted zip with a parent-directory ("..") entry name could escape the
destination directory during extraction, writing arbitrary files on the
host (e.g. into startup/autostart folders). ExtractZipContents built the
output path by concatenating the raw zip entry name onto the destination
with no traversal check.
Changes:
- Add HasParentDirComponent() utility in Core/Util/PathUtil and use it in
GameManager::ExtractZipContents to reject entries with a ".." component.
Guard both the directory-creation and file-writing passes.
- Expose ExtractZipContents as public for testing.
- Add unittest/TestZipSlip which crafts a zip with a "../evil.txt" entry
and verifies it is not written outside the destination directory.
Serializing the kernel memory block lists (userMemory, kernelMemory,
volatileMemory) wrote a full Section header per block and re-zeroed
each block's tag padding with strlen+memset on every save. Games keep
on the order of a thousand blocks alive, so the per-block overhead is
both measurable save time and wasted payload bytes.
Zero-pad tags once at write time instead (Block constructor and
SetAllocated), so the v2 form can store blocks raw: start, size, taken,
tag - no per-block section machinery, no per-save tag scrubbing.
Uninitialized padding still never reaches the stream, since every path
that writes a tag now clears it first.
v1 states still load through the old per-block-Section form, which is
kept unchanged.