From 1ad17e10d1531d0022309551319eacb9a4de570c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 21 Sep 2026 14:37:10 -0600 Subject: [PATCH 1/4] Unload the firmware modules we swap in, when the game unloads the library Tekken 6 never reaches gameplay with the real mpeg.prx: it plays its intro movie, returns to the title screen, starts loading a demo match and loads forever. With the sceMpeg HLE it plays fine. The game unloads its video libraries before the level load and expects the memory back. It gets most of it - scePsmf and scePsmfPlayer do go away - but mpeg.prx stays resident, 33KB of it, sitting in the middle of the region the loader then asks for: 08c64000 - 09e24000 18.6MB taken UserSbrk 09e24000 - 09ed4000 720KB free 09ed4000 - 09edc300 33.5KB taken ELF/sceMpeg_library 09edc300 - 09f44000 425KB free 09f44000 - 09f4c000 32KB taken UtilityModule/302_av_atrac3plus 0x09f44000 - 0x09e24000 is 0x120000, which is exactly the allocation that fails. Without mpeg.prx in the way that span is one free block and the level loads. sceUtility notifies the per-library hooks with state 1 when a utility module is loaded and -1 when it is unloaded. The hooks that swap in a firmware module only ever handled the load, so nothing ever took them back out. That affects sceMpeg, sceMp3, sceMp4 and sceAtrac alike; Tekken is just the game whose memory budget is tight enough to notice. The unload has to take out what we put in and nothing else, so the loaded ids are remembered rather than looked up by name: a game like Death Jr ships its own mpeg.prx and loads it itself, and freeing that would be freeing the game's memory. Verified that Death Jr still decodes its 1033 frames with our loader never touching its module. Savestates from before this have no record of what was swapped in, so they keep the old behaviour of leaving the modules loaded rather than risk freeing something the game owns. Co-Authored-By: Claude Opus 5 (1M context) --- Core/HLE/sceKernelModule.cpp | 11 +++++++ Core/HLE/sceKernelModule.h | 6 ++++ Core/HLE/sceUtility.cpp | 58 ++++++++++++++++++++++++++++++++---- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/Core/HLE/sceKernelModule.cpp b/Core/HLE/sceKernelModule.cpp index 60df845931..6af14c266c 100644 --- a/Core/HLE/sceKernelModule.cpp +++ b/Core/HLE/sceKernelModule.cpp @@ -2026,6 +2026,17 @@ static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 load return module; } +bool KernelUnloadModuleByID(SceUID moduleId) { + u32 error; + PSPModule *module = kernelObjects.Get(moduleId, error); + if (!module) { + return false; + } + module->Cleanup(); + kernelObjects.Destroy(moduleId); + return true; +} + bool KernelModuleIsLoaded(std::string_view name) { u32 error; for (SceUID moduleId : loadedModules) { diff --git a/Core/HLE/sceKernelModule.h b/Core/HLE/sceKernelModule.h index 0ba6fc39a7..3bb43a0413 100644 --- a/Core/HLE/sceKernelModule.h +++ b/Core/HLE/sceKernelModule.h @@ -256,6 +256,12 @@ SceUID KernelLoadModule(const std::string &filename, std::string *error_string, // Whether a real (non-HLE-stub) module calling itself this is loaded. Lets a caller tell whether a // library is already provided before bringing in another copy of it. bool KernelModuleIsLoaded(std::string_view name); + +// Take back out a module we brought in ourselves (the firmware swap in sceUtility). Frees the +// memory block, which is the point: a game that unloads its video libraries before a level load +// expects that space back, and one 33KB module left sitting in the middle of it is enough to +// break a contiguous allocation. Returns false if the id is not a live module. +bool KernelUnloadModuleByID(SceUID moduleId); int __KernelStartModule(SceUID moduleId, u32 argsize, u32 argAddr, u32 returnValueAddr, SceKernelSMOption *smoption, bool *needsWait); u32 __KernelStopUnloadSelfModuleWithOrWithoutStatus(u32 exitCode, u32 argSize, u32 argp, u32 statusAddr, u32 optionAddr, bool WithStatus); u32 sceKernelFindModuleByUID(u32 uid); diff --git a/Core/HLE/sceUtility.cpp b/Core/HLE/sceUtility.cpp index fcce5fb477..cdc6e359cf 100644 --- a/Core/HLE/sceUtility.cpp +++ b/Core/HLE/sceUtility.cpp @@ -95,12 +95,17 @@ static const int mp4ModuleDeps[] = {0x0300, 0}; // So the module-list check below is a guard rather than the normal path, and it costs nothing: // asking the list rather than remembering what we loaded means this needs no state of its own. It // is right after a savestate load, across games, and if a game unloads a library and asks again. +// The firmware modules we swapped in for a library whose HLE is disabled, keyed by the utility +// module whose load brought them in. Remembered because the unload has to take out what we put +// in and nothing else: a game that ships its own copy loads it itself, and we must not free that. +static std::map> swappedFirmwareModules; + struct FirmwareModule { const char *path; // in the firmware const char *moduleName; // what the module calls itself once loaded }; -static void LoadFirmwareModules(const char *library, const FirmwareModule *modules, size_t count) { +static void LoadFirmwareModules(int utilityModule, const char *library, const FirmwareModule *modules, size_t count) { for (size_t i = 0; i < count; i++) { if (KernelModuleIsLoaded(modules[i].moduleName)) { DEBUG_LOG(Log::sceUtility, "%s is already loaded - not loading %s on top of it", @@ -133,13 +138,33 @@ static void LoadFirmwareModules(const char *library, const FirmwareModule *modul ERROR_LOG(Log::sceUtility, "Failed to start %s (%08x)", modules[i].path, result); return; } + swappedFirmwareModules[utilityModule].push_back(id); INFO_LOG(Log::sceUtility, "Loaded the real %s", modules[i].path); } } +// The other half: give the memory back when the game says it is done with the library. Reverse +// order, since a later module may import from an earlier one. +static void UnloadFirmwareModules(int utilityModule) { + auto it = swappedFirmwareModules.find(utilityModule); + if (it == swappedFirmwareModules.end()) { + return; + } + for (auto id = it->second.rbegin(); id != it->second.rend(); ++id) { + if (KernelUnloadModuleByID(*id)) { + INFO_LOG(Log::sceUtility, "Unloaded the real module %d we had swapped in", *id); + } + } + swappedFirmwareModules.erase(it); +} + // mpeg.prx needs sceVideocodec, sceMpegbase and sceAudiocodec from us, all of which we implement, // so the module itself is the only thing that has to come from somewhere real. static void NotifyLoadStatusMpegBase(int state, u32 loadAddr, u32 totalSize) { + if (state == -1) { + UnloadFirmwareModules(0x303); + return; + } // The effective flags, not the raw setting: those also account for the compat flags, for a // firmware dump that isn't there, and for the boundary a savestate restored - resolving // imports one way and loading modules the other is how a game ends up with neither. @@ -149,18 +174,22 @@ static void NotifyLoadStatusMpegBase(int state, u32 loadAddr, u32 totalSize) { static const FirmwareModule modules[] = { { "flash0:/kd/mpeg.prx", "sceMpeg_library" }, }; - LoadFirmwareModules("sceMpeg", modules, ARRAY_SIZE(modules)); + LoadFirmwareModules(0x303, "sceMpeg", modules, ARRAY_SIZE(modules)); } // libmp3.prx imports nothing but the kernel and sceAudiocodec, which we have. static void NotifyLoadStatusMp3(int state, u32 loadAddr, u32 totalSize) { + if (state == -1) { + UnloadFirmwareModules(0x304); + return; + } if (state != 1 || !(GetEffectiveDisableHLEFlags() & DisableHLEFlags::sceMp3)) { return; } static const FirmwareModule modules[] = { { "flash0:/kd/libmp3.prx", "sceMp3_Library" }, }; - LoadFirmwareModules("sceMp3", modules, ARRAY_SIZE(modules)); + LoadFirmwareModules(0x304, "sceMp3", modules, ARRAY_SIZE(modules)); } static void NotifyLoadStatusAvcodec(int state, u32 loadAddr, u32 totalSize) { @@ -171,6 +200,10 @@ static void NotifyLoadStatusAvcodec(int state, u32 loadAddr, u32 totalSize) { // functions from sceAudiocodec (Init and Decode) plus ordinary kernel calls, and mp4msv.prx - the // 41 functions libmp4 leans on - imports nothing at all. static void NotifyLoadStatusMp4(int state, u32 loadAddr, u32 totalSize) { + if (state == -1) { + UnloadFirmwareModules(0x308); + return; + } if (state != 1) { return; } @@ -194,10 +227,14 @@ static void NotifyLoadStatusMp4(int state, u32 loadAddr, u32 totalSize) { { "flash0:/kd/mp4msv.prx", "mp4msv_module" }, { "flash0:/kd/libmp4.prx", "sceMp4_library" }, }; - LoadFirmwareModules("sceMp4", modules, ARRAY_SIZE(modules)); + LoadFirmwareModules(0x308, "sceMp4", modules, ARRAY_SIZE(modules)); } static void NotifyLoadStatusAtrac(int state, u32 loadAddr, u32 totalSize) { + if (state == -1) { + UnloadFirmwareModules(0x302); + return; + } if (state == 1) { // The effective flags, for the same reason the loads above use them. if (GetEffectiveDisableHLEFlags() & DisableHLEFlags::sceAtrac) { @@ -208,7 +245,7 @@ static void NotifyLoadStatusAtrac(int state, u32 loadAddr, u32 totalSize) { static const FirmwareModule modules[] = { { "flash0:/kd/libatrac3plus.prx", "sceATRAC3plus_Library" }, }; - LoadFirmwareModules("sceAtrac", modules, ARRAY_SIZE(modules)); + LoadFirmwareModules(0x302, "sceAtrac", modules, ARRAY_SIZE(modules)); return; } @@ -449,13 +486,14 @@ void __UtilityInit() { DeactivateDialog(); SavedataParam::Init(); currentlyLoadedModules.clear(); + swappedFirmwareModules.clear(); volatileUnlockEvent = CoreTiming::RegisterEvent("UtilityVolatileUnlock", UtilityVolatileUnlock); ResetSecondsSinceLastGameSave(); } void __UtilityDoState(PointerWrap &p) { - auto s = p.Section("sceUtility", 1, 6); + auto s = p.Section("sceUtility", 1, 7); if (!s) { return; } @@ -479,6 +517,14 @@ void __UtilityDoState(PointerWrap &p) { } } + if (s >= 7) { + Do(p, swappedFirmwareModules); + } else if (p.mode == p.MODE_READ) { + // An older state has no record of what we swapped in, so the unload notification will + // leave those modules loaded rather than risk freeing something the game owns. + swappedFirmwareModules.clear(); + } + if (s >= 3) { Do(p, volatileUnlockEvent); } else { From 626a442c340649bdf01d4a4c16e86a797e26677d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 21 Sep 2026 15:01:03 -0600 Subject: [PATCH 2/4] Stop shouting about two things the hardware does too Both of these fire once per video in Tekken 6, and neither is a fault. sceAudiocodecReleaseEDRAM warned "failed to remove decoder" whenever there was no decoder to remove. There usually isn't: mpeg.prx calls CheckNeedMem and GetEDRAM to size the allocation, and only creates a decoder if the stream turns out to need one, so releasing without ever having made one is the normal path. Demoted to debug. While there, its signature was one argument too long. audiocodec_260.prx's own sceAudiocodecReleaseEDRAM (080007c0) reads only a0 and sets up a1 through a3 itself, so the "id" we took was whatever the caller happened to leave in the register - which is how the log came to show an sceMpeg error code as the second parameter of an audio call. sceUtilityLoadModule logged MODULE_ALREADY_LOADED at error level. It is a normal answer that games rely on: Tekken 6 asks for av_avcodec three times and never unloads it, ignoring the result each time. Only that one code is demoted; everything else from a module load is still an error. Co-Authored-By: Claude Opus 5 (1M context) --- Core/HLE/sceAudiocodec.cpp | 14 ++++++++++---- Core/HLE/sceUtility.cpp | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Core/HLE/sceAudiocodec.cpp b/Core/HLE/sceAudiocodec.cpp index c0f02fe3a5..acb235ce87 100644 --- a/Core/HLE/sceAudiocodec.cpp +++ b/Core/HLE/sceAudiocodec.cpp @@ -523,11 +523,17 @@ static int sceAudiocodecGetEDRAM(u32 ctxPtr, int codec) { return hleLogInfo(Log::ME, 0, "edram address set to %08x", ctx->edramAddr); } -static int sceAudiocodecReleaseEDRAM(u32 ctxPtr, int id) { - if (removeDecoder(ctxPtr)){ +// One parameter, not two: the real module (audiocodec_260.prx, 080007c0) reads only a0 and sets +// up a1-a3 itself, so a second argument here just logs whatever was left in the register. +// +// Releasing the EDRAM without ever having made a decoder is normal - mpeg.prx calls +// CheckNeedMem/GetEDRAM to size the allocation and only creates a decoder if the stream turns out +// to need one, so there is often nothing here to drop. +static int sceAudiocodecReleaseEDRAM(u32 ctxPtr) { + if (removeDecoder(ctxPtr)) { return hleLogInfo(Log::ME, 0); } - return hleLogWarning(Log::ME, 0, "failed to remove decoder"); + return hleLogDebug(Log::ME, 0, "no decoder for this context"); } static int sceAudiocodecGetOutputBytes(u32 ctxPtr, int codec, u32 outBytesAddr) { @@ -607,7 +613,7 @@ const HLEFunction sceAudiocodec[] = { {0X5B37EB1D, &WrapI_UI, "sceAudiocodecInit", 'i', "xx"}, {0X8ACA11D5, &WrapI_UI, "sceAudiocodecGetInfo", 'i', "xx"}, {0X3A20A200, &WrapI_UI, "sceAudiocodecGetEDRAM", 'i', "xx"}, - {0X29681260, &WrapI_UI, "sceAudiocodecReleaseEDRAM", 'i', "xx"}, + {0X29681260, &WrapI_U, "sceAudiocodecReleaseEDRAM", 'i', "x"}, {0X9D3F790C, &WrapI_UI, "sceAudiocodecCheckNeedMem", 'i', "xx"}, {0X59176A0F, &WrapI_UIU, "sceAudiocodecGetOutputBytes", 'i', "xxp" }, // params are context, codec, outptr {0X3DD7EE1A, &WrapI_UI, "sceAudiocodecInitMono", 'i', "xx"}, // Used by sceAtrac for MOut* functions. diff --git a/Core/HLE/sceUtility.cpp b/Core/HLE/sceUtility.cpp index cdc6e359cf..a993112341 100644 --- a/Core/HLE/sceUtility.cpp +++ b/Core/HLE/sceUtility.cpp @@ -789,6 +789,17 @@ static int UnloadModuleInternal(u32 module, bool av); // Same as sceUtilityLoadModule, just limited in categories. // It seems this just loads module 0x300 + module & 0xFF.. +// Loading a module that is already loaded is a normal answer, not a fault: a game asks for the +// libraries it wants without tracking whether something else already brought them in, and just +// ignores this (Tekken 6 loads av_avcodec three times and never unloads it). Everything else that +// comes back from here is worth an error. +static int LogModuleLoadResult(int result) { + if (result == SCE_ERROR_MODULE_ALREADY_LOADED || result == SCE_ERROR_AV_MODULE_ALREADY_LOADED) { + return hleLogDebug(Log::sceUtility, result, "already loaded"); + } + return hleLogDebugOrError(Log::sceUtility, result); +} + static u32 sceUtilityLoadAvModule(u32 module) { if (module > 7) { ERROR_LOG_REPORT(Log::sceUtility, "sceUtilityLoadAvModule(%i): invalid module id", module); @@ -796,7 +807,7 @@ static u32 sceUtilityLoadAvModule(u32 module) { } int result = LoadModuleInternal(0x300 | module, true); - return hleDelayResult(hleLogDebugOrError(Log::sceUtility, result), "utility av module loaded", 25000); + return hleDelayResult(LogModuleLoadResult(result), "utility av module loaded", 25000); } static u32 sceUtilityUnloadAvModule(u32 module) { @@ -813,9 +824,9 @@ static u32 sceUtilityLoadModule(u32 module) { int result = LoadModuleInternal(module, false); // TODO: Each module has its own timing, technically, but this is a low-end. if (module == 0x3FF) { - return hleDelayResult(hleLogDebugOrError(Log::sceUtility, result), "utility module loaded", 130); + return hleDelayResult(LogModuleLoadResult(result), "utility module loaded", 130); } else { - return hleDelayResult(hleLogDebugOrError(Log::sceUtility, result), "utility module loaded", 25000); + return hleDelayResult(LogModuleLoadResult(result), "utility module loaded", 25000); } } From 333d84935ec207989a665006cbef9492b2d06dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 21 Sep 2026 15:26:51 -0600 Subject: [PATCH 3/4] Add --force-hle, and stop reporting a normal sceAudiocodec re-init --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) --- Core/CmdLine.cpp | 6 ++++++ Core/CmdLine.h | 4 ++++ Core/HLE/sceAudiocodec.cpp | 6 +++++- docs/debugging.md | 7 +++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Core/CmdLine.cpp b/Core/CmdLine.cpp index 692be4b3c3..e2e896bd95 100644 --- a/Core/CmdLine.cpp +++ b/Core/CmdLine.cpp @@ -232,6 +232,7 @@ static const CommandLineParam g_autoParams[] = { {POFF(autoSaveLoadSymbols), CmdParamType::Bool, "auto-save-load-symbols", '\0', "Auto save/load per-module and per-game symbol files (see bAutoSaveLoadSymbols)", CmdLineMode::Both}, {POFF(bootVSH), CmdParamType::Bool, "vsh", '\0', "Boot the VSH (requires files dumped from a PSP in the flash0 directory)"}, {POFF(disableHLE), CmdParamType::Int, "disable-hle", '\0', "Bitmask of libraries to run the real firmware module for instead of our HLE", CmdLineMode::Both}, + {POFF(forceHLE), CmdParamType::Int, "force-hle", '\0', "Bitmask of libraries to run our HLE for, even where the real module is the default", CmdLineMode::Both}, {POFF(memReadAction), CmdParamType::Enum, "memread", '\0', "Set the action for memory read exceptions", CmdLineMode::Both, g_ExceptionActionValues, ARRAY_SIZE(g_ExceptionActionValues)}, {POFF(memWriteAction), CmdParamType::Enum, "memwrite", '\0', "Set the action for memory write exceptions", CmdLineMode::Both, g_ExceptionActionValues, ARRAY_SIZE(g_ExceptionActionValues)}, {POFF(breakAction), CmdParamType::Enum, "break", '\0', "Set the action for break exceptions", CmdLineMode::Both, g_ExceptionActionValues, ARRAY_SIZE(g_ExceptionActionValues)}, @@ -565,6 +566,11 @@ void CommandLineOptions::ApplyToConfig() const { g_Config.DoNotSaveSetting(&g_Config.iDisableHLE); } + if (forceHLE.has_value()) { + g_Config.iForceEnableHLE = forceHLE.value(); + g_Config.DoNotSaveSetting(&g_Config.iForceEnableHLE); + } + if (logLevel.has_value()) { g_logManager.SetAllLogLevels(logLevel.value()); } diff --git a/Core/CmdLine.h b/Core/CmdLine.h index 0ae02fe088..61040e9b60 100644 --- a/Core/CmdLine.h +++ b/Core/CmdLine.h @@ -96,6 +96,10 @@ struct CommandLineOptions { // libraries. Needs a firmware dump under the NAND directory. std::optional disableHLE; + // The opposite: put our HLE back for libraries that now run the real module by default. The + // way to compare the two without editing a config, and the way out if the real one breaks a game. + std::optional forceHLE; + // Headless: install the game update in a .pkg (given as the boot filename) into this // directory, then exit without booting anything. The directory is the game folder itself - // the app puts that under PSP/GAME/, but here the caller picks. See diff --git a/Core/HLE/sceAudiocodec.cpp b/Core/HLE/sceAudiocodec.cpp index acb235ce87..ff84c26412 100644 --- a/Core/HLE/sceAudiocodec.cpp +++ b/Core/HLE/sceAudiocodec.cpp @@ -229,8 +229,12 @@ static int __AudioCodecInitCommon(u32 ctxPtr, int codec, bool mono) { return hleLogError(Log::ME, SCE_KERNEL_ERROR_OUT_OF_RANGE, "Invalid codec"); } + // Re-initialising a context that still has a decoder is normal, not a report-worthy + // surprise: mpeg.prx sizes the allocation through a scratch context of its own and only ever + // releases that one, so the context it actually decodes through still holds a decoder when the + // next movie starts. Once per video, on every game running the real module. if (removeDecoder(ctxPtr)) { - WARN_LOG_REPORT(Log::HLE, "sceAudiocodecInit(%08x, %d): replacing existing context", ctxPtr, codec); + INFO_LOG(Log::HLE, "sceAudiocodecInit(%08x, %d): replacing existing context", ctxPtr, codec); } // Initialize the codec memory. diff --git a/docs/debugging.md b/docs/debugging.md index 5593578ffc..ccdae10d8c 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -178,6 +178,13 @@ it looked in (usually enough to spot that it's the one beside the exe), and exit machine with no firmware would fail. So when a measurement depends on the real module, pass the flag explicitly even though it's on by default, and the run will tell you if it didn't get it. +`--force-hle=` is the other direction, and the one to reach for when deciding whether something is +our fault: it puts our HLE back for libraries that now run the real module by default, so the same +repro can be run both ways and the logs diffed. That is how the leftover warnings in Tekken 6 were +sorted - three appeared identically with `--force-hle=16`, which made them the game's own, and the +fourth only under the real module, which made it ours. Neither `--nand=` pointing somewhere empty +nor `--appendconfig` does this job: the firmware gets found anyway and the setting is per-game. + ## Debugging and breakpoint considerations It might be worth trying the interpreter - all types of breakpoints are the most reliable with this CPU backend. From 23278cb2fdf573f0ceffd980a3e89371e42f4f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 21 Sep 2026 15:47:40 -0600 Subject: [PATCH 4/4] Headless: turn the draw frame over each emulated frame, not once per run A long headless run on Vulkan dies in VulkanPushPool::CreateBlock. Watching the allocator, it makes a fresh 8MB block roughly twice a second and garbage collects none of them - about 13MB a second of device memory, which runs out after a minute or two. Nothing is leaking as such. The push buffers are recycled by BeginFrame, which walks the blocks belonging to the current frame index and marks them unused. Headless called draw->BeginFrame() once before the run loop and draw->EndFrame() once after, so that recycling pass ran exactly once for the whole run and every allocation after the first had to take a new block. This is the same mistake one level up from the host frame, which already turns over per emulated frame for the same reason - the comment there says a single host frame spanning the run meant the texture cache and framebuffer manager never decayed anything. The draw context needs the same treatment, nested the way the app nests them: draw frame outside, host frame inside. Verified on a two-minute Tekken 6 run: new blocks created goes from around 200 to zero, and the run ends on its timeout instead of asserting. Framedump rendering tests are unchanged - the same 23 of 30 fail before and after, which is a separate pre-existing matter on this platform. Also taught frametests.py to look for an ARM64 build, gated on the machine's own architecture the way test.py already does. It was picking a stale x64 Debug binary, which is exactly the trap that makes a rendering comparison meaningless. Co-Authored-By: Claude Opus 5 (1M context) --- frametests.py | 8 +++++++- headless/Headless.cpp | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/frametests.py b/frametests.py index ffcd0b31e8..5065edc2cd 100644 --- a/frametests.py +++ b/frametests.py @@ -28,13 +28,19 @@ import re import shlex import shutil import subprocess +import platform import sys import time from pathlib import Path # test.py-style candidate paths for the headless binary, relative to the # current working directory, in preference order. -HEADLESS_CANDIDATES = [ +# The machine's own architecture comes first, the same way test.py picks: an x64 build runs on +# Windows-on-ARM too, under emulation, so looking for it first quietly tests the emulated build. +HEADLESS_CANDIDATES = ([ + "Windows/ARM64/Debug/PPSSPPHeadless.exe", + "Windows/ARM64/Release/PPSSPPHeadless.exe", +] if platform.machine().lower() in ("arm64", "aarch64") else []) + [ "Windows/x64/Debug/PPSSPPHeadless.exe", "Windows/Debug/PPSSPPHeadless.exe", "Windows/x64/Release/PPSSPPHeadless.exe", diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 414bd04cc3..cbe2cb2213 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -454,12 +454,24 @@ static bool RunAutoTest(GraphicsContext *graphicsContext, CoreParameter &corePar if (coreState == CORE_NEXTFRAME) { // INFO_LOG(Log::System, "(frame)"); coreState = CORE_RUNNING_CPU; - // Close and reopen the host frame, which is what the app does once per displayed - // frame. All the GPU's per-frame work hangs off BeginHostFrame - the texture cache's + // Close and reopen the frame, which is what the app does once per displayed frame. + // All the GPU's per-frame work hangs off BeginHostFrame - the texture cache's // StartFrame and the framebuffer manager's DecimateFBOs - so with a single host frame // spanning the whole run, none of it ever ran here, and a long test decayed nothing. + // + // The draw context's frame has to turn over too, and for the same reason one level up: + // Vulkan's push buffers are recycled by BeginFrame, so one frame spanning the run means + // nothing is ever reused and every allocation takes a fresh 8MB block - about 13MB a + // second, which runs a long test out of device memory. Draw frame outside, host frame + // inside, the way the app nests them. if (gpu) { gpu->EndHostFrame(); + } + if (draw) { + draw->EndFrame(); + draw->BeginFrame(Draw::DebugFlags::NONE); + } + if (gpu) { gpu->BeginHostFrame(g_Config.GetDisplayLayoutConfig(DeviceOrientation::Landscape)); } }