From 05de369578371cfd67141eb645880abbd818dfca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 5 Nov 2026 14:57:09 +0100 Subject: [PATCH 1/2] POPS: Upload images the emulator writes to VRAM with the CPU The PS1 emulator writes an image that comes through the GP0 data port (rather than by DMA) into VRAM one pixel at a time with the CPU. The hardware renderers never saw those pixels, so a later copy out of the PS1 VRAM framebuffer read stale texels. The two row ends of that loop now go to an HLE call, which uploads the finished rectangle with a new PerformWriteRectFromMemory(). Co-Authored-By: Claude Opus 5.5 --- Core/HLE/PopsBoot.cpp | 53 +++++++++++++++++++++++++ GPU/Common/FramebufferManagerCommon.cpp | 2 +- GPU/GPUCommon.cpp | 8 ++++ GPU/GPUCommon.h | 2 + GPU/Software/SoftGpu.cpp | 4 ++ GPU/Software/SoftGpu.h | 1 + docs/POPS.md | 4 +- 7 files changed, 72 insertions(+), 2 deletions(-) diff --git a/Core/HLE/PopsBoot.cpp b/Core/HLE/PopsBoot.cpp index b74b6d3a99..ee0a759e21 100644 --- a/Core/HLE/PopsBoot.cpp +++ b/Core/HLE/PopsBoot.cpp @@ -19,6 +19,7 @@ // This stands in for the parts of the POPS boot PPSSPP doesn't run: the kernel's init, and the // Media Engine the manager's ME loop runs on. See docs/POPS.md. +#include #include #include #include @@ -51,6 +52,8 @@ #include "Core/MemMapHelpers.h" #include "Core/Screenshot.h" #include "Core/System.h" +#include "GPU/GPU.h" +#include "GPU/GPUCommon.h" static bool g_popsBoot = false; static bool g_popsConvertedPBP = false; @@ -303,10 +306,43 @@ static int __PopsReadIcon0(u32 file, u32 buf, u32 offset, u32 size) { return hleLogDebug(Log::sceKernel, (int)size); } +// The emulator's state for a CPU-to-VRAM image transfer through the GP0 port, from its gp: the +// rectangle (x0, y0, x1, y1), then the current x and y. +static const u32 POPS_IMAGE_LOAD_STATE = 0x363C; + +// The end of a row of such a transfer, which the emulator writes pixel by pixel with the CPU. When +// it's the last row, the rectangle goes into the framebuffer. +static void __PopsImageLoadRow() { + // The loop's registers are live across the call. + hleSkipDeadbeef(); + const u32 state = currentMIPS->r[MIPS_REG_GP] + POPS_IMAGE_LOAD_STATE; + const u16 x0 = Memory::ReadUnchecked_U16(state), y0 = Memory::ReadUnchecked_U16(state + 2); + const u16 x1 = Memory::ReadUnchecked_U16(state + 4), y1 = Memory::ReadUnchecked_U16(state + 6); + const u16 y = Memory::ReadUnchecked_U16(state + 10); + if ((u16)(y + 1) != y1 || !gpu) { + return; + } + // VRAM is 1024x512 and the transfer wraps around its edges. + const int w = std::min((u16)(x1 - x0), (u16)1024), h = std::min((u16)(y1 - y0), (u16)512); + for (int ys = 0; ys < h;) { + const int sy = (y0 + ys) & 511; + const int sh = std::min(h - ys, 512 - sy); + for (int xs = 0; xs < w;) { + const int sx = (x0 + xs) & 1023; + const int sw = std::min(w - xs, 1024 - sx); + gpu->PerformWriteRectFromMemory(0x04000000 + (sy * 1024 + sx) * 2, 1024, sw, sh, GE_FORMAT_5551); + xs += sw; + } + ys += sh; + } + DEBUG_LOG(Log::sceKernel, "POPS: image load %d,%d %dx%d", x0, y0, w, h); +} + const HLEFunction PopsBootCalls[] = { { 0x13370006, &WrapI_UUU<__PopsDeflateDecompress>, "__PopsDeflateDecompress", 'i', "xxx" }, { 0x13370007, &WrapI_UUU<__PopsGetVersionKey>, "__PopsGetVersionKey", 'i', "xxx" }, { 0x13370008, &WrapI_UUUU<__PopsReadIcon0>, "__PopsReadIcon0", 'i', "xxxx" }, + { 0x13370009, &WrapV_V<__PopsImageLoadRow>, "__PopsImageLoadRow", 'v', "" }, }; void Register_PopsBootCalls() { @@ -440,6 +476,23 @@ void __PopsBootPatchEmulator(u32 textAddr, u32 textSize, u32 decodeStub) { } currentMIPS->InvalidateICacheRangeDeferred(textAddr, textSize); + // The GP0 port's image transfer ends its rows in two places (after the word's first pixel or its + // second): lhu rN, y1(gp); addiu rM, a2, 1; beq rM, rN; sh rM, y(gp). + int imageLoadRows = 0; + for (u32 addr = textAddr; addr + 16 <= textAddr + textSize; addr += 4) { + const u32 op = Memory::ReadUnchecked_U32(addr); + const u32 next = Memory::ReadUnchecked_U32(addr + 4); + const bool first = op == 0x97983642 && next == 0x24C60001 && (Memory::ReadUnchecked_U32(addr + 8) >> 16) == 0x10D8 && Memory::ReadUnchecked_U32(addr + 12) == 0xA7863646; + const bool second = op == 0x97903642 && next == 0x24C40001 && (Memory::ReadUnchecked_U32(addr + 8) >> 16) == 0x1090 && Memory::ReadUnchecked_U32(addr + 12) == 0xA7843646; + if (first || second) { + PatchTrampoline(addr, "__PopsImageLoadRow", op, addr + 8); + imageLoadRows++; + } + } + if (imageLoadRows != 2) { + WARN_LOG(Log::sceKernel, "POPS: found %d of the 2 GP0 image transfer row ends, hardware renderers may miss its pixels", imageLoadRows); + } + if (!g_popsConvertedPBP) { return; } diff --git a/GPU/Common/FramebufferManagerCommon.cpp b/GPU/Common/FramebufferManagerCommon.cpp index 3749c28f23..0a909fa5b3 100644 --- a/GPU/Common/FramebufferManagerCommon.cpp +++ b/GPU/Common/FramebufferManagerCommon.cpp @@ -1257,7 +1257,7 @@ void FramebufferManagerCommon::WriteRectFromMemory(u32 addr, int size, int strid VirtualFramebuffer *target = nullptr; for (auto vfb : vfbs_) { - if (!vfb->fbo || vfb->FbStrideInBytes() != byteStride || addr <= vfb->fb_address || addr >= vfb->fb_address + vfb->BufferByteSize(RASTER_COLOR)) { + if (!vfb->fbo || vfb->FbStrideInBytes() != byteStride || addr < vfb->fb_address || addr >= vfb->fb_address + vfb->BufferByteSize(RASTER_COLOR)) { continue; } if (!target || vfb->colorBindSeq > target->colorBindSeq) { diff --git a/GPU/GPUCommon.cpp b/GPU/GPUCommon.cpp index a3d3d78ea3..428ffc4c11 100644 --- a/GPU/GPUCommon.cpp +++ b/GPU/GPUCommon.cpp @@ -2686,6 +2686,14 @@ void GPUCommon::PerformWriteFormattedFromMemory(u32 addr, int size, int frameWid InvalidateCache(addr, size, GPU_INVALIDATE_SAFE); } +void GPUCommon::PerformWriteRectFromMemory(u32 addr, int stride, int width, int height, GEBufferFormat format) { + const int bpp = BufferFormatBytesPerPixel(format); + if (Memory::IsVRAMAddress(addr) && framebufferManager_) { + framebufferManager_->WriteRectFromMemory(addr, height * stride * bpp, stride, format, width); + } + InvalidateCache(addr, ((height - 1) * stride + width) * bpp, GPU_INVALIDATE_SAFE); +} + bool GPUCommon::PerformReadbackStencilToMemory(u32 dest, int size) { if (Memory::IsVRAMAddress(dest) && framebufferManager_->MayIntersectFramebufferColor(dest)) { return framebufferManager_->ReadbackStencilToMemory(dest, size); diff --git a/GPU/GPUCommon.h b/GPU/GPUCommon.h index 4346bf1a0f..e2f1f20c6f 100644 --- a/GPU/GPUCommon.h +++ b/GPU/GPUCommon.h @@ -174,6 +174,8 @@ public: // width is the stride. rectWidth, if set, is how many pixels of each row were written, which lets // a write that starts partway into a framebuffer row be uploaded without clobbering the rest. virtual void PerformWriteFormattedFromMemory(u32 addr, int size, int width, GEBufferFormat format, int rectWidth = 0); + // The CPU has written a width x height rectangle of pixels at addr, rows stride pixels apart. + virtual void PerformWriteRectFromMemory(u32 addr, int stride, int width, int height, GEBufferFormat format); virtual bool PerformWriteStencilFromMemory(u32 dest, int size, WriteStencil flags); // Reads the stencil of whole framebuffer rows into the alpha bytes in RAM, leaving the color alone. // For games that test stencil with the CPU. Only 8888 for now. diff --git a/GPU/Software/SoftGpu.cpp b/GPU/Software/SoftGpu.cpp index 4be76c783f..0ed0ec1fe3 100644 --- a/GPU/Software/SoftGpu.cpp +++ b/GPU/Software/SoftGpu.cpp @@ -1506,6 +1506,10 @@ void SoftGPU::PerformWriteFormattedFromMemory(u32 addr, int size, int width, GEB NoteVideoRange(addr, (u32)size); } +void SoftGPU::PerformWriteRectFromMemory(u32 addr, int stride, int width, int height, GEBufferFormat format) { + InvalidateCache(addr, ((height - 1) * stride + width) * BufferFormatBytesPerPixel(format), GPU_INVALIDATE_HINT); +} + bool SoftGPU::PerformMemoryCopy(u32 dest, u32 src, int size, GPUCopyFlag flags) { // Drawing can still be queued after a list stalled (FinishDeferred). drawEngine_->transformUnit.FlushIfOverlap(this, "memcpy", false, src, size, size, 1); diff --git a/GPU/Software/SoftGpu.h b/GPU/Software/SoftGpu.h index 4661faeb99..ff250d8eda 100644 --- a/GPU/Software/SoftGpu.h +++ b/GPU/Software/SoftGpu.h @@ -184,6 +184,7 @@ public: std::vector GetFramebufferList() const override { return std::vector(); } void InvalidateCache(u32 addr, int size, GPUInvalidationType type) override; void PerformWriteFormattedFromMemory(u32 addr, int size, int width, GEBufferFormat format, int rectWidth) override; + void PerformWriteRectFromMemory(u32 addr, int stride, int width, int height, GEBufferFormat format) override; bool PerformMemoryCopy(u32 dest, u32 src, int size, GPUCopyFlag flags = GPUCopyFlag::NONE) override; bool PerformMemorySet(u32 dest, u8 v, int size) override; bool PerformReadbackToMemory(u32 dest, int size) override; diff --git a/docs/POPS.md b/docs/POPS.md index b1ff7f0938..c30cadc083 100644 --- a/docs/POPS.md +++ b/docs/POPS.md @@ -43,7 +43,9 @@ without them. The emulator keeps the PS1's 1024x512 VRAM at `0x04000000` as one 16-bit framebuffer, and reads and writes it outside where it draws. It's a fixed framebuffer: created whole, from memory, and uploaded without -filtering. Its 24-bit video is unpacked three bytes per pixel into `0x04100000`, which is kept at 1x. It +filtering. An image the game sends to VRAM through the GP0 port rather than by DMA is written there by the +CPU, pixel by pixel, which the hardware renderers don't see: the end of each row of that loop is hooked, and +the finished rectangle is uploaded (Ridge Racer's title screen flag). Its 24-bit video is unpacked three bytes per pixel into `0x04100000`, which is kept at 1x. It draws sprites on pixel centers, which above 1x read their neighbours' texels, so those are moved back half a pixel. And it builds video frames by ORing passes together, so draws whose logic op is done in the shader are drawn one at a time. From ac4fb587fe0e5ba66c1598f13677cfbe7850d00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 5 Nov 2026 15:29:02 +0100 Subject: [PATCH 2/2] POPS: Find the GP0 image load's row ends in every emulator version The registers and the gp offset of the loop's state differ between firmware versions and models (pops.prx and pops_0Xg.prx from 3.80 to 6.61), so it's matched by the shape of its four instructions, and the offset is taken from them and kept in savestates. The exact words only matched 6.60 and 6.61's 01g to 04g. Co-Authored-By: Claude Opus 5.5 --- Core/HLE/PopsBoot.cpp | 56 ++++++++++++++++++++++++++++--------------- docs/POPS.md | 3 ++- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/Core/HLE/PopsBoot.cpp b/Core/HLE/PopsBoot.cpp index ee0a759e21..bc4b410d3a 100644 --- a/Core/HLE/PopsBoot.cpp +++ b/Core/HLE/PopsBoot.cpp @@ -100,6 +100,8 @@ static const PopsManLayout POPSMAN_LAYOUTS[] = { { 0x1404, 0x030, 0, 0x3390, 0x33B0, 0x33B8, 0x0538, 0x05C4, 0x0138, 1 }, // 3.95-3.96 }; static int g_popsManLayout = 0; +// Where the emulator keeps its GP0 image transfer's rectangle, from its gp (0 if not found). +static u32 g_popsImageLoadState = 0; static const u32 SCE_POPSMAN_LOAD_MODULE_NID = 0x29B3FB24; @@ -112,6 +114,7 @@ void __PopsBootShutdown() { g_popsConvertedPBP = false; g_popsIcon0Usable = true; g_popsManLayout = 0; + g_popsImageLoadState = 0; g_popsPBPPath.clear(); // The kernel objects and memory are already gone with the kernel. if (g_popsInitThread) { @@ -141,7 +144,7 @@ static void DoHelperThread(PointerWrap &p, std::unique_ptr &thr } void __PopsBootDoState(PointerWrap &p) { - auto s = p.Section("PopsBoot", 0, 2); + auto s = p.Section("PopsBoot", 0, 3); if (!s) { if (p.mode == PointerWrap::MODE_READ) { __PopsBootShutdown(); @@ -161,6 +164,12 @@ void __PopsBootDoState(PointerWrap &p) { } else { g_popsManLayout = 0; } + if (s >= 3) { + Do(p, g_popsImageLoadState); + } else { + // Older states don't have the hook in the emulator's code. + g_popsImageLoadState = 0; + } } static const PopsManLayout &PopsMan() { @@ -306,16 +315,16 @@ static int __PopsReadIcon0(u32 file, u32 buf, u32 offset, u32 size) { return hleLogDebug(Log::sceKernel, (int)size); } -// The emulator's state for a CPU-to-VRAM image transfer through the GP0 port, from its gp: the -// rectangle (x0, y0, x1, y1), then the current x and y. -static const u32 POPS_IMAGE_LOAD_STATE = 0x363C; - -// The end of a row of such a transfer, which the emulator writes pixel by pixel with the CPU. When -// it's the last row, the rectangle goes into the framebuffer. +// The end of a row of a CPU-to-VRAM image transfer through the GP0 port, which the emulator writes +// pixel by pixel with the CPU. When it's the last row, the rectangle goes into the framebuffer. Its +// state: the rectangle (x0, y0, x1, y1), then the current x and y. static void __PopsImageLoadRow() { // The loop's registers are live across the call. hleSkipDeadbeef(); - const u32 state = currentMIPS->r[MIPS_REG_GP] + POPS_IMAGE_LOAD_STATE; + if (!g_popsImageLoadState) { + return; + } + const u32 state = currentMIPS->r[MIPS_REG_GP] + g_popsImageLoadState; const u16 x0 = Memory::ReadUnchecked_U16(state), y0 = Memory::ReadUnchecked_U16(state + 2); const u16 x1 = Memory::ReadUnchecked_U16(state + 4), y1 = Memory::ReadUnchecked_U16(state + 6); const u16 y = Memory::ReadUnchecked_U16(state + 10); @@ -477,20 +486,29 @@ void __PopsBootPatchEmulator(u32 textAddr, u32 textSize, u32 decodeStub) { currentMIPS->InvalidateICacheRangeDeferred(textAddr, textSize); // The GP0 port's image transfer ends its rows in two places (after the word's first pixel or its - // second): lhu rN, y1(gp); addiu rM, a2, 1; beq rM, rN; sh rM, y(gp). - int imageLoadRows = 0; + // second): lhu rN, y1(gp); addiu rM, rY, 1; beq rM, rN; sh rM, y(gp), with y just after y1. The + // registers and offsets differ between versions (3.80 to 6.61), the shape doesn't. + std::vector> rowEnds; // address, y1 for (u32 addr = textAddr; addr + 16 <= textAddr + textSize; addr += 4) { - const u32 op = Memory::ReadUnchecked_U32(addr); - const u32 next = Memory::ReadUnchecked_U32(addr + 4); - const bool first = op == 0x97983642 && next == 0x24C60001 && (Memory::ReadUnchecked_U32(addr + 8) >> 16) == 0x10D8 && Memory::ReadUnchecked_U32(addr + 12) == 0xA7863646; - const bool second = op == 0x97903642 && next == 0x24C40001 && (Memory::ReadUnchecked_U32(addr + 8) >> 16) == 0x1090 && Memory::ReadUnchecked_U32(addr + 12) == 0xA7843646; - if (first || second) { - PatchTrampoline(addr, "__PopsImageLoadRow", op, addr + 8); - imageLoadRows++; + const u32 lhu = Memory::ReadUnchecked_U32(addr); + const u32 addiu = Memory::ReadUnchecked_U32(addr + 4); + const u32 beq = Memory::ReadUnchecked_U32(addr + 8); + const u32 sh = Memory::ReadUnchecked_U32(addr + 12); + const u32 rN = (lhu >> 16) & 31, rM = (addiu >> 16) & 31; + const u32 y1 = lhu & 0xFFFF; + if ((lhu & 0xFFE00000) != 0x97800000 || (addiu & 0xFC00FFFF) != 0x24000001 || (beq >> 16) != (0x1000 | (rM << 5) | rN) || + sh != (0xA7800000 | (rM << 16) | ((y1 + 4) & 0xFFFF)) || y1 < 6) { + continue; } + rowEnds.emplace_back(addr, y1); } - if (imageLoadRows != 2) { - WARN_LOG(Log::sceKernel, "POPS: found %d of the 2 GP0 image transfer row ends, hardware renderers may miss its pixels", imageLoadRows); + if (rowEnds.size() == 2 && rowEnds[0].second == rowEnds[1].second) { + for (const auto &rowEnd : rowEnds) { + PatchTrampoline(rowEnd.first, "__PopsImageLoadRow", Memory::ReadUnchecked_U32(rowEnd.first), rowEnd.first + 8); + } + g_popsImageLoadState = rowEnds[0].second - 6; + } else { + WARN_LOG(Log::sceKernel, "POPS: found %d GP0 image transfer row ends, not 2 alike, hardware renderers may miss its pixels", (int)rowEnds.size()); } if (!g_popsConvertedPBP) { diff --git a/docs/POPS.md b/docs/POPS.md index c30cadc083..2a20feb9e5 100644 --- a/docs/POPS.md +++ b/docs/POPS.md @@ -45,7 +45,8 @@ The emulator keeps the PS1's 1024x512 VRAM at `0x04000000` as one 16-bit framebu it outside where it draws. It's a fixed framebuffer: created whole, from memory, and uploaded without filtering. An image the game sends to VRAM through the GP0 port rather than by DMA is written there by the CPU, pixel by pixel, which the hardware renderers don't see: the end of each row of that loop is hooked, and -the finished rectangle is uploaded (Ridge Racer's title screen flag). Its 24-bit video is unpacked three bytes per pixel into `0x04100000`, which is kept at 1x. It +the finished rectangle is uploaded (Ridge Racer's title screen flag). The loop is found by its shape, which +is the same in every emulator from 3.80 to 6.61 and every model's, while its registers and offsets aren't. Its 24-bit video is unpacked three bytes per pixel into `0x04100000`, which is kept at 1x. It draws sprites on pixel centers, which above 1x read their neighbours' texels, so those are moved back half a pixel. And it builds video frames by ORing passes together, so draws whose logic op is done in the shader are drawn one at a time.