gpu.buffer.*'s "uri" output type let a client supply an arbitrary
stackWidth with no upper bound, used as the starting divisor in a loop
that decrements until it evenly divides the buffer's actual (small)
pixel count - a client sending a huge stackWidth (up to ~2 billion)
stalls the connection's handler thread for that many iterations.
Clamp it to the actual pixel count first.
gpu.buffer.texture's level parameter was forwarded as-is (u32) into
GPU_GetCurrentTexture(), which takes a plain int - a client-supplied
value whose u32->int conversion is negative skips backends' "level >=
mip count" bounds check (which only fires for level > 0), reaching
backend texture-copy code with a bogus mip index. Reject it upfront.
ReplayExecuteBlob's bounds check (i + item.info.size > sz) can
overflow on platforms where size_t is 32-bit, since item.info.size is
a client/file-supplied u32 read from replay.execute's base64 blob -
a crafted value near UINT32_MAX could wrap the sum below sz, passing
the check before an out-of-bounds memcpy. Use a subtraction-based
check instead, which can't overflow regardless of size_t width.
WebSocketMemoryBreakpointParams::Parse() (used by add/update) checks
for address + size wrapping around before computing the end address,
but memory.breakpoint.remove computed it inline without that check.
Apply the same check for consistency - a crafted size could otherwise
wrap the computed end below address, causing RemoveMemCheck to operate
on an unintended range.
Ioctl's ISO9660 path table read re-read sector `block` (the first
sector, already consumed by the preceding ReadBlocks) for the trailing
partial sector instead of `block + blocks`, returning duplicated data
from the start of the table instead of its actual tail.
ReadDirectory() advanced by the raw on-disk dir.size without checking
it's at least as large as the record's own header+identifier. A
crafted directory sector could set dir.size = 1 repeatedly, making the
loop reinterpret the same overlapping bytes as many separate entries -
allocating far more TreeEntry objects than the sector's actual size
should allow.
rc_bittree()/rc_number() can index bm_dist_bits up to column 50 and
bm_dist up to row 43 (when decoding a "long distance" match, i.e.
match_len > 2), but the arrays were only sized for 39 and 18
respectively. A crafted LZRC-compressed block (reachable via
NPDRMDemoBlockDevice::ReadBlock) could drive these indices out of
range, corrupting adjacent probability tables within the same
LZRC_DECODE struct via rc_bit()'s read-modify-write. Size the arrays
for the indices the algorithm can actually produce, and initialize
them via sizeof() so the memset in rc_init stays correct.
fileName is taken verbatim from the index file (only a leading slash
is stripped) and then used essentially unsanitized to build a path
under basePath (GetLocalPath() is a plain string join, unlike
MetaFileSystem::RealPath which does collapse ".." for normal game file
access). A crafted index file - these virtual-disc folders are commonly
shared/downloaded as homebrew - could use a ".." component to make
PPSSPP probe or open arbitrary host files/directories outside the
intended folder just by loading it. Reuse the existing
HasParentDirComponent() helper (already used for the same purpose in
GameManager's zip extraction) to reject such entries.
numFrames and numBlocks are derived from the same attacker-controlled
64-bit total_bytes field, but independently truncated to 32 bits using
different divisors (frameSize vs. the fixed 2048-byte block size).
With extreme total_bytes/block_size combinations the two truncations
can disagree so that numBlocks (which gates ReadBlock's bounds check)
describes more blocks than numFrames actually covers - ReadBlock then
indexes the numFrames+1-sized `index` array one or more elements past
its end. Reject any header where this could happen before allocating
anything.
The block table read from an NPDRM PBP's PSAR blob is only reversibly
XOR-scrambled, not otherwise validated, so a crafted file fully
controls table_[block].size/offset and the header's LBA/block-size
fields. Several of these were used without checks:
- table_[block].size could exceed blockSize_, causing ReadAt and the
KIRK cipher update to write past the end of blockBuf_/tempBuf_ (both
allocated as exactly blockSize_ bytes) - a heap buffer overflow.
- The block index derived from blockNumber (which can come from an
attacker-influenced /sce_lbn.../_size... raw sector open) was never
bounds-checked against numBlocks_ before indexing table_[].
- blockLBAs_ could be 0, dividing by zero both when computing
numBlocks_ and when computing the block index in ReadBlock.
- lbaSize_ could underflow if lbaEnd < lbaStart, and tableSize_
(numBlocks_ * sizeof(table_info)) was computed in 32-bit, so a large
numBlocks_ could wrap it to a small value - passing the "did we read
the whole table" check while only actually reading (and
descrambling) a small prefix, leaving the rest of the table_ array
as untouched, uninitialized heap memory that ReadBlock() would later
trust.
Reject all of these instead.
blocksActuallyRead rounded bytesRead up to the next whole block
unconditionally, intending to handle the legitimate case where the
very last block of the file is naturally shorter than BLOCK_SIZE
(cache_ is deliberately over-allocated for that). But it applied the
same rounding to any short read, including a genuine failure or a
dropped connection mid-file (this loader can sit on top of the whole
Remote ISO chain via CachingFileLoader/HTTPFileLoader when "Cache full
ISO in RAM" is enabled) - marking a block as fully cached when only a
few of its bytes were actually written. Since cache_ is malloc'd (not
zeroed), every later read of that block would serve uninitialized heap
memory as if it were real file data.
Only round up when the short read's end position exactly matches the
true end of the file.
SaveIntoCache checked `readBytes != 0` instead of comparing against the
full expected length, so any nonzero-but-short read from the backend
(e.g. a Remote ISO connection dropping mid-file) was treated as a
complete success: in the multi-block path this marked *all* requested
blocks (up to 16) as fully cached and wrote the uninitialized tail of
the read buffer to the on-disk cache file, and in both paths the
short/uninitialized data was also copied straight into the caller's
output buffer and counted in the return value - so a read failure was
reported (and permanently cached) as success. Only treat a block as
read once the backend actually delivered the full blockSize_ for it,
and stop before caching or returning anything for blocks it didn't.
Also fixes two latent bugs in the same functions, unreachable in the
current call graph (DiskCachingFileLoader is only ever driven by
CachingFileLoader, which always issues block-aligned reads) but wrong
if ever called otherwise:
- The multi-block loop reused the batch's initial `offset` (the
position within the *first* block) for every subsequent block
instead of resetting it to 0, which would both read from the wrong
place in `wholeRead` and mis-copy less than a full block for i > 0.
- ReadBlockData() applied `offset` to the destination pointer instead
of the file seek position, which would both read the wrong bytes
from disk and write up to `offset` bytes past the end of the
caller's buffer.
LoadCacheIndex's sanity check on persisted block indices used `>`
instead of `>=` against maxBlocks_ (blockIndexLookup_ only has
maxBlocks_ entries, valid indices 0..maxBlocks_-1), so a corrupted
cache file's index entry with block == maxBlocks_ exactly would pass
validation and then index one past the end of blockIndexLookup_.
SaveIntoCache() discarded backend_->ReadAt()'s return value entirely
and unconditionally marked the requested block(s) as cached. A short
or failed read from the backend (e.g. a Remote ISO connection dropping
mid-file, now that LocalFileLoader/RetryingFileLoader correctly report
failures as 0 rather than a huge count) would still get stored as a
"valid" cached block, permanently serving its uninitialized tail as if
it were real file data on every later read, with no retry.
Only insert a block once we've confirmed the backend actually
delivered the full BLOCK_SIZE for it.
ReadAt()'s contract is to return the number of bytes/units actually
read. On every platform branch, an OS-level read failure (ReadFile
returning FALSE, or pread/read returning -1) was fed straight into a
division by `bytes` without checking for it first:
- Windows explicitly returned (size_t)-1.
- Elsewhere, the signed -1 from pread/read was implicitly converted to
size_t (via the usual arithmetic conversions with the unsigned
`bytes`) before the division, producing a huge bogus count instead
of a small one.
Every caller in the caching chain (CachingFileLoader,
RamCachingFileLoader, RetryingFileLoader, ZipFileLoader's libzip
source callback) loops on "did we get at least what we asked for",
which a huge return value trivially satisfies - so a local I/O error
(removable media ejected, a content-URI permission problem mid-read,
etc.) would be reported as a fully successful read of whatever
uninitialized memory happened to be in the destination buffer.
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.
sceFontGetCharGlyphImage_Clip stored the glyph over the destination buffer.
Hardware adds it with saturation instead, so on hardware a glyph never erases
what was already in the buffer - the transparent parts contribute zero - while
we cleared them to 0. Text drawn as overlapping glyphs lost the earlier ones.
Measured on a PSP-1000 against a dumped ltn0.pgf, sweeping the pen position's
fractional part, both 4bpp and 8bpp, and several destination pre-fill values so
that "wrote a 0 here" could be told apart from "did not write here":
- The result is min(dst + blend, max), not blend.
- The two horizontal weights are rounded in opposite directions, down for the
left neighbour and up for the pixel itself. That is not a free choice: it is
what makes the pair sum to exactly the format's full scale whenever both
samples are full ink, for every fraction. Rounding the combined sum once, as
before, disagrees with hardware on about a fifth of the blended pixels.
- 4bpp blends the raw nibble and 8bpp the value swizzled to 8 bit. Blending at
8 bit and narrowing afterwards is not the same thing and misses ~0.6%.
- The fractional part of yPos64 is discarded entirely. There is no vertical
blending and the rectangle never grows downwards, so renderY2 loses its +1
and the two render loops collapse into one - xFrac == 0 needs no special
case, the first term vanishes and the second collapses to exactly b.
Only PSP_FONT_PIXELFORMAT_4 and _8 render at all. _4_REV, _24 and _32 return 0
and leave the buffer completely untouched, which is also the answer to the
"not sure how to make these produce an image" note in pspautotests'
charglyphimageclip.cpp. They are skipped now, with a report so that a game
relying on them can be found - only one buffer configuration was measured.
Verified with pspautotests: the new charglyphimagexfrac goes from 388 of 601
differing lines to passing, and charglyphimage and charglyphimageclip improve
from 57 differing lines to 3. No other font test changes. The remaining 3 are
a separate, pre-existing issue with bytesPerLine of 0 and 1.
Not covered: clip rectangles that actually clip, negative pen positions, and
the shadow glyph entry points.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndroidHwScale defaults to 0 instead of the old device/resolution-based heuristic (which is
removed along with DefaultAndroidHwScale()).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4QAoxV2KY7ek4PcZw3WvY
...
Games often use the framebuffer alpha channel for non-visual purposes,
so saved PNGs could look fully transparent in image viewers. Force alpha
to 255 when writing PNG screenshots unless --screenshot-keep-alpha is
passed. The MSE comparison ignores alpha either way.
- Add frametests.py: walks a dump tree, renders each dump per config variant
through PPSSPPHeadless, generates reference images when missing and compares
MSE when present, and writes a self-contained HTML report. The JSON config
(which lives with the test set, not in the repo) points at the data tree
and defines variants as suffix -> CLI args, e.g. 'soft': '--graphics=software'.
- Headless: --screenshot-save saves PNG when the path ends in .png; new
--screenshot-diff always writes a visual comparison when comparing;
screenshot comparison failures (mismatch or unloadable reference) now fail
the test instead of passing silently.
- Read back framebuffers top-down, flipping only for BMP output/input
(fixes upside-down PNG references). Sync libretro copy accordingly.
- Document the system in docs/frametest.md; add AGENTS.md reference.
Our Qt backend has long been left behind and doesn't even support Vulkan
currently. There would be a lot of work to make it viable, and I don't
think anyone is really interested.
ImGui on SDL will soon fulfill the need for a more classic user interface
with a menu bar on Linux, and on Mac we already have a native UI.