From 76582e1d701c6bedbc87d7f599565e3f46ef69da Mon Sep 17 00:00:00 2001 From: danzaio Date: Sat, 10 Oct 2026 15:28:54 -0300 Subject: [PATCH 01/62] fix(libSceAjm): sceAjmStrError maps error codes to messages sceAjmStrError was an unimplemented stub: it called AjmStub, which raises a NotImplemented exception through NotImplemented_nid_no_patch, then returned nullptr. A title that maps an AJM error code to a string crashed on the throw, and a caller that survived it still got a null description. Replace the stub with a pure lookup modeled on gai_strerror_nid_postfix in libScePosixForWebKit/Resolver.cpp: a static table maps the five implemented SCE_AJM_ERROR_* codes (0x80930002/3/4/5/7, from libSceAjm.native/src/Ajm.cpp:29-33) to their messages, and every other input, including 0 and 0x80930001, falls back to a single "Unknown AJM error" literal. The function never throws and never returns nullptr. GuestAjm.cpp gains TestStrError, checking that each implemented code returns a non-null, non-empty string and the same pointer on repeat, and that 0, 0x80930001 and 0x7fffffff share the fallback literal. AI-assisted. --- core/libs/prx/libSceAjm.native/Export.cpp | 17 ++++++++++++++--- core/libs/tests/GuestAjm.cpp | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/core/libs/prx/libSceAjm.native/Export.cpp b/core/libs/prx/libSceAjm.native/Export.cpp index e6c8fcbd75..823467e5b4 100644 --- a/core/libs/prx/libSceAjm.native/Export.cpp +++ b/core/libs/prx/libSceAjm.native/Export.cpp @@ -27,9 +27,20 @@ int APS5_VABI sceAjmBatchJobEncode(AjmBatchInfo* info, uint32_t instance, const } const char* APS5_VABI sceAjmStrError(int error) { - (void)error; - AjmStub(__func__); - return nullptr; + struct Entry { + int code; + const char* message; + }; + static const Entry messages[] = { + {static_cast(0x80930002), "Invalid context"}, + {static_cast(0x80930003), "Invalid instance"}, + {static_cast(0x80930004), "Invalid batch"}, + {static_cast(0x80930005), "Invalid parameter"}, + {static_cast(0x80930007), "Out of resources"}, + }; + for (const Entry& entry : messages) + if (entry.code == error) return entry.message; + return "Unknown AJM error"; } int APS5_VABI sceAjmDecWVorbisCreateHeaderPacket(void) { diff --git a/core/libs/tests/GuestAjm.cpp b/core/libs/tests/GuestAjm.cpp index f11cb7c10d..212f79caa6 100644 --- a/core/libs/tests/GuestAjm.cpp +++ b/core/libs/tests/GuestAjm.cpp @@ -34,6 +34,7 @@ int APS5_VABI sceAjmBatchJobSetResampleParameters(AjmBatchInfo*, std::uint32_t, int APS5_VABI sceAjmBatchJobGetResampleInfo(AjmBatchInfo*, std::uint32_t, void*); int APS5_VABI sceAjmBatchJobSetResampleParametersEx(AjmBatchInfo*, std::uint32_t, float, float, std::uint32_t, void*); int APS5_VABI sceAjmBatchJobDecodeSplit(AjmBatchInfo*, std::uint32_t, const AjmBuffer*, std::size_t, const AjmBuffer*, std::size_t, void*); +const char* APS5_VABI sceAjmStrError(int); } static void Require(bool value) { if (!value) std::abort(); } @@ -1073,6 +1074,21 @@ void TestAt9RunDecodesWholeInput(std::uint32_t context) { Require(sceAjmInstanceDestroy(context, instance) == 0); } +void TestStrError() { + const int implemented[] = {static_cast(0x80930002), static_cast(0x80930003), static_cast(0x80930004), static_cast(0x80930005), static_cast(0x80930007)}; + for (int code : implemented) { + const char* text = sceAjmStrError(code); + Require(text != nullptr && std::strlen(text) > 0); + Require(sceAjmStrError(code) == text); + } + const char* fallback = sceAjmStrError(0); + Require(fallback != nullptr && std::strlen(fallback) > 0); + for (int code : {static_cast(0x80930001), 0x7fffffff}) + Require(sceAjmStrError(code) == fallback); + for (int code : implemented) + Require(sceAjmStrError(code) != fallback); +} + } int main() { @@ -1120,5 +1136,6 @@ int main() { TestResampleAt9(context); TestAt9GaplessSegments(context); TestAt9RunDecodesWholeInput(context); + TestStrError(); Require(sceAjmFinalize(context) == 0); } From 283bebf25ed77a3126764089f824d30738e600ef Mon Sep 17 00:00:00 2001 From: battleblob1 Date: Tue, 20 Oct 2026 15:39:09 +0300 Subject: [PATCH 02/62] test(agc): cover negated packed 16-bit multiplies of masked factors #8280 keeps the 24-bit factor reads of v_mul_lo_u16 and v_pk_mul_lo_u16 so AMD's Windows driver cannot see that the factors fit in 16 bits. A negated packed factor is read as that field XORed with 0x8000, so two cases now run v_pk_mul_lo_u16 with neg_lo on a low lane and neg_hi on a high lane, with the other half of both factors masked to zero. Co-Authored-By: Claude Opus 5.5 --- .../tests/execution/MulLoU16HighHalf.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/core/libs/prx/libSceAgcDriver/tests/execution/MulLoU16HighHalf.cpp b/core/libs/prx/libSceAgcDriver/tests/execution/MulLoU16HighHalf.cpp index f64c0541dd..76e260802a 100644 --- a/core/libs/prx/libSceAgcDriver/tests/execution/MulLoU16HighHalf.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/execution/MulLoU16HighHalf.cpp @@ -56,6 +56,18 @@ alignas(256) constexpr std::array PackedHighLaneCode{ 0x80010a03, 0xbf810000, }; +alignas(256) constexpr std::array PackedNegatedLowLaneCode{ + 0x34020082, 0x34060080, 0xe0302000, 0x80000401, 0xe0302004, 0x80000501, 0xe0302008, 0x80000601, + 0xbf8c3f70, 0x360808ff, 0x0000ffff, 0x360a0aff, 0x0000ffff, 0xcc01400a, 0x38020b04, 0xe0702000, + 0x80010a03, 0xbf810000, +}; + +alignas(256) constexpr std::array PackedNegatedHighLaneCode{ + 0x34020082, 0x34060080, 0xe0302000, 0x80000401, 0xe0302004, 0x80000501, 0xe0302008, 0x80000601, + 0xbf8c3f70, 0x360808ff, 0xffff0000, 0x360a0aff, 0xffff0000, 0xcc01410a, 0x18020b04, 0xe0702000, + 0x80010a03, 0xbf810000, +}; + constexpr std::array, 8> Edges{{ {0xffffffffu, 0xffffffffu, 0x00000000u}, {0x0000ffffu, 0x0000ffffu, 0xabcd1234u}, {0xffff0000u, 0xffff0000u, 0xffffffffu}, {0x8000ffffu, 0x7fff8000u, 0x12345678u}, {0x12345678u, 0x9abcdef0u, 0x80000000u}, {0x00010001u, 0x00010001u, 0x0000ffffu}, @@ -136,6 +148,10 @@ int main() { Check("v_pk_mul_lo_u16 of factors whose high halves are zero", [](std::uint32_t a, std::uint32_t b, std::uint32_t) { return Low16Product(a, b); }); Run(*device, PackedHighLaneCode); Check("v_pk_mul_lo_u16 of factors whose low halves are zero", [](std::uint32_t a, std::uint32_t b, std::uint32_t) { return Low16Product(a >> 16u, b >> 16u) << 16u; }); + Run(*device, PackedNegatedLowLaneCode); + Check("v_pk_mul_lo_u16 neg_lo:[1,0] of factors whose high halves are zero", [](std::uint32_t a, std::uint32_t b, std::uint32_t) { return Low16Product(a ^ 0x8000u, b); }); + Run(*device, PackedNegatedHighLaneCode); + Check("v_pk_mul_lo_u16 neg_hi:[1,0] of factors whose low halves are zero", [](std::uint32_t a, std::uint32_t b, std::uint32_t) { return Low16Product((a >> 16u) ^ 0x8000u, b >> 16u) << 16u; }); std::puts("mul lo u16 high half tests passed"); return 0; } catch (const std::exception& error) { From 9cad0cd5e899dfb2e0707fb768da9ca98589f590 Mon Sep 17 00:00:00 2001 From: battleblob1 Date: Tue, 20 Oct 2026 23:10:14 +0300 Subject: [PATCH 03/62] fix(shader): keep 24-bit factor reads that a negating xor feeds into a multiply A negated packed 16-bit factor is read as BitFieldUExtract(raw, 0, 24) ^ 0x8000, so the multiply uses the xor and not the extract. The folder then dropped the 24-bit read of a factor the program had masked to 16 bits, and AMD's Windows driver again saw a product of 16-bit values. feedsMultiply now also counts a multiply reached through an xor with a constant. Co-Authored-By: Claude Opus 5.5 --- .../prx/libSceAgcDriver/tests/ConstantBitTest.cpp | 12 +++++++----- .../recompiler/Optimization/src/ConstantFolder.cpp | 2 +- docs/dev/debt/shader.md | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/tests/ConstantBitTest.cpp b/core/libs/prx/libSceAgcDriver/tests/ConstantBitTest.cpp index 06d73e6578..4738fe3243 100644 --- a/core/libs/prx/libSceAgcDriver/tests/ConstantBitTest.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/ConstantBitTest.cpp @@ -125,7 +125,7 @@ bool halvesRejoin() { return rejoined == &word && fieldBack; } -bool factorFieldKept(bool multiplied) { +bool factorFieldKept(bool multiplied, bool negated) { IrProgram program; auto& block = program.CreateBlock(); program.SetEntryBlock(block); @@ -136,13 +136,14 @@ bool factorFieldKept(bool multiplied) { auto& sum = builder.Emit(IrOpcode::IAdd32, IrType::U32, {&builder.Constant(1u), &lane}); auto& masked = builder.Emit(IrOpcode::BitwiseAnd32, IrType::U32, {&sum, &builder.Constant(0xffffu)}); auto& field = builder.Emit(IrOpcode::BitFieldUExtract, IrType::U32, {&masked, &builder.Constant(0u), &builder.Constant(24u)}); - auto& used = multiplied ? builder.IMul(field, field) : field; + auto& factor = negated ? builder.Emit(IrOpcode::BitwiseXor32, IrType::U32, {&field, &builder.Constant(0x8000u)}) : field; + auto& used = multiplied ? builder.IMul(factor, factor) : factor; auto& user = builder.Emit(IrOpcode::ReferenceU32, IrType::Void, {&used}); static_cast(builder.Emit(IrOpcode::Return, IrType::Void, {})); ConstantFolder().Fold(program); const IrValue* result = user.Argument(0)->Resolve(); if (!multiplied) return result == &masked; - return result->Opcode() == IrOpcode::IMul32 && result->Argument(0)->Resolve() == &field && field.Opcode() == IrOpcode::BitFieldUExtract && field.Argument(0)->Resolve() == &masked; + return result->Opcode() == IrOpcode::IMul32 && result->Argument(0)->Resolve() == &factor && field.Opcode() == IrOpcode::BitFieldUExtract && field.Argument(0)->Resolve() == &masked; } bool reversesTo(std::uint32_t word, std::uint32_t expected) { @@ -240,8 +241,9 @@ int main() { passed &= run("a mask under a merge of the halves reads through it", mergedHalfMask()); passed &= run("half extractions read the half that was merged in", halfExtractions()); passed &= run("the two halves of one word rejoin into the word", halvesRejoin()); - passed &= run("a 24-bit field read of a value masked to 16 bits folds into the value", factorFieldKept(false)); - passed &= run("a 24-bit field read that a multiply uses is kept, even for a factor masked to 16 bits", factorFieldKept(true)); + passed &= run("a 24-bit field read of a value masked to 16 bits folds into the value", factorFieldKept(false, false)); + passed &= run("a 24-bit field read that a multiply uses is kept, even for a factor masked to 16 bits", factorFieldKept(true, false)); + passed &= run("a 24-bit field read that a multiply uses through a negating xor is kept, even for a factor masked to 16 bits", factorFieldKept(true, true)); return passed ? 0 : 1; } catch (const std::exception& error) { std::fprintf(stderr, "%s\n", error.what()); diff --git a/core/shader/recompiler/Optimization/src/ConstantFolder.cpp b/core/shader/recompiler/Optimization/src/ConstantFolder.cpp index 1eb0dd3650..1c7917ac7f 100644 --- a/core/shader/recompiler/Optimization/src/ConstantFolder.cpp +++ b/core/shader/recompiler/Optimization/src/ConstantFolder.cpp @@ -226,7 +226,7 @@ bool foldRedundantMask(IrValue& value) { bool feedsMultiply(const IrValue& value) { const auto& uses = value.Uses(); - return std::any_of(uses.begin(), uses.end(), [](const IrValue* use) { return use->Opcode() == IrOpcode::IMul32; }); + return std::any_of(uses.begin(), uses.end(), [](const IrValue* use) { return use->Opcode() == IrOpcode::IMul32 || (use->Opcode() == IrOpcode::BitwiseXor32 && (isImmediate(*use->Argument(0)->Resolve(), IrType::U32) || isImmediate(*use->Argument(1)->Resolve(), IrType::U32)) && feedsMultiply(*use)); }); } bool isImmediateU32(const IrValue& value, std::uint32_t immediate) { diff --git a/docs/dev/debt/shader.md b/docs/dev/debt/shader.md index 4353f3bd8e..c7177af624 100644 --- a/docs/dev/debt/shader.md +++ b/docs/dev/debt/shader.md @@ -115,7 +115,7 @@ - [v_ldexp_f32 in denormal modes 1 and 2](../../../core/shader/recompiler/Translation/src/FloatInstructions.cpp) follows the `FLOAT_MODE` f32 denormal field: mode 1 keeps a subnormal input and flushes a result below 2^-126 to a zero of its sign in every f32 round mode, also one that rounds to 2^-126 at subnormal precision (`0x3fffffff` scaled by 2^-127); mode 2 flushes a subnormal input to a zero of its sign and rounds a subnormal result in the f32 round mode of `FLOAT_MODE`, as mode 3 does. Measured on RDNA2 (gfx1030, Radeon RX 6800, AMD Windows driver 32.0.21045.5002 through HIP) over 10,260 rows (54 inputs of both signs: zeros, subnormals, normals near 2^-126 and 1.0, the largest finite value, infinities and NaNs, each scaled by 190 exponents from -2^31 to 2^31-1) at IEEE=0 and IEEE=1 with each f32 denormal mode 0 to 3 and f32 round mode 0 to 3, DX10_CLAMP=1, f16 denormals kept, f16 round to nearest even and FP16_OVFL=0; with the `clamp` modifier the result is clamped as in the other modes. The PS5's gfx1013 is not measured. - [v_log_f32 and v_log_f16](../../../core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp) keep the driver's log2 unless, for inputs within 1/8 of 1.0, it is more than 4 ULP from an eight-term series in x - 1, which then replaces it. `v_log_f16` goes through the same f32 lowering and is rounded to half. The series is evaluated for every `v_log` and chosen with `OpSelect` rather than a branch, so its seven fused multiply-adds and two multiplies are an unconditional cost. Measured on RDNA2 over all 3,145,728 f32 inputs within 1/8 of 1.0 (gfx1035, IEEE 0, f32 denormals flushed), the hardware is within 1 ULP of the correctly rounded log2 and the series within 2 ULP of it and within 3 ULP of the hardware (2 except at 0x3f752e47, 0x3f7baab0 and 0x3f7bc4fb), so a kept driver value is within 7 ULP of the hardware and a driver that returns the hardware's bits never triggers the gate; RADV (Mesa 26.2.4) returns the hardware's bits on the 9,280 such inputs measured on gfx1036; NVIDIA (615.71.09) was up to 23,947,836 ULP off at 1 - 2^-24 and is within 6 ULP inside the band after the change; outside it its own log2 stays up to 9 ULP off (measured up to 1.375). Over every f16 input, the change moves one `v_log_f16` result on NVIDIA (0x3bed, 1 ULP, onto the hardware's bits) and none on RADV. - [Ordered not-equal, less-than and greater-than compares](../../../core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp) (shader recompiler) - `FPOrdNotEqual32` is emitted as `OpFUnordNotEqual`, and `FPOrdLessThan32` and `FPOrdGreaterThan32` as their ordered forms, each with both operands checked for NaN on their bits; the results are the same for every input. The AMD Windows driver 32.0.13058.8001 (Radeon 860M, RDNA 3.5) returns 1 for a NaN operand when any float form of not-equal feeds a ballot on a 32-wide subgroup (#825), and so does a less-than or greater-than against an infinity the driver sees as a constant (#3536), presumably rewritten into a not-equal by its compiler -- [16-bit integer multiplies](../../../core/shader/recompiler/Translation/src/IntegerInstructions.cpp) (`v_mul_lo_u16`, `v_pk_mul_lo_u16`) (shader recompiler) - a factor taken from the low half of its source is read as the low 24 bits of the register, not the low 16; the low 16 bits of the product are the same for every input. The AMD Windows driver 32.0.21045.5002 (RX 6700 XT, RDNA 2) stores the full 32-bit product when two values it knows fit in 16 bits are multiplied, the product is reduced to 16 bits and nothing else is merged into the stored value: `v_mul_lo_u16` into a register whose high half is zero, also with factors masked to 16 bits by the program, and `v_pk_mul_lo_u16` whose high lanes are zero wrote 0xfffe0001 for 0xffff * 0xffff where the GPU writes 0x00000001. Reducing the product with `v_and_b32`, `v_bfe_u32` or a shift pair loses the same bits; factors read as 24-bit fields do not. The [constant folder](../../../core/shader/recompiler/Optimization/src/ConstantFolder.cpp) keeps a field read that a multiply uses, even when the program already masked the factor to 16 bits, since folding it away brings the full product back (#8271). Measured against the same instructions run through the [hardware oracle](../HW_ORACLE.md) on that GPU; other drivers are not checked +- [16-bit integer multiplies](../../../core/shader/recompiler/Translation/src/IntegerInstructions.cpp) (`v_mul_lo_u16`, `v_pk_mul_lo_u16`) (shader recompiler) - a factor taken from the low half of its source is read as the low 24 bits of the register, not the low 16; the low 16 bits of the product are the same for every input. The AMD Windows driver 32.0.21045.5002 (RX 6700 XT, RDNA 2) stores the full 32-bit product when two values it knows fit in 16 bits are multiplied, the product is reduced to 16 bits and nothing else is merged into the stored value: `v_mul_lo_u16` into a register whose high half is zero, also with factors masked to 16 bits by the program, and `v_pk_mul_lo_u16` whose high lanes are zero wrote 0xfffe0001 for 0xffff * 0xffff where the GPU writes 0x00000001. Reducing the product with `v_and_b32`, `v_bfe_u32` or a shift pair loses the same bits; factors read as 24-bit fields do not. The [constant folder](../../../core/shader/recompiler/Optimization/src/ConstantFolder.cpp) keeps a field read that a multiply uses, directly or through an xor with a constant (a negated packed factor), even when the program already masked the factor to 16 bits, since folding it away brings the full product back (#8271). Measured against the same instructions run through the [hardware oracle](../HW_ORACLE.md) on that GPU; other drivers are not checked - [f32 compares in denormal modes 1 and 2](../../../core/shader/recompiler/Translation/src/VectorInstructions.cpp) (`v_cmp_X_f32` and `v_cmpx_X_f32` with X = eq, ge, gt, le, lg, lt, neq, nge, ngt, nle, nlg, nlt) flush their f32 denormal inputs by bit 0 of the `FLOAT_MODE` f32 denormal field: mode 1 keeps them as mode 3 does, and mode 2 flushes them as mode 0 does. Measured on RDNA2 (gfx1030, Radeon RX 6800, AMD Windows driver 32.0.21045.5002 through HIP) over 484 operand pairs of zeros, denormals, minimum normals, ones, the largest finite value, infinities and quiet and signaling NaNs of both signs, in VOPC form, at IEEE=0 and IEEE=1 with each f32 denormal mode 0 to 3, f32 round to nearest even, DX10_CLAMP=1, f16 denormals kept, f16 round to nearest even and FP16_OVFL=0. The PS5's gfx1013 is not measured. - [f16 compares](../../../core/shader/recompiler/Translation/src/CompareInstructions.cpp) (`v_cmp_X_f16` and `v_cmpx_X_f16` with X = eq, ge, gt, le, lg, lt, neq, nge, ngt, nle, nlg, nlt) flush their f16 denormal inputs to a zero of the same sign by bit 0 of the `FLOAT_MODE` f16 denormal field: modes 0 and 2 flush them, 1 and 3 keep them. Measured on RDNA2 (gfx1031, Radeon RX 6700 XT, AMD Windows driver 32.0.21045.5002 through HIP) over 256 operand pairs of zeros, denormals, minimum normals, ones, the largest finite value, infinities and quiet and signaling NaNs of both signs, in VOPC form, at IEEE=0 and IEEE=1 with each f16 denormal mode 0 to 3, f16 round to nearest even, DX10_CLAMP=1, f32 denormals kept and FP16_OVFL=0. Shaders without a known float mode keep the previous lowering. The PS5's gfx1013 is not measured. - [DB_SHADER_CONTROL.PRIMITIVE_ORDERED_PIXEL_SHADER](../../../core/shader/recompiler/SpirvBackend/src/SpirvModuleEmitter.cpp) (shader recompiler) - the host pixel interlock spans the whole shader, not only up to `s_sendmsg` ORDERED_PS_DONE. From aaad8e9b8a7b2393a170d0dd2d6f5144117a3b00 Mon Sep 17 00:00:00 2001 From: Daniele Giovane <77807986+yabanana@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:50:47 +0200 Subject: [PATCH 04/62] feat(libkernel_sys): implement pwrite through the shared POSIX provider --- core/libs/CMakeLists.txt | 14 ++ core/libs/prx/libkernel/File/include/File.hpp | 1 + core/libs/prx/libkernel/File/src/Stdio.cpp | 4 + .../prx/libkernel_sys/Export.Discovery.cpp | 5 - core/libs/prx/libkernel_sys/Export.cpp | 5 + core/libs/tests/GuestKernelSysPwrite.cpp | 144 ++++++++++++++++++ docs/dev/debt/libkernel_sys.md | 2 + 7 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 core/libs/tests/GuestKernelSysPwrite.cpp diff --git a/core/libs/CMakeLists.txt b/core/libs/CMakeLists.txt index 2854a5fa08..d41016f037 100644 --- a/core/libs/CMakeLists.txt +++ b/core/libs/CMakeLists.txt @@ -295,6 +295,20 @@ if(BUILD_TESTING) add_test(NAME nid_elf_layout COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/nid/tests/test_elf_layout.py $) endif() + add_test_executable(guest_kernel_sys_pwrite_tests tests/GuestKernelSysPwrite.cpp + nid/src/NidCompute.cpp nid/src/Sha1.cpp) + target_include_directories(guest_kernel_sys_pwrite_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/nid/include) + target_link_libraries(guest_kernel_sys_pwrite_tests PRIVATE ${CMAKE_DL_LIBS}) + add_custom_target(guest_kernel_sys_pwrite_libraries DEPENDS + ${PATCHED_DIR}/libkernel_sys.prx ${PATCHED_DIR}/libkernel.prx ${LIBC_PATCHED}) + add_dependencies(guest_kernel_sys_pwrite_tests guest_kernel_sys_pwrite_libraries) + configure_windows_unwind(guest_kernel_sys_pwrite_tests) + if(MINGW) + target_link_options(guest_kernel_sys_pwrite_tests PRIVATE -static -static-libgcc -static-libstdc++) + endif() + add_test(NAME guest_kernel_sys_pwrite COMMAND guest_kernel_sys_pwrite_tests ${UNPATCHED_DIR} unpatched) + add_test(NAME guest_kernel_sys_pwrite_patched COMMAND guest_kernel_sys_pwrite_tests ${PATCHED_DIR} patched) + set_tests_properties(guest_kernel_sys_pwrite guest_kernel_sys_pwrite_patched PROPERTIES TIMEOUT 20) add_test_executable(guest_kernel_sys_tests tests/GuestKernelSys.cpp) target_include_directories(guest_kernel_sys_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(guest_kernel_sys_tests PRIVATE libkernel_sys libkernel libc) diff --git a/core/libs/prx/libkernel/File/include/File.hpp b/core/libs/prx/libkernel/File/include/File.hpp index 6c8fbfadc2..0d3beee52b 100644 --- a/core/libs/prx/libkernel/File/include/File.hpp +++ b/core/libs/prx/libkernel/File/include/File.hpp @@ -20,6 +20,7 @@ std::int64_t APS5_VABI sceKernelLseek(int d, std::int64_t offset, int whence); int APS5_VABI sceKernelStat(const char* path, FileStat* sb); int APS5_VABI sceKernelUnlink(const char* path); int APS5_VABI sceKernelFcntl(int d, int command, ...); +std::int64_t PosixPwrite_nid_no_patch(int d, const void* buf, std::size_t nbytes, std::int64_t offset); #ifdef _WIN32 std::int64_t NativePositioned_nid_no_patch(int descriptor, void* buf, std::size_t nbytes, std::int64_t offset, bool write); #endif diff --git a/core/libs/prx/libkernel/File/src/Stdio.cpp b/core/libs/prx/libkernel/File/src/Stdio.cpp index 2146bbe2d6..31ef2542de 100644 --- a/core/libs/prx/libkernel/File/src/Stdio.cpp +++ b/core/libs/prx/libkernel/File/src/Stdio.cpp @@ -974,6 +974,10 @@ int64_t APS5_VABI pread_nid_postfix(int d, void* buf, size_t nbytes, int64_t off } int64_t APS5_VABI pwrite_nid_disambig1_nid_postfix(int d, const void* buf, size_t nbytes, int64_t offset) { + return PosixPwrite_nid_no_patch(d, buf, nbytes, offset); +} + +std::int64_t PosixPwrite_nid_no_patch(int d, const void* buf, std::size_t nbytes, std::int64_t offset) { if (ExceedsTransferLimit(nbytes)) return PosixFailure(GUEST_EINVAL); if (buf == nullptr && nbytes != 0) return PosixFailure(GUEST_EFAULT); if (offset < 0) return PosixFailure(GUEST_EINVAL); diff --git a/core/libs/prx/libkernel_sys/Export.Discovery.cpp b/core/libs/prx/libkernel_sys/Export.Discovery.cpp index 8b3da583e4..dc2b59d63d 100644 --- a/core/libs/prx/libkernel_sys/Export.Discovery.cpp +++ b/core/libs/prx/libkernel_sys/Export.Discovery.cpp @@ -533,11 +533,6 @@ int APS5_VABI ptrace_nid_postfix(void) { return 0; } -int APS5_VABI pwrite_nid_postfix(void) { - NotImplemented_nid_no_patch(__func__); - return 0; -} - int APS5_VABI reboot_nid_postfix(void) { NotImplemented_nid_no_patch(__func__); return 0; diff --git a/core/libs/prx/libkernel_sys/Export.cpp b/core/libs/prx/libkernel_sys/Export.cpp index 9c7d6fc2b6..343fdc9a25 100644 --- a/core/libs/prx/libkernel_sys/Export.cpp +++ b/core/libs/prx/libkernel_sys/Export.cpp @@ -23,6 +23,7 @@ #include "prx/libkernel/File/include/DirectoryDescriptor.hpp" #include "prx/libc/include/CreationMask.hpp" #include "prx/libkernel/File/include/FileFlags.hpp" +#include "prx/libkernel/File/include/File.hpp" #ifdef _WIN32 #ifndef NOMINMAX #define NOMINMAX @@ -268,6 +269,10 @@ std::uint16_t APS5_VABI umask_nid_postfix(std::uint16_t mask) { return File::ExchangeCreationMask_nid_no_patch(mask); } +std::int64_t APS5_VABI pwrite_nid_postfix(int d, const void* buf, std::size_t nbytes, std::int64_t offset) { + return PosixPwrite_nid_no_patch(d, buf, nbytes, offset); +} + int APS5_VABI _fstatfs_nid_postfix(int d, void* buf); int APS5_VABI fstatfs_nid_postfix(int d, void* buf) { diff --git a/core/libs/tests/GuestKernelSysPwrite.cpp b/core/libs/tests/GuestKernelSysPwrite.cpp new file mode 100644 index 0000000000..8b87e9e7ab --- /dev/null +++ b/core/libs/tests/GuestKernelSysPwrite.cpp @@ -0,0 +1,144 @@ +#include "prx/libc/include/general/VabiMacros.hpp" +#include "prx/libkernel/File/include/FileFlags.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else +#include +#endif + +static void Require(bool value, std::source_location location = std::source_location::current()) { + if (!value) { + std::fprintf(stderr, "Kernel sys pwrite check failed at line %u\n", location.line()); + std::abort(); + } +} + +static void* LoadLibraryFile(const std::filesystem::path& path) { +#ifdef _WIN32 + auto* module = reinterpret_cast(LoadLibraryExW(path.c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH)); +#else + auto* module = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); + if (!module) std::fprintf(stderr, "%s\n", dlerror()); +#endif + Require(module != nullptr); + return module; +} + +template +static Function LoadFunction(void* module, const char* name, bool patched, const char* unpatched = nullptr) { + const auto symbol = patched ? Nid::ComputeNid(name, "") : unpatched ? std::string(unpatched) : std::string(name) + "_nid_postfix"; +#ifdef _WIN32 + const auto function = reinterpret_cast(reinterpret_cast(GetProcAddress(static_cast(module), symbol.c_str()))); +#else + const auto function = reinterpret_cast(dlsym(module, symbol.c_str())); +#endif + if (!function) std::fprintf(stderr, "Missing symbol %s\n", symbol.c_str()); + Require(function != nullptr); + return function; +} + +int main(int argc, char** argv) { + Require(argc == 3); + const auto directory = std::filesystem::absolute(argv[1]); + const bool patched = std::strcmp(argv[2], "patched") == 0; + auto* sys = LoadLibraryFile(directory / "libkernel_sys.prx"); + auto* kernel = LoadLibraryFile(directory / "libkernel.prx"); + using Pwrite = std::int64_t (APS5_VABI*)(int, const void*, std::size_t, std::int64_t); + const auto writeAt = LoadFunction(sys, "pwrite", patched); + const auto kernelWriteAt = LoadFunction(kernel, "pwrite", patched, "pwrite_nid_disambig1_nid_postfix"); + Require(writeAt != kernelWriteAt); + const auto open = LoadFunction(kernel, "open", patched); + const auto close = LoadFunction(kernel, "close", patched); + const auto seek = LoadFunction(kernel, "lseek", patched); + const auto readAt = LoadFunction(kernel, "pread", patched); + const auto error = LoadFunction(kernel, "__error", patched); + const auto shmOpen = LoadFunction(kernel, "shm_open", patched); + const auto truncate = LoadFunction(kernel, "ftruncate", patched); + const auto pipe = LoadFunction(kernel, "pipe", patched); + const auto socket = LoadFunction(kernel, "socket", patched); + const auto root = std::filesystem::path("anyps5-kernel-sys-pwrite-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + Require(std::filesystem::create_directory(root)); + const auto path = (root / "data").string(); + { std::ofstream stream(path, std::ios::binary); stream << "abcdefgh"; Require(stream.good()); } +#ifdef _WIN32 + const auto sparseFile = CreateFileW(std::filesystem::path(path).c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, OPEN_EXISTING, 0, nullptr); + Require(sparseFile != INVALID_HANDLE_VALUE); + DWORD returned = 0; + Require(DeviceIoControl(sparseFile, FSCTL_SET_SPARSE, nullptr, 0, nullptr, 0, &returned, nullptr) != FALSE); + Require(CloseHandle(sparseFile) != FALSE); +#endif + const int descriptor = open(path.c_str(), SCE_KERNEL_O_RDWR, 0); + Require(descriptor >= 0); + Require(seek(descriptor, 3, SEEK_SET) == 3); + *error() = 13; + Require(writeAt(descriptor, "XY", 2, 5) == 2 && *error() == 13); + Require(seek(descriptor, 0, SEEK_CUR) == 3); + char contents[8]{}; + Require(readAt(descriptor, contents, sizeof(contents), 0) == 8 && std::memcmp(contents, "abcdeXYh", 8) == 0); + constexpr std::int64_t largeOffset = (std::int64_t{1} << 32) + 17; + Require(writeAt(descriptor, "Z", 1, largeOffset) == 1); + Require(std::filesystem::file_size(path) == largeOffset + 1); + Require(readAt(descriptor, contents, 2, largeOffset - 1) == 2 && contents[0] == 0 && contents[1] == 'Z'); + Require(readAt(descriptor, contents, 8, 0) == 8 && std::memcmp(contents, "abcdeXYh", 8) == 0); + Require(writeAt(descriptor, nullptr, 0, 0) == 0); + Require(writeAt(descriptor, nullptr, 1, 0) == -1 && *error() == 14); + Require(writeAt(descriptor, "x", 1, -1) == -1 && *error() == 22); + Require(writeAt(-1, "x", 1, 0) == -1 && *error() == 9); + Require(writeAt(-1, nullptr, 0, 0) == -1 && *error() == 9); + const auto oversized = static_cast(std::numeric_limits::max()) + 1; + Require(writeAt(descriptor, "x", oversized, 0) == -1 && *error() == 22); + Require(seek(descriptor, 0, SEEK_CUR) == 3); + Require(truncate(descriptor, 8) == 0); + Require(close(descriptor) == 0); +#ifndef _WIN32 + const int append = open(path.c_str(), SCE_KERNEL_O_WRONLY | SCE_KERNEL_O_APPEND, 0); + Require(append >= 0 && seek(append, 2, SEEK_SET) == 2); + Require(writeAt(append, "!", 1, 0) == 1 && seek(append, 0, SEEK_CUR) == 2); + Require(std::filesystem::file_size(path) == 9); + Require(close(append) == 0); +#endif + const int readOnly = open(path.c_str(), SCE_KERNEL_O_RDONLY, 0); + Require(readOnly >= 0); + Require(writeAt(readOnly, "x", 1, 0) == -1 && *error() == 9); +#ifndef _WIN32 + Require(readAt(readOnly, contents, 1, 8) == 1 && contents[0] == '!'); +#endif + Require(close(readOnly) == 0); + int descriptors[2]{}; + Require(pipe(descriptors) == 0); + Require(writeAt(descriptors[1], "x", 1, 0) == -1 && *error() == 29); + Require(close(descriptors[0]) == 0 && close(descriptors[1]) == 0); + const int sock = socket(2, 1, 0); + Require(sock >= 0); + Require(writeAt(sock, "x", 1, 0) == -1 && *error() == 29); + Require(close(sock) == 0); + Require(writeAt(sock, "x", 1, 0) == -1 && *error() == 9); + const int shared = shmOpen(reinterpret_cast(1), 0x2, 0600); + Require(shared >= 0 && truncate(shared, 0x4000) == 0); + Require(seek(shared, 7, SEEK_SET) == 7); + Require(writeAt(shared, "shared", 6, 0x1000) == 6); + Require(readAt(shared, contents, 6, 0x1000) == 6 && std::memcmp(contents, "shared", 6) == 0); + Require(seek(shared, 0, SEEK_CUR) == 7); + Require(writeAt(shared, "tail", 4, 0x3ffe) == 2); + Require(readAt(shared, contents, 4, 0x3ffe) == 2 && std::memcmp(contents, "ta", 2) == 0); + Require(seek(shared, 0, SEEK_CUR) == 7); + Require(close(shared) == 0); + std::filesystem::remove_all(root); + return 0; +} diff --git a/docs/dev/debt/libkernel_sys.md b/docs/dev/debt/libkernel_sys.md index 25499ae3d1..6332a6b568 100644 --- a/docs/dev/debt/libkernel_sys.md +++ b/docs/dev/debt/libkernel_sys.md @@ -5,6 +5,8 @@ ### Functional +- [pwrite](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) shares the POSIX positioned-write implementation and remaining limitations of [libkernel](libkernel.md), including error precedence and Windows append handling. Console behaviour is unverified. + - [fchdir](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) changes the guest working directory to the descriptor's directory: on Windows through the path recorded when the descriptor was opened, on Linux through the `/proc/self/fd` link target. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. Outside Windows it checks search permission on the directory with `faccessat`, which fails with `EACCES` as FreeBSD `change_dir` does; on Windows that check is not modelled. A directory renamed after it was opened is still reached through the recorded path. Console behaviour is unverified - [openat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `open`. Any other relative path is joined to the descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to it, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. As with `open`, `O_NOFOLLOW` and `O_NONBLOCK` are ignored. PPSA99003 opens directory entries this way. Console behaviour is unverified - [fchmodat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `chmod`; any other relative path is joined to the descriptor's host directory the same way as for `openat`, so guest path aliases and case-insensitive name matching do not apply to it and a `..` component is not kept inside the guest root. A flag other than `0` or `AT_SYMLINK_NOFOLLOW` (0x200) returns `EINVAL`; the mode is masked to `07777`. With `AT_SYMLINK_NOFOLLOW` a symbolic link returns success silently on Windows and Linux, since the host cannot change a link's own mode, while a regular file is changed either way. The mode maps to the host file mode, which on Windows is only the CRT's read-only bit. PPSA99621 calls it through `std::filesystem::permissions`. Console behaviour is unverified From 8db02c3f8ba0a8fb81c2ce49db9bb7430f391784 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 21 Oct 2026 20:52:51 +0100 Subject: [PATCH 05/62] feat(libkernel_sys): implement revoke --- core/libs/CMakeLists.txt | 11 ++++ .../prx/libkernel_sys/Export.Discovery.cpp | 17 +++++- core/libs/tests/GuestKernelSysRevoke.cpp | 56 +++++++++++++++++++ docs/dev/debt/libkernel_sys.md | 1 + 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 core/libs/tests/GuestKernelSysRevoke.cpp diff --git a/core/libs/CMakeLists.txt b/core/libs/CMakeLists.txt index 737f8fa20f..ecbae5d1b3 100644 --- a/core/libs/CMakeLists.txt +++ b/core/libs/CMakeLists.txt @@ -424,6 +424,17 @@ if(BUILD_TESTING) "PATH=path_list_prepend:$") endif() + add_test_executable(guest_kernel_sys_revoke_tests tests/GuestKernelSysRevoke.cpp) + target_include_directories(guest_kernel_sys_revoke_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + target_link_libraries(guest_kernel_sys_revoke_tests PRIVATE libkernel_sys libkernel libc) + configure_windows_unwind(guest_kernel_sys_revoke_tests) + add_test(NAME guest_kernel_sys_revoke COMMAND guest_kernel_sys_revoke_tests) + set_tests_properties(guest_kernel_sys_revoke PROPERTIES TIMEOUT 20) + if(WIN32) + set_tests_properties(guest_kernel_sys_revoke PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() + add_test_executable(guest_netctl_ap_tests tests/GuestNetCtlAp.cpp) target_include_directories(guest_netctl_ap_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(guest_netctl_ap_tests PRIVATE libSceNetCtlAp libc) diff --git a/core/libs/prx/libkernel_sys/Export.Discovery.cpp b/core/libs/prx/libkernel_sys/Export.Discovery.cpp index 8b3da583e4..caf918e915 100644 --- a/core/libs/prx/libkernel_sys/Export.Discovery.cpp +++ b/core/libs/prx/libkernel_sys/Export.Discovery.cpp @@ -543,9 +543,20 @@ int APS5_VABI reboot_nid_postfix(void) { return 0; } -int APS5_VABI revoke_nid_postfix(void) { - NotImplemented_nid_no_patch(__func__); - return 0; +int* APS5_VABI __error_nid_postfix(); +int APS5_VABI stat_nid_postfix(const char* path, FileStat* status); + +int APS5_VABI revoke_nid_postfix(const char* path) { + constexpr std::uint16_t typeMask = 0170000; + constexpr std::uint16_t characterDevice = 0020000; + constexpr int guestEinval = 22; + FileStat status{}; + if (stat_nid_postfix(path, &status) != 0) return -1; + if ((status.st_mode & typeMask) != characterDevice) { + *__error_nid_postfix() = guestEinval; + return -1; + } + NotImplemented_nid_no_patch("revoke of a character device"); } int APS5_VABI rfork_thread_nid_postfix(void) { diff --git a/core/libs/tests/GuestKernelSysRevoke.cpp b/core/libs/tests/GuestKernelSysRevoke.cpp new file mode 100644 index 0000000000..acb57edd24 --- /dev/null +++ b/core/libs/tests/GuestKernelSysRevoke.cpp @@ -0,0 +1,56 @@ +#include "prx/libc/include/general/VabiMacros.hpp" +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +int* APS5_VABI __error_nid_postfix(); +int APS5_VABI revoke_nid_postfix(const char* path); +} + +namespace { + +constexpr int Enoent = 2; +constexpr int Efault = 14; +constexpr int Enotdir = 20; +constexpr int Einval = 22; +constexpr int Enametoolong = 63; + +void Require(bool condition, std::source_location location = std::source_location::current()) { + if (condition) return; + std::fprintf(stderr, "check failed at line %u\n", static_cast(location.line())); + std::exit(1); +} + +int Error() { + return *__error_nid_postfix(); +} + +bool Fails(const std::string& path, int error) { + return revoke_nid_postfix(path.c_str()) == -1 && Error() == error; +} + +} + +int main() { + const auto root = std::filesystem::path("anyps5-revoke-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + Require(std::filesystem::create_directory(root)); + const auto file = (root / "file.txt").string(); + std::ofstream(file).put('x'); + + Require(Fails(file, Einval)); + Require(Fails(root.string(), Einval)); + Require(Fails((root / "missing").string(), Enoent)); + Require(Fails(file + "/below", Enotdir)); + Require(Fails("", Enoent)); + Require(Fails(std::string(1024, 'a'), Enametoolong)); + Require(revoke_nid_postfix(nullptr) == -1 && Error() == Efault); + + std::filesystem::remove_all(root); + std::puts("guest_kernel_sys_revoke passed"); + return 0; +} diff --git a/docs/dev/debt/libkernel_sys.md b/docs/dev/debt/libkernel_sys.md index 25499ae3d1..9e2c334e18 100644 --- a/docs/dev/debt/libkernel_sys.md +++ b/docs/dev/debt/libkernel_sys.md @@ -4,6 +4,7 @@ - [fchmodat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) - `AT_SYMLINK_NOFOLLOW` on a symbolic link (on Windows and Linux) ### Functional +- [revoke](../../../core/libs/prx/libkernel_sys/Export.Discovery.cpp) (libkernel_sys) - a character device throws, because device revocation is not modelled. Other paths follow FreeBSD 11 `sys_revoke`: path errors as `stat` reports them, and `EINVAL` for anything that is not a character device. Guest paths have no `/dev`, so `/dev/null` and the like fail with `ENOENT`, not as devices - [fchdir](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) changes the guest working directory to the descriptor's directory: on Windows through the path recorded when the descriptor was opened, on Linux through the `/proc/self/fd` link target. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. Outside Windows it checks search permission on the directory with `faccessat`, which fails with `EACCES` as FreeBSD `change_dir` does; on Windows that check is not modelled. A directory renamed after it was opened is still reached through the recorded path. Console behaviour is unverified - [openat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `open`. Any other relative path is joined to the descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to it, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. As with `open`, `O_NOFOLLOW` and `O_NONBLOCK` are ignored. PPSA99003 opens directory entries this way. Console behaviour is unverified From b28c59f7f538a1ecb37ffbbd68dc4ca411a8f361 Mon Sep 17 00:00:00 2001 From: Daniele Giovane <77807986+yabanana@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:51:37 +0200 Subject: [PATCH 06/62] fix(libkernel): retain shared-memory descriptor flags on Windows --- core/libs/prx/libkernel/File/src/SharedMemory.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/core/libs/prx/libkernel/File/src/SharedMemory.cpp b/core/libs/prx/libkernel/File/src/SharedMemory.cpp index ad8cf04a03..3f5f736b00 100644 --- a/core/libs/prx/libkernel/File/src/SharedMemory.cpp +++ b/core/libs/prx/libkernel/File/src/SharedMemory.cpp @@ -201,10 +201,7 @@ int CreateAnonymousFile() { const int error = _wsopen_s(&descriptor, path.c_str(), _O_CREAT | _O_EXCL | _O_RDWR | _O_BINARY | _O_TEMPORARY | _O_SHORT_LIVED | _O_NOINHERIT, _SH_DENYNO, _S_IREAD | _S_IWRITE); - if (error == 0) { - File::RememberDescriptor(descriptor, SCE_KERNEL_O_RDWR, true); - return descriptor; - } + if (error == 0) return descriptor; if (error != EEXIST) { errno = error; return -1; @@ -467,7 +464,12 @@ extern "C" int APS5_VABI shm_open_nid_postfix(const char* path, int flags, int m } if (accessMode == SCE_KERNEL_O_RDONLY) return Fail(GuestInvalid); const int descriptor = CreateAnonymousFile(); - if (descriptor >= 0) return descriptor; + if (descriptor >= 0) { +#ifdef _WIN32 + File::RememberDescriptor(descriptor, flags, true); +#endif + return descriptor; + } const int error = errno; if (const int guest = GuestError(error)) return Fail(guest); throw std::runtime_error("shm_open: creating the host anonymous file failed, errno=" + std::to_string(error)); From f07cd284b232a29de7ccecd4833091cca427828b Mon Sep 17 00:00:00 2001 From: Daniele Giovane <77807986+yabanana@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:57:08 +0200 Subject: [PATCH 07/62] fix(libkernel): bound sequential shared-memory writes on Windows --- core/libs/prx/libkernel/File/src/Stdio.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/libs/prx/libkernel/File/src/Stdio.cpp b/core/libs/prx/libkernel/File/src/Stdio.cpp index b8d59103dd..b2f2f31897 100644 --- a/core/libs/prx/libkernel/File/src/Stdio.cpp +++ b/core/libs/prx/libkernel/File/src/Stdio.cpp @@ -323,6 +323,11 @@ static std::int64_t NativeTransferBuffer(int descriptor, void* buf, std::size_t throw std::runtime_error("NativeTransfer: nbytes exceeds platform limit"); } if (write) { + if (File::HasFixedSizeWrites(descriptor)) { + const auto position = WithoutParameterHandler([descriptor] { return ::_lseeki64(descriptor, 0, SEEK_CUR); }); + if (position < 0 || !LimitSharedMemoryWrite(descriptor, nbytes, position)) return -1; + if (nbytes == 0) return 0; + } const auto result = File::WriteSharedMemory_nid_no_patch(descriptor, buf, nbytes); if (result != -2) return result; if (File::Appends(descriptor)) { From b843e6c1919a6a55e0cd02f358e392523cbc5283 Mon Sep 17 00:00:00 2001 From: Daniele Giovane <77807986+yabanana@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:01:31 +0200 Subject: [PATCH 08/62] fix(libkernel_sys): retain the current process-query declaration --- core/libs/prx/libkernel_sys/Export.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/libs/prx/libkernel_sys/Export.cpp b/core/libs/prx/libkernel_sys/Export.cpp index 1dfb2901b2..f85600958f 100644 --- a/core/libs/prx/libkernel_sys/Export.cpp +++ b/core/libs/prx/libkernel_sys/Export.cpp @@ -30,7 +30,7 @@ #include "prx/libc/include/CreationMask.hpp" #include "prx/libkernel/File/include/FileFlags.hpp" #include "prx/libkernel/File/include/File.hpp" -#include "prx/libkernel/KernelErrors.hpp" +extern "C" int APS5_VABI getpid_nid_postfix(void); #ifdef _WIN32 #ifndef NOMINMAX #define NOMINMAX From f302e4983744497d08f8db6135e39c083fd3746b Mon Sep 17 00:00:00 2001 From: Daniele Giovane <77807986+yabanana@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:53:22 +0200 Subject: [PATCH 09/62] docs(libkernel_sys): remove resolved pwrite error-precedence debt --- docs/dev/debt/libkernel_sys.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libkernel_sys.md b/docs/dev/debt/libkernel_sys.md index 0fbab2f0d9..49b3ad5c99 100644 --- a/docs/dev/debt/libkernel_sys.md +++ b/docs/dev/debt/libkernel_sys.md @@ -11,7 +11,7 @@ ### Functional - [sched_rr_get_interval](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) accepts pid 0 or the host-backed guest pid and reports the FreeBSD 11 ULE default time slice for the usual AMD64 clock rates (94 ms); other pids return `ESRCH`, and a null interval returns `EFAULT`. AnyPS5 does not run a guest ULE scheduler or track a per-process configured quantum, so this reports the modeled default and does not reflect runtime scheduling. Console-specific clock rates and quantum configuration are unverified -- [pwrite](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) shares the POSIX positioned-write implementation and remaining limitations of [libkernel](libkernel.md), including error precedence. Console behaviour is unverified. +- [pwrite](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) shares the POSIX positioned-write implementation and remaining limitations of [libkernel](libkernel.md). Console behaviour is unverified. - [fchdir](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) changes the guest working directory to the descriptor's directory: on Windows through the path recorded when the descriptor was opened, on Linux through the `/proc/self/fd` link target. It returns `EBADF` for a descriptor that is not open, `EINVAL` for a pipe or socket, as FreeBSD `getvnode` does for a descriptor without a vnode, and `ENOTDIR` for any other descriptor that is not a directory. Outside Windows it checks search permission on the directory with `faccessat`, which fails with `EACCES` as FreeBSD `change_dir` does; on Windows that check is not modelled. A directory renamed after it was opened is still reached through the recorded path. Console behaviour is unverified - [openat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `open`. Any other relative path is joined to the descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to it, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. As with `open`, `O_NOFOLLOW` and `O_NONBLOCK` are ignored. PPSA99003 opens directory entries this way. Console behaviour is unverified - [fchmodat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `chmod`; any other relative path is joined to the descriptor's host directory the same way as for `openat`, so guest path aliases and case-insensitive name matching do not apply to it and a `..` component is not kept inside the guest root. A flag other than `0` or `AT_SYMLINK_NOFOLLOW` (0x200) returns `EINVAL`; the mode is masked to `07777`. With `AT_SYMLINK_NOFOLLOW` a symbolic link returns success silently on Windows and Linux, since the host cannot change a link's own mode, while a regular file is changed either way. The mode maps to the host file mode, which on Windows is only the CRT's read-only bit. PPSA99621 calls it through `std::filesystem::permissions`. Console behaviour is unverified From c5447f8aaffede92d8fae6dad8fa3226dde2ff31 Mon Sep 17 00:00:00 2001 From: "Kang, Harry Zixuan" Date: Fri, 23 Oct 2026 05:09:28 -0700 Subject: [PATCH 10/62] fix(libkernel): bound scalar writes to anonymous shared memory --- core/libs/prx/libkernel/File/include/SharedMemory.hpp | 3 +++ core/libs/prx/libkernel/File/src/Open.cpp | 5 +++++ core/libs/prx/libkernel/File/src/Stdio.cpp | 6 +++--- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/core/libs/prx/libkernel/File/include/SharedMemory.hpp b/core/libs/prx/libkernel/File/include/SharedMemory.hpp index 5c9ffc9f47..bdaa1bd007 100644 --- a/core/libs/prx/libkernel/File/include/SharedMemory.hpp +++ b/core/libs/prx/libkernel/File/include/SharedMemory.hpp @@ -14,6 +14,9 @@ std::int64_t WriteSharedMemory_nid_no_patch(int descriptor, const void* buffer, std::int64_t PwriteSharedMemory_nid_no_patch(int descriptor, const void* buffer, std::size_t size, std::int64_t offset); std::int64_t SeekSharedMemory_nid_no_patch(int descriptor, std::int64_t offset, int whence); int TruncateSharedMemory_nid_no_patch(int descriptor, std::int64_t length); +#ifdef _WIN32 +bool LimitSharedMemoryWrite_nid_no_patch(int descriptor, std::size_t& size, std::int64_t offset); +#endif #ifndef _WIN32 std::int64_t WritevSharedMemory_nid_no_patch(int descriptor, const struct iovec* vectors, int count); std::int64_t PwritevSharedMemory_nid_no_patch(int descriptor, const struct iovec* vectors, int count, std::int64_t offset); diff --git a/core/libs/prx/libkernel/File/src/Open.cpp b/core/libs/prx/libkernel/File/src/Open.cpp index 9b79078010..c5f43c6964 100644 --- a/core/libs/prx/libkernel/File/src/Open.cpp +++ b/core/libs/prx/libkernel/File/src/Open.cpp @@ -74,6 +74,11 @@ static int NativeWrite(int fd, const void* buf, std::size_t n) { throw std::runtime_error("sceKernelWrite: nbytes exceeds platform limit"); } const auto positioned = File::LockPositionedTransfer(fd); + if (File::HasFixedSizeWrites(fd)) { + const auto position = ::_lseeki64(fd, 0, SEEK_CUR); + if (position < 0 || !File::LimitSharedMemoryWrite_nid_no_patch(fd, n, position)) return -1; + if (n == 0) return 0; + } const auto shared = File::WriteSharedMemory_nid_no_patch(fd, buf, n); if (shared != -2) return static_cast(shared); if (File::Appends(fd)) { diff --git a/core/libs/prx/libkernel/File/src/Stdio.cpp b/core/libs/prx/libkernel/File/src/Stdio.cpp index b2f2f31897..65f000d061 100644 --- a/core/libs/prx/libkernel/File/src/Stdio.cpp +++ b/core/libs/prx/libkernel/File/src/Stdio.cpp @@ -245,7 +245,7 @@ void File::SharePositionedTransfer(int from, int to) { if (!source) source = std::make_shared(); g_positionedLocks[to] = source; } -static bool LimitSharedMemoryWrite(int descriptor, std::size_t& size, std::int64_t offset) { +bool File::LimitSharedMemoryWrite_nid_no_patch(int descriptor, std::size_t& size, std::int64_t offset) { if (offset < 0) { errno = EINVAL; return false; } const auto length = WithoutParameterHandler([descriptor] { return ::_filelengthi64(descriptor); }); if (length < 0) return false; @@ -258,7 +258,7 @@ static std::int64_t NativePositionedTransfer(int descriptor, void* buf, std::siz throw std::runtime_error("NativePositioned: nbytes exceeds platform limit"); } if (write && File::HasFixedSizeWrites(descriptor)) { - if (!LimitSharedMemoryWrite(descriptor, nbytes, offset)) return -1; + if (!File::LimitSharedMemoryWrite_nid_no_patch(descriptor, nbytes, offset)) return -1; if (nbytes == 0) return 0; } const auto handle = reinterpret_cast(WithoutParameterHandler([descriptor] { return ::_get_osfhandle(descriptor); })); @@ -325,7 +325,7 @@ static std::int64_t NativeTransferBuffer(int descriptor, void* buf, std::size_t if (write) { if (File::HasFixedSizeWrites(descriptor)) { const auto position = WithoutParameterHandler([descriptor] { return ::_lseeki64(descriptor, 0, SEEK_CUR); }); - if (position < 0 || !LimitSharedMemoryWrite(descriptor, nbytes, position)) return -1; + if (position < 0 || !File::LimitSharedMemoryWrite_nid_no_patch(descriptor, nbytes, position)) return -1; if (nbytes == 0) return 0; } const auto result = File::WriteSharedMemory_nid_no_patch(descriptor, buf, nbytes); From 6d2225ecb232f345c5f457f303c289b031bc1eba Mon Sep 17 00:00:00 2001 From: Daniele Giovane <77807986+yabanana@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:59:06 +0100 Subject: [PATCH 11/62] test(libkernel): check named-object write bounds before concurrency --- core/libs/tests/GuestNamedShmWindows.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/libs/tests/GuestNamedShmWindows.cpp b/core/libs/tests/GuestNamedShmWindows.cpp index 401ac8df2e..22ad687142 100644 --- a/core/libs/tests/GuestNamedShmWindows.cpp +++ b/core/libs/tests/GuestNamedShmWindows.cpp @@ -223,6 +223,12 @@ void Run() { Require(original >= 0 && *__error_nid_postfix() == 73, "exclusive creation failed or changed guest errno"); VerifyUntrackedDescriptor(original); Require(ftruncate_nid_postfix(original, 64) == 0, "sizing the named object failed"); + const int reopened = shm_open_nid_postfix(name.c_str(), ReadWrite, 0); + Require(reopened >= 0 && File::IsSharedMemoryDescriptor_nid_no_patch(reopened), "reopened named descriptor is not tracked"); + Require(pwrite_nid_disambig1_nid_postfix(reopened, "x", 1, 64) == 0, "positioned write at the named-object boundary extended it"); + struct _stat64 bounded{}; + Require(::_fstat64(reopened, &bounded) == 0 && bounded.st_size == 64, "positioned write changed the named-object size"); + Require(close_nid_postfix(reopened) == 0, "closing reopened named descriptor failed"); VerifyConcurrentDescriptorReuse(name); const char originalBytes[] = "original"; Require(pwrite_nid_disambig1_nid_postfix(original, originalBytes, sizeof(originalBytes), 0) == sizeof(originalBytes), From 46944599148b266557d3a095045ebce363ef998c Mon Sep 17 00:00:00 2001 From: ionut27001 <194206737+ionut27001@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:40:17 +0200 Subject: [PATCH 12/62] fix(libSceSysmodule): reject the internal module ids measured on a PS5 In a payload process on a PS5 (firmware 12.70), sceSysmoduleIsLoadedInternal, sceSysmoduleLoadModuleInternal and sceSysmoduleUnloadModuleInternal returned 0x805A1000 for the internal ids 0x8000ffff and 0x80ffff00, which the module table does not list, and sceSysmoduleIsLoadedInternal did the same for 0x00ffffff. Those ids now return 0x805A1000; every other missing internal id still throws, since the console's general rule and the WithArg variants are not measured. The public ids are left to main's measured id list. --- core/libs/prx/libSceSysmodule/CMakeLists.txt | 9 +++++++ core/libs/prx/libSceSysmodule/Export.cpp | 13 ++++++++++ .../prx/libSceSysmodule/tests/InvalidIds.cpp | 25 +++++++++++++++++++ core/libs/tests/GuestSysmodule.cpp | 4 +-- docs/dev/debt/libSceSysmodule.md | 1 + 5 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 core/libs/prx/libSceSysmodule/tests/InvalidIds.cpp diff --git a/core/libs/prx/libSceSysmodule/CMakeLists.txt b/core/libs/prx/libSceSysmodule/CMakeLists.txt index b03a2433c9..8d323e61ed 100644 --- a/core/libs/prx/libSceSysmodule/CMakeLists.txt +++ b/core/libs/prx/libSceSysmodule/CMakeLists.txt @@ -29,4 +29,13 @@ if(BUILD_TESTING) set_tests_properties(guest_sysmodule PROPERTIES ENVIRONMENT_MODIFICATION "PATH=path_list_prepend:$") endif() + add_test_executable(guest_sysmodule_invalid_ids_tests ${CMAKE_CURRENT_SOURCE_DIR}/tests/InvalidIds.cpp) + target_include_directories(guest_sysmodule_invalid_ids_tests PRIVATE ${LIBS_INCLUDE_DIR}) + target_link_libraries(guest_sysmodule_invalid_ids_tests PRIVATE libSceSysmodule libkernel libc) + configure_windows_unwind(guest_sysmodule_invalid_ids_tests) + add_test(NAME guest_sysmodule-invalid-ids COMMAND guest_sysmodule_invalid_ids_tests) + if(WIN32) + set_tests_properties(guest_sysmodule-invalid-ids PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() endif() diff --git a/core/libs/prx/libSceSysmodule/Export.cpp b/core/libs/prx/libSceSysmodule/Export.cpp index 6855f1f6af..562206d7e6 100644 --- a/core/libs/prx/libSceSysmodule/Export.cpp +++ b/core/libs/prx/libSceSysmodule/Export.cpp @@ -32,6 +32,10 @@ const char* findModuleName(const std::uint32_t id) { return it != kModuleTable.end() ? it->second : nullptr; } +bool isMeasuredRejectedInternalId(const std::uint32_t id) { + return id == 0x8000ffffu || id == 0x80ffff00u; +} + bool isPublicModuleId(const std::uint16_t id) { return std::binary_search(std::begin(kPublicModuleIds), std::end(kPublicModuleIds), id); } @@ -255,6 +259,9 @@ int APS5_VABI sceSysmoduleLoadModuleInternal(std::uint32_t id) { return SCE_SYSMODULE_ERROR_INVALID_VALUE; } if (!findModuleName(id)) { + if (isMeasuredRejectedInternalId(id)) { + return SCE_SYSMODULE_ERROR_INVALID_VALUE; + } throw std::runtime_error(std::string("sceSysmoduleLoadModuleInternal: unknown id ") + std::to_string(id)); } std::lock_guard lock(gMutex); @@ -267,6 +274,9 @@ int APS5_VABI sceSysmoduleUnloadModuleInternal(std::uint32_t id) { return SCE_SYSMODULE_ERROR_INVALID_VALUE; } if (!findModuleName(id)) { + if (isMeasuredRejectedInternalId(id)) { + return SCE_SYSMODULE_ERROR_INVALID_VALUE; + } throw std::runtime_error(std::string("sceSysmoduleUnloadModuleInternal: unknown id ") + std::to_string(id)); } std::lock_guard lock(gMutex); @@ -305,6 +315,9 @@ int APS5_VABI sceSysmoduleIsLoadedInternal(std::uint32_t id) { return SCE_SYSMODULE_ERROR_INVALID_VALUE; } if (!findModuleName(id)) { + if (isMeasuredRejectedInternalId(id) || id == 0x00ffffffu) { + return SCE_SYSMODULE_ERROR_INVALID_VALUE; + } throw std::runtime_error(std::string("sceSysmoduleIsLoadedInternal: unknown id ") + std::to_string(id)); } std::lock_guard lock(gMutex); diff --git a/core/libs/prx/libSceSysmodule/tests/InvalidIds.cpp b/core/libs/prx/libSceSysmodule/tests/InvalidIds.cpp new file mode 100644 index 0000000000..7aa9967657 --- /dev/null +++ b/core/libs/prx/libSceSysmodule/tests/InvalidIds.cpp @@ -0,0 +1,25 @@ +#include "prx/libSceSysmodule/include/Api.hpp" +#include "prx/libc/include/general/VabiMacros.hpp" +#include +#include + +namespace { + +constexpr int kInvalidValue = static_cast(0x805A1000); + +void Require(bool value) { if (!value) std::abort(); } + +void CheckInternalIdRejected(std::uint32_t id) { + Require(sceSysmoduleIsLoadedInternal(id) == kInvalidValue); + Require(sceSysmoduleLoadModuleInternal(id) == kInvalidValue); + Require(sceSysmoduleIsLoadedInternal(id) == kInvalidValue); + Require(sceSysmoduleUnloadModuleInternal(id) == kInvalidValue); +} + +} + +int main() { + CheckInternalIdRejected(0x80ffff00); + CheckInternalIdRejected(0x8000ffff); + Require(sceSysmoduleIsLoadedInternal(0x00ffffff) == kInvalidValue); +} diff --git a/core/libs/tests/GuestSysmodule.cpp b/core/libs/tests/GuestSysmodule.cpp index c4955a5348..16c8bc4434 100644 --- a/core/libs/tests/GuestSysmodule.cpp +++ b/core/libs/tests/GuestSysmodule.cpp @@ -107,7 +107,7 @@ void CheckUnwindInfo() { #endif std::uint16_t UnknownModuleId() { - for (std::uint32_t id = 0xffff; id != 0; --id) { + for (std::uint32_t id = 0xff; id != 0; --id) { if (kModuleTable.find(id) == kModuleTable.end()) return static_cast(id); } std::abort(); @@ -214,7 +214,7 @@ int main() { Require(result == 0x5a5a5a5a); } - Require(ThrowsRuntimeError([] { sceSysmoduleIsLoadedInternal(0x00ffffffu); })); + Require(sceSysmoduleIsLoadedInternal(0x00ffffffu) == kInvalidValue); Require(sceSysmoduleIsLoadedInternal(kFiberModuleId) == kModuleUnloaded); Require(sceSysmoduleIsLoaded(kFiberModuleId) == kModuleUnloaded); Require(sceSysmoduleLoadModuleInternal(kFiberModuleId) == 0); diff --git a/docs/dev/debt/libSceSysmodule.md b/docs/dev/debt/libSceSysmodule.md index 7049c717ee..268ef42f6f 100644 --- a/docs/dev/debt/libSceSysmodule.md +++ b/docs/dev/debt/libSceSysmodule.md @@ -3,6 +3,7 @@ ### Silent stubs - [sceSysmoduleIsLoaded, sceSysmoduleLoadModule, sceSysmoduleUnloadModule](../../../core/libs/prx/libSceSysmodule/Export.cpp) (libSceSysmodule) accept the 141 ids `sceSysmoduleIsLoaded` did not reject in a payload process on a PS5 (firmware 13.60) and return `0x805A1000` for every other id, as the console does; that includes 51 ids the module table names from older firmware, such as 0x0001 (`libSceAudioOut`) and 0x0080 (`libSceJson`). Load and unload are counted as measured. The 21 accepted ids the table does not name (0x0111, 0x0117, 0x011c to 0x011f, 0x0121 to 0x0124, 0x0129, 0x012c to 0x012e, 0x0133, 0x0134, 0x0136 and 0x013a to 0x013d) are logged and return 0 without loading anything, so `sceSysmoduleIsLoaded` reports them as loaded; which module each loads is not known. Loading id 0x00ff ended the payload process. One title (PPSA04354) calls `sceSysmoduleIsLoaded` with id 0x96 at twelve sites and loads it only when the result is 0x805a1001; any other result lets it continue. +- [sceSysmoduleIsLoadedInternal, sceSysmoduleLoadModuleInternal, sceSysmoduleUnloadModuleInternal, sceSysmoduleLoadModuleInternalWithArg, sceSysmoduleUnloadModuleInternalWithArg](../../../core/libs/prx/libSceSysmodule/Export.cpp) (libSceSysmodule) - an internal id missing from the module table throws, except 0x8000ffff and 0x80ffff00, and 0x00ffffff for `sceSysmoduleIsLoadedInternal`, which return `0x805A1000` as `sceSysmoduleIsLoadedInternal`, `sceSysmoduleLoadModuleInternal` and `sceSysmoduleUnloadModuleInternal` did for them in a payload process on a PS5 (firmware 12.70). Which other internal ids the console rejects, and what the `WithArg` variants return for a missing id, is not measured; `sceSysmoduleIsLoadedInternal(0x800000c4)`, also missing from the table, returned `0x805A1001` there and throws here. ### Unknown function info From 73138098d481a955ab4f2b88a0e3b00e54c21f8a Mon Sep 17 00:00:00 2001 From: battleblob1 Date: Tue, 27 Oct 2026 11:47:00 +0200 Subject: [PATCH 13/62] docs(shader): keep the 16-bit multiply debt entry under 1000 characters Co-Authored-By: Claude Opus 5.5 --- docs/dev/debt/shader.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/shader.md b/docs/dev/debt/shader.md index c7177af624..ce13d511ec 100644 --- a/docs/dev/debt/shader.md +++ b/docs/dev/debt/shader.md @@ -115,7 +115,7 @@ - [v_ldexp_f32 in denormal modes 1 and 2](../../../core/shader/recompiler/Translation/src/FloatInstructions.cpp) follows the `FLOAT_MODE` f32 denormal field: mode 1 keeps a subnormal input and flushes a result below 2^-126 to a zero of its sign in every f32 round mode, also one that rounds to 2^-126 at subnormal precision (`0x3fffffff` scaled by 2^-127); mode 2 flushes a subnormal input to a zero of its sign and rounds a subnormal result in the f32 round mode of `FLOAT_MODE`, as mode 3 does. Measured on RDNA2 (gfx1030, Radeon RX 6800, AMD Windows driver 32.0.21045.5002 through HIP) over 10,260 rows (54 inputs of both signs: zeros, subnormals, normals near 2^-126 and 1.0, the largest finite value, infinities and NaNs, each scaled by 190 exponents from -2^31 to 2^31-1) at IEEE=0 and IEEE=1 with each f32 denormal mode 0 to 3 and f32 round mode 0 to 3, DX10_CLAMP=1, f16 denormals kept, f16 round to nearest even and FP16_OVFL=0; with the `clamp` modifier the result is clamped as in the other modes. The PS5's gfx1013 is not measured. - [v_log_f32 and v_log_f16](../../../core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp) keep the driver's log2 unless, for inputs within 1/8 of 1.0, it is more than 4 ULP from an eight-term series in x - 1, which then replaces it. `v_log_f16` goes through the same f32 lowering and is rounded to half. The series is evaluated for every `v_log` and chosen with `OpSelect` rather than a branch, so its seven fused multiply-adds and two multiplies are an unconditional cost. Measured on RDNA2 over all 3,145,728 f32 inputs within 1/8 of 1.0 (gfx1035, IEEE 0, f32 denormals flushed), the hardware is within 1 ULP of the correctly rounded log2 and the series within 2 ULP of it and within 3 ULP of the hardware (2 except at 0x3f752e47, 0x3f7baab0 and 0x3f7bc4fb), so a kept driver value is within 7 ULP of the hardware and a driver that returns the hardware's bits never triggers the gate; RADV (Mesa 26.2.4) returns the hardware's bits on the 9,280 such inputs measured on gfx1036; NVIDIA (615.71.09) was up to 23,947,836 ULP off at 1 - 2^-24 and is within 6 ULP inside the band after the change; outside it its own log2 stays up to 9 ULP off (measured up to 1.375). Over every f16 input, the change moves one `v_log_f16` result on NVIDIA (0x3bed, 1 ULP, onto the hardware's bits) and none on RADV. - [Ordered not-equal, less-than and greater-than compares](../../../core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp) (shader recompiler) - `FPOrdNotEqual32` is emitted as `OpFUnordNotEqual`, and `FPOrdLessThan32` and `FPOrdGreaterThan32` as their ordered forms, each with both operands checked for NaN on their bits; the results are the same for every input. The AMD Windows driver 32.0.13058.8001 (Radeon 860M, RDNA 3.5) returns 1 for a NaN operand when any float form of not-equal feeds a ballot on a 32-wide subgroup (#825), and so does a less-than or greater-than against an infinity the driver sees as a constant (#3536), presumably rewritten into a not-equal by its compiler -- [16-bit integer multiplies](../../../core/shader/recompiler/Translation/src/IntegerInstructions.cpp) (`v_mul_lo_u16`, `v_pk_mul_lo_u16`) (shader recompiler) - a factor taken from the low half of its source is read as the low 24 bits of the register, not the low 16; the low 16 bits of the product are the same for every input. The AMD Windows driver 32.0.21045.5002 (RX 6700 XT, RDNA 2) stores the full 32-bit product when two values it knows fit in 16 bits are multiplied, the product is reduced to 16 bits and nothing else is merged into the stored value: `v_mul_lo_u16` into a register whose high half is zero, also with factors masked to 16 bits by the program, and `v_pk_mul_lo_u16` whose high lanes are zero wrote 0xfffe0001 for 0xffff * 0xffff where the GPU writes 0x00000001. Reducing the product with `v_and_b32`, `v_bfe_u32` or a shift pair loses the same bits; factors read as 24-bit fields do not. The [constant folder](../../../core/shader/recompiler/Optimization/src/ConstantFolder.cpp) keeps a field read that a multiply uses, directly or through an xor with a constant (a negated packed factor), even when the program already masked the factor to 16 bits, since folding it away brings the full product back (#8271). Measured against the same instructions run through the [hardware oracle](../HW_ORACLE.md) on that GPU; other drivers are not checked +- [16-bit integer multiplies](../../../core/shader/recompiler/Translation/src/IntegerInstructions.cpp) (`v_mul_lo_u16`, `v_pk_mul_lo_u16`) (shader recompiler) - a factor from the low half of its source is read as the low 24 bits, not the low 16; the product's low 16 bits do not change. The AMD Windows driver 32.0.21045.5002 (RX 6700 XT) stores the full 32-bit product when both factors are known to fit 16 bits and nothing else is merged into the stored value (0xfffe0001 for 0xffff * 0xffff, where the GPU writes 0x00000001); reducing the product with `v_and_b32`, `v_bfe_u32` or a shift pair loses the same bits, 24-bit factor reads do not. The [constant folder](../../../core/shader/recompiler/Optimization/src/ConstantFolder.cpp) keeps a field read that a multiply uses, directly or through an xor with a constant, even when the factor is already masked to 16 bits, since folding it away brings the full product back (#8271). Measured with the [hardware oracle](../HW_ORACLE.md) on that GPU - [f32 compares in denormal modes 1 and 2](../../../core/shader/recompiler/Translation/src/VectorInstructions.cpp) (`v_cmp_X_f32` and `v_cmpx_X_f32` with X = eq, ge, gt, le, lg, lt, neq, nge, ngt, nle, nlg, nlt) flush their f32 denormal inputs by bit 0 of the `FLOAT_MODE` f32 denormal field: mode 1 keeps them as mode 3 does, and mode 2 flushes them as mode 0 does. Measured on RDNA2 (gfx1030, Radeon RX 6800, AMD Windows driver 32.0.21045.5002 through HIP) over 484 operand pairs of zeros, denormals, minimum normals, ones, the largest finite value, infinities and quiet and signaling NaNs of both signs, in VOPC form, at IEEE=0 and IEEE=1 with each f32 denormal mode 0 to 3, f32 round to nearest even, DX10_CLAMP=1, f16 denormals kept, f16 round to nearest even and FP16_OVFL=0. The PS5's gfx1013 is not measured. - [f16 compares](../../../core/shader/recompiler/Translation/src/CompareInstructions.cpp) (`v_cmp_X_f16` and `v_cmpx_X_f16` with X = eq, ge, gt, le, lg, lt, neq, nge, ngt, nle, nlg, nlt) flush their f16 denormal inputs to a zero of the same sign by bit 0 of the `FLOAT_MODE` f16 denormal field: modes 0 and 2 flush them, 1 and 3 keep them. Measured on RDNA2 (gfx1031, Radeon RX 6700 XT, AMD Windows driver 32.0.21045.5002 through HIP) over 256 operand pairs of zeros, denormals, minimum normals, ones, the largest finite value, infinities and quiet and signaling NaNs of both signs, in VOPC form, at IEEE=0 and IEEE=1 with each f16 denormal mode 0 to 3, f16 round to nearest even, DX10_CLAMP=1, f32 denormals kept and FP16_OVFL=0. Shaders without a known float mode keep the previous lowering. The PS5's gfx1013 is not measured. - [DB_SHADER_CONTROL.PRIMITIVE_ORDERED_PIXEL_SHADER](../../../core/shader/recompiler/SpirvBackend/src/SpirvModuleEmitter.cpp) (shader recompiler) - the host pixel interlock spans the whole shader, not only up to `s_sendmsg` ORDERED_PS_DONE. From bda2deec7f8a5e460c527f60a0bfa8ddb280f772 Mon Sep 17 00:00:00 2001 From: ionut27001 <194206737+ionut27001@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:16:17 +0200 Subject: [PATCH 14/62] docs(libSceSysmodule): scope the measured internal ids to the functions measured The debt entry listed the WithArg variants and then the three rejected ids without saying which functions return 0x805A1000 for them. It now names the functions measured on firmware 12.70 and states that the WithArg variants still throw for those ids. --- docs/dev/debt/libSceSysmodule.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libSceSysmodule.md b/docs/dev/debt/libSceSysmodule.md index 268ef42f6f..f6e3bf3015 100644 --- a/docs/dev/debt/libSceSysmodule.md +++ b/docs/dev/debt/libSceSysmodule.md @@ -3,7 +3,7 @@ ### Silent stubs - [sceSysmoduleIsLoaded, sceSysmoduleLoadModule, sceSysmoduleUnloadModule](../../../core/libs/prx/libSceSysmodule/Export.cpp) (libSceSysmodule) accept the 141 ids `sceSysmoduleIsLoaded` did not reject in a payload process on a PS5 (firmware 13.60) and return `0x805A1000` for every other id, as the console does; that includes 51 ids the module table names from older firmware, such as 0x0001 (`libSceAudioOut`) and 0x0080 (`libSceJson`). Load and unload are counted as measured. The 21 accepted ids the table does not name (0x0111, 0x0117, 0x011c to 0x011f, 0x0121 to 0x0124, 0x0129, 0x012c to 0x012e, 0x0133, 0x0134, 0x0136 and 0x013a to 0x013d) are logged and return 0 without loading anything, so `sceSysmoduleIsLoaded` reports them as loaded; which module each loads is not known. Loading id 0x00ff ended the payload process. One title (PPSA04354) calls `sceSysmoduleIsLoaded` with id 0x96 at twelve sites and loads it only when the result is 0x805a1001; any other result lets it continue. -- [sceSysmoduleIsLoadedInternal, sceSysmoduleLoadModuleInternal, sceSysmoduleUnloadModuleInternal, sceSysmoduleLoadModuleInternalWithArg, sceSysmoduleUnloadModuleInternalWithArg](../../../core/libs/prx/libSceSysmodule/Export.cpp) (libSceSysmodule) - an internal id missing from the module table throws, except 0x8000ffff and 0x80ffff00, and 0x00ffffff for `sceSysmoduleIsLoadedInternal`, which return `0x805A1000` as `sceSysmoduleIsLoadedInternal`, `sceSysmoduleLoadModuleInternal` and `sceSysmoduleUnloadModuleInternal` did for them in a payload process on a PS5 (firmware 12.70). Which other internal ids the console rejects, and what the `WithArg` variants return for a missing id, is not measured; `sceSysmoduleIsLoadedInternal(0x800000c4)`, also missing from the table, returned `0x805A1001` there and throws here. +- [sceSysmoduleIsLoadedInternal, sceSysmoduleLoadModuleInternal, sceSysmoduleUnloadModuleInternal, sceSysmoduleLoadModuleInternalWithArg, sceSysmoduleUnloadModuleInternalWithArg](../../../core/libs/prx/libSceSysmodule/Export.cpp) (libSceSysmodule) - an internal id missing from the module table throws, except 0x8000ffff and 0x80ffff00 in `sceSysmoduleIsLoadedInternal`, `sceSysmoduleLoadModuleInternal` and `sceSysmoduleUnloadModuleInternal`, and 0x00ffffff in `sceSysmoduleIsLoadedInternal`, which return `0x805A1000` as those functions did for them in a payload process on a PS5 (firmware 12.70); the `WithArg` variants throw for them too. Which other internal ids the console rejects, and what the `WithArg` variants return for a missing id, is not measured; `sceSysmoduleIsLoadedInternal(0x800000c4)`, also missing from the table, returned `0x805A1001` there and throws here. ### Unknown function info From 37e14bff9b5c79e5962e7ead63a06a9e9032728f Mon Sep 17 00:00:00 2001 From: Islam ElShahaby Date: Sun, 18 Oct 2026 21:09:27 +0300 Subject: [PATCH 15/62] feat(libc): implement std::_Throw_C_error --- core/libs/prx/libc/src/RuntimeSupport.cpp | 23 +++++++++++++++- core/libs/tests/GuestAmprCppError.S | 3 ++- core/libs/tests/GuestAmprCppError.cpp | 32 ++++++++++++++++++++++- docs/dev/debt/libc.md | 1 + 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/core/libs/prx/libc/src/RuntimeSupport.cpp b/core/libs/prx/libc/src/RuntimeSupport.cpp index 0b95021e58..af1de0b9d9 100644 --- a/core/libs/prx/libc/src/RuntimeSupport.cpp +++ b/core/libs/prx/libc/src/RuntimeSupport.cpp @@ -147,8 +147,29 @@ unsigned int APS5_VABI _ZSt14_Random_devicev_nid_postfix() { return value; } +static constexpr int ThrdNomem = 1; +static constexpr int ThrdTimedout = 2; +static constexpr int ThrdBusy = 3; +static constexpr int ThrdError = 4; + +static constexpr int CppDeviceOrResourceBusy = 0; +static constexpr int CppInvalidArgument = 1; +static constexpr int CppResourceUnavailableTryAgain = 6; + +[[noreturn]] void ThrowCppError_nid_no_patch(int code); + [[noreturn]] void APS5_VABI _ZSt14_Throw_C_errori_nid_postfix(int code) { - throw std::runtime_error("_Throw_C_error: C11 thread error " + std::to_string(code)); + switch (code) { + case ThrdNomem: + case ThrdTimedout: + ThrowCppError_nid_no_patch(CppResourceUnavailableTryAgain); + case ThrdBusy: + ThrowCppError_nid_no_patch(CppDeviceOrResourceBusy); + case ThrdError: + ThrowCppError_nid_no_patch(CppInvalidArgument); + default: + throw std::out_of_range("_Throw_C_error: " + std::to_string(code) + " is not a _Thrd_t error code"); + } } void APS5_VABI _Lockfilelock_nid_postfix(FileStream* stream) { diff --git a/core/libs/tests/GuestAmprCppError.S b/core/libs/tests/GuestAmprCppError.S index f5e0769766..19e8dc9c18 100644 --- a/core/libs/tests/GuestAmprCppError.S +++ b/core/libs/tests/GuestAmprCppError.S @@ -17,7 +17,8 @@ GuestCatch: .cfi_def_cfa_register %rbp subq $16, %rsp .Lcatch_call: - call _ZSt16_Throw_Cpp_errori_nid_postfix + movq FixtureThrow(%rip), %rax + call *%rax .Lcatch_call_end: ud2 .Lcatch_landing: diff --git a/core/libs/tests/GuestAmprCppError.cpp b/core/libs/tests/GuestAmprCppError.cpp index 62d9843363..57e5fcbc2f 100644 --- a/core/libs/tests/GuestAmprCppError.cpp +++ b/core/libs/tests/GuestAmprCppError.cpp @@ -42,6 +42,7 @@ extern const unsigned char _ZTISt12system_error_nid_postfix[]; void APS5_VABI GuestCatch(int code); +void (APS5_VABI *FixtureThrow)(int) = nullptr; const void* FixtureCatchType = nullptr; const void* FixtureOtherType = nullptr; const Expected* FixtureExpected = nullptr; @@ -129,6 +130,7 @@ extern "C" void APS5_VABI FixtureInspect(void* object) { } static void Catch(const void* type, int code) { + FixtureThrow = _ZSt16_Throw_Cpp_errori_nid_postfix; FixtureCatchType = type; FixtureExpected = &Errors[code]; const auto before = FixtureInspected; @@ -136,6 +138,15 @@ static void Catch(const void* type, int code) { Require(FixtureInspected == before + 1, 2); } +static void CatchCError(const void* type, int code, int cppIndex) { + FixtureThrow = _ZSt14_Throw_C_errori_nid_postfix; + FixtureCatchType = type; + FixtureExpected = &Errors[cppIndex]; + const auto before = FixtureInspected; + GuestCatch(code); + Require(FixtureInspected == before + 1, 5); +} + static void CheckOutOfRange(int code) { bool threw = false; try { @@ -147,6 +158,16 @@ static void CheckOutOfRange(int code) { Require(threw, 3); } +static void CheckCErrorOutOfRange(int code) { + bool threw = false; + try { + _ZSt14_Throw_C_errori_nid_postfix(code); + } catch (const std::out_of_range& error) { + threw = std::string(error.what()).find(std::to_string(code)) != std::string::npos; + } + Require(threw, 6); +} + int main() { const std::array api {}; ApplicationHeapRegister_nid_no_patch(api.data()); @@ -156,6 +177,15 @@ int main() { Catch(_ZTISt9exception_nid_postfix, 6); CheckOutOfRange(-1); CheckOutOfRange(static_cast(std::size(Errors))); - std::puts("guest _Throw_Cpp_error system_error, generic category and range: PASS"); + CatchCError(_ZTISt12system_error_nid_postfix, 1, 6); + CatchCError(_ZTISt12system_error_nid_postfix, 2, 6); + CatchCError(_ZTISt12system_error_nid_postfix, 3, 0); + CatchCError(_ZTISt12system_error_nid_postfix, 4, 1); + CatchCError(_ZTISt13runtime_error_nid_postfix, 3, 0); + CatchCError(_ZTISt9exception_nid_postfix, 4, 1); + CheckCErrorOutOfRange(-1); + CheckCErrorOutOfRange(0); + CheckCErrorOutOfRange(5); + std::puts("guest _Throw_Cpp_error and _Throw_C_error system_error, generic category and range: PASS"); return 0; } diff --git a/docs/dev/debt/libc.md b/docs/dev/debt/libc.md index ff23d63ebf..4152a9d574 100644 --- a/docs/dev/debt/libc.md +++ b/docs/dev/debt/libc.md @@ -82,6 +82,7 @@ - [qsort_s](../../../core/libs/prx/libc/src/FileAndHeap.cpp) (libc) sorts as `qsort` does and passes its context as the comparator's third argument, as FreeBSD 13's [`qsort_s`](https://github.com/freebsd/freebsd-src/blob/release/13.0.0/lib/libc/stdlib/qsort.c#L97-L127) (C11 Annex K) does. It returns `EINVAL` after calling the constraint handler when `n` or `es` exceeds `RSIZE_MAX`, or when `n` is not 0 and `a` or `cmp` is null; FreeBSD 11, which the rest of libc follows, has no `qsort_s`. An element size of 0 sorts nothing, where FreeBSD loops over the same address. The console's behaviour is unverified; the Quake homebrew (#6698) imports it from its `libOSMesa.so.8` - [__udivti3](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc, re-exported by libSceAmpr) - the hardware traps on a zero divisor; this throws instead - [__umodti3](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) - the hardware traps on a zero divisor; this throws instead +- [std::_Throw_C_error](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) follows Dinkumware's `thread0.cpp` as shipped with [Visual C++ 2013](https://github.com/ganboing/crt.vc12/blob/f1d47126bf13d1c9dccaa6c53b72217fd0474968/src/thread0.cpp) and in [microsoft/STL](https://github.com/microsoft/STL/blob/da52dc0fcadbd83690cfd1ed32408d2847439493/stl/src/thread0.cpp), without console verification: maps `_Thrd_nomem` (1) and `_Thrd_timedout` (2) to `_Cpp_error` 6 (`resource_unavailable_try_again`), `_Thrd_busy` (3) to 0 (`device_or_resource_busy`), and `_Thrd_error` (4) to 1 (`invalid_argument`) through `std::_Throw_Cpp_error`, and throws `std::out_of_range` on the host for any other code where VC12 calls `abort()` - [__modti3](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) - the hardware traps on a zero divisor; this throws instead - [snprintf and vsnprintf](../../../core/libs/prx/libc/src/Formatting.cpp) (libc) accept any size up to `SIZE_MAX`, as a payload process on a PS5 (firmware 13.60) does. A null buffer with a nonzero size throws here, where the console writes through it, and a result longer than `INT_MAX` throws, which was not measured From de06d80e6100cc1fe96fb46b82bb3ba3c46163ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Franch?= Date: Mon, 19 Oct 2026 18:46:19 +0200 Subject: [PATCH 16/62] fix(libc): match console _Throw_C_error: code 5 throws operation_not_permitted, other codes return Based on the disassembly of the PRAGMATA libc.prx reported by Ondra97 on #7648. Co-Authored-By: Ondra97 Co-Authored-By: Claude Sonnet 5.5 --- core/libs/prx/libc/src/RuntimeSupport.cpp | 8 ++++++-- core/libs/tests/GuestAmprCppError.cpp | 16 ++++++++-------- docs/dev/debt/libc.md | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/core/libs/prx/libc/src/RuntimeSupport.cpp b/core/libs/prx/libc/src/RuntimeSupport.cpp index af1de0b9d9..eb8ad0c317 100644 --- a/core/libs/prx/libc/src/RuntimeSupport.cpp +++ b/core/libs/prx/libc/src/RuntimeSupport.cpp @@ -151,14 +151,16 @@ static constexpr int ThrdNomem = 1; static constexpr int ThrdTimedout = 2; static constexpr int ThrdBusy = 3; static constexpr int ThrdError = 4; +static constexpr int ThrdPermission = 5; static constexpr int CppDeviceOrResourceBusy = 0; static constexpr int CppInvalidArgument = 1; +static constexpr int CppOperationNotPermitted = 4; static constexpr int CppResourceUnavailableTryAgain = 6; [[noreturn]] void ThrowCppError_nid_no_patch(int code); -[[noreturn]] void APS5_VABI _ZSt14_Throw_C_errori_nid_postfix(int code) { +void APS5_VABI _ZSt14_Throw_C_errori_nid_postfix(int code) { switch (code) { case ThrdNomem: case ThrdTimedout: @@ -167,8 +169,10 @@ static constexpr int CppResourceUnavailableTryAgain = 6; ThrowCppError_nid_no_patch(CppDeviceOrResourceBusy); case ThrdError: ThrowCppError_nid_no_patch(CppInvalidArgument); + case ThrdPermission: + ThrowCppError_nid_no_patch(CppOperationNotPermitted); default: - throw std::out_of_range("_Throw_C_error: " + std::to_string(code) + " is not a _Thrd_t error code"); + return; } } diff --git a/core/libs/tests/GuestAmprCppError.cpp b/core/libs/tests/GuestAmprCppError.cpp index 57e5fcbc2f..10ca07b1e1 100644 --- a/core/libs/tests/GuestAmprCppError.cpp +++ b/core/libs/tests/GuestAmprCppError.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -158,14 +159,13 @@ static void CheckOutOfRange(int code) { Require(threw, 3); } -static void CheckCErrorOutOfRange(int code) { - bool threw = false; +static void CheckCErrorReturns(int code) { try { _ZSt14_Throw_C_errori_nid_postfix(code); - } catch (const std::out_of_range& error) { - threw = std::string(error.what()).find(std::to_string(code)) != std::string::npos; + } catch (...) { + std::fprintf(stderr, "guest _Throw_C_error unexpectedly threw for %d\n", code); + FixtureFail(6); } - Require(threw, 6); } int main() { @@ -183,9 +183,9 @@ int main() { CatchCError(_ZTISt12system_error_nid_postfix, 4, 1); CatchCError(_ZTISt13runtime_error_nid_postfix, 3, 0); CatchCError(_ZTISt9exception_nid_postfix, 4, 1); - CheckCErrorOutOfRange(-1); - CheckCErrorOutOfRange(0); - CheckCErrorOutOfRange(5); + const std::array returnCodes {std::numeric_limits::min(), -1, 0, 6, std::numeric_limits::max()}; + for (const int code : returnCodes) CheckCErrorReturns(code); + CatchCError(_ZTISt12system_error_nid_postfix, 5, 4); std::puts("guest _Throw_Cpp_error and _Throw_C_error system_error, generic category and range: PASS"); return 0; } diff --git a/docs/dev/debt/libc.md b/docs/dev/debt/libc.md index 4152a9d574..7647306cdc 100644 --- a/docs/dev/debt/libc.md +++ b/docs/dev/debt/libc.md @@ -82,7 +82,7 @@ - [qsort_s](../../../core/libs/prx/libc/src/FileAndHeap.cpp) (libc) sorts as `qsort` does and passes its context as the comparator's third argument, as FreeBSD 13's [`qsort_s`](https://github.com/freebsd/freebsd-src/blob/release/13.0.0/lib/libc/stdlib/qsort.c#L97-L127) (C11 Annex K) does. It returns `EINVAL` after calling the constraint handler when `n` or `es` exceeds `RSIZE_MAX`, or when `n` is not 0 and `a` or `cmp` is null; FreeBSD 11, which the rest of libc follows, has no `qsort_s`. An element size of 0 sorts nothing, where FreeBSD loops over the same address. The console's behaviour is unverified; the Quake homebrew (#6698) imports it from its `libOSMesa.so.8` - [__udivti3](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc, re-exported by libSceAmpr) - the hardware traps on a zero divisor; this throws instead - [__umodti3](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) - the hardware traps on a zero divisor; this throws instead -- [std::_Throw_C_error](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) follows Dinkumware's `thread0.cpp` as shipped with [Visual C++ 2013](https://github.com/ganboing/crt.vc12/blob/f1d47126bf13d1c9dccaa6c53b72217fd0474968/src/thread0.cpp) and in [microsoft/STL](https://github.com/microsoft/STL/blob/da52dc0fcadbd83690cfd1ed32408d2847439493/stl/src/thread0.cpp), without console verification: maps `_Thrd_nomem` (1) and `_Thrd_timedout` (2) to `_Cpp_error` 6 (`resource_unavailable_try_again`), `_Thrd_busy` (3) to 0 (`device_or_resource_busy`), and `_Thrd_error` (4) to 1 (`invalid_argument`) through `std::_Throw_Cpp_error`, and throws `std::out_of_range` on the host for any other code where VC12 calls `abort()` +- [std::_Throw_C_error](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) follows disassembly of PRAGMATA's (PPSA02530) bundled `libc.prx`: maps 1 and 2 to generic error 35 (`resource_unavailable_try_again`), 3 to 16 (`device_or_resource_busy`), 4 to 22 (`invalid_argument`), and 5 to 1 (`operation_not_permitted`), throwing a guest `std::system_error` through the existing C++ error helper. Other values return without throwing. Other SDK versions have not been inspected - [__modti3](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) - the hardware traps on a zero divisor; this throws instead - [snprintf and vsnprintf](../../../core/libs/prx/libc/src/Formatting.cpp) (libc) accept any size up to `SIZE_MAX`, as a payload process on a PS5 (firmware 13.60) does. A null buffer with a nonzero size throws here, where the console writes through it, and a result longer than `INT_MAX` throws, which was not measured From 2b34b0217219d34a2aca78d07135039dc24468d9 Mon Sep 17 00:00:00 2001 From: Islam ElShahaby Date: Wed, 28 Oct 2026 23:01:18 +0300 Subject: [PATCH 17/62] fix(libc): preserve unexpected thread error handling --- core/libs/prx/libc/include/Api.hpp | 2 +- core/libs/prx/libc/src/Threads.cpp | 13 ++----------- docs/dev/debt/libc.md | 2 +- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/core/libs/prx/libc/include/Api.hpp b/core/libs/prx/libc/include/Api.hpp index 593d7d1906..c3f6ca5fa8 100644 --- a/core/libs/prx/libc/include/Api.hpp +++ b/core/libs/prx/libc/include/Api.hpp @@ -1365,7 +1365,7 @@ int APS5_VABI dl_iterate_phdr_nid_postfix(int (*callback)(dl_phdr_info*, std::si [[noreturn]] void APS5_VABI _Exit_nid_postfix(int code); [[noreturn]] void APS5_VABI catchReturnFromMain_nid_postfix(int status); [[noreturn]] void APS5_VABI quick_exit_nid_postfix(int status); -[[noreturn]] void APS5_VABI _ZSt14_Throw_C_errori_nid_postfix(int code); +void APS5_VABI _ZSt14_Throw_C_errori_nid_postfix(int code); [[noreturn]] char* APS5_VABI fgetln_nid_postfix(FileStream*, std::size_t*); [[noreturn]] void APS5_VABI _Assert_nid_postfix(const char* message, const char* location); [[noreturn]] void APS5_VABI _ZSt16_Throw_Cpp_errori_nid_postfix(int code); diff --git a/core/libs/prx/libc/src/Threads.cpp b/core/libs/prx/libc/src/Threads.cpp index 08292776eb..c9c86b9a68 100644 --- a/core/libs/prx/libc/src/Threads.cpp +++ b/core/libs/prx/libc/src/Threads.cpp @@ -431,17 +431,8 @@ static_assert(offsetof(GuestPad, condition) == 0x8 && offsetof(GuestPad, mutex) [[noreturn]] void ThrowCppError_nid_no_patch(int code); static void RequireThrd(int result, const char* what) { - switch (result) { - case threadSuccess: - return; - case threadNomem: - case threadTimedout: - ThrowCppError_nid_no_patch(6); - case threadBusy: - ThrowCppError_nid_no_patch(0); - case threadError: - ThrowCppError_nid_no_patch(1); - } + if (result == threadSuccess) return; + _ZSt14_Throw_C_errori_nid_postfix(result); throw std::runtime_error(std::string(what) + " failed with " + std::to_string(result)); } diff --git a/docs/dev/debt/libc.md b/docs/dev/debt/libc.md index 7647306cdc..85a2520594 100644 --- a/docs/dev/debt/libc.md +++ b/docs/dev/debt/libc.md @@ -82,7 +82,7 @@ - [qsort_s](../../../core/libs/prx/libc/src/FileAndHeap.cpp) (libc) sorts as `qsort` does and passes its context as the comparator's third argument, as FreeBSD 13's [`qsort_s`](https://github.com/freebsd/freebsd-src/blob/release/13.0.0/lib/libc/stdlib/qsort.c#L97-L127) (C11 Annex K) does. It returns `EINVAL` after calling the constraint handler when `n` or `es` exceeds `RSIZE_MAX`, or when `n` is not 0 and `a` or `cmp` is null; FreeBSD 11, which the rest of libc follows, has no `qsort_s`. An element size of 0 sorts nothing, where FreeBSD loops over the same address. The console's behaviour is unverified; the Quake homebrew (#6698) imports it from its `libOSMesa.so.8` - [__udivti3](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc, re-exported by libSceAmpr) - the hardware traps on a zero divisor; this throws instead - [__umodti3](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) - the hardware traps on a zero divisor; this throws instead -- [std::_Throw_C_error](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) follows disassembly of PRAGMATA's (PPSA02530) bundled `libc.prx`: maps 1 and 2 to generic error 35 (`resource_unavailable_try_again`), 3 to 16 (`device_or_resource_busy`), 4 to 22 (`invalid_argument`), and 5 to 1 (`operation_not_permitted`), throwing a guest `std::system_error` through the existing C++ error helper. Other values return without throwing. Other SDK versions have not been inspected +- [std::_Throw_C_error](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) follows disassembly of a bundled PS5 `libc.prx`: maps 1 and 2 to generic error 35 (`resource_unavailable_try_again`), 3 to 16 (`device_or_resource_busy`), 4 to 22 (`invalid_argument`), and 5 to 1 (`operation_not_permitted`), throwing a guest `std::system_error` through the existing C++ error helper. Other values return without throwing. Other SDK versions have not been inspected - [__modti3](../../../core/libs/prx/libc/src/RuntimeSupport.cpp) (libc) - the hardware traps on a zero divisor; this throws instead - [snprintf and vsnprintf](../../../core/libs/prx/libc/src/Formatting.cpp) (libc) accept any size up to `SIZE_MAX`, as a payload process on a PS5 (firmware 13.60) does. A null buffer with a nonzero size throws here, where the console writes through it, and a result longer than `INT_MAX` throws, which was not measured From d4bca9d014cc63feb953cffb516a39a47a1f7a70 Mon Sep 17 00:00:00 2001 From: Dean Galvin Date: Sun, 1 Nov 2026 21:00:16 +1300 Subject: [PATCH 18/62] fix(libSceAjm): return the console's sceAjmStrError texts Replace the five provisional messages with the table read from the console's own export on a PS5 (firmware 13.40, PPSA01317), as reported on #3205: "ok" for 0, the 24 texts for 0x80930002 to 0x80930019, and "(unknown)" for every other value. The test checks each exact text, the "ok" case and the unknown fallback. The debt entry now records that the table is implemented and that only that firmware was measured. --- core/libs/prx/libSceAjm.native/Export.cpp | 43 ++++++++++++++++------- core/libs/tests/GuestAjm.cpp | 28 ++++++++++----- docs/dev/debt/libSceAjm.native.md | 2 +- 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/core/libs/prx/libSceAjm.native/Export.cpp b/core/libs/prx/libSceAjm.native/Export.cpp index 2e720010de..cd21433283 100644 --- a/core/libs/prx/libSceAjm.native/Export.cpp +++ b/core/libs/prx/libSceAjm.native/Export.cpp @@ -68,20 +68,37 @@ int APS5_VABI sceAjmBatchJobEncode(AjmBatchInfo* info, uint32_t instance, const } const char* APS5_VABI sceAjmStrError(int error) { - struct Entry { - int code; - const char* message; + // Texts read from the console's own export (PS5 firmware 13.40, PPSA01317), reported on #3205. + static const char* const messages[] = { + "context does not exist", // 0x80930002 + "instance does not exist", // 0x80930003 + "batch does not exist", // 0x80930004 + "invalid parameter", // 0x80930005 + "not enough memory", // 0x80930006 + "not enough resources", // 0x80930007 + "codec is not supported", // 0x80930008 + "codec is already registered in the context", // 0x80930009 + "codec is not registered in the context", // 0x8093000a + "wrong revision in the flags", // 0x8093000b + "flag is not supported", // 0x8093000c + "busy", // 0x8093000d + "priority is invalid", // 0x8093000e + "in progress", // 0x8093000f + "operation cannot be performed at the moment", // 0x80930010 + "malformed batch", // 0x80930011 + "error at job creation", // 0x80930012 + "invalid opcode", // 0x80930013 + "priority violation", // 0x80930014 + "buffer is too big", // 0x80930015 + "invalid address", // 0x80930016 + "batch was cancelled", // 0x80930017 + "specified memory area is already registered", // 0x80930018 + "specified memory area is not registered", // 0x80930019 }; - static const Entry messages[] = { - {static_cast(0x80930002), "Invalid context"}, - {static_cast(0x80930003), "Invalid instance"}, - {static_cast(0x80930004), "Invalid batch"}, - {static_cast(0x80930005), "Invalid parameter"}, - {static_cast(0x80930007), "Out of resources"}, - }; - for (const Entry& entry : messages) - if (entry.code == error) return entry.message; - return "Unknown AJM error"; + if (error == 0) return "ok"; + const std::uint32_t index = static_cast(error) - 0x80930002u; + if (index < sizeof(messages) / sizeof(messages[0])) return messages[index]; + return "(unknown)"; } int APS5_VABI sceAjmDecWVorbisCreateHeaderPacket(WVorbisHeaderPacketParameter* param) { diff --git a/core/libs/tests/GuestAjm.cpp b/core/libs/tests/GuestAjm.cpp index eaaf186907..7c30ffc7bd 100644 --- a/core/libs/tests/GuestAjm.cpp +++ b/core/libs/tests/GuestAjm.cpp @@ -1932,18 +1932,28 @@ void TestAt9RunDecodesWholeInput(std::uint32_t context) { } void TestStrError() { - const int implemented[] = {static_cast(0x80930002), static_cast(0x80930003), static_cast(0x80930004), static_cast(0x80930005), static_cast(0x80930007)}; - for (int code : implemented) { + const char* const expected[] = { + "context does not exist", "instance does not exist", "batch does not exist", "invalid parameter", + "not enough memory", "not enough resources", "codec is not supported", + "codec is already registered in the context", "codec is not registered in the context", + "wrong revision in the flags", "flag is not supported", "busy", "priority is invalid", "in progress", + "operation cannot be performed at the moment", "malformed batch", "error at job creation", + "invalid opcode", "priority violation", "buffer is too big", "invalid address", "batch was cancelled", + "specified memory area is already registered", "specified memory area is not registered", + }; + static_assert(sizeof(expected) / sizeof(expected[0]) == 24); + for (std::uint32_t i = 0; i < 24; ++i) { + const int code = static_cast(0x80930002u + i); const char* text = sceAjmStrError(code); - Require(text != nullptr && std::strlen(text) > 0); + Require(text != nullptr && std::strcmp(text, expected[i]) == 0); Require(sceAjmStrError(code) == text); } - const char* fallback = sceAjmStrError(0); - Require(fallback != nullptr && std::strlen(fallback) > 0); - for (int code : {static_cast(0x80930001), 0x7fffffff}) - Require(sceAjmStrError(code) == fallback); - for (int code : implemented) - Require(sceAjmStrError(code) != fallback); + const char* ok = sceAjmStrError(0); + Require(ok != nullptr && std::strcmp(ok, "ok") == 0); + for (std::uint32_t code : {0x80930000u, 0x80930001u, 0x8093001au, 0x8093001bu, 0x8092ffffu, 1u, 0xffffffffu, 0x80020001u, 0x8a6e0001u, 0x7fffffffu}) { + const char* text = sceAjmStrError(static_cast(code)); + Require(text != nullptr && std::strcmp(text, "(unknown)") == 0); + } } void TestAt9FlagRules(std::uint32_t context) { diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index da7e79d812..03043f298a 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -30,7 +30,7 @@ - [sceAjmBatchJobInlineBuffer](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - the behaviour is firmware 13.42's routine, [run in an x86-64 emulator](https://github.com/boykopovar/AnyPS5/pull/11238#issuecomment-6418132093) for `libSceAjm.sprx` and `libSceAjmi.sprx`, not measured on a console: bits 5-31 of the old header word are kept with kind 7, as in [fpPS4](https://github.com/red-prig/fpPS4/blob/04cefd43e6fddd1ab033e7980cd356d14c964905/src/ajm/ps4_libsceajm.pas#L399-L410), the second word holds the low 32 bits of the size rounded up to 8, the data follows with the padding left as it was, the batch address points at the copy, and nothing is checked - [sceAjmBatchStartBuffer refusals](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - probe batches through the console's builders on a PS4 showed that a run buffer chunk of size zero is dropped whatever its address (the job runs as without it: 0x10 and nothing consumed for no room, 0 for no input), and that a null address with a nonzero size refuses the batch before any of its jobs runs, with 0x80930011 returned and the error record holding 0x80930016, the job chunk's address, the chunk's offset in the job and the job's return address. Control buffer chunks follow the same rule here, not measured. A size not a multiple of 8, a chunk that overruns its job or the batch, or a job without flags are refused the same way with 0x80930011 in the record; the console's report for those was not measured - [sceAjmBatchStartBuffer null and empty batches](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm, PS4) - a null batch address returns 0x80930005 whatever the size, as the PS5 builders' sceAjmBatchInitialize does for a null buffer, and a batch of zero bytes at a non-null address is accepted, runs nothing and completes at once (a null address with size 0 is refused like any null address); neither was measured on a console (the probe that passed a null batch froze the console before a result was written, so it is not retried on hardware) -- [sceAjmStrError](../../../core/libs/prx/libSceAjm.native/Export.cpp) (libSceAjm.native) - throws as unimplemented. The console's texts were read by calling its own export in PPSA01317 on a PS5 (firmware 13.40, reported on #3205): `ok` for 0, 24 lowercase texts without a full stop for 0x80930002 to 0x80930019 (`context does not exist` to `specified memory area is not registered`), and `(unknown)` for every other value including 0x80930000, 0x80930001 and 0x8093001a; none is implemented here. 14 titles and modules import it. Two were disassembled for this entry: the cri_ware_unity plugin of PPSA25936 calls it once (0x120929) after a negative Ajm result and passes the returned string with the code to a logging or formatting function; PPSA07426 calls it after a negative `sceAjmBatchWait` result (0xe863f0) and, after a success, for two job codes (0xe862f9 and 0xe86308) when the first is nonzero, and formats the strings into a text buffer. What those functions do with the strings was not followed, and the other importers were not read. +- [sceAjmStrError](../../../core/libs/prx/libSceAjm.native/Export.cpp) (libSceAjm.native) - returns the console's texts, which were read by calling its own export in PPSA01317 on a PS5 (firmware 13.40, reported on #3205): `ok` for 0, 24 lowercase texts without a full stop for 0x80930002 to 0x80930019 (`context does not exist` to `specified memory area is not registered`), and `(unknown)` for every other value including 0x80930000, 0x80930001 and 0x8093001a; the same table and fallback are returned here. Only that one firmware and title were measured; other PS5 firmware versions are unverified, and a later PS4 report differs on 0x80930018 and 0x80930019. 14 titles and modules import it. Two were disassembled for this entry: the cri_ware_unity plugin of PPSA25936 calls it once (0x120929) after a negative Ajm result and passes the returned string with the code to a logging or formatting function; PPSA07426 calls it after a negative `sceAjmBatchWait` result (0xe863f0) and, after a success, for two job codes (0xe862f9 and 0xe86308) when the first is nonzero, and formats the strings into a text buffer. What those functions do with the strings was not followed, and the other importers were not read. - [ATRAC9 run results](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - probe runs of a title's ATRAC9 stream through the console's builders on a PS4 showed the run result naming the side that ran out: `PARTIAL_INPUT` (0x8) when the input ran out with room left, on a superframe boundary or not; `NOT_ENOUGH_ROOM` (0x10) when the room ran out with input left, after any number of frames, and with nothing consumed for a zero room; both (0x18) when the input ended just as the output filled, which is every Wwise run job. ATRAC9 run jobs with `RUN_MULTIPLE_FRAMES` follow those rules here, vibration streams included, which were not traced; resampled runs and the other codecs keep the earlier rule (a trailing partial frame is partial input, a run that produced nothing is out of room) - [ATRAC9 run without RUN_MULTIPLE_FRAMES](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the same PS4 probe decoded exactly one frame and reported 0 for such a run, and a job read from a PS4 batch buffer (sceAjmBatchStartBuffer) does the same here. A job from the PS5 builders still decodes its whole input, as FMOD in PPSA29141 needs (it puts two run jobs in one batch with the second's input right after the whole of the first's); whether a PS5 stops after one frame is not measured. MP3 and Opus run jobs stop after one frame on both paths. The console also rejected the tests' synthetic silent superframe after three frames (`CODEC_ERROR | FATAL`, internal result -577) where LibAtrac9 decodes four, so that vector proves job plumbing, not decoding; on the title's stream LibAtrac9 matched the console's PCM within 4 LSB, 83.5% of the samples exactly From e4192979ebfd06f43fbbfeb9004dd90462d64244 Mon Sep 17 00:00:00 2001 From: Shahid Shamim Shah Date: Tue, 20 Oct 2026 12:31:12 +0530 Subject: [PATCH 19/62] fix(relinker): preserve patch inputs across all outputs --- core/relinker/CMakeLists.txt | 3 + core/relinker/main.cpp | 8 +- .../tests/test_patch_input_preservation.py | 80 +++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 core/relinker/relinker/tests/test_patch_input_preservation.py diff --git a/core/relinker/CMakeLists.txt b/core/relinker/CMakeLists.txt index 48b918bfea..883fe6184f 100644 --- a/core/relinker/CMakeLists.txt +++ b/core/relinker/CMakeLists.txt @@ -905,6 +905,9 @@ if(BUILD_TESTING) add_asan_test_options(input_magic max_allocation_size_mb=512) add_test(NAME input_preservation COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_input_preservation.py $) + add_test(NAME patch_input_preservation COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_patch_input_preservation.py $) + set_tests_properties(patch_input_preservation PROPERTIES TIMEOUT 20) add_test(NAME output_collisions COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_output_collisions.py $) add_test(NAME secondary_output_collisions COMMAND ${Python3_EXECUTABLE} diff --git a/core/relinker/main.cpp b/core/relinker/main.cpp index 2321d1763d..485a1bd683 100644 --- a/core/relinker/main.cpp +++ b/core/relinker/main.cpp @@ -368,6 +368,8 @@ int Run(const int argc, char* argv[]) { for (const auto& artifact : guestArtifacts) if (SameOutputFile(artifact.SourcePath, path)) throw Domain::RelinkerException(std::string(kind) + " output would overwrite an input module: " + Io::Utf8Path(path)); + if (!args.noPatches && std::filesystem::is_regular_file(patchFile) && SameOutputFile(patchFile, path)) + throw Domain::RelinkerException(std::string(kind) + " output would overwrite the patch file: " + Io::Utf8Path(path)); }; for (std::size_t index = 1; index < executables.size(); ++index) checkOutputInputs(Io::NativePath(executables[index].OutputPath), "Executable"); if (desktopEntry) { @@ -397,11 +399,7 @@ int Run(const int argc, char* argv[]) { } checkOutputInputs(Io::NativePath(absPath), "Executable"); - if (!manifestPath.empty()) { - checkOutputInputs(manifestPath, "Patch manifest"); - if (std::filesystem::exists(manifestPath) && std::filesystem::exists(patchFile) && std::filesystem::equivalent(manifestPath, patchFile)) - throw Domain::RelinkerException("Patch manifest output would overwrite the patch file: " + Io::Utf8Path(manifestPath)); - } + if (!manifestPath.empty()) checkOutputInputs(manifestPath, "Patch manifest"); for (const auto& artifact : guestArtifacts) checkOutputInputs(artifact.Path, "Guest module"); std::vector outputPaths; diff --git a/core/relinker/relinker/tests/test_patch_input_preservation.py b/core/relinker/relinker/tests/test_patch_input_preservation.py new file mode 100644 index 0000000000..418cfead68 --- /dev/null +++ b/core/relinker/relinker/tests/test_patch_input_preservation.py @@ -0,0 +1,80 @@ +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +from test_game_patches import patch_site, patch_text +from test_guest_intel_trampolines import main_fixture +from test_secondary_output_collisions import prepare_secondary + + +def main(): + relinker = Path(sys.argv[1]).resolve() + text = patch_text(*patch_site(main_fixture()), target="input.elf").encode() + with tempfile.TemporaryDirectory(prefix="anyps5-patch-input-") as directory: + work = Path(directory) + for windows in (False, True): + command, outputs, _ = prepare_secondary(work / f"control-{windows}", windows) + config = outputs["executable"].parent / "relinker-patches.toml" + config.write_bytes(text) + result = subprocess.run([str(relinker), *command], cwd=config.parent, + capture_output=True, text=True, timeout=20) + assert result.returncode == 0 and config.read_bytes() == text, (result.stdout, result.stderr) + outputs["manifest"] = config.parent / "output.patches.toml" + for name in outputs: + for alias in ("direct", "hardlink", "symlink"): + case = work / f"{windows}-{name}-{alias}" + command, paths, inputs = prepare_secondary(case, windows) + paths["manifest"] = case / "output.patches.toml" + target = paths[name] + config = target if alias == "direct" else case / "relinker-patches.toml" + config.write_bytes(text) + if alias == "hardlink": + os.link(config, target) + elif alias == "symlink": + try: + target.symlink_to(config) + except OSError as error: + if os.name != "nt" or error.winerror != 1314: + raise + print("Patch symbolic-link case skipped: Windows symlink privilege unavailable") + continue + for path in paths.values(): + if path != target: + path.write_bytes(b"previous output") + original = {path: path.read_bytes() for path in paths.values()} + options = ["--patches", str(config)] if alias == "direct" else [] + result = subprocess.run([str(relinker), *options, *command], cwd=case, + capture_output=True, text=True, timeout=20) + assert result.returncode == 2 and "would overwrite the patch file" in result.stderr, ( + windows, name, alias, result.stdout, result.stderr) + assert config.read_bytes() == text + for path, data in {**inputs, **original}.items(): + assert path.read_bytes() == data, path + assert not list(case.rglob(".anyps5-output-identity-*")) + + case = work / "empty-config" + command, outputs, inputs = prepare_secondary(case, False) + config = outputs["registry"] + config.write_text("format = 1\n") + result = subprocess.run([str(relinker), "--patches", str(config), *command], cwd=case, + capture_output=True, text=True, timeout=20) + assert result.returncode == 2 and "would overwrite the patch file" in result.stderr, (result.stdout, result.stderr) + assert config.read_text() == "format = 1\n" + assert not outputs["executable"].exists() + for path, data in inputs.items(): + assert path.read_bytes() == data, path + + case = work / "disabled-config" + command, _, _ = prepare_secondary(case, False) + config = case / "relinker-patches.toml" + config.write_bytes(text) + result = subprocess.run([str(relinker), "--no-patches", *command[:-1], str(config)], cwd=case, + capture_output=True, text=True, timeout=20) + assert result.returncode == 0 and config.read_bytes().startswith(b"\x7fELF"), (result.stdout, result.stderr) + print("Patch input preservation tests passed") + + +if __name__ == "__main__": + main() From 062bee70effe50cada7a80505bfc6721c5c9bad7 Mon Sep 17 00:00:00 2001 From: Shahid Shamim Shah Date: Sun, 1 Nov 2026 20:54:57 +0530 Subject: [PATCH 20/62] test(relinker): reduce patch preservation repetitions --- core/relinker/CMakeLists.txt | 8 +- .../tests/test_patch_input_preservation.py | 109 ++++++++++-------- 2 files changed, 63 insertions(+), 54 deletions(-) diff --git a/core/relinker/CMakeLists.txt b/core/relinker/CMakeLists.txt index 883fe6184f..47dd71e9dd 100644 --- a/core/relinker/CMakeLists.txt +++ b/core/relinker/CMakeLists.txt @@ -905,9 +905,11 @@ if(BUILD_TESTING) add_asan_test_options(input_magic max_allocation_size_mb=512) add_test(NAME input_preservation COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_input_preservation.py $) - add_test(NAME patch_input_preservation COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_patch_input_preservation.py $) - set_tests_properties(patch_input_preservation PROPERTIES TIMEOUT 20) + foreach(mode linux windows controls) + add_test(NAME patch_input_preservation_${mode} COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_patch_input_preservation.py $ ${mode}) + set_tests_properties(patch_input_preservation_${mode} PROPERTIES TIMEOUT 20) + endforeach() add_test(NAME output_collisions COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_output_collisions.py $) add_test(NAME secondary_output_collisions COMMAND ${Python3_EXECUTABLE} diff --git a/core/relinker/relinker/tests/test_patch_input_preservation.py b/core/relinker/relinker/tests/test_patch_input_preservation.py index 418cfead68..07631b08bd 100644 --- a/core/relinker/relinker/tests/test_patch_input_preservation.py +++ b/core/relinker/relinker/tests/test_patch_input_preservation.py @@ -11,10 +11,12 @@ from test_secondary_output_collisions import prepare_secondary def main(): relinker = Path(sys.argv[1]).resolve() + mode = sys.argv[2] if len(sys.argv) > 2 else "all" + formats = (False, True) if mode == "all" else (mode == "windows",) text = patch_text(*patch_site(main_fixture()), target="input.elf").encode() with tempfile.TemporaryDirectory(prefix="anyps5-patch-input-") as directory: work = Path(directory) - for windows in (False, True): + for windows in (() if mode == "controls" else formats): command, outputs, _ = prepare_secondary(work / f"control-{windows}", windows) config = outputs["executable"].parent / "relinker-patches.toml" config.write_bytes(text) @@ -22,57 +24,62 @@ def main(): capture_output=True, text=True, timeout=20) assert result.returncode == 0 and config.read_bytes() == text, (result.stdout, result.stderr) outputs["manifest"] = config.parent / "output.patches.toml" - for name in outputs: - for alias in ("direct", "hardlink", "symlink"): - case = work / f"{windows}-{name}-{alias}" - command, paths, inputs = prepare_secondary(case, windows) - paths["manifest"] = case / "output.patches.toml" - target = paths[name] - config = target if alias == "direct" else case / "relinker-patches.toml" - config.write_bytes(text) - if alias == "hardlink": - os.link(config, target) - elif alias == "symlink": - try: - target.symlink_to(config) - except OSError as error: - if os.name != "nt" or error.winerror != 1314: - raise - print("Patch symbolic-link case skipped: Windows symlink privilege unavailable") - continue - for path in paths.values(): - if path != target: - path.write_bytes(b"previous output") - original = {path: path.read_bytes() for path in paths.values()} - options = ["--patches", str(config)] if alias == "direct" else [] - result = subprocess.run([str(relinker), *options, *command], cwd=case, - capture_output=True, text=True, timeout=20) - assert result.returncode == 2 and "would overwrite the patch file" in result.stderr, ( - windows, name, alias, result.stdout, result.stderr) - assert config.read_bytes() == text - for path, data in {**inputs, **original}.items(): - assert path.read_bytes() == data, path - assert not list(case.rglob(".anyps5-output-identity-*")) + aliases = ("symlink", "direct", "hardlink") + cases = [(name, "symlink" if name == "executable" else aliases[index % len(aliases)]) + for index, name in enumerate(outputs)] + cases.extend(("executable", alias) for alias in ("direct", "hardlink")) + for name, alias in cases: + case = work / f"{windows}-{name}-{alias}" + command, paths, inputs = prepare_secondary(case, windows) + paths["manifest"] = case / "output.patches.toml" + target = paths[name] + config = target if alias == "direct" else case / "relinker-patches.toml" + config.write_bytes(text) + if alias == "hardlink": + os.link(config, target) + elif alias == "symlink": + try: + target.symlink_to(config) + except OSError as error: + if os.name != "nt" or error.winerror != 1314: + raise + print("Windows symlink privilege unavailable; checking the output through its direct path") + alias = "direct" + config = target + config.write_bytes(text) + for path in paths.values(): + if path != target: + path.write_bytes(b"previous output") + original = {path: path.read_bytes() for path in paths.values()} + options = ["--patches", str(config)] if alias == "direct" else [] + result = subprocess.run([str(relinker), *options, *command], cwd=case, + capture_output=True, text=True, timeout=20) + assert result.returncode == 2 and "would overwrite the patch file" in result.stderr, ( + windows, name, alias, result.stdout, result.stderr) + assert config.read_bytes() == text + for path, data in {**inputs, **original}.items(): + assert path.read_bytes() == data, path + assert not list(case.rglob(".anyps5-output-identity-*")) + if mode in ("all", "controls"): + case = work / "empty-config" + command, outputs, inputs = prepare_secondary(case, False) + config = outputs["registry"] + config.write_text("format = 1\n") + result = subprocess.run([str(relinker), "--patches", str(config), *command], cwd=case, + capture_output=True, text=True, timeout=20) + assert result.returncode == 2 and "would overwrite the patch file" in result.stderr, (result.stdout, result.stderr) + assert config.read_text() == "format = 1\n" + assert not outputs["executable"].exists() + for path, data in inputs.items(): + assert path.read_bytes() == data, path - case = work / "empty-config" - command, outputs, inputs = prepare_secondary(case, False) - config = outputs["registry"] - config.write_text("format = 1\n") - result = subprocess.run([str(relinker), "--patches", str(config), *command], cwd=case, - capture_output=True, text=True, timeout=20) - assert result.returncode == 2 and "would overwrite the patch file" in result.stderr, (result.stdout, result.stderr) - assert config.read_text() == "format = 1\n" - assert not outputs["executable"].exists() - for path, data in inputs.items(): - assert path.read_bytes() == data, path - - case = work / "disabled-config" - command, _, _ = prepare_secondary(case, False) - config = case / "relinker-patches.toml" - config.write_bytes(text) - result = subprocess.run([str(relinker), "--no-patches", *command[:-1], str(config)], cwd=case, - capture_output=True, text=True, timeout=20) - assert result.returncode == 0 and config.read_bytes().startswith(b"\x7fELF"), (result.stdout, result.stderr) + case = work / "disabled-config" + command, _, _ = prepare_secondary(case, False) + config = case / "relinker-patches.toml" + config.write_bytes(text) + result = subprocess.run([str(relinker), "--no-patches", *command[:-1], str(config)], cwd=case, + capture_output=True, text=True, timeout=20) + assert result.returncode == 0 and config.read_bytes().startswith(b"\x7fELF"), (result.stdout, result.stderr) print("Patch input preservation tests passed") From 6345fe47b70c8630cd42e37617779bf9edb9cd55 Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:42:19 +0200 Subject: [PATCH 21/62] fix(agc): hold a flip until the previous flip submission's work completed, and raise no event from flips A flip packet delivered the queue's graphics end-of-pipe event after the work recorded before it. On the console a flip raises no such event: its interrupt context is always a typed token that the kernel routes to the display path (PS5 13.60 kernel), and neither Minecraft (PPSA17221) nor Death Stranding 2 (PPSA02015) sends a release that requests one. Death Stranding 2 registers the event before it fills its handler table, so the first flip's event reached a null handler (issue 6462). Minecraft did need it, but only because of when the flip hold ends. It writes its frame label after the flip in the same buffer and, after its next submission, waits on the event until that label is 1. The hold released the title at the flip packet, so it read the label before the worker had written it and slept until the flip event came. The hold now ends when the previous flip submission ran to its end and its recorded GPU work completed, so the label is in memory when the title reads it. The per-queue flip event latch is removed with the event: a queue's own release interrupts deliver as before. --- .../prx/libSceAgcDriver/Eq/include/Event.hpp | 2 +- .../libs/prx/libSceAgcDriver/Eq/src/Event.cpp | 9 +--- .../Execution/include/Driver/Driver.hpp | 11 ++--- .../src/Driver/Packets/PacketExecution.cpp | 45 +++++++----------- .../src/Driver/Queues/QueueWorker.cpp | 17 +++++-- .../src/Driver/Queues/Submission.cpp | 21 ++++++++- core/libs/prx/libSceAgcDriver/tests/Flip.cpp | 46 ++++++++++++++++++- .../libs/prx/libSceAgcDriver/tests/Submit.cpp | 40 +++------------- docs/dev/debt/libSceAgcDriver.md | 2 +- 9 files changed, 108 insertions(+), 85 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Eq/include/Event.hpp b/core/libs/prx/libSceAgcDriver/Eq/include/Event.hpp index 3f25b907df..9792696727 100644 --- a/core/libs/prx/libSceAgcDriver/Eq/include/Event.hpp +++ b/core/libs/prx/libSceAgcDriver/Eq/include/Event.hpp @@ -4,7 +4,7 @@ #include #include "SceTypes.hpp" -bool AgcDriverDeliverEopInterrupt(std::uint32_t queue); +void AgcDriverDeliverEopInterrupt(std::uint32_t queue); extern "C" int APS5_VABI sceAgcDriverAddEqEvent(KernelEqueue eq, int id, void* udata); extern "C" int APS5_VABI sceAgcDriverDeleteEqEvent(KernelEqueue eq, int id); diff --git a/core/libs/prx/libSceAgcDriver/Eq/src/Event.cpp b/core/libs/prx/libSceAgcDriver/Eq/src/Event.cpp index 055e83ecbe..339d258622 100644 --- a/core/libs/prx/libSceAgcDriver/Eq/src/Event.cpp +++ b/core/libs/prx/libSceAgcDriver/Eq/src/Event.cpp @@ -67,20 +67,15 @@ int DeleteEvent(KernelEqueue eq, std::uintptr_t ident, const char* caller) { } -bool AgcDriverDeliverEopInterrupt(std::uint32_t queue) { +void AgcDriverDeliverEopInterrupt(std::uint32_t queue) { std::vector registrations; { std::lock_guard lock(g_mutex); registrations = g_registrations; } - bool delivered = false; for (const auto& registration : registrations) { - if (registration.queue == queue) { - EqueueTriggerEvent_nid_postfix(registration.eq, registration.ident, EvfiltGraphicsCore, nullptr); - delivered = true; - } + if (registration.queue == queue) EqueueTriggerEvent_nid_postfix(registration.eq, registration.ident, EvfiltGraphicsCore, nullptr); } - return delivered; } extern "C" { diff --git a/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp b/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp index 9fffb25ded..6001bc181a 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp +++ b/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp @@ -148,7 +148,9 @@ private: void holdFlipBehindWorker(const Submission& submission); bool awaitsTitle(std::uint64_t awaited) const; void noteAwaitingTitle(std::uint32_t queue, std::uint64_t awaited); - void releaseFlipHold(); + void releaseFlipHold(std::uint64_t workSerial); + std::uint64_t submitFlipWork(std::uint32_t queue); + void waitFlipWork(std::uint64_t workSerial); void reserveOutputs(Submission& submission); void executeRewindTail(const Submission& stalled); void enqueue(Submission submission); @@ -159,7 +161,7 @@ private: void deferCompletion(const Submission& submission); void submitOpenWork(const std::shared_ptr& progress = {}); void wakeQueueZeroForCompletions(); - void completeSubmission(std::uint64_t serial, std::uint64_t received, std::span labelWrites, const std::shared_ptr& progress); + void completeSubmission(std::uint64_t serial, std::uint64_t received, std::span labelWrites, const std::shared_ptr& progress, bool holdsFlip); static bool waitFree(const Submission& submission); bool queue0Before(std::uint64_t received) const; bool waitFreeBefore(std::uint64_t received) const; @@ -388,7 +390,6 @@ private: FrameTiming* includeTimingSubmission(const Submission& submission, bool firstSegment); void execute(const Submission& submission); void deliverGuestEopInterrupt(std::uint32_t queue); - void deliverFlipEopInterrupt(std::uint32_t queue); void markCompleted(std::uint64_t serial); static const std::atomic*& workerQueued(); void reapCompletionLabels(); @@ -414,9 +415,6 @@ private: std::atomic flipSerial{0}; std::atomic flipBatchesUnsignaled{0}; - std::mutex eopSelfSignalMutex; - std::unordered_map eopSelfSignalFlips; - alignas(64) std::atomic packetsInFlight{0}; std::atomic packetsDone{0}; @@ -499,6 +497,7 @@ private: std::atomic queue0Awaited{0}; std::atomic flipsAhead{0}; std::atomic flipHolders{0}; + std::atomic flipWorkSerial{0}; std::atomic queue0AwaitsTitle{false}; std::atomic evidenceReads{0}; diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp index 4907b257e8..163c5646cd 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp @@ -25,7 +25,6 @@ namespace AgcDriver::DriverDetail { namespace { -constexpr std::uint32_t eopSelfSignalFlipLimit = 4; struct RingOrder { std::uint32_t queue = 0xffffffffu; @@ -122,20 +121,6 @@ bool& drawnSinceSeal() { } void Driver::deliverGuestEopInterrupt(std::uint32_t queue) { - if (!AgcDriverDeliverEopInterrupt(queue)) return; - std::lock_guard lock(eopSelfSignalMutex); - eopSelfSignalFlips[queue] = 0; -} - -void Driver::deliverFlipEopInterrupt(std::uint32_t queue) { - { - std::lock_guard lock(eopSelfSignalMutex); - const auto latched = eopSelfSignalFlips.find(queue); - if (latched != eopSelfSignalFlips.end()) { - if (++latched->second <= eopSelfSignalFlipLimit) return; - eopSelfSignalFlips.erase(latched); - } - } AgcDriverDeliverEopInterrupt(queue); } @@ -162,14 +147,18 @@ void Driver::execute(const Submission& submission) { auto* submissionTiming = includeTimingSubmission(submission, true); struct FlipHold { Driver& driver; + std::uint32_t queue; bool held; - void Release() { + void Finish() { if (!held) return; + const auto workSerial = driver.submitFlipWork(queue); held = false; - driver.releaseFlipHold(); + driver.releaseFlipHold(workSerial); } - ~FlipHold() { Release(); } - } flipHold{*this, submission.holdsFlip}; + ~FlipHold() { + if (held) driver.releaseFlipHold(0); + } + } flipHold{*this, submission.queue, submission.holdsFlip}; const bool pipelined = submission.queue == 0 && DrawPipeline::Depth() != 0; static const bool orderedWaits = std::getenv("APS5_PIPELINE_DRAIN_LABELS") == nullptr; DrawPipeline::Active() = pipelined; @@ -414,7 +403,6 @@ void Driver::execute(const Submission& submission) { if (deviceUse.owns_lock()) deviceUse.unlock(); CheckFailure(); std::uint64_t batchesAtFlip = 0, unsignaledAtFlip = 0; - bool flipInterruptDeferred = false; Graphics::StorageTexture::NoteFlip(); if (!drains) { @@ -424,13 +412,8 @@ void Driver::execute(const Submission& submission) { if (localDevice != nullptr) static_cast(Graphics::StorageTexture::StoreDrawnLinear("flip")); if (!recordLabelsForPacket(localDevice.get(), submission.queue) && localDevice != nullptr) localDevice->SubmitRecorded(submission.queue == 0); - if (localDevice != nullptr) { - localDevice->FlipBatches(batchesAtFlip, unsignaledAtFlip); - const auto queueId = submission.queue; - flipInterruptDeferred = localDevice->AfterRecordedWork([this, queueId] { deliverFlipEopInterrupt(queueId); }, false); - } + if (localDevice != nullptr) localDevice->FlipBatches(batchesAtFlip, unsignaledAtFlip); } - if (!flipInterruptDeferred) deliverFlipEopInterrupt(submission.queue); ++flipsCounted; Graphics::NoteHostImportFrame(); if (batchesAtFlip != 0) flipSerial = batchesAtFlip; @@ -457,7 +440,6 @@ void Driver::execute(const Submission& submission) { if (queueTiming) frame->CollectBackground(); frame->NoteFlipBatches(batchesAtFlip, unsignaledAtFlip); CaptureTrace::Log("flip frame=%llu submission=%llu offset=%zu batch=%llu unsignaled=%llu", static_cast(frameSerial.load()), static_cast(submission.serial), cursor, static_cast(batchesAtFlip), static_cast(unsignaledAtFlip)); - flipHold.Release(); submission.flips.at(cursor)->GpuReady(frame); } else if (opcode == 0x15 || opcode == 0x16) { packetCounters.CountDispatch(); @@ -608,12 +590,15 @@ void Driver::execute(const Submission& submission) { static const bool submitAtEnd = std::getenv("APS5_SUBMIT_AT_END") != nullptr; if (pipelined && orderedCompletion && deferredLabels().labels.empty()) { if (submission.rewindTail == nullptr) { + flipHold.held = false; deferCompletion(submission); return; } if ((std::atomic_ref(*const_cast(submission.rewindTail - 1)).load(std::memory_order_acquire) & 0x80000000u) != 0) { - DrawPipeline::Queue0().Enqueue([this, progress = submission.progress] { - submitOpenWork(progress); + const bool holdsFlip = std::exchange(flipHold.held, false); + DrawPipeline::Queue0().Enqueue([this, holdsFlip, progress = submission.progress] { + if (holdsFlip) releaseFlipHold(submitFlipWork(0)); + else submitOpenWork(progress); wakeQueueZeroForCompletions(); }, {}); endTiming.Finish(); @@ -626,6 +611,7 @@ void Driver::execute(const Submission& submission) { auto& costs = submissionCosts(submission.queue); if (!submitAtEnd && submission.rewindTail == nullptr && submission.queue == 0 && workerQueued() != nullptr && workerQueued()->load(std::memory_order_acquire) != 0) { ++costs.endSkipped; + flipHold.Finish(); return; } ++costs.endSubmits; @@ -636,6 +622,7 @@ void Driver::execute(const Submission& submission) { recordDeferredLabels(localDevice.get(), submission.queue); if (localDevice != nullptr) localDevice->SubmitRecorded(submission.queue == 0); } + flipHold.Finish(); endTiming.Finish(); if (submission.rewindTail != nullptr) { if (submission.progress) submitOpenWork(submission.progress); diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/QueueWorker.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/QueueWorker.cpp index e30e7dfe0f..7117eb8a65 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/QueueWorker.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/QueueWorker.cpp @@ -70,8 +70,8 @@ void Driver::deferCompletion(const Submission& submission) { queue0Uncommitted.push_back(received); } completionDeferred() = true; - DrawPipeline::Queue0().Enqueue([this, serial = submission.serial, received, labelWrites = submission.labelWrites, progress = submission.progress] { - completeSubmission(serial, received, labelWrites, progress); + DrawPipeline::Queue0().Enqueue([this, serial = submission.serial, received, labelWrites = submission.labelWrites, progress = submission.progress, holdsFlip = submission.holdsFlip] { + completeSubmission(serial, received, labelWrites, progress, holdsFlip); }, {}); } @@ -95,7 +95,15 @@ void Driver::wakeQueueZeroForCompletions() { changed.notify_all(); } -void Driver::completeSubmission(std::uint64_t serial, std::uint64_t received, std::span labelWrites, const std::shared_ptr& progress) { +std::uint64_t Driver::submitFlipWork(std::uint32_t queue) { + GuestMemory::TagGpuLockSite(GuestMemory::GpuLockSite::End); + std::lock_guard gpuLock(GuestMemory::GpuMutex()); + const auto localDevice = device.Load(); + recordDeferredLabels(localDevice.get(), queue); + return localDevice != nullptr ? localDevice->SubmitAndEpoch() : 0; +} + +void Driver::completeSubmission(std::uint64_t serial, std::uint64_t received, std::span labelWrites, const std::shared_ptr& progress, bool holdsFlip) { struct Complete { Driver& driver; std::uint64_t serial, received; @@ -113,7 +121,8 @@ void Driver::completeSubmission(std::uint64_t serial, std::uint64_t received, st if (notify) driver.changed.notify_all(); } } complete{*this, serial, received, labelWrites}; - submitOpenWork(progress); + if (holdsFlip) releaseFlipHold(submitFlipWork(0)); + else submitOpenWork(progress); } const std::atomic*& Driver::workerQueued() { diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp index 76facb3cd7..d75af50e13 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp @@ -281,10 +281,28 @@ void Driver::holdFlipBehindWorker(const Submission& submission) { flipHolders.fetch_add(1, std::memory_order_acq_rel); changed.wait(lock, [&] { return failure != nullptr || stopping.load(std::memory_order_acquire) || shutdownToken.stop_requested() || flipsAhead.load(std::memory_order_acquire) == 0 || queue0AwaitsTitle.load(std::memory_order_acquire); }); flipHolders.fetch_sub(1, std::memory_order_acq_rel); + if (failure != nullptr || stopping.load(std::memory_order_acquire) || shutdownToken.stop_requested() || flipsAhead.load(std::memory_order_acquire) != 0) return; + const auto workSerial = flipWorkSerial.load(std::memory_order_acquire); + lock.unlock(); + waitFlipWork(workSerial); } -void Driver::releaseFlipHold() { +void Driver::waitFlipWork(std::uint64_t workSerial) { + if (workSerial == 0) return; + const auto localDevice = device.Load(); + if (localDevice == nullptr) return; + if (localDevice->CanWaitUnlocked()) { + localDevice->WaitRecorded(workSerial); + return; + } + GuestMemory::TagGpuLockSite(GuestMemory::GpuLockSite::Wait); + std::lock_guard gpuLock(GuestMemory::GpuMutex()); + localDevice->WaitIdle(); +} + +void Driver::releaseFlipHold(std::uint64_t workSerial) { std::lock_guard lock(mutex); + if (workSerial > flipWorkSerial.load(std::memory_order_relaxed)) flipWorkSerial.store(workSerial, std::memory_order_release); flipsAhead.fetch_sub(1, std::memory_order_acq_rel); if (flipHolders.load(std::memory_order_acquire) != 0) changed.notify_all(); } @@ -498,7 +516,6 @@ void Driver::Submit(const Packet* packet, std::uint32_t queue, bool cpuFlip) { submission.flips.begin()->second->GpuReady(frame); lock.unlock(); Graphics::NoteHostImportFrame(); - AgcDriverDeliverEopInterrupt(0); return; } submission.shaders = shaders; diff --git a/core/libs/prx/libSceAgcDriver/tests/Flip.cpp b/core/libs/prx/libSceAgcDriver/tests/Flip.cpp index 849703ad95..4d667ac03b 100644 --- a/core/libs/prx/libSceAgcDriver/tests/Flip.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/Flip.cpp @@ -282,6 +282,50 @@ void testFlipHold() { AgcDriverUnregisterVideoOutput_nid_postfix(7, output); } +void testFlipHoldCoversTrailingLabel() { + auto output = std::make_shared(); + AgcDriverRegisterVideoOutput_nid_postfix(7, output); + { + std::lock_guard lock(output->state->mutex); + output->state->gate = true; + output->state->waiting = false; + } + struct GateRelease { + std::shared_ptr state; + ~GateRelease() { + { + std::lock_guard lock(state->mutex); + state->gate = false; + } + state->changed.notify_all(); + } + } gateRelease{output->state}; + alignas(8) static std::atomic label = 0; + label.store(0, std::memory_order_release); + const auto labelAddress = reinterpret_cast(&label); + std::array words{0xc004105c, 7, 0xfffffffeu, 1, 0, 0, 0xc0021018, 7, 0, 0, 0xc0033700, 0x04100200, static_cast(labelAddress), static_cast(static_cast(labelAddress) >> 32u), 1}; + Packet frame{words.data(), static_cast(words.size()), 0, {}}; + check(sceAgcDriverSubmitDcb(&frame) == 0, "flip submission with a trailing label failed"); + { + std::unique_lock lock(output->state->mutex); + check(output->state->changed.wait_for(lock, std::chrono::seconds(5), [&] { return output->state->waiting; }), "worker did not reach the rendering wait after the flip"); + } + auto next = std::async(std::launch::async, [] { submitFlip(); }); + check(next.wait_for(std::chrono::milliseconds(200)) == std::future_status::timeout, "a flip was accepted while the previous flip submission had not written its trailing label"); + check(label.load(std::memory_order_acquire) == 0, "a label behind a closed rendering wait was written"); + { + std::lock_guard lock(output->state->mutex); + output->state->gate = false; + } + output->state->changed.notify_all(); + check(next.wait_for(std::chrono::seconds(5)) == std::future_status::ready, "a flip stayed held after the previous flip submission completed"); + next.get(); + check(label.load(std::memory_order_acquire) == 1, "a flip was accepted before the previous flip submission's trailing label landed"); + AgcDriverWaitIdle_nid_postfix(); + check(output->state->ready == 2, "flips around a trailing label were lost"); + AgcDriverUnregisterVideoOutput_nid_postfix(7, output); +} + void testComputeRenderingWait() { auto output = std::make_shared(); AgcDriverRegisterVideoOutput_nid_postfix(7, output); @@ -457,7 +501,7 @@ int main(int argc, char** argv) { return 0; } if (argc == 2) testReset(std::string(argv[1]) == "compute"); - else { testFlipAndBoundary(); testFlipHold(); testComputeRenderingWait(); testComputeFlip(); testFailure(); } + else { testFlipAndBoundary(); testFlipHold(); testFlipHoldCoversTrailingLabel(); testComputeRenderingWait(); testComputeFlip(); testFailure(); } const auto shutdown = expectFailure([] { LibcRunShutdown_nid_postfix(); }); check(shutdown.find(argc == 2 ? (std::string(argv[1]) == "compute" ? "not readable" : "required shader register") : "intentional flip failure") != std::string::npos, "shutdown lost worker failure"); std::puts("AGC flip and suspend tests passed"); diff --git a/core/libs/prx/libSceAgcDriver/tests/Submit.cpp b/core/libs/prx/libSceAgcDriver/tests/Submit.cpp index 5c0440e4f8..f1dc731af9 100644 --- a/core/libs/prx/libSceAgcDriver/tests/Submit.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/Submit.cpp @@ -238,8 +238,7 @@ void testEndOfPipeInterrupts() { AgcDriverWaitIdle_nid_postfix(); check(output->ready.load() == 1, "flip-only queue flip was not handed to its output"); check(AgcDriver::Graphics::HostImportFramesEnded() == 1, "the first flip did not end a host import frame"); - check(owner->GetTriggeredEvents(events.data(), 2) == 1 && events[0].udata == &graphicsTag && events[0].data == 1 && sceAgcDriverGetEqEventType(events.data()) == 0, "flip on a queue that never requested a release interrupt did not raise its event"); - check(owner->GetTriggeredEvents(events.data(), 2) == 0, "flip-only queue event was not cleared"); + check(owner->GetTriggeredEvents(events.data(), 2) == 0, "a flip raised the graphics end-of-pipe event"); std::array words{0xc0064900, 0, 1u << 24u, 0, 0, 0, 0, 0}; Packet packet{words.data(), static_cast(words.size()), 0, {}}; check(sceAgcDriverSubmitDcb(&packet) == 0 && sceAgcDriverSubmitDcb(&packet) == 0, "interrupt submit failed"); @@ -256,39 +255,12 @@ void testEndOfPipeInterrupts() { check(sceAgcDriverSubmitDcb(&packet) == 0, "plain release submit failed"); AgcDriverWaitIdle_nid_postfix(); check(owner->GetTriggeredEvents(events.data(), 2) == 0, "release without INT_SEL raised an interrupt"); - check(sceAgcDriverSubmitDcb(&flipPacket) == 0, "flip after a release interrupt submit failed"); - AgcDriverWaitIdle_nid_postfix(); - check(output->ready.load() == 2, "flip after a release interrupt was not handed to its output"); - check(AgcDriver::Graphics::HostImportFramesEnded() == 2, "a flip packet executed on the queue did not end a host import frame"); - check(owner->GetTriggeredEvents(events.data(), 2) == 0, "flip on a queue that already raised its own release interrupt raised a second event"); - words[2] = 1u << 24u; - check(sceAgcDriverSubmitDcb(&packet) == 0, "release before sustained flips submit failed"); - AgcDriverWaitIdle_nid_postfix(); - check(owner->GetTriggeredEvents(events.data(), 2) == 1, "a release interrupt did not deliver on a queue whose flip event is suppressed"); - std::uint32_t sustainedFlips = 0; - std::uint32_t flipsUntilEvent = 0; - while (sustainedFlips < 8 && flipsUntilEvent == 0) { - check(sceAgcDriverSubmitDcb(&flipPacket) == 0, "sustained flip submit failed"); + for (int index = 0; index < 8; ++index) { + check(sceAgcDriverSubmitDcb(&flipPacket) == 0, "flip after a release submit failed"); AgcDriverWaitIdle_nid_postfix(); - ++sustainedFlips; - check(output->ready.load() == static_cast(2 + sustainedFlips), "sustained flip was not handed to its output"); - if (owner->GetTriggeredEvents(events.data(), 2) != 0) flipsUntilEvent = sustainedFlips; - } - check(flipsUntilEvent != 0, "a flip never raised an event again on a queue whose own release interrupts stopped"); - check(flipsUntilEvent > 2, "a flip raised an event within two flips of the queue's own release interrupt"); - check(sceAgcDriverSubmitDcb(&flipPacket) == 0, "flip after the latch cleared submit failed"); - AgcDriverWaitIdle_nid_postfix(); - check(output->ready.load() == static_cast(3 + sustainedFlips), "flip after the latch cleared was not handed to its output"); - check(owner->GetTriggeredEvents(events.data(), 2) == 1, "a flip did not raise an event after the latch cleared"); - words[2] = 1u << 24u; - check(sceAgcDriverSubmitDcb(&packet) == 0, "release after a cleared latch submit failed"); - AgcDriverWaitIdle_nid_postfix(); - check(owner->GetTriggeredEvents(events.data(), 2) == 1, "a release interrupt did not deliver after the latch cleared"); - for (int index = 0; index < 2; ++index) { - check(sceAgcDriverSubmitDcb(&flipPacket) == 0, "flip paired with a release interrupt submit failed"); - AgcDriverWaitIdle_nid_postfix(); - check(output->ready.load() == static_cast(4 + sustainedFlips + index), "flip paired with a release interrupt was not handed to its output"); - check(owner->GetTriggeredEvents(events.data(), 2) == 0, "a flip raised an event within two flips of the queue's own release interrupt"); + check(output->ready.load() == 2 + index, "flip after a release was not handed to its output"); + check(AgcDriver::Graphics::HostImportFramesEnded() == static_cast(2 + index), "a flip packet executed on the queue did not end a host import frame"); + check(owner->GetTriggeredEvents(events.data(), 2) == 0, "a flip after a release raised the graphics end-of-pipe event"); } const auto framesBefore = AgcDriver::Graphics::HostImportFramesEnded(); const auto readyBefore = output->ready.load(); diff --git a/docs/dev/debt/libSceAgcDriver.md b/docs/dev/debt/libSceAgcDriver.md index ec905ee835..eef8fe95f7 100644 --- a/docs/dev/debt/libSceAgcDriver.md +++ b/docs/dev/debt/libSceAgcDriver.md @@ -322,13 +322,13 @@ - [Descriptor pool recycling](../../../core/libs/prx/libSceAgcDriver/Graphics/src/ShaderResources.cpp) frees sets individually only on lavapipe: [RADV 26.2.2](https://gitlab.freedesktop.org/mesa/mesa/-/blob/mesa-26.2.2/src/amd/vulkan/radv_descriptor_pool.c) initializes its free-set heap with 32 bytes beyond the backing allocation. An allocation-only RX 6600 test reproduces acceptance of a 4,128-byte set in a 4,096-byte pool; the reset-only path rejects it. Pools reset after their last set is released. A cached set can retain unused slots in an exhausted pool until eviction; peak gameplay pool memory remains unmeasured. - [16-bit depth with stencil](../../../core/libs/prx/libSceAgcDriver/Graphics/src/DepthSurface.cpp) (libSceAgcDriver) is stored as `D24_UNORM_S8_UINT`, or `D32_SFLOAT_S8_UINT`, on a device without `D16_UNORM_S8_UINT` as an attachment. Depth comparisons, clears and the depth image see the wider format's precision, so results at the 16-bit quantization boundary can differ from the console; the constant depth bias is scaled by 256 for D24, and a non-zero constant on the D32 float fallback throws. Not measured on console hardware. - [Depth surfaces created over storage writes](../../../core/libs/prx/libSceAgcDriver/Graphics/src/DepthSurface.cpp) (libSceAgcDriver): a depth surface created without HTILE copies its depth plane from the storage image at the plane's address, a pending one first, when that image has the plane's extent, one level, a 2D view or a 2D array view (its layer 0 is copied) and a 16-bit format for a 16-bit plane or a 32-bit format for a 32-bit plane. A multisampled surface, a surface the device stores in another format, a surface with HTILE and a surface without such an image start from `DB_DEPTH_CLEAR`, and the stencil plane from `DB_STENCIL_CLEAR`; CPU writes to the plane's memory are not read. Not measured on the console, where the plane is that memory. -- [Flip packets raise the graphics end-of-pipe event](../../../core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp) (libSceAgcDriver) - a flip delivers the queue's end-of-pipe event (`sceAgcDriverAddEqEvent`) once the work recorded before it completes, as a `RELEASE_MEM` with `INT_SEL` does, but only on a queue that has never delivered an interrupt from such a release: the queue is latched the first time a release interrupt reaches the event queue, and flips on that queue deliver nothing while its own release interrupts keep arriving. The latch is cleared when the queue has flipped five times without another interrupt from its own releases: the fifth flip delivers the event and unlatched the queue, so a title that waits on the flip event after latching recovers only if it submits further flips. The bound cannot be by time: the next release interrupt can arrive seconds later than a suppressed flip across loading stalls, and delivering an unexpected flip event on a queue that pairs flips with releases can crash; no fixed window short enough to unblock a waiting title keeps such a title alive. Whether the console raises the event from the flip itself or from an interrupt its flip sequence contains is not measured; nor is how the console behaves for a title that waits on the flip event and also raises release interrupts on the same queue. - [Heap mirror reference storage](../../../core/libs/prx/libSceAgcDriver/Graphics/src/ReferenceStorage.cpp) (libSceAgcDriver): the 8-buffer/2 GiB idle cap and 2 s reuse window are unmeasured choices. Idle release is polled by a take, an eligible return, a heap mirror sweep or a present; without another poll, expired buffers remain until process shutdown. A title taking references less often than every 2 s reallocates them. The cap covers idle storage only, not buffers still held by active references. - [Host import windows and budget](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) (libSceAgcDriver): a registered range larger than 64 MiB is imported one 64 MiB window at a time for buffer and fill accesses; an access across window edges imports the cells it touches, grown to the union of the range's imports it overlaps. An address-based build (`AcquireRegistered`) imports the range whole and replaces its windows. Live imports stay under a budget: `APS5_HOST_IMPORT_MIB`, else the larger of 6 GiB and half the physical memory, at most the largest host heap less half that heap, kept between 2 and 4 GiB (imports, type-3 mirrors and buffers and textures placed in host memory share it; on a 16 GiB machine whose host heap is half the memory, a 2 GiB reserve left too little for the rest, and on a 32 GiB machine a reserve of half its 16 GiB heap left imports 8 GiB, less than the 10466 MiB one title holds at once). Where the device enables `VK_EXT_memory_budget`, that host heap's reported budget less its other usage and an eighth, in 64 MiB steps, read at most once a second, also bounds the default budget once an import's `vkAllocateMemory` has failed; a set `APS5_HOST_IMPORT_MIB` is never lowered by the report. The report's other usage includes the host-visible copies and heap mirrors that stand in for refused imports, so it grows with the refusals; the first time the report would lower the budget, `[gpu] host import budget: host heap ...` prints the heap's reported budget and usage and the live imports. An import's `vkAllocateMemory` failure within an eighth of the budget, or the third in a row, lowers the budget to the live imports until the device or the configured budget changes; an isolated failure further below only marks its range failed. Live imports are not capped by `maxMemoryAllocationCount`: NVIDIA's Windows driver reports 4096, while Village held 27,167 live imports without a failure. A new import beyond it retires the least recently used imports not used in the current or the previous frame (a frame ends at a flip packet or a CPU flip), so a set used in every frame keeps its imports and what does not fit is copied, as below the budget; until two frames have ended imports age by lookups, an import being evictable 256 lookups after its last use; after that, without flips (a loading stretch, compute-only work) no frame ends and nothing more becomes evictable, so imports that do not fit are copied instead of evicting by recency; one that would not fit even then is refused before anything is retired, and imports made when a range is mapped never evict. An address-based build does not evict either, except for a range the budget refused (or remembers refusing) and no mirror serves whose copy would take the build's copies past `APS5_ADDRESS_COPY_MAX_MIB`; that eviction also takes imports the recency rule protects whose single range is releasable and write-watched, so a heap mirror can serve it, as long as the build has not taken them yet. A heap mirror, sparse or not, whose range has left the write watch is refreshed in full at each use, every block counted as written, unless the build can import its range first, evicting for it as the copy cap does, with the imports of earlier builds no longer held; a build plans its range again only once the import path takes the range. A request that may not evict (an address-based build, an import when a range is mapped) remembers a refused range or block, with the live bytes and the budget at the refusal, and is answered from that memory, still counted as refused, until live imports drop below those bytes, the budget changes or one of its ranges changes. A request that may evict remembers its refusal too, answering later requests for the same run from memory with the room the refusal left (a request whose range fits in that room steps down to a smaller block without scanning the imports again, and a refusal that stepped down is remembered for each block it tried), while no import was made or retired, the budget has not grown, the import window and the run are unchanged, and no import can have become evictable since: no frame has ended (or, before two frames have ended, 256 lookups have not passed), and no build holding an import two frames old has raised its floor. A join remembers its refusal the same way, apart from other requests for the same run, so a buffer bound across refused ranges does not try the join again at every upload. The shutdown line `[gpu] host imports at shutdown` gives the live, made, refused (and remembered) and failed counts per requester. The 64 MiB window, the two-frame protection, the 2 GiB reserve, the eighth, the 64 MiB steps and the three failures are choices, not measured. - [Piece-split ranges over the host import budget](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) (libSceAgcDriver): an address-based build imports a registered range larger than the device's largest allocation in pieces. If the budget refuses a piece, the range is mirrored or copied without the evicting retry other refused ranges get when their copy would pass `APS5_ADDRESS_COPY_MAX_MIB`, so such a copy can still exceed that cap and end the build. - [Device-local staging](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) (libSceAgcDriver) - a written or atomic buffer element inside a host import, including one an address-based compute build reaches through its pointers, is copied to device memory before the dispatch and copied back after it. The copy-back stores only the bytes the shader changed and the import still holds at their copy-in value, so a CPU store in between survives; where both changed the same byte, the CPU's byte is kept, whichever the console would store last. The whole range is still copied back, rolling CPU stores back, at the unaligned ends of a range, for an element or import offset that is not 4-byte aligned, without a shadow copy or buffer device addresses, with `APS5_NO_CHANGED_COPY_BACK`, and when a synchronous draw stores a GPU-copied region from the CPU (no copy-back recorded, no copy-in snapshot). A CPU store made while the copy-back runs can still be overwritten by it. - [Write-back conflicts with CPU stores](../../../core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp) (libSceAgcDriver) - a buffer region the CPU copies for a dispatch is stored back with `WriteChangedKeepingGuest`: a byte takes the shader's value only where the shader changed it and guest memory still holds the uploaded value, so a CPU store made after the upload wins where both changed the same byte. A CPU store that restores the uploaded value is indistinguishable from no store and takes the shader's byte. Writable image mirrors, heap mirrors, storage image write-backs and tiled colour targets use `WriteChanged`, which stores every byte the GPU changed: a CPU store into the same byte between the upload and the write-back is overwritten there, as before; a CPU store into a byte the GPU left alone survives on every path. Which side the console would store last is not measured. - [Committed reads of released pages](../../../core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp) (libSceAgcDriver) - `ReadCommitted` and `EqualsCommitted` find the readable pages of a range first and read them afterwards. On Windows they read through `ReadProcessMemory`, so a page released in between reads as zero and is left out of the comparison instead of faulting: Demon's Souls (PPSA01342) samples a texture at 0x38400 of 352 MiB while it loads the tutorial level, a range that holds no guest allocation and on Windows covers host heap pages other threads release, which stopped it with an access violation. A texture outside every registered guest allocation still takes the host bytes found there, as it did before; what the console returns for it is not measured. On Linux the pages are copied directly, so a page released between the check and the copy still faults. +- [Flip submissions hold the next flip until their work completed](../../../core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp) (libSceAgcDriver) - a queue-0 submission that carries a flip returns only after every earlier flip submission ran to its end and its recorded GPU work completed, so the labels a frame writes after its flip are in memory when the title reads them after its next submission. Flips raise no graphics end-of-pipe event (from the PS5 13.60 kernel: a flip's interrupt context is always typed and goes to the display path, never to this event). Not measured: how far the console lets a title run ahead of frames its GPU has not finished. - [Periodic pipeline cache saves skip busy intervals](../../../core/libs/prx/libSceAgcDriver/Graphics/src/PipelineCache.cpp) (libSceAgcDriver) - the saver thread snapshots the Vulkan pipeline cache only when no pipeline creation holds it (`TryLockForSnapshot`); otherwise it skips that 10 s interval. A blocking snapshot queued behind a long creation (an NVIDIA compile of a 1.5M-word compute pipeline runs for minutes) makes the writer-preferring `shared_mutex` hold every later pipeline creation on every queue until that compile ends, which froze Yōtei's (PPSA26344) loading at frame 797-799 for the whole compile. While creations overlap without a gap, no periodic save happens, so a crash in that stretch loses the pipelines built since the last save; `SaveNow` and the teardown save still wait for the lock. How often busy stretches skip saves in practice is not measured. - [Registration preparation at submit](../../../core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp) (libSceAgcDriver) - `Driver::Submit` waits for every registration-time shader preparation queued before the call, not only those of the shaders the commands use, so a submission behind a long queue of unrelated preparations waits for all of them on the submitting thread. Waiting there instead of in the queue worker keeps the GPU side of a submission short: a title that sees a submission take seconds treats the GPU as hung (Goat Simulator 3, PPSA04159, then stops submitting and its garbage collector fails a 10 s timeout). On the console shaders are compiled ahead of time, so neither wait exists; how long a title tolerates a slow submit call is not known. - [Resource cache entries holding released textures](../../../core/libs/prx/libSceAgcDriver/Graphics/src/ShaderResources.cpp) (libSceAgcDriver) - a sampled texture the texture cache drops while something else holds it, or makes without caching (`admitTexture` refusing a first use), is marked released, and `ResourceCache::Insert` refuses descriptor sets that hold one and removes the entries that do. The removal waits for the next insert: until then `Find` still returns those entries and their textures stay alive. One process-wide flag starts the sweep, so while the sampled cache is over its budget every insert scans all entries (1,024 at start, up to 16,384 after growth, or `APS5_RESOURCE_CACHE_ENTRIES`, each with its textures); that cost is not measured. When `ReleaseTextureCaches` runs on device memory exhaustion, the sampled textures these entries hold are only marked released and are freed at the next insert, so the immediate allocation retry gets no memory back from them. A draw or dispatch whose textures the cache refused builds its descriptor sets again on each use for as long as the cache stays full; the count bounds (`APS5_RESOURCE_CACHE_ENTRIES`, `APS5_RESOURCE_CACHE_TABLE_ELEMENTS`) still bound nothing by bytes, and sampled textures made outside the texture cache (the disabled cache, depth-surface copies) are never marked. Measured on one title on one RTX 4090: with the default bounds, sampled textures held outside the cache peaked at 2,836 MiB (7,830 MiB before) and device-local heap 0 stayed under its 22.9 GB budget for the whole run (26,357 MiB before, which aborted with `VK_ERROR_OUT_OF_DEVICE_MEMORY` at the wolf scene). Whether the extra descriptor-set builds cost frame time elsewhere is not measured. From 9ca493cfc7296440afbe495ec51b5113d2d2575b Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:51:11 +0200 Subject: [PATCH 22/62] test(agc): expect a flip to be held while the previous flip submission has not completed --- core/libs/prx/libSceAgcDriver/tests/Flip.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceAgcDriver/tests/Flip.cpp b/core/libs/prx/libSceAgcDriver/tests/Flip.cpp index 4d667ac03b..43a8dd8106 100644 --- a/core/libs/prx/libSceAgcDriver/tests/Flip.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/Flip.cpp @@ -173,8 +173,11 @@ void testFlipAndBoundary() { AgcDriverUnregisterVideoOutput_nid_postfix(7, output); auto replacement = std::make_shared(); AgcDriverRegisterVideoOutput_nid_postfix(7, replacement); - submitFlip(); + auto next = std::async(std::launch::async, [] { submitFlip(); }); + check(next.wait_for(std::chrono::milliseconds(200)) == std::future_status::timeout, "a flip was accepted before the previous flip submission completed"); release.Release(); + check(next.wait_for(std::chrono::seconds(5)) == std::future_status::ready, "a flip stayed held after the previous flip submission completed"); + next.get(); AgcDriverWaitIdle_nid_postfix(); check(output->state->ready == 1 && replacement->state->ready == 1, "registration lifetime or FIFO was lost"); check(output->state->failed == 0, "successful request failed"); From c676f7c93763f822fecc80df82e71dd4c00a16f1 Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:19:04 +0200 Subject: [PATCH 23/62] fix(agc): carry the release's interrupt context in the graphics end-of-pipe event --- .../prx/libSceAgcDriver/Eq/include/Event.hpp | 2 +- .../libs/prx/libSceAgcDriver/Eq/src/Event.cpp | 12 ++++--- .../libs/prx/libSceAgcDriver/Eq/src/Query.cpp | 12 ++----- .../Execution/include/Driver/Driver.hpp | 9 ++--- .../src/Driver/Packets/PacketExecution.cpp | 23 ++++++++---- .../src/Driver/Packets/PacketMemory.cpp | 28 +++++++-------- .../libs/prx/libSceAgcDriver/tests/Submit.cpp | 35 +++++++++++-------- 7 files changed, 68 insertions(+), 53 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Eq/include/Event.hpp b/core/libs/prx/libSceAgcDriver/Eq/include/Event.hpp index 9792696727..ffdff3df09 100644 --- a/core/libs/prx/libSceAgcDriver/Eq/include/Event.hpp +++ b/core/libs/prx/libSceAgcDriver/Eq/include/Event.hpp @@ -4,7 +4,7 @@ #include #include "SceTypes.hpp" -void AgcDriverDeliverEopInterrupt(std::uint32_t queue); +void AgcDriverDeliverEopInterrupt(std::uint32_t queue, std::uint32_t context); extern "C" int APS5_VABI sceAgcDriverAddEqEvent(KernelEqueue eq, int id, void* udata); extern "C" int APS5_VABI sceAgcDriverDeleteEqEvent(KernelEqueue eq, int id); diff --git a/core/libs/prx/libSceAgcDriver/Eq/src/Event.cpp b/core/libs/prx/libSceAgcDriver/Eq/src/Event.cpp index 339d258622..1254c09e3d 100644 --- a/core/libs/prx/libSceAgcDriver/Eq/src/Event.cpp +++ b/core/libs/prx/libSceAgcDriver/Eq/src/Event.cpp @@ -33,9 +33,11 @@ int AddEvent(KernelEqueue eq, std::uint32_t queue, std::uintptr_t ident, void* u event.event.ident = ident; event.event.filter = EvfiltGraphicsCore; event.event.flags = EV_ADD | EV_CLEAR; + event.event.data = static_cast(queue); event.event.udata = udata; - event.filter.triggerFunc = [](KernelEqueueEvent* e, void*) { - e->event.data = e->triggered ? e->event.data + 1 : 1; + event.filter.triggerFunc = [](KernelEqueueEvent* e, void* data) { + if (data == nullptr) throw std::runtime_error("graphics event triggered without its interrupt data"); + e->event.data = static_cast(*static_cast(data)); e->triggered = true; }; event.filter.resetFunc = [](KernelEqueueEvent* e) { @@ -67,14 +69,16 @@ int DeleteEvent(KernelEqueue eq, std::uintptr_t ident, const char* caller) { } -void AgcDriverDeliverEopInterrupt(std::uint32_t queue) { +void AgcDriverDeliverEopInterrupt(std::uint32_t queue, std::uint32_t context) { std::vector registrations; { std::lock_guard lock(g_mutex); registrations = g_registrations; } for (const auto& registration : registrations) { - if (registration.queue == queue) EqueueTriggerEvent_nid_postfix(registration.eq, registration.ident, EvfiltGraphicsCore, nullptr); + if (registration.queue != queue) continue; + std::uint64_t data = static_cast(static_cast(registration.queue)) | (static_cast(context) << 16u); + EqueueTriggerEvent_nid_postfix(registration.eq, registration.ident, EvfiltGraphicsCore, &data); } } diff --git a/core/libs/prx/libSceAgcDriver/Eq/src/Query.cpp b/core/libs/prx/libSceAgcDriver/Eq/src/Query.cpp index c0987dc2b7..dc00d7ad80 100644 --- a/core/libs/prx/libSceAgcDriver/Eq/src/Query.cpp +++ b/core/libs/prx/libSceAgcDriver/Eq/src/Query.cpp @@ -17,22 +17,14 @@ uint32_t APS5_VABI sceAgcDriverGetEqContextId(const KernelEvent* ev) { if (ev->filter != -14) { throw std::runtime_error(std::string(__func__) + ": not a graphics event"); } - if (ev->ident > std::numeric_limits::max()) { - throw std::runtime_error(std::string(__func__) + ": context id overflow"); - } - return static_cast(ev->ident); + return static_cast(static_cast(ev->data) >> 16u); } int APS5_VABI sceAgcDriverGetEqEventType(const KernelEvent* ev) { if (ev == nullptr || reinterpret_cast(ev) % alignof(KernelEvent) != 0) { throw std::runtime_error(std::string(__func__) + ": null or misaligned event"); } - if (ev->filter == -14) { - if (ev->ident > static_cast(std::numeric_limits::max())) { - throw std::runtime_error(std::string(__func__) + ": graphics event type overflow"); - } - return static_cast(ev->ident); - } + if (ev->filter == -14) return static_cast(static_cast(static_cast(ev->data))); if (ev->data < std::numeric_limits::min() || ev->data > std::numeric_limits::max()) { throw std::runtime_error(std::string(__func__) + ": event type overflow"); } diff --git a/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp b/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp index 6001bc181a..44c40e8b6b 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp +++ b/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp @@ -299,10 +299,10 @@ private: static std::vector> drawWriteRanges(const Graphics::State& graphics, std::span stages); void commitDraw(std::shared_ptr localDevice, std::uint32_t queue, const Graphics::State& graphics, const Pm4::DrawParameters& drawParameters, std::span stages, std::span snapshots, const std::shared_ptr& recipe, const std::vector>& recipeStages, std::uint64_t drawKey, VulkanDevice::DrawTraceContext trace = {}, std::uint64_t shape = 0); bool labelPacketQueues(std::span packet, std::uint32_t opcode); - bool enqueueLabelPacket(std::span packet, std::uint32_t opcode, std::uint32_t queue); + bool enqueueLabelPacket(std::span packet, std::uint32_t opcode, std::uint32_t queue, std::optional interrupt); bool enqueueDmaPacket(std::span packet, std::uint32_t opcode, std::uint32_t queue, const QueueState& state); void commitDmaStore(std::uint32_t queue, std::uint64_t address, std::span bytes); - void commitLabel(std::uint32_t queue, std::uint64_t address, std::span bytes, bool endOfPipeInterrupt); + void commitLabel(std::uint32_t queue, std::uint64_t address, std::span bytes, std::optional interrupt); void submitDueAfterCommit(VulkanDevice* localDevice); std::optional newestWriterLocked(std::uint64_t begin, std::uint64_t end) const; std::optional newestWriter(std::uint64_t begin, std::uint64_t end); @@ -382,14 +382,15 @@ private: void recordQueuedLabelsByTry(std::uint32_t queue, std::atomic& counter); static void recordQueuedLabelsFromHook(); void flushBetweenPackets(std::uint32_t queue, std::uint32_t header, bool labelPacket); - bool preparePacketMemory(const Submission& submission, QueueState& queue, std::span packet, std::uint32_t header, std::uint32_t opcode, bool& wroteOnGpu, bool& endOfPipeInterrupt, bool& interruptDeferred, bool& drawPacket, bool& sampleDump); + bool preparePacketMemory(const Submission& submission, QueueState& queue, std::span packet, std::uint32_t header, std::uint32_t opcode, std::optional interrupt, bool& wroteOnGpu, bool& endOfPipeInterrupt, bool& interruptDeferred, bool& drawPacket, bool& sampleDump); void dumpSampleCounters(std::uint64_t address); template static void timed(double WorkerProfile::*bucket, TWork&& work); FrameTiming* frameTiming(); FrameTiming* includeTimingSubmission(const Submission& submission, bool firstSegment); void execute(const Submission& submission); - void deliverGuestEopInterrupt(std::uint32_t queue); + void deliverGuestEopInterrupt(std::uint32_t queue, std::uint32_t context); + static std::optional releaseInterrupt(std::span packet, std::uint32_t opcode, bool carriesFlip); void markCompleted(std::uint64_t serial); static const std::atomic*& workerQueued(); void reapCompletionLabels(); diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp index 163c5646cd..e9dadd3fd0 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #include namespace AgcDriver::DriverDetail { @@ -120,8 +122,18 @@ bool& drawnSinceSeal() { } -void Driver::deliverGuestEopInterrupt(std::uint32_t queue) { - AgcDriverDeliverEopInterrupt(queue); +void Driver::deliverGuestEopInterrupt(std::uint32_t queue, std::uint32_t context) { + AgcDriverDeliverEopInterrupt(queue, context); +} + +std::optional Driver::releaseInterrupt(std::span packet, std::uint32_t opcode, bool carriesFlip) { + if (opcode != 0x49 || carriesFlip || packet.size() < 3) return std::nullopt; + const auto interruptSelect = (packet[2] >> 24u) & 7u; + if (interruptSelect == 0 || interruptSelect == 3) return std::nullopt; + if (packet.size() < 8) throw std::runtime_error("AGC: an interrupting release has no interrupt context dword"); + const auto context = packet[7] & 0x0fffffffu; + if ((context & 0x08000000u) != 0) throw std::runtime_error("AGC: a release asks for typed interrupt context " + std::to_string(context) + ", which is not delivered as a graphics end-of-pipe event; not implemented"); + return context; } template @@ -283,7 +295,7 @@ void Driver::execute(const Submission& submission) { if (submission.queue != 0 && Graphics::Recorder::DispatchOverlapEnabled()) noteRingPacket(submission.queue, submission.serial, opcode, header); PerformanceTimer timing("Driver.Packet"); if (!predicated) noteLabelIssue(packet, submission.queue); - if (pipelined && !predicated && header != FlipPacketHeader && header != RenderingWaitPacketHeader && !submission.flips.contains(cursor) && (enqueueLabelPacket(packet, opcode, submission.queue) || enqueueDmaPacket(packet, opcode, submission.queue, queue))) { + if (pipelined && !predicated && header != FlipPacketHeader && header != RenderingWaitPacketHeader && !submission.flips.contains(cursor) && (enqueueLabelPacket(packet, opcode, submission.queue, releaseInterrupt(packet, opcode, false)) || enqueueDmaPacket(packet, opcode, submission.queue, queue))) { traceLabel(packet, submission.queue); recent.Record(cursor); cursor = nextCursor; @@ -374,7 +386,7 @@ void Driver::execute(const Submission& submission) { if (profilePackets) packetProfile.flushMs += std::chrono::duration(packetTimer.start - flushStart).count(); bool wroteOnGpu = false, endOfPipeInterrupt = false, interruptDeferred = false, drawPacket = false, sampleDump = false; - const bool drains = preparePacketMemory(submission, queue, packet, header, opcode, wroteOnGpu, endOfPipeInterrupt, interruptDeferred, drawPacket, sampleDump); + const bool drains = preparePacketMemory(submission, queue, packet, header, opcode, releaseInterrupt(packet, opcode, submission.flips.contains(cursor)), wroteOnGpu, endOfPipeInterrupt, interruptDeferred, drawPacket, sampleDump); traceLabel(packet, submission.queue); timing.Mark("prepare_memory"); @@ -398,7 +410,6 @@ void Driver::execute(const Submission& submission) { Pm4::Execute(packet, queue); if (const auto label = Pm4::DecodeLabelWrite(packet)) noteLabelStore(label->address, label->Bytes(), ++eventSerial, submission.queue); } - if (endOfPipeInterrupt && !interruptDeferred) deliverGuestEopInterrupt(submission.queue); } if (deviceUse.owns_lock()) deviceUse.unlock(); CheckFailure(); @@ -575,7 +586,7 @@ void Driver::execute(const Submission& submission) { if (const auto label = Pm4::DecodeLabelWrite(packet)) noteLabelStore(label->address, label->Bytes(), ++eventSerial, submission.queue); } } - if (endOfPipeInterrupt && !interruptDeferred) deliverGuestEopInterrupt(submission.queue); + if (endOfPipeInterrupt && !interruptDeferred) deliverGuestEopInterrupt(submission.queue, packet[7] & 0x0fffffffu); } if (drawPacket || (sampleDump && wroteOnGpu)) Graphics::Recorder::CountRecordedWork(); timing.Mark(header == RenderingWaitPacketHeader ? "rendering_wait" : opcode == 0x3c || opcode == 0x93 ? "memory_wait" : opcode == 0x15 || opcode == 0x16 ? "dispatch" : drawPacket ? "draw" : "execute"); diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketMemory.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketMemory.cpp index 887e45d265..906334c6fd 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketMemory.cpp @@ -54,15 +54,14 @@ std::optional decodeCopy(std::span packet) } -bool Driver::preparePacketMemory(const Submission& submission, QueueState& queue, std::span packet, std::uint32_t header, std::uint32_t opcode, bool& wroteOnGpu, bool& endOfPipeInterrupt, bool& interruptDeferred, bool& drawPacket, bool& sampleDump) { +bool Driver::preparePacketMemory(const Submission& submission, QueueState& queue, std::span packet, std::uint32_t header, std::uint32_t opcode, std::optional interrupt, bool& wroteOnGpu, bool& endOfPipeInterrupt, bool& interruptDeferred, bool& drawPacket, bool& sampleDump) { static const bool drainAll = std::getenv("APS5_DRAIN_ALL") != nullptr; static const bool profile = std::getenv("APS5_PROFILE_DRAW") != nullptr; wroteOnGpu = false; bool orderedAlready = false; - const auto interruptSelect = opcode == 0x49 ? (packet[2] >> 24u) & 7u : 0u; - endOfPipeInterrupt = interruptSelect != 0 && interruptSelect != 3; + endOfPipeInterrupt = interrupt.has_value(); interruptDeferred = false; if (!drainAll && endOfPipeInterrupt) { const auto label = Pm4::DecodeLabelWrite(packet); @@ -94,7 +93,8 @@ bool Driver::preparePacketMemory(const Submission& submission, QueueState& queue } if (reason == 0 || reason == 5) { const auto queueId = submission.queue; - interruptDeferred = localDevice->AfterRecordedWork([this, queueId] { deliverGuestEopInterrupt(queueId); }, submission.queue == 0); + const auto context = *interrupt; + interruptDeferred = localDevice->AfterRecordedWork([this, queueId, context] { deliverGuestEopInterrupt(queueId, context); }, submission.queue == 0); if (interruptDeferred && workOpen) localDevice->SubmitRecorded(submission.queue == 0); wroteOnGpu = true; } else if (reason == 1) { @@ -436,10 +436,9 @@ bool Driver::labelPacketQueues(std::span packet, std::uint3 return opcode == 0x49 && ((packet[2] >> 29u) == 0 || (packet[3] | (static_cast(packet[4]) << 32u)) == 0); } -bool Driver::enqueueLabelPacket(std::span packet, std::uint32_t opcode, std::uint32_t queue) { +bool Driver::enqueueLabelPacket(std::span packet, std::uint32_t opcode, std::uint32_t queue, std::optional interrupt) { if (!labelPacketQueues(packet, opcode)) return false; - const auto interruptSelect = opcode == 0x49 ? (packet[2] >> 24u) & 7u : 0u; - const bool endOfPipeInterrupt = interruptSelect != 0 && interruptSelect != 3; + const bool endOfPipeInterrupt = interrupt.has_value(); const auto label = Pm4::DecodeLabelWrite(packet); if (!label.has_value()) { if (opcode != 0x49) return false; @@ -462,10 +461,10 @@ bool Driver::enqueueLabelPacket(std::span packet, std::uint if (endOfPipeInterrupt) bumpEpoch(&EpochBumps::drains); auto stored = bytes; auto labelBytes = bytes; - auto enqueue =[this, queue, opcode, address, bytes = std::move(bytes), endOfPipeInterrupt, writes, stored = std::move(stored)]() mutable { - DrawPipeline::Queue0().Enqueue([this, queue, opcode, address, bytes = std::move(bytes), endOfPipeInterrupt] { + auto enqueue =[this, queue, opcode, address, bytes = std::move(bytes), interrupt, writes, stored = std::move(stored)]() mutable { + DrawPipeline::Queue0().Enqueue([this, queue, opcode, address, bytes = std::move(bytes), interrupt] { GuestMemory::SetCurrentPacket(opcode, queue); - commitLabel(queue, address, bytes, endOfPipeInterrupt); + commitLabel(queue, address, bytes, interrupt); }, std::move(writes), address, std::move(stored)); }; if (queue == 0 && DrawFinisher::Overlap() && DrawFinisher::Busy()) { @@ -481,7 +480,7 @@ bool Driver::enqueueLabelPacket(std::span packet, std::uint return true; } -void Driver::commitLabel(std::uint32_t queue, std::uint64_t address, std::span bytes, bool endOfPipeInterrupt) { +void Driver::commitLabel(std::uint32_t queue, std::uint64_t address, std::span bytes, std::optional interrupt) { GuestMemory::TagGpuLockSite(GuestMemory::GpuLockSite::Label); std::lock_guard gpuLock(GuestMemory::GpuMutex()); const auto localDevice = device.Load(); @@ -503,14 +502,15 @@ void Driver::commitLabel(std::uint32_t queue, std::uint64_t address, std::spanAfterRecordedWork([this, queue] { deliverGuestEopInterrupt(queue); }, queue == 0); + deferred = localDevice->AfterRecordedWork([this, queue, context] { deliverGuestEopInterrupt(queue, context); }, queue == 0); if (deferred && workOpen) localDevice->SubmitRecorded(queue == 0); } - if (!deferred) deliverGuestEopInterrupt(queue); + if (!deferred) deliverGuestEopInterrupt(queue, context); } submitDueAfterCommit(localDevice.get()); } diff --git a/core/libs/prx/libSceAgcDriver/tests/Submit.cpp b/core/libs/prx/libSceAgcDriver/tests/Submit.cpp index f1dc731af9..c0ede60429 100644 --- a/core/libs/prx/libSceAgcDriver/tests/Submit.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/Submit.cpp @@ -71,24 +71,20 @@ std::string expectFailure(TAction action) { void testEvents() { KernelEvent event{}; event.filter = -14; - event.ident = 0x40; - event.data = 123; - check(sceAgcDriverGetEqEventType(&event) == 0x40, "graphics event uses wrong field"); + event.ident = 0x20; + event.data = (0x38 << 16) | 0x20; + check(sceAgcDriverGetEqEventType(&event) == ((0x38 << 16) | 0x20), "graphics event type is not the event data"); + check(sceAgcDriverGetEqContextId(&event) == 0x38, "graphics event context id is not the data's upper bits"); + event.data = static_cast((std::uint64_t{0x0fffffff} << 16u) | 0x28); + check(sceAgcDriverGetEqContextId(&event) == 0x0fffffffu, "graphics event context id lost its upper bits"); + check(sceAgcDriverGetEqEventType(&event) == static_cast(0xffff0028u), "graphics event type is not the data's low word"); event.filter = -1; event.data = -17; check(sceAgcDriverGetEqEventType(&event) == -17, "non-graphics event uses wrong field"); event.data = std::numeric_limits::max(); expectFailure([&] { sceAgcDriverGetEqEventType(&event); }); - event.filter = -14; - event.ident = std::numeric_limits::max(); - expectFailure([&] { sceAgcDriverGetEqEventType(&event); }); expectFailure([] { sceAgcDriverGetEqEventType(nullptr); }); expectFailure([&] { sceAgcDriverGetEqEventType(reinterpret_cast(reinterpret_cast(&event) + 1)); }); - event.ident = 0x29; - check(sceAgcDriverGetEqContextId(&event) == 0x29, "graphics event context id uses wrong field"); - event.ident = std::numeric_limits::max(); - expectFailure([&] { sceAgcDriverGetEqContextId(&event); }); - event.ident = 1; event.filter = -1; expectFailure([&] { sceAgcDriverGetEqContextId(&event); }); expectFailure([] { sceAgcDriverGetEqContextId(nullptr); }); @@ -244,13 +240,24 @@ void testEndOfPipeInterrupts() { check(sceAgcDriverSubmitDcb(&packet) == 0 && sceAgcDriverSubmitDcb(&packet) == 0, "interrupt submit failed"); AgcDriverWaitIdle_nid_postfix(); check(owner->GetTriggeredEvents(events.data(), 2) == 1, "graphics end-of-pipe interrupt was not delivered to its queue only"); - check(events[0].filter == -14 && events[0].udata == &graphicsTag && events[0].data == 2 && sceAgcDriverGetEqEventType(events.data()) == 0, "graphics end-of-pipe event encoding is wrong"); + check(events[0].filter == -14 && events[0].udata == &graphicsTag && events[0].data == 0 && sceAgcDriverGetEqEventType(events.data()) == 0, "graphics end-of-pipe event encoding is wrong"); check(sceAgcDriverGetEqContextId(events.data()) == 0, "graphics end-of-pipe event reports another context"); check(owner->GetTriggeredEvents(events.data(), 2) == 0, "delivered interrupt was not cleared"); + words[7] = 0x38; + check(sceAgcDriverSubmitDcb(&packet) == 0, "interrupt submit with a context failed"); + AgcDriverWaitIdle_nid_postfix(); + check(owner->GetTriggeredEvents(events.data(), 2) == 1 && events[0].udata == &graphicsTag && events[0].data == (0x38 << 16), "graphics end-of-pipe event does not carry the release's interrupt context"); + check(sceAgcDriverGetEqContextId(events.data()) == 0x38 && sceAgcDriverGetEqEventType(events.data()) == (0x38 << 16), "graphics end-of-pipe event reports another context"); + words[7] = 0xf0000041u; + check(sceAgcDriverSubmitDcb(&packet) == 0, "interrupt submit with high context bits failed"); + AgcDriverWaitIdle_nid_postfix(); + check(owner->GetTriggeredEvents(events.data(), 2) == 1 && sceAgcDriverGetEqContextId(events.data()) == 0x41, "the interrupt context kept bits above its 28 bits"); + words[7] = 0x38; check(sceAgcDriverSubmitAcb(0x20, &packet) == 0, "compute interrupt submit failed"); AgcDriverWaitIdle_nid_postfix(); - check(owner->GetTriggeredEvents(events.data(), 2) == 1 && events[0].udata == &computeTag && sceAgcDriverGetEqEventType(events.data()) == 0x20, "compute end-of-pipe interrupt missing"); - check(sceAgcDriverGetEqContextId(events.data()) == 0x20, "compute end-of-pipe event reports another context"); + check(owner->GetTriggeredEvents(events.data(), 2) == 1 && events[0].udata == &computeTag && events[0].data == ((0x38 << 16) | 0x20) && sceAgcDriverGetEqEventType(events.data()) == ((0x38 << 16) | 0x20), "compute end-of-pipe interrupt missing"); + check(sceAgcDriverGetEqContextId(events.data()) == 0x38, "compute end-of-pipe event reports another context"); + words[7] = 0; words[2] = 0; check(sceAgcDriverSubmitDcb(&packet) == 0, "plain release submit failed"); AgcDriverWaitIdle_nid_postfix(); From 0e620ad9f0fde92dc150461081de5734940d2ba7 Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:22:38 +0200 Subject: [PATCH 24/62] test(agc): check the last end-of-pipe label's interrupt context instead of an interrupt count --- core/libs/prx/libSceAgcDriver/tests/Submit.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/tests/Submit.cpp b/core/libs/prx/libSceAgcDriver/tests/Submit.cpp index c0ede60429..f0ed264bda 100644 --- a/core/libs/prx/libSceAgcDriver/tests/Submit.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/Submit.cpp @@ -333,9 +333,9 @@ std::vector commands(const std::array&... packe return words; } -std::array endOfPipeLabel(volatile std::uint32_t* address, std::uint32_t value) { +std::array endOfPipeLabel(volatile std::uint32_t* address, std::uint32_t value, std::uint32_t context) { const auto target = reinterpret_cast(address); - return {0xc0064900, 0x514, (1u << 29u) | (2u << 24u), static_cast(target), static_cast(static_cast(target) >> 32u), value, 0, 0}; + return {0xc0064900, 0x514, (1u << 29u) | (2u << 24u), static_cast(target), static_cast(static_cast(target) >> 32u), value, 0, context}; } void testEndOfPipeLabelsWithoutWork() { @@ -346,12 +346,12 @@ void testEndOfPipeLabelsWithoutWork() { int tag = 0; check(sceAgcDriverAddEqEvent(eq, 0, &tag) == 0, "graphics event registration failed"); submit(0x20, commands(waitEqual(&second, 2), writeData(&done, 1))); - submit(0, commands(endOfPipeLabel(&first, 1), endOfPipeLabel(&second, 2))); + submit(0, commands(endOfPipeLabel(&first, 1, 0x11), endOfPipeLabel(&second, 2, 0x12))); waitFor(&done, 1, "an end-of-pipe label with no work before it never landed"); check(first == 1 && second == 2, "end-of-pipe labels with no work before them landed wrong"); AgcDriverWaitIdle_nid_postfix(); std::array events{}; - check(owner->GetTriggeredEvents(events.data(), 2) == 1 && events[0].udata == &tag && events[0].data == 2, "end-of-pipe labels with no work before them lost their interrupts"); + check(owner->GetTriggeredEvents(events.data(), 2) == 1 && events[0].udata == &tag && sceAgcDriverGetEqContextId(events.data()) == 0x12, "end-of-pipe labels with no work before them lost their interrupts"); check(sceAgcDriverDeleteEqEvent(eq, 0) == 0, "graphics event deletion failed"); owner.reset(); check(sceKernelDeleteEqueue(eq) == 0, "event queue deletion failed"); From 59f30affb6b7659f9b0359dcabb68418c4af635f Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:24:55 +0200 Subject: [PATCH 25/62] fix(agc): release the next flip before presentation and hold only command-buffer flips for the previous flip submission's work The flip hold was released at the end of the submission, after the presenter's GpuReady. A presenter that blocks there held every later flip, CPU flips included, so video_out_flip_lifetime, _reopen and _cpuflip timed out. Keep flipsAhead released before GpuReady as before and count open flip submissions separately; only a command-buffer flip waits for them and for their recorded work. --- .../Execution/include/Driver/Driver.hpp | 6 +++-- .../src/Driver/Packets/PacketExecution.cpp | 24 ++++++++++++------- .../src/Driver/Queues/QueueWorker.cpp | 2 +- .../src/Driver/Queues/Submission.cpp | 21 ++++++++++++---- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp b/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp index 44c40e8b6b..681bfb0dc5 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp +++ b/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Driver.hpp @@ -145,10 +145,11 @@ private: static bool copySegment(Submission& submission, const std::uint32_t* guest, std::size_t words, std::size_t& budget); static void readRegisterLists(Submission& submission); void waitForFlipRoom(const Submission& submission); - void holdFlipBehindWorker(const Submission& submission); + void holdFlipBehindWorker(const Submission& submission, bool cpuFlip); bool awaitsTitle(std::uint64_t awaited) const; void noteAwaitingTitle(std::uint32_t queue, std::uint64_t awaited); - void releaseFlipHold(std::uint64_t workSerial); + void releaseFlipHold(); + void finishFlipWork(std::uint64_t workSerial); std::uint64_t submitFlipWork(std::uint32_t queue); void waitFlipWork(std::uint64_t workSerial); void reserveOutputs(Submission& submission); @@ -498,6 +499,7 @@ private: std::atomic queue0Awaited{0}; std::atomic flipsAhead{0}; std::atomic flipHolders{0}; + std::atomic flipWorkOpen{0}; std::atomic flipWorkSerial{0}; std::atomic queue0AwaitsTitle{false}; diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp index e9dadd3fd0..a9b569b0a4 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Packets/PacketExecution.cpp @@ -161,16 +161,23 @@ void Driver::execute(const Submission& submission) { Driver& driver; std::uint32_t queue; bool held; - void Finish() { + bool working; + void Release() { if (!held) return; - const auto workSerial = driver.submitFlipWork(queue); held = false; - driver.releaseFlipHold(workSerial); + driver.releaseFlipHold(); + } + void Finish() { + if (!working) return; + const auto workSerial = driver.submitFlipWork(queue); + working = false; + driver.finishFlipWork(workSerial); } ~FlipHold() { - if (held) driver.releaseFlipHold(0); + Release(); + if (working) driver.finishFlipWork(0); } - } flipHold{*this, submission.queue, submission.holdsFlip}; + } flipHold{*this, submission.queue, submission.holdsFlip, submission.holdsFlip}; const bool pipelined = submission.queue == 0 && DrawPipeline::Depth() != 0; static const bool orderedWaits = std::getenv("APS5_PIPELINE_DRAIN_LABELS") == nullptr; DrawPipeline::Active() = pipelined; @@ -451,6 +458,7 @@ void Driver::execute(const Submission& submission) { if (queueTiming) frame->CollectBackground(); frame->NoteFlipBatches(batchesAtFlip, unsignaledAtFlip); CaptureTrace::Log("flip frame=%llu submission=%llu offset=%zu batch=%llu unsignaled=%llu", static_cast(frameSerial.load()), static_cast(submission.serial), cursor, static_cast(batchesAtFlip), static_cast(unsignaledAtFlip)); + flipHold.Release(); submission.flips.at(cursor)->GpuReady(frame); } else if (opcode == 0x15 || opcode == 0x16) { packetCounters.CountDispatch(); @@ -601,14 +609,14 @@ void Driver::execute(const Submission& submission) { static const bool submitAtEnd = std::getenv("APS5_SUBMIT_AT_END") != nullptr; if (pipelined && orderedCompletion && deferredLabels().labels.empty()) { if (submission.rewindTail == nullptr) { - flipHold.held = false; + flipHold.working = false; deferCompletion(submission); return; } if ((std::atomic_ref(*const_cast(submission.rewindTail - 1)).load(std::memory_order_acquire) & 0x80000000u) != 0) { - const bool holdsFlip = std::exchange(flipHold.held, false); + const bool holdsFlip = std::exchange(flipHold.working, false); DrawPipeline::Queue0().Enqueue([this, holdsFlip, progress = submission.progress] { - if (holdsFlip) releaseFlipHold(submitFlipWork(0)); + if (holdsFlip) finishFlipWork(submitFlipWork(0)); else submitOpenWork(progress); wakeQueueZeroForCompletions(); }, {}); diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/QueueWorker.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/QueueWorker.cpp index 7117eb8a65..a50fc98699 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/QueueWorker.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/QueueWorker.cpp @@ -121,7 +121,7 @@ void Driver::completeSubmission(std::uint64_t serial, std::uint64_t received, st if (notify) driver.changed.notify_all(); } } complete{*this, serial, received, labelWrites}; - if (holdsFlip) releaseFlipHold(submitFlipWork(0)); + if (holdsFlip) finishFlipWork(submitFlipWork(0)); else submitOpenWork(progress); } diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp index d75af50e13..d0bfa09a76 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp @@ -263,7 +263,7 @@ void Driver::noteAwaitingTitle(std::uint32_t queue, std::uint64_t awaited) { changed.notify_all(); } -void Driver::holdFlipBehindWorker(const Submission& submission) { +void Driver::holdFlipBehindWorker(const Submission& submission, bool cpuFlip) { if (submission.queue != 0 || onWorkerThread()) return; bool candidate = false; for (std::size_t cursor = 0; cursor < submission.commands.size() && !candidate; cursor += Pm4::PacketWords(submission.commands[cursor])) { @@ -281,7 +281,11 @@ void Driver::holdFlipBehindWorker(const Submission& submission) { flipHolders.fetch_add(1, std::memory_order_acq_rel); changed.wait(lock, [&] { return failure != nullptr || stopping.load(std::memory_order_acquire) || shutdownToken.stop_requested() || flipsAhead.load(std::memory_order_acquire) == 0 || queue0AwaitsTitle.load(std::memory_order_acquire); }); flipHolders.fetch_sub(1, std::memory_order_acq_rel); - if (failure != nullptr || stopping.load(std::memory_order_acquire) || shutdownToken.stop_requested() || flipsAhead.load(std::memory_order_acquire) != 0) return; + if (cpuFlip || failure != nullptr || stopping.load(std::memory_order_acquire) || shutdownToken.stop_requested() || flipsAhead.load(std::memory_order_acquire) != 0) return; + flipHolders.fetch_add(1, std::memory_order_acq_rel); + changed.wait(lock, [&] { return failure != nullptr || stopping.load(std::memory_order_acquire) || shutdownToken.stop_requested() || flipWorkOpen.load(std::memory_order_acquire) == 0 || queue0AwaitsTitle.load(std::memory_order_acquire); }); + flipHolders.fetch_sub(1, std::memory_order_acq_rel); + if (failure != nullptr || stopping.load(std::memory_order_acquire) || shutdownToken.stop_requested() || flipWorkOpen.load(std::memory_order_acquire) != 0) return; const auto workSerial = flipWorkSerial.load(std::memory_order_acquire); lock.unlock(); waitFlipWork(workSerial); @@ -300,10 +304,16 @@ void Driver::waitFlipWork(std::uint64_t workSerial) { localDevice->WaitIdle(); } -void Driver::releaseFlipHold(std::uint64_t workSerial) { +void Driver::releaseFlipHold() { + std::lock_guard lock(mutex); + flipsAhead.fetch_sub(1, std::memory_order_acq_rel); + if (flipHolders.load(std::memory_order_acquire) != 0) changed.notify_all(); +} + +void Driver::finishFlipWork(std::uint64_t workSerial) { std::lock_guard lock(mutex); if (workSerial > flipWorkSerial.load(std::memory_order_relaxed)) flipWorkSerial.store(workSerial, std::memory_order_release); - flipsAhead.fetch_sub(1, std::memory_order_acq_rel); + flipWorkOpen.fetch_sub(1, std::memory_order_acq_rel); if (flipHolders.load(std::memory_order_acquire) != 0) changed.notify_all(); } @@ -488,7 +498,7 @@ void Driver::Submit(const Packet* packet, std::uint32_t queue, bool cpuFlip) { readRegisterLists(submission); if (APS5_ENABLE_TIMING_LOG) submission.validatedAt = std::chrono::steady_clock::now(); waitForFlipRoom(submission); - holdFlipBehindWorker(submission); + holdFlipBehindWorker(submission, cpuFlip); AwaitRegistrationPreparation(); if (LibraryPrebuildsWaiting()) PrioritizeLibraryPrebuilds(BoundGraphicsPrograms(submission)); if (APS5_ENABLE_TIMING_LOG) submission.roomReadyAt = std::chrono::steady_clock::now(); @@ -535,6 +545,7 @@ void Driver::Submit(const Packet* packet, std::uint32_t queue, bool cpuFlip) { if (queue == 0 && !submission.flips.empty()) { submission.holdsFlip = true; flipsAhead.fetch_add(1, std::memory_order_acq_rel); + flipWorkOpen.fetch_add(1, std::memory_order_acq_rel); } if (!submission.flips.empty()) ++flipSubmissionsOfThread(); enqueue(std::move(submission)); From 77515e2146607b1d8dc78c6e12ff0a4ed14a0d1e Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:20:46 +0200 Subject: [PATCH 26/62] fix(agc): run the completions of the previous flip submission's work before the next flip returns waitFlipWork waited for the recorded work's serial but never reaped it, so labels written by completion actions (write-backs and labels stored when a batch is reaped) could still hold their old value when the title read them after its next flip. Reap the batches up to that serial under the GPU mutex, as the packet drain does. --- .../Execution/src/Driver/Queues/Submission.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp index d0bfa09a76..0551e52ba3 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp @@ -293,15 +293,27 @@ void Driver::holdFlipBehindWorker(const Submission& submission, bool cpuFlip) { void Driver::waitFlipWork(std::uint64_t workSerial) { if (workSerial == 0) return; - const auto localDevice = device.Load(); + auto localDevice = device.Load(); if (localDevice == nullptr) return; if (localDevice->CanWaitUnlocked()) { - localDevice->WaitRecorded(workSerial); + try { + localDevice->WaitRecorded(workSerial); + } catch (...) { + GuestMemory::TagGpuLockSite(GuestMemory::GpuLockSite::Wait); + std::lock_guard gpuLock(GuestMemory::GpuMutex()); + localDevice.reset(); + throw; + } + GuestMemory::TagGpuLockSite(GuestMemory::GpuLockSite::Wait); + std::lock_guard gpuLock(GuestMemory::GpuMutex()); + localDevice->ReapRecorded(workSerial); + localDevice.reset(); return; } GuestMemory::TagGpuLockSite(GuestMemory::GpuLockSite::Wait); std::lock_guard gpuLock(GuestMemory::GpuMutex()); localDevice->WaitIdle(); + localDevice.reset(); } void Driver::releaseFlipHold() { From 39b65b53834febea7934760d758ad0032a412894 Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:20:46 +0200 Subject: [PATCH 27/62] test(agc): check that a release carrying a flip raises no event and that a typed interrupt context fails --- core/libs/prx/libSceAgcDriver/tests/Submit.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceAgcDriver/tests/Submit.cpp b/core/libs/prx/libSceAgcDriver/tests/Submit.cpp index f0ed264bda..ba9d445a52 100644 --- a/core/libs/prx/libSceAgcDriver/tests/Submit.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/Submit.cpp @@ -269,6 +269,12 @@ void testEndOfPipeInterrupts() { check(AgcDriver::Graphics::HostImportFramesEnded() == static_cast(2 + index), "a flip packet executed on the queue did not end a host import frame"); check(owner->GetTriggeredEvents(events.data(), 2) == 0, "a flip after a release raised the graphics end-of-pipe event"); } + words[2] = 2u << 24u; + words[7] = AgcDriverReserveEopFlip_nid_postfix(AgcDriver::FlipInfo{7, 0, 1, 0}); + check(sceAgcDriverSubmitDcb(&packet) == 0, "end-of-pipe flip release submit failed"); + AgcDriverWaitIdle_nid_postfix(); + check(output->ready.load() == 10, "end-of-pipe flip was not handed to its output"); + check(owner->GetTriggeredEvents(events.data(), 2) == 0, "a release that carries a flip raised the graphics end-of-pipe event"); const auto framesBefore = AgcDriver::Graphics::HostImportFramesEnded(); const auto readyBefore = output->ready.load(); std::thread([] { AgcDriverSubmitFlip_nid_postfix(AgcDriver::FlipInfo{7, -1, 1, 0}); }).join(); @@ -1100,7 +1106,11 @@ int main(int argc, char** argv) { try { if (argc == 2 && std::string_view(argv[1]) == "--eop-interrupts") { testEndOfPipeInterrupts(); - LibcRunShutdown_nid_postfix(); + std::array typed{0xc0064900, 0, 1u << 24u, 0, 0, 0, 0, 0x08000038u}; + Packet typedPacket{typed.data(), static_cast(typed.size()), 0, {}}; + check(sceAgcDriverSubmitDcb(&typedPacket) == 0, "typed interrupt context submit failed"); + check(expectFailure([] { AgcDriverWaitIdle_nid_postfix(); }).find("typed interrupt context") != std::string::npos, "a release with a typed interrupt context did not fail"); + check(expectFailure([] { LibcRunShutdown_nid_postfix(); }).find("typed interrupt context") != std::string::npos, "shutdown lost the typed interrupt context failure"); std::puts("AGC end-of-pipe interrupt queue mode tests passed"); return 0; } From f9e1ac270c07834fa0e3cace14049b35c6dba64e Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:43:54 +0200 Subject: [PATCH 28/62] docs(agc): describe the end-of-pipe event's context and which flips are held --- docs/dev/debt/libSceAgcDriver.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev/debt/libSceAgcDriver.md b/docs/dev/debt/libSceAgcDriver.md index eef8fe95f7..88e9c84fec 100644 --- a/docs/dev/debt/libSceAgcDriver.md +++ b/docs/dev/debt/libSceAgcDriver.md @@ -84,7 +84,7 @@ - [Alpha-to-coverage `0x1aa01`](../../../core/libs/prx/libSceAgcDriver/Graphics/src/State.cpp) (AGC driver) - `DB_ALPHA_TO_MASK = 0x1aa01` throws `dithered or rounded alpha-to-coverage is unsupported`. It differs from the accepted undithered `0xaa01` by bit 16 (`0x10000`), which the accepted dithered `0x18701` also has: [radeonsi](https://gitlab.freedesktop.org/mesa/mesa/-/blob/bffd91947efc1932da99aca69fc540d6d4201b0c/src/gallium/drivers/radeonsi/si_state.c#L371-L381) programs `OFFSET_ROUND(1)` only with the dithered offsets and `OFFSET_ROUND(0)` for `0xaa01`, so it never programs this combination. PPSA12361 reaches it on main 74acfad65b on an RTX 4080 SUPER (`run_024721.err` lines 3484-3486) and an RX 6400 (`run_025512.err` lines 2667-2671). What the console covers for this value is not measured, so it stays unsupported. - [MSAA_ENABLE on a single-sample target](../../../core/libs/prx/libSceAgcDriver/Graphics/src/State.cpp) (AGC driver) - `PA_SC_MODE_CNTL_0.MSAA_ENABLE` with `PA_SC_AA_CONFIG.MSAA_NUM_SAMPLES = 0` is drawn like the same state without the bit when sample 0 of the four `PA_SC_AA_SAMPLE_LOCS_PIXEL_*_0` registers is at the pixel center and the draw rasterizes filled triangles, lines or rectangles (`VGT_PRIMITIVE_TYPE` 2, 3, 7 or 17 as well as the triangle types) with alpha-to-coverage and conservative rasterization off. [RADV](https://gitlab.freedesktop.org/mesa/mesa/-/blob/745709a000482a4a24c91faef5c5a40bd653a863/src/amd/vulkan/radv_cmd_buffer.c#L13606-13609) programs this state for one-sample custom sample locations on GFX10 and later, where the bit makes the rasterizer use the programmed location of sample 0 ([061bc615](https://gitlab.freedesktop.org/mesa/mesa/-/commit/061bc6151a11597f720535f00e79a5752e24aff6)); that the bit changes nothing else for such a draw is inferred from that and is not measured on hardware. For lines the bit may also select the console's multisample (rectangular) line rule instead of the aliased one, so which pixels a line covers under this state is unmeasured. PPSA03541 reaches a rect-list draw in this state, and PPSA31246 a line list. Other locations of sample 0, absent location registers, points, alpha-to-coverage and conservative rasterization throw - [A multisampled target rasterized without MSAA_ENABLE](../../../core/libs/prx/libSceAgcDriver/Graphics/src/State.cpp) (AGC driver) - not measured on hardware: `PA_SC_AA_CONFIG.MSAA_NUM_SAMPLES` > 0 with `PA_SC_MODE_CNTL_0.MSAA_ENABLE` clear puts every color, depth and stencil sample at the pixel center, as in GL without multisampling ([ARB_multisample](https://github.com/KhronosGroup/OpenGL-Registry/blob/6af574a14089/extensions/ARB/ARB_multisample.txt#L185), [RADV 061bc615](https://gitlab.freedesktop.org/mesa/mesa/-/commit/061bc6151a11)). Lines cover whole pixels by the host's line rule, not the console's aliased one ([D3D11.3](https://github.com/microsoft/DirectX-Specs/blob/f37b0d6033a1/d3d/archive/D3D11_3_FunctionalSpec.htm#L13705)). Depth sample tests and occlusion counts are unknown. Rasterized points, alpha-to-coverage, and Z interpolation, overrasterization or a sample-mask export with depth throw. PPSA06888 draws a 2x target, PPSA18216 a 4x depth and color pair and 4x lines. -- [sceAgcDriverGetEqContextId](../../../core/libs/prx/libSceAgcDriver/Eq/src/Query.cpp) (libSceAgcDriver) - returns `ident`: an end-of-pipe interrupt is delivered only to the registration whose id equals the raising queue, so `ident` is that context's id (titles register 0 or 1 for graphics and `0x20 + 8 * pipe + queue` for compute). The console's value is not known; KytyPS5 returns the event's `data`, which it fills with the interrupt's context id, whereas `data` here is the EV_CLEAR trigger count. Other event filters throw +- [sceAgcDriverGetEqContextId](../../../core/libs/prx/libSceAgcDriver/Eq/src/Query.cpp) (libSceAgcDriver) - a graphics end-of-pipe event carries `queue | context << 16`, context being the raising `RELEASE_MEM`'s dword 7 masked to 28 bits, as the PS5 13.60 kernel builds the event; `sceAgcDriverGetEqContextId` returns `data >> 16` and `sceAgcDriverGetEqEventType` the low 32 bits, as the PS5 10.01 module reads them. The kernel also packs the pipe, ME, generation and VMID into the low bits; only the queue is filled here, and the other fields are not measured. A typed context (bit 27) is routed elsewhere by the kernel and throws. Other event filters throw - [User data registers](../../../core/libs/prx/libSceAgcDriver/Execution/src/Driver/Diagnostics.cpp) (AGC driver) - a `SPI_SHADER_USER_DATA_*` or `COMPUTE_USER_DATA_*` register the title never wrote reads as 0 when a stage's RSRC2 count covers it, as Kyty (zeroed `UserSgprInfo`) and SharpEmu (missing registers read 0) do, and as AMD PAL and Mesa initialize them; the console's value is not confirmed - [SET_PREDICATION](../../../core/libs/prx/libSceAgcDriver/Execution/src/Pm4.cpp) (AGC driver) - boolean predication follows the AMD PM4 encoding [Mesa](https://gitlab.freedesktop.org/mesa/mesa/-/blob/main/src/amd/common/sid.h) uses: operation 3 reads 64 bits and 4 reads 32 bits, and bit 8 runs predicated packets when the value is non-zero (clear: when it is zero); the bit 12 hint is ignored. The address must have its low 4 bits clear, as `sceAgcDcbSetPredication` writes it. Query predication (Z-pass, primitive count), a predicated COND_EXEC, predicated command buffer chains and flips in predicated command buffers throw - [sceAgcDriverUserDataWritePacket, sceAgcDriverUserDataWritePushMarker, sceAgcDriverUserDataWriteSetMarker, sceAgcDriverUserDataWritePopMarker](../../../core/libs/prx/libSceAgcDriver/Submit/src/Dcb.cpp) (libSceAgcDriver) - the packets follow firmware 13.42's libSceAgcDriver, read from disassembly, not measured on a console: a SET_UCONFIG_REG of register 0x342 (and 0x343 with up to four data bytes), then a WRITE_DATA of the remaining bytes to register 0xC343. The second argument is named `asyncCompute` because firmware libSceAgc passes 1 from its `sceAgcAcb*` marker calls and 0 from `sceAgcDcbPopMarker`; 0 sets header bit 2 in both packets. The marker kinds (0xC0000000 set, 0xC1000000 push, 0xC2000000 pop) and the colour packing are copied without names @@ -328,7 +328,7 @@ - [Device-local staging](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) (libSceAgcDriver) - a written or atomic buffer element inside a host import, including one an address-based compute build reaches through its pointers, is copied to device memory before the dispatch and copied back after it. The copy-back stores only the bytes the shader changed and the import still holds at their copy-in value, so a CPU store in between survives; where both changed the same byte, the CPU's byte is kept, whichever the console would store last. The whole range is still copied back, rolling CPU stores back, at the unaligned ends of a range, for an element or import offset that is not 4-byte aligned, without a shadow copy or buffer device addresses, with `APS5_NO_CHANGED_COPY_BACK`, and when a synchronous draw stores a GPU-copied region from the CPU (no copy-back recorded, no copy-in snapshot). A CPU store made while the copy-back runs can still be overwritten by it. - [Write-back conflicts with CPU stores](../../../core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp) (libSceAgcDriver) - a buffer region the CPU copies for a dispatch is stored back with `WriteChangedKeepingGuest`: a byte takes the shader's value only where the shader changed it and guest memory still holds the uploaded value, so a CPU store made after the upload wins where both changed the same byte. A CPU store that restores the uploaded value is indistinguishable from no store and takes the shader's byte. Writable image mirrors, heap mirrors, storage image write-backs and tiled colour targets use `WriteChanged`, which stores every byte the GPU changed: a CPU store into the same byte between the upload and the write-back is overwritten there, as before; a CPU store into a byte the GPU left alone survives on every path. Which side the console would store last is not measured. - [Committed reads of released pages](../../../core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp) (libSceAgcDriver) - `ReadCommitted` and `EqualsCommitted` find the readable pages of a range first and read them afterwards. On Windows they read through `ReadProcessMemory`, so a page released in between reads as zero and is left out of the comparison instead of faulting: Demon's Souls (PPSA01342) samples a texture at 0x38400 of 352 MiB while it loads the tutorial level, a range that holds no guest allocation and on Windows covers host heap pages other threads release, which stopped it with an access violation. A texture outside every registered guest allocation still takes the host bytes found there, as it did before; what the console returns for it is not measured. On Linux the pages are copied directly, so a page released between the check and the copy still faults. -- [Flip submissions hold the next flip until their work completed](../../../core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp) (libSceAgcDriver) - a queue-0 submission that carries a flip returns only after every earlier flip submission ran to its end and its recorded GPU work completed, so the labels a frame writes after its flip are in memory when the title reads them after its next submission. Flips raise no graphics end-of-pipe event (from the PS5 13.60 kernel: a flip's interrupt context is always typed and goes to the display path, never to this event). Not measured: how far the console lets a title run ahead of frames its GPU has not finished. +- [Flip submissions hold the next flip until their work completed](../../../core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp) (libSceAgcDriver) - a queue-0 submission that carries a flip in its command buffer returns only after every earlier flip submission ran to its end and its recorded GPU work completed and was reaped, so the labels a frame writes after its flip are in memory when the title reads them after its next submission. A CPU flip (`sceVideoOutSubmitFlip`) does not wait, and the next flip is still released before the previous one is presented. Flips raise no graphics end-of-pipe event (from the PS5 13.60 kernel: a flip's interrupt context is always typed and goes to the display path, never to this event). Not measured: how far the console lets a title run ahead of frames its GPU has not finished. - [Periodic pipeline cache saves skip busy intervals](../../../core/libs/prx/libSceAgcDriver/Graphics/src/PipelineCache.cpp) (libSceAgcDriver) - the saver thread snapshots the Vulkan pipeline cache only when no pipeline creation holds it (`TryLockForSnapshot`); otherwise it skips that 10 s interval. A blocking snapshot queued behind a long creation (an NVIDIA compile of a 1.5M-word compute pipeline runs for minutes) makes the writer-preferring `shared_mutex` hold every later pipeline creation on every queue until that compile ends, which froze Yōtei's (PPSA26344) loading at frame 797-799 for the whole compile. While creations overlap without a gap, no periodic save happens, so a crash in that stretch loses the pipelines built since the last save; `SaveNow` and the teardown save still wait for the lock. How often busy stretches skip saves in practice is not measured. - [Registration preparation at submit](../../../core/libs/prx/libSceAgcDriver/Execution/src/Driver/Queues/Submission.cpp) (libSceAgcDriver) - `Driver::Submit` waits for every registration-time shader preparation queued before the call, not only those of the shaders the commands use, so a submission behind a long queue of unrelated preparations waits for all of them on the submitting thread. Waiting there instead of in the queue worker keeps the GPU side of a submission short: a title that sees a submission take seconds treats the GPU as hung (Goat Simulator 3, PPSA04159, then stops submitting and its garbage collector fails a 10 s timeout). On the console shaders are compiled ahead of time, so neither wait exists; how long a title tolerates a slow submit call is not known. - [Resource cache entries holding released textures](../../../core/libs/prx/libSceAgcDriver/Graphics/src/ShaderResources.cpp) (libSceAgcDriver) - a sampled texture the texture cache drops while something else holds it, or makes without caching (`admitTexture` refusing a first use), is marked released, and `ResourceCache::Insert` refuses descriptor sets that hold one and removes the entries that do. The removal waits for the next insert: until then `Find` still returns those entries and their textures stay alive. One process-wide flag starts the sweep, so while the sampled cache is over its budget every insert scans all entries (1,024 at start, up to 16,384 after growth, or `APS5_RESOURCE_CACHE_ENTRIES`, each with its textures); that cost is not measured. When `ReleaseTextureCaches` runs on device memory exhaustion, the sampled textures these entries hold are only marked released and are freed at the next insert, so the immediate allocation retry gets no memory back from them. A draw or dispatch whose textures the cache refused builds its descriptor sets again on each use for as long as the cache stays full; the count bounds (`APS5_RESOURCE_CACHE_ENTRIES`, `APS5_RESOURCE_CACHE_TABLE_ELEMENTS`) still bound nothing by bytes, and sampled textures made outside the texture cache (the disabled cache, depth-surface copies) are never marked. Measured on one title on one RTX 4090: with the default bounds, sampled textures held outside the cache peaked at 2,836 MiB (7,830 MiB before) and device-local heap 0 stayed under its 22.9 GB budget for the whole run (26,357 MiB before, which aborted with `VK_ERROR_OUT_OF_DEVICE_MEMORY` at the wolf scene). Whether the extra descriptor-set builds cost frame time elsewhere is not measured. From 04f94904874156111efea0000631c4839fdeb554 Mon Sep 17 00:00:00 2001 From: fma-rc <188743521+fma-rc@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:59:28 +0100 Subject: [PATCH 29/62] test(videoout): hold the deferred flip behind a GPU wait instead of an output callback the title releases --- core/libs/prx/libSceVideoOut/tests/Flip.cpp | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/core/libs/prx/libSceVideoOut/tests/Flip.cpp b/core/libs/prx/libSceVideoOut/tests/Flip.cpp index 0677ec9012..c15d7647bf 100644 --- a/core/libs/prx/libSceVideoOut/tests/Flip.cpp +++ b/core/libs/prx/libSceVideoOut/tests/Flip.cpp @@ -121,7 +121,6 @@ void testLifetime(bool reopen) { } else { check(expectFailure([&] { sceVideoOutWaitVblank(handle); }).find("closed") != std::string::npos, "closed port accepted a vblank wait"); } - AgcDriverUnregisterVideoOutput_nid_postfix(7, gate); if (reopen) sceVideoOutClose(handle); LibcRunShutdown_nid_postfix(); } @@ -198,13 +197,12 @@ void testPs4Buffers() { void testDeferredBuffer() { const int handle = sceVideoOutOpen(255, 0, 0, nullptr); auto cfg = VideoOutDriver::Get().GetConfig(handle); - auto gate = std::make_shared(); - AgcDriverRegisterVideoOutput_nid_postfix(7, gate); - std::array held{0xc004105c, 7, 0xfffffffeu, 1, 0, 0}; - Packet heldPacket{held.data(), 6, 0, {}}; - check(sceAgcDriverSubmitDcb(&heldPacket) == 0, "the gated flip was not accepted"); - std::array flip{0xc004105c, static_cast(handle), 0, 1, 3, 0}; - Packet flipPacket{flip.data(), 6, 0, {}}; + alignas(8) static std::uint32_t reached = 0; + reached = 0; + const auto label = reinterpret_cast(&reached); + std::array flip{0xc0053c00u, 0x13u, static_cast(label), static_cast(label >> 32u), 1u, 0xffffffffu, 0u, + 0xc004105c, static_cast(handle), 0, 1, 3, 0}; + Packet flipPacket{flip.data(), 13, 0, {}}; check(sceAgcDriverSubmitDcb(&flipPacket) == 0, "a GPU flip of a buffer registered before the GPU reaches it was rejected at submit"); std::vector allocation(65536 + 65535); const auto storage = alignedBuffer(allocation); @@ -212,7 +210,7 @@ void testDeferredBuffer() { VideoOutBufferAttribute2 attribute{}; sceVideoOutSetBufferAttribute2(&attribute, 0x8000000000000000ull, 0, 64, 64, 0, 0, 0); check(sceVideoOutRegisterBuffers2(handle, 0, 0, &buffer, 1, &attribute, 0, nullptr) == 0, "buffer registration failed"); - gate->Release(); + std::atomic_ref(reached).store(1u, std::memory_order_release); { std::unique_lock lock(cfg->mutex); const bool done = cfg->vblankCond.wait_for(lock, std::chrono::seconds(30), [&] { return cfg->failure || cfg->flipStatus.count == 1; }); @@ -223,7 +221,6 @@ void testDeferredBuffer() { AgcDriverWaitIdle_nid_postfix(); sceVideoOutUnregisterBuffers(handle, 0); sceVideoOutClose(handle); - AgcDriverUnregisterVideoOutput_nid_postfix(7, gate); LibcRunShutdown_nid_postfix(); } From eece70468a616a7132c7aa3aa11f8aef74cb42f6 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 21:25:11 +0200 Subject: [PATCH 30/62] feat(tools): read per-title relink settings for title runs Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + tools/tests/test_title_run.py | 55 +++++++++++++++++++++++++++++++++++ tools/title_run.py | 33 +++++++++++++++++++++ tools/titles.json | 11 +++++++ 4 files changed, 100 insertions(+) create mode 100644 tools/tests/test_title_run.py create mode 100644 tools/title_run.py create mode 100644 tools/titles.json diff --git a/.gitignore b/.gitignore index 36be987bde..0941219884 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ downloads/ !demo/web/i18n/*.json !/docs/run-report.schema.json !/docs/run-report-fixtures/*.json +!tools/titles.json !/assets/*.jpg !/assets/*.png diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py new file mode 100644 index 0000000000..f458a99ba7 --- /dev/null +++ b/tools/tests/test_title_run.py @@ -0,0 +1,55 @@ +import json +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import title_run + + +class TitlesTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + + def write(self, data): + path = self.root / "titles.json" + path.write_text(json.dumps(data), encoding="utf-8") + return path + + def test_repository_file_is_valid(self): + titles = title_run.load_titles(title_run.ROOT / "tools/titles.json") + self.assertEqual(titles["PPSA85300"]["fixed_base"], "0x400000") + + def test_unknown_field_is_refused(self): + with self.assertRaisesRegex(ValueError, "PPSA00001: unknown field rlink"): + title_run.load_titles(self.write({"PPSA00001": {"rlink": []}})) + + def test_wrong_type_is_refused(self): + with self.assertRaisesRegex(ValueError, "PPSA00001: seconds must be int"): + title_run.load_titles(self.write({"PPSA00001": {"seconds": "60"}})) + + def test_bad_id_is_refused(self): + with self.assertRaisesRegex(ValueError, "VLC is not a title ID"): + title_run.load_titles(self.write({"VLC": {}})) + + def test_default_entry(self): + self.assertEqual(title_run.relink_args({}, "windows"), ["--windows"]) + self.assertEqual(title_run.relink_args({}, "linux"), []) + + def test_entry_on_windows_and_linux(self): + entry = {"relink": ["--registry"], "fixed_base": "0x400000", "executables": ["picker/picker.bin"]} + self.assertEqual(title_run.relink_args(entry, "windows"), + ["--windows", "--registry", "--windows-fixed-base", "0x400000", "--executable", "picker/picker.bin"]) + self.assertEqual(title_run.relink_args(entry, "linux"), + ["--registry", "--linux-fixed-base", "0x400000", "--executable", "picker/picker.bin"]) + + def test_fixed_base_on_macos_is_refused(self): + with self.assertRaisesRegex(ValueError, "fixed_base is not supported on macos"): + title_run.relink_args({"fixed_base": "0x400000"}, "macos") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/title_run.py b/tools/title_run.py new file mode 100644 index 0000000000..318b47d3df --- /dev/null +++ b/tools/title_run.py @@ -0,0 +1,33 @@ +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +OUTCOMES = ("relink-failed", "crashed", "exited", "stalled", "blank", "rendering") +TITLE_FIELDS = {"name": str, "relink": list, "fixed_base": str, "executables": list, "seconds": int, "env": dict, "input": list} +FIXED_BASE_FLAGS = {"windows": "--windows-fixed-base", "linux": "--linux-fixed-base"} + + +def load_titles(path): + titles = json.loads(Path(path).read_text(encoding="utf-8")) + for title_id, entry in titles.items(): + if not re.fullmatch(r"[A-Z]{4}[0-9]{5}", title_id): + raise ValueError(f"{path}: {title_id} is not a title ID") + for key, value in entry.items(): + if key not in TITLE_FIELDS: + raise ValueError(f"{path}: {title_id}: unknown field {key}") + if not isinstance(value, TITLE_FIELDS[key]): + raise ValueError(f"{path}: {title_id}: {key} must be {TITLE_FIELDS[key].__name__}") + return titles + + +def relink_args(entry, platform): + args = ["--windows"] if platform == "windows" else [] + args += entry.get("relink", []) + if "fixed_base" in entry: + if platform not in FIXED_BASE_FLAGS: + raise ValueError(f"fixed_base is not supported on {platform}") + args += [FIXED_BASE_FLAGS[platform], entry["fixed_base"]] + for executable in entry.get("executables", []): + args += ["--executable", executable] + return args diff --git a/tools/titles.json b/tools/titles.json new file mode 100644 index 0000000000..b786ce49b8 --- /dev/null +++ b/tools/titles.json @@ -0,0 +1,11 @@ +{ + "PPSA01153": {"name": "OliSe Player", "relink": ["--trap-sdk-syscall-stubs"]}, + "PPSA68091": {"name": "Ruffle Flash", "relink": ["--registry"], "fixed_base": "0x400000"}, + "PPSA85300": {"name": "VLC", "relink": ["--registry"], "fixed_base": "0x400000"}, + "PPSA97358": {"name": "XPSemu", "relink": ["--registry"], "fixed_base": "0x400000"}, + "PPSA99004": {"name": "ProsperoAI", "relink": ["--trap-sdk-syscall-stubs"]}, + "PPSA99169": {"name": "RetroArch", "executables": ["picker/picker.bin"]}, + "PPSA99203": {"name": "PS5SX2", "relink": ["--trap-sdk-syscall-stubs"]}, + "PPSA99288": {"name": "Nuvio Max'd", "relink": ["--trap-sdk-syscall-stubs"]}, + "PPSA99360": {"name": "PS5CEMU-HAR", "relink": ["--trap-sdk-syscall-stubs"]} +} From 81aedae4a3ee96e920f5a9756faf134d2f1ac125 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 21:26:47 +0200 Subject: [PATCH 31/62] feat(tools): classify a title run from its exit, summary and last frame Co-Authored-By: Claude Opus 5.5 --- tools/tests/test_title_run.py | 55 +++++++++++++++++++++++++++++++++++ tools/title_run.py | 50 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py index f458a99ba7..9eeff3ae20 100644 --- a/tools/tests/test_title_run.py +++ b/tools/tests/test_title_run.py @@ -1,5 +1,6 @@ import json from pathlib import Path +import struct import sys import tempfile import unittest @@ -51,5 +52,59 @@ class TitlesTests(unittest.TestCase): title_run.relink_args({"fixed_base": "0x400000"}, "macos") +def bmp(path, width, height, color): + rows = b"".join(bytes(channel for x in range(width) for channel in (*color(x, y), 255)) for y in range(height)) + header = struct.pack("<2sIHHI", b"BM", 54 + len(rows), 0, 0, 54) + info = struct.pack(" tolerance: + return False + return True + + +def classify(relinked, exit_code, summary_age, last_frame_uniform): + if not relinked: + return "relink-failed" + if exit_code is not None: + return "crashed" if exit_code != 0 else "exited" + if summary_age is None or summary_age > STALL_SECONDS: + return "stalled" + if last_frame_uniform: + return "blank" + return "rendering" From 85295a70b204ee5c2ae065ff9cb258a97df4c1b5 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 21:28:35 +0200 Subject: [PATCH 32/62] feat(tools): report title runs as a fixed Markdown table and compare two runs Co-Authored-By: Claude Opus 5.5 --- tools/tests/test_title_run.py | 44 +++++++++++++++++++++++++++++ tools/title_run.py | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py index 9eeff3ae20..4bcc466790 100644 --- a/tools/tests/test_title_run.py +++ b/tools/tests/test_title_run.py @@ -106,5 +106,49 @@ class AnalysisTests(unittest.TestCase): self.assertEqual(title_run.classify(*arguments), outcome, arguments) +def run(outcome, cache="cold", fps=0.0, error=""): + return {"outcome": outcome, "cache": cache, "fps": fps, "first_error": error} + + +def summary(titles): + return {"build": {"commit": "abc1234", "dirty": False, "note": "main + #6814"}, + "machine": {"os": "Windows 11", "gpu": "Radeon 860M", "driver": "32.0.13058.8001", "cpu": "Ryzen AI 7 350"}, + "runs": 2, "seconds": 60, "titles": titles} + + +class ReportTests(unittest.TestCase): + def test_cell(self): + runs = [run("crashed"), run("rendering", "warm"), run("crashed", "warm")] + self.assertEqual(title_run.cell(runs, "cold"), "crashed") + self.assertEqual(title_run.cell(runs, "warm"), "crashed 1/2, rendering 1/2") + self.assertEqual(title_run.cell([run("rendering", "warm"), run("rendering", "warm")], "warm"), "rendering 2/2") + self.assertEqual(title_run.cell([run("rendering")], "warm"), "-") + + def test_worst_and_compare(self): + now = summary({"PPSA85300": {"name": "VLC", "version": "0.4", "runs": [run("crashed"), run("crashed", "warm")]}, + "PPSA97358": {"name": "XPSemu", "version": "A2.1", "runs": [run("rendering"), run("rendering", "warm")]}}) + before = summary({"PPSA85300": {"name": "VLC", "version": "0.4", "runs": [run("rendering"), run("rendering", "warm")]}, + "PPSA97358": {"name": "XPSemu", "version": "A2.1", "runs": [run("stalled"), run("stalled", "warm")]}}) + self.assertEqual(title_run.worst(now["titles"]["PPSA85300"]["runs"]), "crashed") + self.assertEqual(title_run.compare(now, before), + ([("PPSA85300", "rendering", "crashed")], [("PPSA97358", "stalled", "rendering")])) + + def test_render_markdown(self): + now = summary({"PPSA85300": {"name": "VLC", "version": "0.4", + "runs": [run("crashed", error="what(): unsupported MIB 1.1 | x"), run("rendering", "warm", 31.0)]}}) + before = summary({"PPSA85300": {"name": "VLC", "version": "0.4", "runs": [run("rendering"), run("rendering", "warm")]}}) + self.assertEqual(title_run.render_markdown(now, before), "\n".join([ + "AnyPS5 `abc1234`, main + #6814. Windows 11, Radeon 860M, driver 32.0.13058.8001, CPU Ryzen AI 7 350. 2 runs per title, 60 s each.", + "", + "Regressions: VLC (PPSA85300) rendering -> crashed", + "", + "| Title | ID | Version | Cold | Warm | FPS | First error | Before |", + "|---|---|---|---|---|---|---|---|", + "| VLC | PPSA85300 | 0.4 | crashed | rendering | 31.0 | what(): unsupported MIB 1.1 \\| x | rendering |", + "", + ])) + self.assertNotIn("Before", title_run.render_markdown(now)) + + if __name__ == "__main__": unittest.main() diff --git a/tools/title_run.py b/tools/title_run.py index 8c4dcadc37..05f6cc1e87 100644 --- a/tools/title_run.py +++ b/tools/title_run.py @@ -81,3 +81,55 @@ def classify(relinked, exit_code, summary_age, last_frame_uniform): if last_frame_uniform: return "blank" return "rendering" + + +def cell(runs, cache): + outcomes = [entry["outcome"] for entry in runs if entry["cache"] == cache] + if not outcomes: + return "-" + if len(outcomes) == 1: + return outcomes[0] + return ", ".join(f"{outcome} {outcomes.count(outcome)}/{len(outcomes)}" for outcome in sorted(set(outcomes), key=OUTCOMES.index)) + + +def worst(runs): + return min((entry["outcome"] for entry in runs), key=OUTCOMES.index) + + +def compare(summary, previous): + regressions, improvements = [], [] + for title_id, title in summary["titles"].items(): + if title_id not in previous["titles"]: + continue + before, now = worst(previous["titles"][title_id]["runs"]), worst(title["runs"]) + if OUTCOMES.index(now) < OUTCOMES.index(before): + regressions.append((title_id, before, now)) + elif OUTCOMES.index(now) > OUTCOMES.index(before): + improvements.append((title_id, before, now)) + return regressions, improvements + + +def render_markdown(summary, previous=None): + build, machine = summary["build"], summary["machine"] + header = f"AnyPS5 `{build['commit']}`" + (" (dirty)" if build["dirty"] else "") + (f", {build['note']}" if build["note"] else "") + header += f". {machine['os']}, {machine['gpu']}, driver {machine['driver']}, CPU {machine['cpu']}." + header += f" {summary['runs']} runs per title, {summary['seconds']} s each." + lines = [header, ""] + columns = ["Title", "ID", "Version", "Cold", "Warm", "FPS", "First error"] + (["Before"] if previous else []) + if previous: + regressions, improvements = compare(summary, previous) + for label, changes in (("Regressions", regressions), ("Improvements", improvements)): + if changes: + names = ", ".join(f"{summary['titles'][title_id]['name']} ({title_id}) {before} -> {now}" for title_id, before, now in changes) + lines += [f"{label}: {names}", ""] + lines += ["| " + " | ".join(columns) + " |", "|" + "---|" * len(columns)] + for title_id, title in sorted(summary["titles"].items()): + runs = title["runs"] + error = next((entry["first_error"] for entry in runs if entry["outcome"] != "rendering"), "") + row = [title["name"], title_id, title["version"], cell(runs, "cold"), cell(runs, "warm"), f"{runs[-1]['fps']:.1f}", + error[:120].replace("|", "\\|")] + if previous: + old = previous["titles"].get(title_id) + row.append(worst(old["runs"]) if old else "new") + lines.append("| " + " | ".join(row) + " |") + return "\n".join(lines + [""]) From 601accf99916ee3bc3cc4c8ae401daaa3126afa4 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 21:29:46 +0200 Subject: [PATCH 33/62] fix(tools): list titles missing from the new run in the title report Co-Authored-By: Claude Opus 5.5 --- tools/tests/test_title_run.py | 6 ++++++ tools/title_run.py | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py index 4bcc466790..2b406b685f 100644 --- a/tools/tests/test_title_run.py +++ b/tools/tests/test_title_run.py @@ -149,6 +149,12 @@ class ReportTests(unittest.TestCase): ])) self.assertNotIn("Before", title_run.render_markdown(now)) + def test_render_markdown_lists_missing_titles(self): + now = summary({"PPSA85300": {"name": "VLC", "version": "0.4", "runs": [run("rendering"), run("rendering", "warm")]}}) + before = summary({"PPSA85300": {"name": "VLC", "version": "0.4", "runs": [run("rendering"), run("rendering", "warm")]}, + "PPSA97358": {"name": "XPSemu", "version": "A2.1", "runs": [run("stalled"), run("stalled", "warm")]}}) + self.assertIn("Missing: XPSemu (PPSA97358)", title_run.render_markdown(now, before)) + if __name__ == "__main__": unittest.main() diff --git a/tools/title_run.py b/tools/title_run.py index 05f6cc1e87..004c8e4878 100644 --- a/tools/title_run.py +++ b/tools/title_run.py @@ -122,7 +122,11 @@ def render_markdown(summary, previous=None): if changes: names = ", ".join(f"{summary['titles'][title_id]['name']} ({title_id}) {before} -> {now}" for title_id, before, now in changes) lines += [f"{label}: {names}", ""] - lines += ["| " + " | ".join(columns) + " |", "|" + "---|" * len(columns)] + missing = sorted(title_id for title_id in previous["titles"] if title_id not in summary["titles"]) + if missing: + names = ", ".join(f"{previous['titles'][title_id]['name']} ({title_id})" for title_id in missing) + lines += [f"Missing: {names}", ""] + lines +=["| " + " | ".join(columns) + " |", "|" + "---|" * len(columns)] for title_id, title in sorted(summary["titles"].items()): runs = title["runs"] error = next((entry["first_error"] for entry in runs if entry["outcome"] != "rendering"), "") From ec900e62e4982682e148b597ba927bd624131d3f Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 21:31:11 +0200 Subject: [PATCH 34/62] feat(tools): find title packages and lay out a relinked title for a run Co-Authored-By: Claude Opus 5.5 --- tools/tests/test_title_run.py | 45 +++++++++++++++++++ tools/title_run.py | 81 +++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py index 2b406b685f..63a0b66f31 100644 --- a/tools/tests/test_title_run.py +++ b/tools/tests/test_title_run.py @@ -156,5 +156,50 @@ class ReportTests(unittest.TestCase): self.assertIn("Missing: XPSemu (PPSA97358)", title_run.render_markdown(now, before)) +def package(root, title_id, version="01.000.000", name="Game"): + (root / "sce_sys").mkdir(parents=True) + (root / "eboot.bin").write_bytes(b"\x7fELF" + bytes(60)) + params = {"titleId": title_id, "contentVersion": version, + "localizedParameters": {"defaultLanguage": "en-US", "en-US": {"titleName": name}}} + (root / "sce_sys/param.json").write_text(json.dumps(params), encoding="utf-8") + return root + + +class PreparationTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + + def test_discover(self): + package(self.root / "a/src/PPSA00001", "PPSA00001", "01.000.110", "Light") + package(self.root / "b/PPSA00002", "PPSA00002") + found = title_run.discover([self.root]) + self.assertEqual(sorted(found), ["PPSA00001", "PPSA00002"]) + self.assertEqual(found["PPSA00001"]["version"], "01.000.110") + self.assertEqual(found["PPSA00001"]["name"], "Light") + + def test_discover_refuses_duplicates(self): + package(self.root / "a/PPSA00001", "PPSA00001") + package(self.root / "b/PPSA00001", "PPSA00001") + with self.assertRaisesRegex(ValueError, "PPSA00001 found more than once"): + title_run.discover([self.root]) + + def test_unwrap_or_copy_keeps_an_elf(self): + source = self.root / "eboot.bin" + source.write_bytes(b"\x7fELF" + bytes(60)) + title_run.unwrap_or_copy(source, self.root / "out/eboot.elf") + self.assertEqual((self.root / "out/eboot.elf").read_bytes(), source.read_bytes()) + + def test_link_tree_skips(self): + source = package(self.root / "pkg", "PPSA00001") + (source / "sce_module").mkdir() + (source / "sce_module/libc.prx").write_bytes(b"x") + title_run.link_tree(source, self.root / "app0", {"sce_module"}) + self.assertTrue((self.root / "app0/sce_sys/param.json").is_file()) + self.assertTrue((self.root / "app0/eboot.bin").is_file()) + self.assertFalse((self.root / "app0/sce_module").exists()) + + if __name__ == "__main__": unittest.main() diff --git a/tools/title_run.py b/tools/title_run.py index 004c8e4878..c449e5997a 100644 --- a/tools/title_run.py +++ b/tools/title_run.py @@ -1,9 +1,17 @@ import json +import os import re +import shutil import struct +import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tools")) + +import unwrap_self +from package_release import windows_runtime OUTCOMES = ("relink-failed", "crashed", "exited", "stalled", "blank", "rendering") TITLE_FIELDS = {"name": str, "relink": list, "fixed_base": str, "executables": list, "seconds": int, "env": dict, "input": list} FIXED_BASE_FLAGS = {"windows": "--windows-fixed-base", "linux": "--linux-fixed-base"} @@ -137,3 +145,76 @@ def render_markdown(summary, previous=None): row.append(worst(old["runs"]) if old else "new") lines.append("| " + " | ".join(row) + " |") return "\n".join(lines + [""]) + + +def discover(directories): + found = {} + for directory in directories: + for param in sorted(Path(directory).glob("**/sce_sys/param.json")): + package = param.parent.parent + if not (package / "eboot.bin").is_file(): + continue + data = json.loads(param.read_text(encoding="utf-8-sig")) + title_id = data["titleId"] + if title_id in found: + raise ValueError(f"{title_id} found more than once: {found[title_id]['package']} and {package}") + localized = data.get("localizedParameters", {}) + name = localized.get(localized.get("defaultLanguage", "en-US"), {}).get("titleName", title_id) + found[title_id] = {"package": package, "version": data.get("contentVersion", ""), "name": name} + return found + + +def unwrap_or_copy(source, target): + target.parent.mkdir(parents=True, exist_ok=True) + if source.read_bytes()[:4] == b"\x7fELF": + shutil.copyfile(source, target) + else: + unwrap_self.unwrap(source, target) + + +def link_file(source, target): + try: + os.link(source, target) + except OSError: + shutil.copy2(source, target) + + +def link_tree(source, target, skip): + for path in sorted(source.rglob("*")): + relative = path.relative_to(source) + if relative.parts[0] in skip: + continue + if path.is_dir(): + (target / relative).mkdir(parents=True, exist_ok=True) + else: + (target / relative).parent.mkdir(parents=True, exist_ok=True) + link_file(path, target / relative) + + +def prepare(title_id, found, entry, platform, build, work): + package = found["package"] + shutil.rmtree(work, ignore_errors=True) + source = work / "input" + (source / "sce_module").mkdir(parents=True) + unwrap_or_copy(package / "eboot.bin", source / "eboot.elf") + for module in sorted((package / "sce_module").glob("*.prx")): + unwrap_or_copy(module, source / "sce_module" / module.name) + for executable in entry.get("executables", []): + unwrap_or_copy(package / executable, source / executable) + executable = work / (title_id + (".exe" if platform == "windows" else "")) + relinker = build / "core/relinker" / ("relinker.exe" if platform == "windows" else "relinker") + with (work / "relink.log").open("w", encoding="utf-8") as log: + relinked = subprocess.run([str(relinker), *relink_args(entry, platform), str(source / "eboot.elf"), str(executable)], + stdout=log, stderr=subprocess.STDOUT) + if relinked.returncode != 0: + return None + link_tree(package, work / "app0", {"sce_module"}) + libs = work / "libs" + libs.mkdir() + for library in sorted((build / "core/libs/libs").glob("*.prx")): + link_file(library, libs / library.name) + if platform == "windows": + runtime = windows_runtime(build) + for name in ("libgcc_s_seh-1.dll", "libstdc++-6.dll", "libwinpthread-1.dll"): + link_file(runtime / name, libs / name) + return executable From 043533f88f478f76440c2e725fa6215177710096 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 21:33:32 +0200 Subject: [PATCH 35/62] feat(tools): follow a title's processes, children included, and end them Co-Authored-By: Claude Opus 5.5 --- tools/tests/test_title_run.py | 29 ++++++++++++ tools/title_run.py | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py index 63a0b66f31..fe682b9ce4 100644 --- a/tools/tests/test_title_run.py +++ b/tools/tests/test_title_run.py @@ -1,8 +1,11 @@ import json +import os from pathlib import Path import struct +import subprocess import sys import tempfile +import time import unittest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -201,5 +204,31 @@ class PreparationTests(unittest.TestCase): self.assertFalse((self.root / "app0/sce_module").exists()) +def wait_until(condition, seconds=10): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if condition(): + return True + time.sleep(0.1) + return False + + +class SessionTests(unittest.TestCase): + def start(self, code): + return title_run.Session([sys.executable, "-c", code], Path.cwd(), dict(os.environ), subprocess.DEVNULL, subprocess.DEVNULL) + + def test_exit_code(self): + session = self.start("import sys; sys.exit(3)") + self.assertTrue(wait_until(lambda: not session.alive())) + self.assertEqual(session.exit_code(), 3) + + def test_children_keep_it_alive_and_are_killed(self): + session = self.start("import subprocess, sys; subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])") + self.assertTrue(wait_until(lambda: session.exit_code() == 0)) + self.assertTrue(session.alive()) + session.kill() + self.assertTrue(wait_until(lambda: not session.alive())) + + if __name__ == "__main__": unittest.main() diff --git a/tools/title_run.py b/tools/title_run.py index c449e5997a..9a97f6b836 100644 --- a/tools/title_run.py +++ b/tools/title_run.py @@ -218,3 +218,91 @@ def prepare(title_id, found, entry, platform, build, work): for name in ("libgcc_s_seh-1.dll", "libstdc++-6.dll", "libwinpthread-1.dll"): link_file(runtime / name, libs / name) return executable + + +if os.name == "nt": + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + user32 = ctypes.WinDLL("user32", use_last_error=True) + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR) + kernel32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE) + kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT) + kernel32.QueryInformationJobObject.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p) + WindowCallback = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + user32.EnumWindows.argtypes = (WindowCallback, wintypes.LPARAM) + user32.GetWindowThreadProcessId.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.DWORD)) + user32.IsWindowVisible.argtypes = (wintypes.HWND,) + user32.SetWindowPos.argtypes = (wintypes.HWND, wintypes.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.UINT) + user32.PostMessageW.argtypes = (wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM) + + class ProcessIdList(ctypes.Structure): + _fields_ = [("assigned", wintypes.DWORD), ("listed", wintypes.DWORD), ("ids", ctypes.c_size_t * 256)] + + +class Session: + def __init__(self, command, cwd, env, stdout, stderr): + if os.name == "nt": + self.process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, creationflags=subprocess.CREATE_NO_WINDOW) + self.job = kernel32.CreateJobObjectW(None, None) + kernel32.AssignProcessToJobObject(self.job, int(self.process._handle)) + else: + self.process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, start_new_session=True) + + def process_ids(self): + listing = ProcessIdList() + kernel32.QueryInformationJobObject(self.job, 3, ctypes.byref(listing), ctypes.sizeof(listing), None) + return set(listing.ids[:listing.listed]) + + def windows(self): + ids, found = self.process_ids(), [] + + def visit(window, _): + owner = wintypes.DWORD() + user32.GetWindowThreadProcessId(window, ctypes.byref(owner)) + if owner.value in ids and user32.IsWindowVisible(window): + found.append(window) + return True + + user32.EnumWindows(WindowCallback(visit), 0) + return found + + def alive(self): + self.process.poll() + if os.name == "nt": + return bool(self.process_ids()) + try: + os.killpg(self.process.pid, 0) + return True + except ProcessLookupError: + return False + + def exit_code(self): + return self.process.poll() + + def lower_windows(self): + if os.name == "nt": + for window in self.windows(): + user32.SetWindowPos(window, wintypes.HWND(1), 0, 0, 0, 0, 0x13) + + def close(self): + if os.name == "nt": + for window in self.windows(): + user32.PostMessageW(window, 0x10, 0, 0) + else: + try: + os.killpg(self.process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + + def kill(self): + if os.name == "nt": + kernel32.TerminateJobObject(self.job, 1) + else: + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.process.wait() From e0243e6e6a52100248d19102e3f0dae7ff9a22e2 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 21:35:43 +0200 Subject: [PATCH 36/62] fix(tools): import signal for the POSIX session and fail loudly when the job cannot hold the title Co-Authored-By: Claude Opus 5.5 --- tools/tests/test_title_run.py | 4 +++- tools/title_run.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py index fe682b9ce4..76b1634f11 100644 --- a/tools/tests/test_title_run.py +++ b/tools/tests/test_title_run.py @@ -215,7 +215,9 @@ def wait_until(condition, seconds=10): class SessionTests(unittest.TestCase): def start(self, code): - return title_run.Session([sys.executable, "-c", code], Path.cwd(), dict(os.environ), subprocess.DEVNULL, subprocess.DEVNULL) + session = title_run.Session([sys.executable, "-c", code], Path.cwd(), dict(os.environ), subprocess.DEVNULL, subprocess.DEVNULL) + self.addCleanup(session.kill) + return session def test_exit_code(self): session = self.start("import sys; sys.exit(3)") diff --git a/tools/title_run.py b/tools/title_run.py index 9a97f6b836..3cec25ccb9 100644 --- a/tools/title_run.py +++ b/tools/title_run.py @@ -2,6 +2,7 @@ import json import os import re import shutil +import signal import struct import subprocess import sys @@ -247,7 +248,10 @@ class Session: if os.name == "nt": self.process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, creationflags=subprocess.CREATE_NO_WINDOW) self.job = kernel32.CreateJobObjectW(None, None) - kernel32.AssignProcessToJobObject(self.job, int(self.process._handle)) + if not self.job or not kernel32.AssignProcessToJobObject(self.job, int(self.process._handle)): + error = ctypes.get_last_error() + self.process.kill() + raise ctypes.WinError(error) else: self.process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, start_new_session=True) From 3c2845900b55a82374af2cd5adb444a303369091 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 21:36:55 +0200 Subject: [PATCH 37/62] fix(tools): report a title that exits before joining the job instead of failing Co-Authored-By: Claude Opus 5.5 --- tools/tests/test_title_run.py | 5 +++++ tools/title_run.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py index 76b1634f11..7353bcb0e4 100644 --- a/tools/tests/test_title_run.py +++ b/tools/tests/test_title_run.py @@ -224,6 +224,11 @@ class SessionTests(unittest.TestCase): self.assertTrue(wait_until(lambda: not session.alive())) self.assertEqual(session.exit_code(), 3) + def test_instant_exit_is_reported(self): + session = self.start("import sys; sys.exit(5)") + self.assertTrue(wait_until(lambda: not session.alive())) + self.assertEqual(session.exit_code(), 5) + def test_children_keep_it_alive_and_are_killed(self): session = self.start("import subprocess, sys; subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])") self.assertTrue(wait_until(lambda: session.exit_code() == 0)) diff --git a/tools/title_run.py b/tools/title_run.py index 3cec25ccb9..76e45dccbe 100644 --- a/tools/title_run.py +++ b/tools/title_run.py @@ -248,7 +248,7 @@ class Session: if os.name == "nt": self.process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, creationflags=subprocess.CREATE_NO_WINDOW) self.job = kernel32.CreateJobObjectW(None, None) - if not self.job or not kernel32.AssignProcessToJobObject(self.job, int(self.process._handle)): + if not self.job or (not kernel32.AssignProcessToJobObject(self.job, int(self.process._handle)) and self.process.poll() is None): error = ctypes.get_last_error() self.process.kill() raise ctypes.WinError(error) From 03f8794ffb3210b49c334cbf44e42a66cdbc80aa Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 21:55:35 +0200 Subject: [PATCH 38/62] feat(tools): run titles with fixed settings and write summary.json and summary.md Co-Authored-By: Claude Opus 5.5 --- tools/title_run.py | 116 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tools/title_run.py b/tools/title_run.py index 76e45dccbe..fca11a513f 100644 --- a/tools/title_run.py +++ b/tools/title_run.py @@ -1,3 +1,5 @@ +import argparse +import datetime import json import os import re @@ -6,6 +8,7 @@ import signal import struct import subprocess import sys +import time from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -310,3 +313,116 @@ class Session: except ProcessLookupError: pass self.process.wait() + + +CLOSE_SECONDS = 15 +FRAME_ENV = {"APS5_DUMP_FRAMES": "100000", "APS5_DUMP_FRAMES_EVERY": "30", "APS5_DUMP_SCALE": "8"} + + +def collect_frames(work, destination): + frames = sorted(work.glob("frame_*.bmp"), key=lambda path: int(path.stem.split("_")[1])) + keep = {frames[0], frames[len(frames) // 2], frames[-1]} if frames else set() + destination.mkdir(parents=True, exist_ok=True) + kept = [] + for frame in frames: + if frame in keep: + shutil.move(frame, destination / frame.name) + kept.append(frame.name) + else: + frame.unlink() + return kept + + +def run_title(executable, work, run_dir, seconds, extra_env): + run_dir.mkdir(parents=True) + env = {**os.environ, "SDL_AUDIODRIVER": "dummy", "SDL_WINDOW_NO_ACTIVATION_WHEN_SHOWN": "1", + "APS5_TRACE_SUMMARY_EVERY": "10", "APS5_TRACE_DIR": str(run_dir), **FRAME_ENV, + "MESA_SHADER_CACHE_DIR": str(work / "driver-cache"), "__GL_SHADER_DISK_CACHE_PATH": str(work / "driver-cache"), **extra_env} + started = time.monotonic() + with (run_dir / "stdout.log").open("wb") as stdout, (run_dir / "stderr.log").open("wb") as stderr: + session = Session([str(executable)], work, env, stdout, stderr) + try: + while session.alive() and time.monotonic() - started < seconds: + session.lower_windows() + time.sleep(0.5) + elapsed = round(time.monotonic() - started, 1) + alive, ended = session.alive(), time.time() + exit_code = None if alive else session.exit_code() + if alive: + session.close() + deadline = time.monotonic() + CLOSE_SECONDS + while session.alive() and time.monotonic() < deadline: + time.sleep(0.5) + finally: + session.kill() + text = "".join((run_dir / name).read_text(encoding="utf-8", errors="replace") for name in ("stdout.log", "stderr.log")) + summary_path = run_dir / "aps5-summary.jsonl" + summary = read_summary(summary_path) + frames = collect_frames(work, run_dir / "frames") + uniform = frame_is_uniform(run_dir / "frames" / frames[-1]) if frames else None + age = ended - summary_path.stat().st_mtime if summary_path.is_file() else None + cpu = re.search(r"host CPU (.+?): has", text) + return {"outcome": classify(True, exit_code, age, uniform), "exit_code": exit_code, "seconds": elapsed, + "presents": summary["presents"], "fps": summary["fps"], "first_error": first_error(text), + "warnings": warning_counts(text), "frames": frames, "gpu": summary["gpu"], "driver": summary["driver"], + "os": summary["os"], "cpu": cpu.group(1) if cpu else ""} + + +def git(*args): + return subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True).stdout.strip() + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Run titles repeatably and report each run's outcome.") + parser.add_argument("ids", nargs="*", help="title IDs to run (default: every title found)") + parser.add_argument("--games", action="append", type=Path, required=True, help="directory searched for extracted packages") + parser.add_argument("--build", type=Path, default=ROOT / "build") + parser.add_argument("--out", type=Path, default=Path("title-runs")) + parser.add_argument("--runs", type=int, default=2) + parser.add_argument("--seconds", type=int, help="run length for every title (default: the title's, else 60)") + parser.add_argument("--note", default="", help="free text for the report, e.g. 'main + #6814'") + parser.add_argument("--compare", type=Path, help="summary.json of an earlier run") + parser.add_argument("--titles", type=Path, default=ROOT / "tools/titles.json") + args = parser.parse_args(argv) + platform = "windows" if os.name == "nt" else "macos" if sys.platform == "darwin" else "linux" + titles = load_titles(args.titles) + found = discover(args.games) + ids = args.ids or sorted(found) + missing = [title_id for title_id in ids if title_id not in found] + if missing: + raise SystemExit(f"not found under --games: {', '.join(missing)}") + commit = git("rev-parse", "--short", "HEAD") + output = args.out.resolve() / f"{commit}-{datetime.datetime.now():%Y%m%d-%H%M%S}" + summary = {"build": {"commit": commit, "dirty": bool(git("status", "--porcelain", "--untracked-files=no")), "note": args.note}, + "machine": {"os": "", "gpu": "", "driver": "", "cpu": ""}, "runs": args.runs, "seconds": args.seconds or 60, + "driver_cache": "shared (not isolated)" if platform == "windows" else "isolated", "titles": {}} + for title_id in ids: + entry = titles.get(title_id, {}) + seconds = args.seconds or entry.get("seconds", 60) + work = output / title_id / "work" + executable = prepare(title_id, found[title_id], entry, platform, args.build, work) + runs = [] + for number in range(1, args.runs + 1): + if executable is None: + result = {"outcome": "relink-failed", "exit_code": None, "seconds": 0, "presents": 0, "fps": 0.0, + "first_error": first_error((work / "relink.log").read_text(encoding="utf-8", errors="replace")), + "warnings": {}, "frames": []} + else: + result = run_title(executable, work, output / title_id / f"run{number}", seconds, entry.get("env", {})) + for key in ("os", "gpu", "driver", "cpu"): + value = result.pop(key) + summary["machine"][key] = summary["machine"][key] or value + runs.append({"run": number, "cache": "cold" if number == 1 else "warm", **result}) + print(f"{title_id} run{number}: {result['outcome']}", flush=True) + summary["titles"][title_id] = {"name": entry.get("name", found[title_id]["name"]), "version": found[title_id]["version"], "runs": runs} + previous = json.loads(args.compare.read_text(encoding="utf-8")) if args.compare else None + (output / "summary.json").write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8") + report = render_markdown(summary, previous) + (output / "summary.md").write_text(report, encoding="utf-8") + print(report) + print(f"written to {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2283a126aec2d1bf1aef7dbd5f51da0043118081 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 22:00:19 +0200 Subject: [PATCH 39/62] feat(tools): keep title windows off the screen and count device loss as an error Co-Authored-By: Claude Opus 5.5 --- tools/tests/test_title_run.py | 1 + tools/title_run.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py index 7353bcb0e4..2c949810eb 100644 --- a/tools/tests/test_title_run.py +++ b/tools/tests/test_title_run.py @@ -72,6 +72,7 @@ class AnalysisTests(unittest.TestCase): def test_first_error(self): text = "Transferring control\n what(): sysctl_nid_postfix: unsupported MIB 1.1\nFATAL: abort()\n" self.assertEqual(title_run.first_error(text), "what(): sysctl_nid_postfix: unsupported MIB 1.1") + self.assertEqual(title_run.first_error("radv/ps5: the GPU has not reached submission 12 (at 11) in 10 s\nVkResult is ERROR_DEVICE_LOST\n"), "radv/ps5: the GPU has not reached submission 12 (at 11) in 10 s") self.assertEqual(title_run.first_error("all fine\n"), "") def test_warning_counts(self): diff --git a/tools/title_run.py b/tools/title_run.py index fca11a513f..83a34e1b32 100644 --- a/tools/title_run.py +++ b/tools/title_run.py @@ -46,7 +46,7 @@ def relink_args(entry, platform): return args -ERROR = re.compile(r"what\(\):|FATAL|not implemented|unhandled exception") +ERROR = re.compile(r"what\(\):|FATAL|not implemented|unhandled exception|DEVICE_LOST|has not reached submission") WARNINGS = ("destroyed with GPU results pending", "guest memory changed while GPU results were pending") STALL_SECONDS = 20 @@ -240,6 +240,7 @@ if os.name == "nt": user32.GetWindowThreadProcessId.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.DWORD)) user32.IsWindowVisible.argtypes = (wintypes.HWND,) user32.SetWindowPos.argtypes = (wintypes.HWND, wintypes.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.UINT) + user32.GetSystemMetrics.argtypes = (ctypes.c_int,) user32.PostMessageW.argtypes = (wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM) class ProcessIdList(ctypes.Structure): @@ -289,10 +290,12 @@ class Session: def exit_code(self): return self.process.poll() - def lower_windows(self): + def move_windows_offscreen(self): if os.name == "nt": + x = user32.GetSystemMetrics(76) + user32.GetSystemMetrics(78) + 64 + y = user32.GetSystemMetrics(77) for window in self.windows(): - user32.SetWindowPos(window, wintypes.HWND(1), 0, 0, 0, 0, 0x13) + user32.SetWindowPos(window, wintypes.HWND(1), x, y, 0, 0, 0x11) def close(self): if os.name == "nt": @@ -343,8 +346,8 @@ def run_title(executable, work, run_dir, seconds, extra_env): session = Session([str(executable)], work, env, stdout, stderr) try: while session.alive() and time.monotonic() - started < seconds: - session.lower_windows() - time.sleep(0.5) + session.move_windows_offscreen() + time.sleep(0.1 if time.monotonic() - started < 10 else 0.5) elapsed = round(time.monotonic() - started, 1) alive, ended = session.alive(), time.time() exit_code = None if alive else session.exit_code() From 6e4c6d828f3658b3c07da2f4f02649a96e1615f8 Mon Sep 17 00:00:00 2001 From: LeandroLP23 Date: Sat, 17 Oct 2026 22:03:26 +0200 Subject: [PATCH 40/62] fix(tools): keep a batch's summary when an artifact is damaged or --compare is wrong Co-Authored-By: Claude Opus 5.5 --- tools/tests/test_title_run.py | 8 +++++++ tools/title_run.py | 43 +++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/tools/tests/test_title_run.py b/tools/tests/test_title_run.py index 2c949810eb..3af0d6054c 100644 --- a/tools/tests/test_title_run.py +++ b/tools/tests/test_title_run.py @@ -85,6 +85,8 @@ class AnalysisTests(unittest.TestCase): {"presents": 200, "window": {"ms": 10000}, "gpu": "B", "driver": "2", "os": "W"}] path.write_text("".join(json.dumps(line) + "\n" for line in lines), encoding="utf-8") self.assertEqual(title_run.read_summary(path), {"presents": 500, "fps": 25.0, "gpu": "B", "driver": "2", "os": "W"}) + path.write_text(path.read_text(encoding="utf-8") + '{"presents": 3', encoding="utf-8") + self.assertEqual(title_run.read_summary(path)["presents"], 500) self.assertEqual(title_run.read_summary(self.root / "missing.jsonl")["presents"], 0) def test_frame_is_uniform(self): @@ -94,6 +96,12 @@ class AnalysisTests(unittest.TestCase): self.assertTrue(title_run.frame_is_uniform(black)) self.assertFalse(title_run.frame_is_uniform(noisy)) self.assertTrue(title_run.frame_is_uniform(nearly)) + truncated = self.root / "truncated.bmp" + truncated.write_bytes(black.read_bytes()[:40]) + self.assertIsNone(title_run.frame_is_uniform(truncated)) + cut = self.root / "cut.bmp" + cut.write_bytes(noisy.read_bytes()[:-20]) + self.assertIsNone(title_run.frame_is_uniform(cut)) def test_classify(self): cases = [ diff --git a/tools/title_run.py b/tools/title_run.py index 83a34e1b32..1d9144d848 100644 --- a/tools/title_run.py +++ b/tools/title_run.py @@ -60,7 +60,12 @@ def warning_counts(text): def read_summary(path): - lines = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] if path.is_file() else [] + lines = [] + for line in path.read_text(encoding="utf-8").splitlines() if path.is_file() else []: + try: + lines.append(json.loads(line)) + except ValueError: + pass presents = sum(line.get("presents", 0) for line in lines) seconds = sum(line.get("window", {}).get("ms", 0) for line in lines) / 1000 last = lines[-1] if lines else {} @@ -70,10 +75,15 @@ def read_summary(path): def frame_is_uniform(path, tolerance=8): data = path.read_bytes() - offset = struct.unpack_from(" Date: Sat, 17 Oct 2026 22:05:00 +0200 Subject: [PATCH 41/62] docs(tracing): describe title_run and register its test Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 3 +++ docs/dev/TRACING.md | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index f135ac95ff..f8bafedd10 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -567,6 +567,9 @@ if(BUILD_TESTING) add_test(NAME performance_compare COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_performance_compare.py) set_tests_properties(performance_compare PROPERTIES TIMEOUT 20) + add_test(NAME title_run COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_title_run.py) + set_tests_properties(title_run PROPERTIES TIMEOUT 60) endif() endif() diff --git a/docs/dev/TRACING.md b/docs/dev/TRACING.md index b9a5f8aa59..f8d1343c5b 100644 --- a/docs/dev/TRACING.md +++ b/docs/dev/TRACING.md @@ -76,3 +76,7 @@ See [Build](BUILD.md#performance-trace) for the trace format, summary fields and ## Shader registers `APS5_TRACE_SHADER_REGS=all` prints register snapshots for every shader. Set it to a shader code address in hexadecimal to print one shader's registers. + +## Running titles repeatably + +`python tools/title_run.py --games [ID ...]` relinks and runs extracted title packages found under `--games` (a directory holding `eboot.bin` and `sce_sys/param.json`) and writes `summary.json` and a fixed-format `summary.md` under `--out` (default `title-runs/-