From b77e7c4e6623e848c406b491be482298d13f5cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 17 Sep 2026 16:45:48 -0600 Subject: [PATCH 1/5] sceVideocodec: implement CopyYCbCr This is what sceMpegAvcCopyYCbCr is built on, and a game that wants the raw YCbCr rather than letting sceMpegbase convert to RGB uses it and nothing else. mpeg.prx builds the descriptor on its own stack and avcodec.prx reads it back at 0x800015c4. Dimensions in pixels at 0x00/0x04, the eight frame buffers from 0x0c but ordered 0,2,4,6 then 1,3,5,7, and from 0x2c the destination Y with Cb and Cr following it contiguously - ordinary planar YUV420. Checked against what the game passes: the eight addresses are exactly the buffers we handed out, and the three destinations are spaced width*height and width*height/4 apart. Un-tiling is the same operation the colour conversion already does, so that moves out of sceMpegbase.cpp as ReadTiledYCbCr rather than being written twice. Co-Authored-By: Claude Opus 5 (1M context) --- Core/HLE/sceMpegbase.cpp | 2 +- Core/HLE/sceMpegbase.h | 6 +++++ Core/HLE/sceVideocodec.cpp | 51 +++++++++++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/Core/HLE/sceMpegbase.cpp b/Core/HLE/sceMpegbase.cpp index d07d3b0ab1..7167ec9f86 100644 --- a/Core/HLE/sceMpegbase.cpp +++ b/Core/HLE/sceMpegbase.cpp @@ -174,7 +174,7 @@ static const u8 *MpegBaseFramePointer(u32 addr, int size) { // // Untangling it into plain planes costs one pass per frame, which keeps the conversion below // readable and is not where the time goes. -static bool ReadTiledYCbCr(const u32 *buffers, int width, int height, +bool ReadTiledYCbCr(const u32 *buffers, int width, int height, std::vector &luma, std::vector &cb, std::vector &cr) { const int width2 = width >> 1; const int height2 = height >> 1; diff --git a/Core/HLE/sceMpegbase.h b/Core/HLE/sceMpegbase.h index 6bf855d464..cf85d1a4ca 100644 --- a/Core/HLE/sceMpegbase.h +++ b/Core/HLE/sceMpegbase.h @@ -35,3 +35,9 @@ void __MpegBaseDoState(PointerWrap &p); // instead. The payload is moved out and dropped from the table, so each one is decoded once. // Empty if nothing was copied to that address. std::vector MpegBaseTakePESPacket(u32 dest); + +// Un-tiles a decoded frame from the eight buffers the Media Engine lays it out in into three +// planes. The buffers are in sceVideocodec's order: four luma, then four chroma. cb and cr come +// out at half width and half height, as YUV420 does. +bool ReadTiledYCbCr(const u32 *buffers, int width, int height, + std::vector &luma, std::vector &cb, std::vector &cr); diff --git a/Core/HLE/sceVideocodec.cpp b/Core/HLE/sceVideocodec.cpp index 8f1c81618f..f99dffa34f 100644 --- a/Core/HLE/sceVideocodec.cpp +++ b/Core/HLE/sceVideocodec.cpp @@ -653,11 +653,54 @@ static int sceVideocodecSetMode(u32 ctxAddr, int type) { return hleLogWarning(Log::ME, 0, "UNIMPL"); } -// 0xD95C24D5. Copies a decoded YCbCr frame between two sets of buffers through the ME (op -// 0x21521BE5) - the videocodec-level counterpart of sceMpegBaseYCrCbCopy. mpeg.prx calls it on the -// sceMpegAvcCopyYCbCr path. Nothing we run reaches it, so it stays a stub for now. +// 0xD95C24D5. Hands a decoded frame back to the caller as three planes, which is what +// sceMpegAvcCopyYCbCr is built on - the videocodec-level counterpart of sceMpegBaseYCrCbCopy. +// Games that want the raw YCbCr rather than letting sceMpegbase convert to RGB use this and nothing +// else (Monster Hunter Portable 3rd calls it once per decoded frame and never calls a Csc). +// +// mpeg.prx builds the descriptor on its own stack (AvcCopyDeeper in mpeg.prx 2.60) and avcodec.prx +// reads it back at 0x800015c4, which is where the layout below comes from: +// +// 0x00 width in pixels 0x04 height in pixels +// 0x0c the eight frame buffers, but ordered 0,2,4,6 then 1,3,5,7 rather than 0..7 +// 0x2c destination Y, then Cb at +width*height and Cr a further width*height/4 on - so the +// three planes are contiguous, and the caller gets ordinary planar YUV420. static int sceVideocodecCopyYCbCr(u32 ctxAddr, int type) { - return hleLogWarning(Log::ME, 0, "UNIMPL"); + if (!Memory::IsValidRange(ctxAddr, 0x38)) { + return hleLogError(Log::ME, -1, "bad descriptor pointer"); + } + const int width = (int)Memory::ReadUnchecked_U32(ctxAddr + 0x00); + const int height = (int)Memory::ReadUnchecked_U32(ctxAddr + 0x04); + if (width <= 0 || height <= 0 || width > 1024 || height > 1024) { + return hleLogError(Log::ME, -1, "unreasonable frame size %dx%d", width, height); + } + + // Back into the order the rest of our code uses: four luma, then four chroma. + static const int fromDescriptor[8] = { 0, 2, 4, 6, 1, 3, 5, 7 }; + u32 buffers[8]{}; + for (int i = 0; i < 8; i++) { + buffers[fromDescriptor[i]] = Memory::ReadUnchecked_U32(ctxAddr + 0x0c + i * 4); + } + + std::vector luma, cb, cr; + if (!ReadTiledYCbCr(buffers, width, height, luma, cb, cr)) { + return hleLogError(Log::ME, -1, "YCbCr buffers not readable"); + } + + const u32 dst[3] = { + Memory::ReadUnchecked_U32(ctxAddr + 0x2c), + Memory::ReadUnchecked_U32(ctxAddr + 0x30), + Memory::ReadUnchecked_U32(ctxAddr + 0x34), + }; + const std::vector *planes[3] = { &luma, &cb, &cr }; + for (int i = 0; i < 3; i++) { + const u32 size = (u32)planes[i]->size(); + if (!Memory::IsValidRange(dst[i], size)) { + return hleLogError(Log::ME, -1, "plane %d (%08x, %d bytes) not writable", i, dst[i], size); + } + Memory::MemcpyUnchecked(dst[i], planes[i]->data(), size); + } + return hleLogDebug(Log::ME, 0, "%dx%d -> %08x %08x %08x", width, height, dst[0], dst[1], dst[2]); } const HLEFunction sceVideocodec[] = { From 08573668c50be9407ac2b5303437a977705213c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 17 Sep 2026 17:22:51 -0600 Subject: [PATCH 2/5] sceAudiocodec: fill in the MP3 version in GetInfo, log what GetInfo read out sceAudiocodecInit puts 9999 in the version field to mean "not known yet", and filling it in is what this call is for - libmp3.prx reads it straight back out. We wrote every other MP3 field and left that one alone, so the real libmp3.prx got as far as GetInfo and then stopped without ever asking for a decode. While here, read the fields off the frame rather than claiming 128kbps 44.1kHz stereo unconditionally, which is what the hardware does with them. They are the raw MPEG header fields apart from the version index, which has its own numbering. The old fixed values stay as the fallback for when there is no readable frame to look at. Also carries a CheckNeedMem log tweak that was already in the tree. Co-Authored-By: Claude Opus 5 (1M context) --- Core/HLE/sceAudiocodec.cpp | 49 ++++++++++++++++++++++++++++++-------- Core/HLE/sceIo.cpp | 2 +- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/Core/HLE/sceAudiocodec.cpp b/Core/HLE/sceAudiocodec.cpp index cb00a38b5a..dd8aaba767 100644 --- a/Core/HLE/sceAudiocodec.cpp +++ b/Core/HLE/sceAudiocodec.cpp @@ -21,6 +21,7 @@ #include "Core/HLE/HLE.h" #include "Core/HLE/FunctionWrappers.h" #include "Core/HLE/sceAudiocodec.h" +#include "Core/HLE/sceKernelMemory.h" #include "Core/HLE/ErrorCodes.h" #include "Core/MemMap.h" #include "Core/Reporting.h" @@ -414,19 +415,47 @@ static int sceAudiocodecGetInfo(u32 ctxPtr, int codec) { // Write some expected values. switch (codec) { case PSP_CODEC_MP3: - // When this is called, the caller has written: - // * inptr - // * outptr - // * fmt.mp3.maxFrameBytes = 0x5A1 - // Our response is written to a bunch of fields, but I really don't know much - // about what the values are - this is handled internally in the ME. + { + // The caller has written inBuf, outBuf and maxFrameBytes, and left version at the 9999 + // sceAudiocodecInit puts there to mean "not known yet". Filling that in is the point of + // this call - libmp3.prx reads it straight back out, and leaving it at 9999 is what made + // Meruru no Atelier Plus go silent: it got this far and then stopped without ever asking + // for a decode. + // + // The hardware reads these off the frame, so read them off the frame. Apart from the + // version index, which has its own numbering, they are the raw MPEG header fields. ctx->fmt.mp3.unk3c = 3; - ctx->fmt.mp3.bitrateIndex = 9; - ctx->fmt.mp3.sampleRateIndex = 0; ctx->fmt.mp3.unk60 = 1; - ctx->fmt.mp3.channelConfig = 1; + + // Header version bits are 0 = MPEG2.5, 2 = MPEG2, 3 = MPEG1 (1 is reserved); the field + // wants 0 = MPEG2, 1 = MPEG1, 2 = MPEG2.5. + static const int versionFromHeader[4] = { 2, -1, 0, 1 }; + const u8 *header = Memory::IsValidRange(ctx->inBuf, 4) ? Memory::GetPointerUnchecked(ctx->inBuf) : nullptr; + const bool haveFrame = header && header[0] == 0xFF && (header[1] & 0xE0) == 0xE0 && + versionFromHeader[(header[1] >> 3) & 3] >= 0; + if (haveFrame) { + ctx->fmt.mp3.version = versionFromHeader[(header[1] >> 3) & 3]; + ctx->fmt.mp3.bitrateIndex = (header[2] >> 4) & 0x0F; + ctx->fmt.mp3.sampleRateIndex = (header[2] >> 2) & 0x03; + ctx->fmt.mp3.channelConfig = (header[3] >> 6) & 0x03; + INFO_LOG(Log::ME, "GetInfo MP3: sdk=%08x version=%d bitrateIdx=%d sampleRateIdx=%d channelConfig=%d (hdr %02x %02x %02x %02x)", + sceKernelGetCompiledSdkVersion(), (int)ctx->fmt.mp3.version, (int)ctx->fmt.mp3.bitrateIndex, (int)ctx->fmt.mp3.sampleRateIndex, + (int)ctx->fmt.mp3.channelConfig, header[0], header[1], header[2], header[3]); + } else { + // Nothing readable to look at. Claim 128kbps 44.1kHz stereo, as this used to + // unconditionally - but do set the version, since 9999 stops the caller dead. + // Worth hearing about: everything the caller does with the stream follows from these, + // so if a game ever lands here its audio will be wrong in a way that starts right here. + WARN_LOG(Log::ME, "sceAudiocodecGetInfo: no MP3 frame at inBuf %08x, guessing 128kbps 44.1kHz stereo", + ctx->inBuf); + ctx->fmt.mp3.version = 1; + ctx->fmt.mp3.bitrateIndex = 9; + ctx->fmt.mp3.sampleRateIndex = 0; + ctx->fmt.mp3.channelConfig = 1; + } break; } + } return hleLogInfo(Log::ME, 0, "codec=%s", GetCodecName(codec)); } @@ -471,7 +500,7 @@ static int sceAudiocodecCheckNeedMem(u32 ctxPtr, int codec) { ctx->err = 0; ctx->magic = 0x5100601; - return hleLogWarning(Log::ME, 0, "%s", GetCodecName(codec)); + return hleLogInfo(Log::ME, 0, "%s: %x", GetCodecName(codec), ctx->neededMem); } static int sceAudiocodecGetEDRAM(u32 ctxPtr, int codec) { diff --git a/Core/HLE/sceIo.cpp b/Core/HLE/sceIo.cpp index 45e4733713..b25069be6b 100644 --- a/Core/HLE/sceIo.cpp +++ b/Core/HLE/sceIo.cpp @@ -999,7 +999,7 @@ static u32 npdrmRead(FileNode *f, u8 *data, int size) { PGD_DESC *pgd = f->pgdInfo; if (!pgd) { // When pgdInfo is null, fall back to reading the file in non-encrypted mode - WARN_LOG(Log::IO, "npdrmRead: pgdInfo is null for file %s, reading as non-encrypted", f->fullpath.c_str()); + DEBUG_LOG(Log::IO, "npdrmRead: pgdInfo is null for file %s, reading as non-encrypted", f->fullpath.c_str()); return (u32)pspFileSystem.ReadFile(f->handle, data, size); } u32 block, offset, blockPos; From 7ed059843373a57abbaa6ebb9b79000009dfc427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 17 Sep 2026 17:34:03 -0600 Subject: [PATCH 3/5] sceAudiocodec: report the decoded size in bytes, not samples The field at 0x24 is a byte count, like srcBytesRead next to it, and sceAudiocodecGetOutputBytes describes the same quantity the same way (0x1200 for MPEG1 MP3). We were putting the sample count there, a quarter of the value, and libmp3.prx takes it as the length of the PCM to pass on. Renamed to dstBytesWritten so it reads like what it is. Nothing on our side consumed the field, so this only changes what the firmware modules see. mpeg.prx ignores it, which is why Atrac3+ playback was unaffected either way, but libatrac3plus.prx does read it. Co-Authored-By: Claude Opus 5 (1M context) --- Core/HLE/sceAudiocodec.cpp | 6 +++++- Core/HLE/sceAudiocodec.h | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Core/HLE/sceAudiocodec.cpp b/Core/HLE/sceAudiocodec.cpp index dd8aaba767..c0f02fe3a5 100644 --- a/Core/HLE/sceAudiocodec.cpp +++ b/Core/HLE/sceAudiocodec.cpp @@ -398,7 +398,11 @@ static int sceAudiocodecDecode(u32 ctxPtr, int codec) { } ctx->srcBytesRead = inDataConsumed + headerBytes; - ctx->dstSamplesWritten = outSamples; + // In bytes, not samples. sceAudiocodecGetOutputBytes describes the same quantity in bytes + // (0x1200 for MPEG1 MP3), and libmp3.prx takes this as the length of the PCM to hand on - + // reporting the sample count instead gave it a quarter of every frame, which played back + // fast and metallic. The decoder always writes stereo 16-bit, whatever the source is. + ctx->dstBytesWritten = outSamples * 2 * (int)sizeof(int16_t); } return hleLogDebug(Log::ME, 0, "codec %s sampleRate: %d bytesPerFrame: %d channels: %d", GetCodecName(codec), sampleRate, bytesPerFrame, channels); } diff --git a/Core/HLE/sceAudiocodec.h b/Core/HLE/sceAudiocodec.h index e298cba0b7..ce48f7598b 100644 --- a/Core/HLE/sceAudiocodec.h +++ b/Core/HLE/sceAudiocodec.h @@ -50,7 +50,7 @@ struct SceAudiocodecCodec { u32 inBuf; // 0x18 the raw frame to decode s32 srcBytesRead; // 0x1c written by the decoder u32 outBuf; // 0x20 where decoded PCM goes - s32 dstSamplesWritten; // 0x24 written by the decoder + s32 dstBytesWritten; // 0x24 written by the decoder, in bytes like srcBytesRead // Codec-specific, 0x28..0x67. union { From e6fa47291d020e36fd8a6699486fdd4c3e65843e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 17 Sep 2026 17:22:51 -0600 Subject: [PATCH 4/5] Log: give the printf output the same format as the others Headless is the only thing that uses it, and it had its own shorter format with no thread, file or line, so a pattern that matched a log from the app quietly matched nothing in one from headless. Costs a wrong conclusion the first time you do it. Co-Authored-By: Claude Opus 5 (1M context) --- Common/Log/LogManager.cpp | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/Common/Log/LogManager.cpp b/Common/Log/LogManager.cpp index be728c9bcd..d89bdab8bf 100644 --- a/Common/Log/LogManager.cpp +++ b/Common/Log/LogManager.cpp @@ -515,27 +515,8 @@ void LogManager::StdioLog(const LogMessage &message) { } void PrintfLog(const LogMessage &message) { - const char *category = message.log; - - switch (message.level) { - case LogLevel::LVERBOSE: - fprintf(stderr, "V %s: %s", category, message.msg.c_str()); - break; - case LogLevel::LDEBUG: - fprintf(stderr, "D %s: %s", category, message.msg.c_str()); - break; - case LogLevel::LINFO: - fprintf(stderr, "I %s: %s", category, message.msg.c_str()); - break; - case LogLevel::LERROR: - fprintf(stderr, "E %s: %s", category, message.msg.c_str()); - break; - case LogLevel::LWARNING: - fprintf(stderr, "W %s: %s", category, message.msg.c_str()); - break; - case LogLevel::LNOTICE: - default: - fprintf(stderr, "N %s: %s", category, message.msg.c_str()); - break; - } + // Same shape as the stdio and file outputs the other builds use. It used to be its own + // shorter format, which meant a pattern that matched a log from the app quietly matched + // nothing in one from headless. + fprintf(stderr, "%s %s %s", message.timestamp, message.header, message.msg.c_str()); } From 01be454b7a3570d25517bc47bcf06ee95d279ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 17 Sep 2026 18:17:57 -0600 Subject: [PATCH 5/5] sceMp3: keep accepting sample rates a PSP would refuse, on purpose libmp3.prx only accepts a rate other than 44.1kHz from a game built with SDK 3.09.05 or later, and audio/mp3/init has the hardware's answers for the rest. PPSSPP has nonetheless accepted them from every game that declares an SDK version, because the threshold was written as decimal 3090500 rather than 0x03090500 - an accident, but one people have come to rely on. Beats and games like it build levels out of MP3s the user supplies, and refusing an ordinary 48kHz file looks like a bug to whoever supplied it. So the hardware answer now goes only to something that declares no SDK version at all, which in practice means the test, and the comment says that is a choice rather than an oversight. Being strict again is a one-line change, with the two lines it would cost named next to it. Co-Authored-By: Claude Opus 5 (1M context) --- Core/HLE/sceMp3.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Core/HLE/sceMp3.cpp b/Core/HLE/sceMp3.cpp index d82a0a5529..fff5c2f5d4 100644 --- a/Core/HLE/sceMp3.cpp +++ b/Core/HLE/sceMp3.cpp @@ -480,7 +480,24 @@ static int sceMp3Init(u32 mp3) { // TODO: Should return 0x80671301 (unsupported version?) WARN_LOG_REPORT(Log::ME, "sceMp3Init: invalid data: not MPEG v1"); } - if (samplerate != 44100 && sdkver < 3090500) { + // DELIBERATELY MORE LENIENT THAN A PSP. + // + // libmp3.prx only accepts a rate other than 44.1kHz from a game built with SDK 3.09.05 or + // later - it compares the compiled SDK version against 0x030904FF - and audio/mp3/init carries + // the hardware's answers for the rest: 48kHz and 32kHz both come back as 0x80671302. + // + // We only give that answer to something that declares no SDK version at all, which in practice + // means the test. Anything that declares one gets its rate accepted whatever it is. That is + // what PPSSPP has always done here, by accident - the threshold was written as decimal 3090500 + // rather than 0x03090500, so every real version cleared it - but it is worth keeping on + // purpose. Beats and games like it build levels out of MP3s the user supplies, and refusing an + // ordinary 48kHz file looks like a bug to whoever supplied it. + // + // Note this only applies here. Under DisableHLE for sceMp3 the check is inside libmp3.prx and + // it does refuse the file, because the only thing we hand it is the sample rate index - which + // it also uses to look up the rate it plays at, so claiming 44.1kHz to get past the check would + // play the stream at the wrong speed. To be strict again, compare against 0x030904FF. + if (samplerate != 44100 && sdkver == 0) { return hleDelayResult(hleLogError(Log::ME, SCE_MP3_ERROR_BAD_SAMPLE_RATE, "invalid data: not 44.1kHz"), "mp3 init", PARSE_DELAY_MS); }