diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000..2880b98b5b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,92 @@ +# DESCRIPTION: GitLab CI/CD for libRetro (NOT FOR GitLab-proper) +# +# Builds yaps2_libretro.so on the libretro buildbot (git.libretro.com mirror) +# for Linux x86_64 and aarch64. Inert on GitHub — the GitHub Actions +# workflows under .github/workflows/ are the project's own CI. +# +# The buildbot containers ship no Clang and none of the pinned third-party +# libraries, so before_script installs clang-17 from apt.llvm.org and reuses +# .github/workflows/scripts/linux/build-dependencies-runner.sh to build the +# same dependency set the GitHub nightly uses (shaderc, SDL3, zstd, lz4, ...). + +############################################################################## +################################# BOILERPLATE ################################ +############################################################################## + +# Core definitions +.core-defs: + variables: + GIT_SUBMODULE_STRATEGY: recursive + CORENAME: yaps2 + # The core is emitted in the pcsx2-libretro/ subdirectory of the build + # tree; the ci-templates pick it up from $BUILD_DIR/$EXTRA_PATH/. + EXTRA_PATH: pcsx2-libretro + CORE_ARGS: >- + -DCMAKE_C_COMPILER=clang-17 + -DCMAKE_CXX_COMPILER=clang++-17 + -DCMAKE_EXE_LINKER_FLAGS_INIT=-fuse-ld=lld-17 + -DCMAKE_MODULE_LINKER_FLAGS_INIT=-fuse-ld=lld-17 + -DCMAKE_SHARED_LINKER_FLAGS_INIT=-fuse-ld=lld-17 + -DCMAKE_PREFIX_PATH=$CI_PROJECT_DIR/.deps + -DENABLE_LIBRETRO=ON + -DENABLE_QT_UI=OFF + -DENABLE_SDL_FRONTEND=OFF + -DENABLE_GSRUNNER=OFF + -DENABLE_TESTS=OFF + -DENABLE_RECOMPILER_TEST_HOOKS=OFF + -DENABLE_SETCAP=OFF + -DUSE_BACKTRACE=OFF + -DX11_API=OFF + -DWAYLAND_API=OFF + -DDISABLE_ADVANCE_SIMD=TRUE + -DOVERRIDE_HOST_PAGE_SIZE=4096 + -DCMAKE_DISABLE_PRECOMPILE_HEADERS=ON + +.core-defs-linux: + extends: .core-defs + before_script: + # The ci-template's script builds with `-j $NUMPROC`; its before_script + # (which normally sets it) is replaced by this one. + - export NUMPROC=$(($(nproc) / 2)); [ "$NUMPROC" -lt 1 ] && export NUMPROC=1 + - apt-get update + - >- + DEBIAN_FRONTEND=noninteractive apt-get -y install + build-essential cmake curl git ninja-build nasm pkg-config wget + gnupg lsb-release software-properties-common + zlib1g-dev libaio-dev libasound2-dev libcurl4-openssl-dev + libdbus-1-dev libdrm-dev libegl-dev libevdev-dev libgbm-dev + libgudev-1.0-dev libopengl-dev libpcap-dev libssl-dev libudev-dev + - wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh + - chmod +x /tmp/llvm.sh + - /tmp/llvm.sh 17 + - DEBIAN_FRONTEND=noninteractive apt-get -y install lld-17 + - ./.github/workflows/scripts/linux/build-dependencies-runner.sh "$CI_PROJECT_DIR/.deps" + +# Inclusion templates, required for the build to work +include: + # Linux + - project: 'libretro-infrastructure/ci-templates' + file: '/linux-cmake.yml' + +# Stages for building +stages: + - build-prepare + - build-shared + +############################################################################## +#################################### STAGES ################################## +############################################################################## +# +################################### DESKTOPS ################################# +# Linux 64-bit +libretro-build-linux-x64: + extends: + - .libretro-linux-cmake-x86_64 + - .core-defs-linux + image: $CI_SERVER_HOST:5050/libretro-infrastructure/libretro-build-amd64-ubuntu:backports + +# Linux 64-bit (ARM) +libretro-build-linux-aarch64: + extends: + - .libretro-linux-cmake-aarch64 + - .core-defs-linux diff --git a/pcsx2-libretro/CMakeLists.txt b/pcsx2-libretro/CMakeLists.txt index 6d8f5ed8bd..804ba3db04 100644 --- a/pcsx2-libretro/CMakeLists.txt +++ b/pcsx2-libretro/CMakeLists.txt @@ -40,3 +40,8 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") set_target_properties(pcsx2-libretro PROPERTIES LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/link.T") endif() + +# The libretro buildbot's ci-templates build the target named +# _libretro (see .gitlab-ci.yml). +add_custom_target(yaps2_libretro) +add_dependencies(yaps2_libretro pcsx2-libretro) diff --git a/pcsx2-qt/Settings/AdvancedSettingsWidget.cpp b/pcsx2-qt/Settings/AdvancedSettingsWidget.cpp index 4b6ecc4153..e197e03733 100644 --- a/pcsx2-qt/Settings/AdvancedSettingsWidget.cpp +++ b/pcsx2-qt/Settings/AdvancedSettingsWidget.cpp @@ -23,6 +23,7 @@ AdvancedSettingsWidget::AdvancedSettingsWidget(SettingsWindow* settings_dialog, SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.eeFastmem, "EmuCore/CPU/Recompiler", "EnableFastmem", true); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.pauseOnTLBMiss, "EmuCore/CPU/Recompiler", "PauseOnTLBMiss", false); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.extraMemory, "EmuCore/CPU", "ExtraMemory", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.eeFpuGuardedAddSub, "EmuCore/CPU/Recompiler", "fpuGuardedAddSub", true); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.vu0Recompiler, "EmuCore/CPU/Recompiler", "EnableVU0", true); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.vu1Recompiler, "EmuCore/CPU/Recompiler", "EnableVU1", true); @@ -99,6 +100,11 @@ AdvancedSettingsWidget::AdvancedSettingsWidget(SettingsWindow* settings_dialog, dialog()->registerWidgetHelp(m_ui.extraMemory, tr("Enable Extended RAM (Dev Console)"), tr("Unchecked"), tr("Exposes additional memory to the virtual machine, expanding the EE and IOP memory to 128MB and 8MB respectively.")); + dialog()->registerWidgetHelp(m_ui.eeFpuGuardedAddSub, tr("FPU Add/Sub Guard Bits"), tr("Checked"), + //: "Guard bits" = extra low mantissa bits a standards-compliant FPU keeps during add/sub; the PS2's EE FPU does not, so the recompiler masks them to match. + tr("Emulates the EE FPU's missing add/sub mantissa guard bits for hardware-accurate results. Leave checked; a few games (e.g. True Crime, Jak 3) misrender without it. " + "Unchecking is a minor speedup for EE-FPU-heavy games verified to render correctly without it. Has no effect when the EE Clamping Mode is set to Full.")); + dialog()->registerWidgetHelp(m_ui.vu0RoundingMode, tr("VU0 Rounding Mode"), tr("Chop/Zero (Default)"), tr("Changes how PCSX2 handles rounding while emulating the Emotion Engine's Vector Unit 0 (EE VU0). " "The default value handles the vast majority of games; modifying this setting when a game is not having a visible problem will cause stability issues and/or crashes.")); diff --git a/pcsx2-qt/Settings/AdvancedSettingsWidget.ui b/pcsx2-qt/Settings/AdvancedSettingsWidget.ui index 393528742d..63793e33b9 100644 --- a/pcsx2-qt/Settings/AdvancedSettingsWidget.ui +++ b/pcsx2-qt/Settings/AdvancedSettingsWidget.ui @@ -180,6 +180,13 @@ + + + + FPU Add/Sub Guard Bits + + + @@ -555,6 +562,7 @@ eeFastmem pauseOnTLBMiss extraMemory + eeFpuGuardedAddSub vu0RoundingMode vu0ClampMode vu1RoundingMode diff --git a/pcsx2/Config.h b/pcsx2/Config.h index 265f70eb4b..0321f0ed64 100644 --- a/pcsx2/Config.h +++ b/pcsx2/Config.h @@ -680,7 +680,8 @@ struct Pcsx2Config bool fpuOverflow : 1, fpuExtraOverflow : 1, - fpuFullMode : 1; + fpuFullMode : 1, + fpuGuardedAddSub : 1; // EE FPU add/sub guard-bit emulation (single-precision fast path). ON by default — the PS2-accurate behavior. Opt-OUT globally via INI for EE-FPU-heavy titles verified to render fine without it (each ADD.S/SUB.S then costs one op instead of the guard sequence). Independent of the clamp tiers: Full mode runs the DOUBLE path, which guards unconditionally regardless of this bit. bool EnableEECache : 1; @@ -1589,6 +1590,7 @@ namespace EmuFolders #define CHECK_FPU_EXTRA_OVERFLOW (EmuConfig.Cpu.Recompiler.fpuExtraOverflow) // If enabled, Operands are checked for infinities before being used in the FPU recs #define CHECK_FPU_EXTRA_FLAGS 1 // Always enabled now // Sets D/I flags on FPU instructions #define CHECK_FPU_FULL (EmuConfig.Cpu.Recompiler.fpuFullMode) +#define CHECK_FPU_GUARDED (EmuConfig.Cpu.Recompiler.fpuGuardedAddSub) // If enabled (default), add/sub emulate the PS2 FPU's missing mantissa guard bits on the single-precision fast path. Disable only for EE-heavy titles confirmed not to need it. //------------ EE Recompiler defines - Comment to disable a recompiler --------------- diff --git a/pcsx2/ImGui/FullscreenUI_Settings.cpp b/pcsx2/ImGui/FullscreenUI_Settings.cpp index e62cd6475f..67f28c14d0 100644 --- a/pcsx2/ImGui/FullscreenUI_Settings.cpp +++ b/pcsx2/ImGui/FullscreenUI_Settings.cpp @@ -5535,6 +5535,10 @@ void FullscreenUI::DrawAdvancedSettingsPage() DrawToggleSetting(bsi, FSUI_ICONSTR(ICON_FA_MEMORY, "Enable Extended RAM (Dev Console)"), FSUI_CSTR("Exposes additional memory to the virtual machine, expanding the EE and IOP memory to 128MB and 8MB respectively."), "EmuCore/CPU", "ExtraMemory", false); + DrawToggleSetting(bsi, FSUI_ICONSTR(ICON_FA_PLUS_MINUS, "FPU Add/Sub Guard Bits"), + FSUI_CSTR("Emulates the EE FPU's missing add/sub guard bits for hardware-accurate results (default). A few games need it; " + "disabling is a minor speedup for EE-FPU-heavy games verified correct without it. No effect in Full clamping mode."), + "EmuCore/CPU/Recompiler", "fpuGuardedAddSub", true); MenuHeading(FSUI_CSTR("Vector Units")); DrawIntListSetting(bsi, FSUI_ICONSTR(ICON_FA_ARROW_TREND_DOWN, "VU0 Rounding Mode"), diff --git a/pcsx2/Pcsx2Config.cpp b/pcsx2/Pcsx2Config.cpp index f682513c5e..7dd5ae9407 100644 --- a/pcsx2/Pcsx2Config.cpp +++ b/pcsx2/Pcsx2Config.cpp @@ -473,6 +473,7 @@ Pcsx2Config::RecompilerOptions::RecompilerOptions() fpuOverflow = true; //fpuExtraOverflow = false; //fpuFullMode = false; + fpuGuardedAddSub = true; // PS2-accurate add/sub guard-bit emulation; opt-out for perf on titles verified not to need it. } void Pcsx2Config::RecompilerOptions::ApplySanityCheck() @@ -551,6 +552,7 @@ void Pcsx2Config::RecompilerOptions::LoadSave(SettingsWrapper& wrap) SettingsWrapBitBool(fpuOverflow); SettingsWrapBitBool(fpuExtraOverflow); SettingsWrapBitBool(fpuFullMode); + SettingsWrapBitBool(fpuGuardedAddSub); } u32 Pcsx2Config::RecompilerOptions::GetEEClampMode() const diff --git a/pcsx2/arm64/BaseblockEx-arm64.h b/pcsx2/arm64/BaseblockEx-arm64.h index 96489c06c4..073f698e00 100644 --- a/pcsx2/arm64/BaseblockEx-arm64.h +++ b/pcsx2/arm64/BaseblockEx-arm64.h @@ -27,9 +27,28 @@ // Range: B imm26 covers ±128 MB. The EE recompiler region is 64 MB // (HostMemoryMap::EErecSize), and JITCompile lives in the same region, // so all link sites are reachable with a single B. +// +// Link-map liveness (2026-07-20). Entries used to accumulate for the whole +// lifetime of the map: Link() inserts unconditionally, and nothing pruned. +// Every recompile of a caller emits its branch at a *fresh* code address and +// appended another entry, so New(pc) re-patched every site the map had ever +// seen for that pc — each with its own 4-byte icache flush. Measured on +// Dirge of Cerberus: 13,372 stale sites on one PC, and 93.5% of EE-thread +// cycles inside __aarch64_sync_cache_range. Each entry now carries the +// identity of the block that emitted it (owner startpc + that block's code +// address), and both Link() and New() erase entries whose owner is gone or has +// since been recompiled. Cost becomes O(live callers) instead of O(all callers +// ever), and the map stops growing without bound. +// +// Liveness rests on two invariants: block code is bump-allocated and never +// reclaimed until a full Reset() (which also clears the map), so an fnptr +// value never identifies two different compiles; and BASEBLOCKEX::fnptr is +// authoritative for the block's *current* code, which is why New() updates +// it when a startpc is recompiled in place rather than leaving it stale. #pragma once +#include #include #include "common/HostSys.h" @@ -39,12 +58,31 @@ class Arm64BaseBlocks { protected: - using linkmap_t = std::multimap; + // A registered branch site, tagged with the block that emitted it so + // New() can tell live sites from ones left behind by a superseded + // compile. See the liveness note in the file header. + struct LinkSite + { + // Patch site address, with kLinkSiteCallBit in bit 0. + uptr site; + // Block that emitted this site. owner_fnptr == 0 means "untracked": + // the site was registered outside a compile (only the direct unit + // tests do this), and is treated as permanently live. + u32 owner_startpc; + uptr owner_fnptr; + }; + + using linkmap_t = std::multimap; BaseBlockArray blocks; linkmap_t links; uptr jitcompile = 0; + // The block currently being compiled, published by New(). Link() stamps + // it onto every site it records. + u32 emitting_startpc = 0; + uptr emitting_fnptr = 0; + // Link sites are 4-byte-aligned instruction addresses, so bit 0 of the // stored uptr is free: it tags the site's branch form. Untagged = B, // tagged = BL (call-ret stack call sites — the BL pushes the hardware @@ -63,7 +101,13 @@ protected: return (call ? 0x94000000u : 0x14000000u) | (static_cast(imm26) & 0x03FFFFFFu); } - static void PatchAtomic(uptr site, u32 instr) + // Store only, no cache maintenance. Valid ONLY for a site inside the + // block currently being emitted: that buffer has not been executed yet, + // and armEndBlock() issues one whole-range flush over it before it can + // be. (AetherSX2 does the same — its Link() performs no flush at all, + // leaving it to the single range flush in armEndBlock.) + // Never use this on code that is already live; use PatchAtomic. + static void PatchWord(uptr site, u32 instr) { // 4-byte aligned word stores are atomic on AArch64. `site` is the RX // address; under iOS dual-map W^X the store goes through the RW alias @@ -71,15 +115,83 @@ protected: // for the toggle modes; on Darwin the "signal handler" caller is // really the Mach exception-handler thread, so that scope is safe. *reinterpret_cast(armGetWritableCodePtr(reinterpret_cast(site))) = instr; + } + + // (HostSys::FlushInstructionCache, not the raw builtin: on Darwin the + // builtin lowers to a compiler-rt ___clear_cache call that the iOS link + // doesn't provide; the wrapper uses sys_icache_invalidate there.) + static void FlushRange(uptr lo, uptr hi) + { + HostSys::FlushInstructionCache(reinterpret_cast(lo), static_cast(hi - lo)); + } + + static void PatchAtomic(uptr site, u32 instr) + { + PatchWord(site, instr); // Then make sure cores fetching instructions see the new word. - // (HostSys::FlushInstructionCache, not the raw builtin: on Darwin the - // builtin lowers to a compiler-rt ___clear_cache call that the iOS - // link doesn't provide; the wrapper uses sys_icache_invalidate there.) - HostSys::FlushInstructionCache(reinterpret_cast(site), 4); + FlushRange(site, site + 4); // Signal-safe (counter-only) — Remove() can call this from the // SIGSEGV fastmem handler. } + // Drop every entry for `dest_pc` whose owner is gone or has been + // recompiled since. Called from both Link() and New(): New() alone is not + // enough, because a destination that is never recompiled again would never + // have its list revisited, and the entries its callers keep re-registering + // would accumulate for the life of the map. Erasing a multimap element + // invalidates only that element's iterator, so the end of the range stays + // valid across the walk. + void PruneDeadLinks(u32 dest_pc) + { + const auto range = links.equal_range(dest_pc); + for (auto it = range.first; it != range.second;) + { + if (IsOwnerLive(it->second)) + ++it; + else + it = links.erase(it); + } + } + + // True when `ls` still lives inside a block that is both present and has + // not been recompiled since the site was registered. + bool IsOwnerLive(const LinkSite& ls) + { + if (ls.owner_fnptr == 0) + return true; // untracked registration (direct unit tests) + + const BASEBLOCKEX* owner = Get(ls.owner_startpc); + return owner && owner->startpc == ls.owner_startpc && owner->fnptr == ls.owner_fnptr; + } + + // Coalesce the just-patched sites into as few cache-maintenance ranges as + // possible. The per-call cost is dominated by the `dsb ish` / `isb` pair, + // not by the DC/IC ops, so N adjacent 4-byte flushes cost ~N barrier pairs + // where one range flush costs one. Sites emitted by the same block sit a + // few instructions apart, so this usually collapses to a single range. + static void FlushPatchedSites(uptr* sites, u32 count) + { + if (count == 0) + return; + + std::sort(sites, sites + count); + + constexpr uptr kMaxGap = 256; + uptr lo = sites[0], hi = sites[0] + 4; + for (u32 i = 1; i < count; i++) + { + if (sites[i] - hi <= kMaxGap) + { + hi = sites[i] + 4; + continue; + } + FlushRange(lo, hi); + lo = sites[i]; + hi = sites[i] + 4; + } + FlushRange(lo, hi); + } + public: Arm64BaseBlocks() : blocks(0x4000) @@ -104,25 +216,72 @@ public: BASEBLOCKEX* target = Get(pc); const uptr target_addr = (target && target->startpc == pc) ? target->fnptr : jitcompile; - PatchAtomic(reinterpret_cast(patch_site), + // No flush: the site is in the block being emitted right now, and + // armEndBlock() range-flushes that buffer. See PatchWord. + PatchWord(reinterpret_cast(patch_site), EncodeB(reinterpret_cast(patch_site), target_addr, call)); - links.insert({pc, reinterpret_cast(patch_site) | (call ? kLinkSiteCallBit : 0)}); + // Reap this destination's dead entries before adding ours, so its list + // tracks live callers rather than every caller it has ever had. + PruneDeadLinks(pc); + + links.insert({pc, + LinkSite{reinterpret_cast(patch_site) | (call ? kLinkSiteCallBit : 0), + emitting_startpc, emitting_fnptr}}); } + // Begin compiling the block at `startpc`, whose code starts at `fnptr`. + // Returns its BASEBLOCKEX, creating one if this startpc has no live block. + // + // A startpc can be recompiled while its BASEBLOCKEX is still in the array + // (recClear resets BLOCK->fnptr across a straddled extent but deliberately + // spares the in-progress block's entry). BaseBlockArray::insert() has no + // dedup, so that case must reuse the existing entry — and it must retarget + // it at the new code, otherwise fnptr keeps pointing at the superseded + // compile and every consumer of it is wrong: x86size is computed from a + // dead base, Remove() writes its redirect stub over dead code instead of + // the live entry, and Link() sends new callers into stale code. BASEBLOCKEX* New(u32 startpc, uptr fnptr) { - // Patch any pending links waiting for a block at this PC. After - // patching they go directly to fnptr instead of routing through - // JITCompile. + // Published for Link() to stamp onto the sites this block emits. + emitting_startpc = startpc; + emitting_fnptr = fnptr; + + const int idx = Index(startpc); + BASEBLOCKEX* block = (idx >= 0 && blocks[idx].startpc == startpc) + ? &blocks[idx] : nullptr; + if (block) + block->fnptr = fnptr; + else + block = blocks.insert(startpc, fnptr); + + // Repoint the links waiting on this PC at the new code, dropping any + // left behind by a block that has since been removed or recompiled. + // Those sites are unreachable code; patching them is pure waste, and + // it is that waste which used to dominate the EE thread. + uptr patched[64]; + u32 npatched = 0; + const auto range = links.equal_range(startpc); - for (auto it = range.first; it != range.second; ++it) + for (auto it = range.first; it != range.second;) { - const uptr site = it->second & ~kLinkSiteCallBit; - PatchAtomic(site, EncodeB(site, fnptr, (it->second & kLinkSiteCallBit) != 0)); + if (!IsOwnerLive(it->second)) + { + it = links.erase(it); + continue; + } + + const uptr site = it->second.site & ~kLinkSiteCallBit; + PatchWord(site, EncodeB(site, fnptr, (it->second.site & kLinkSiteCallBit) != 0)); + if (npatched < std::size(patched)) + patched[npatched++] = site; + else + FlushRange(site, site + 4); // overflow: flush as we go + ++it; } - return blocks.insert(startpc, fnptr); + FlushPatchedSites(patched, npatched); + return block; } int LastIndex(u32 startpc) const @@ -180,8 +339,10 @@ public: // Signal-safe: writes a redirect stub at each removed block's entry // point so any stale link still resolves through JITCompile, then // erases from the flat sorted array. Does NOT touch the link map — - // stale entries there are harmless (they just trigger a re-patch on - // the next compile cycle for the same PC). + // mutating an STL container here is not signal-safe. The entries this + // strands are reaped by the next New() for their destination PC, which + // sees the owner is gone; until then the redirect stub keeps any site + // still pointing at this block correct. // // SL-1: a resident self-loop's back-edge is an internal B to the loop-top // (past the entry redirect), so it gets its own atomic repoint — to the @@ -212,6 +373,8 @@ public: { blocks.clear(); links.clear(); + emitting_startpc = 0; + emitting_fnptr = 0; } #ifdef PCSX2_RECOMPILER_TESTS @@ -235,11 +398,22 @@ public: const auto range = links.equal_range(dst_pc); for (auto it = range.first; it != range.second; ++it) { - const uptr site = it->second & ~kLinkSiteCallBit; + const uptr site = it->second.site & ~kLinkSiteCallBit; if (site >= lo && site < hi) return true; } return false; } + + // Test-only: number of registered link sites targeting dst_pc, and the + // total across all destinations. Used to pin that the map stays bounded + // by *live* sites rather than growing with every recompile. + size_t LinkCount(u32 dst_pc) const + { + const auto range = links.equal_range(dst_pc); + return static_cast(std::distance(range.first, range.second)); + } + + size_t TotalLinkCount() const { return links.size(); } #endif }; diff --git a/pcsx2/arm64/iCOP2-arm64.cpp b/pcsx2/arm64/iCOP2-arm64.cpp index 58b628a23f..8564526eec 100644 --- a/pcsx2/arm64/iCOP2-arm64.cpp +++ b/pcsx2/arm64/iCOP2-arm64.cpp @@ -85,9 +85,10 @@ alignas(16) static const u32 s_cop2DestMasks[16][4] = { // tiny compile-time cache keeps them resident in q16..q20 and the op bodies // compute 3-operand NEON straight from the cache registers. // -// Register choice: q16-q26 have no fixed user in EE-block emission context +// Register choice: q16-q24 have no fixed user in EE-block emission context // (q0-q7 = allocator temp/FPR first-fit + vtlb data + mVU macro window, // q8/q9 = pinned FPU clamp constants, q10-q15 = allocator GPR-quad/FPR homes, +// q25/q26 = SL-13 clamp-constant broadcasts (cop2EnsureClampConsts below), // q27/q28 = VOPMULA/VCLIP + flag-body scratch, q29-q31 = per-op scratch). // They are caller-saved and NOT preserved by the fastmem fault thunk (which // only saves allocator-tracked regs), so the cache must never survive any op @@ -468,8 +469,12 @@ static_assert(offsetof(cpuRegistersPack, cop2Rec) + sizeof(EeCop2RecState) <= 16 // (Re)write the pack copies of the COP2 rec constants. Called from // recResetRaw, so the harnesses that reset the rec before compiling are -// covered too. minFloat is the pre-negated clamp lower bound: the clamp -// emitters spend a 1-insn load where they used to spend an Fneg. +// covered too. minFloat is the pre-negated clamp lower bound. Since SL-13 +// the clamp emitters no longer LOAD maxFloat/minFloat (the bounds live +// broadcast in q25/q26, re-materialized from s8/s9 — see +// cop2EnsureClampConsts below); the pack fields stay as the documented +// canonical values (minFloat[i] == maxFloat[i] | 0x80000000 == -FLT_MAX is +// the identity the s9 Dup relies on) and for any future dest-mask work. void cop2RecWritePackConstants() { EeCop2RecState& st = _cpuRegistersPack.cop2Rec; @@ -481,15 +486,80 @@ void cop2RecWritePackConstants() st.denormStatusFlag = 0; } +// ========================================================================= +// SL-13: clamp-constant broadcast residency (q25/q26) +// ========================================================================= +// The clamp bounds live register-resident: q25 = maxFloat.4S (+FLT_MAX per +// lane), q26 = minFloat.4S (-FLT_MAX per lane). Both are excluded from the +// EE NEON allocator pool (NEON_RESERVED_COP2_CLAMPMAX/MIN, iCore-arm64.cpp) +// and from the COP2 macro-mode mVU pool (microRegAlloc::reset(cop2mode)), so +// no EE-block emission can clobber them. Re-materialization is 2 Dups from +// the pinned s8 = +FLT_MAX / s9 = -FLT_MAX callee-saved scalars +// (_DynGen_EnterRecompiledCode) — no memory access, and the sources survive +// every C call by AAPCS64. minFloat[i] == maxFloat[i] | 0x80000000 == +// -FLT_MAX exactly (see cop2RecWritePackConstants), so s9 is the exact +// broadcast source. +// +// Compile-time validity discipline (s_cop2ClampConstsValid): +// - false at block start; the first clamp site emits the 2 Dups. +// - iFlushCall (ANY flushtype — every real C-call seam) invalidates: the +// callee may clobber caller-saved q25/q26. The next clamp site re-Dups. +// - The VPU_STAT-conditional sync seams do NOT invalidate: the shared sync +// stubs re-Dup unconditionally on their taken path after the C calls +// (always sound — q25/q26 can hold nothing else), and their fast path +// touches no NEON. +// - Fastmem sites do NOT invalidate: vtlbGetLiveRegisterMasks ORs q25/q26 +// into the recorded fpr_bitmask while valid, so a backpatched slowmem +// thunk save/restores them around its C call like any live register. +// - The mVU-reuse macro wrappers do NOT invalidate: their pool excludes +// q25/q26 under cop2mode and they emit no C calls. +// - Branch forks and superblock side exits snapshot/restore the flag via +// BranchCompileState (iR5900-arm64.cpp). +// Establishment must stay on unconditionally-executed emission paths — never +// emit the Dups inside a runtime-conditional arm (a post-merge site compiled +// valid would be wrong on the arm that skipped them). All current clamp +// sites are straight-line within their op bodies. + +static bool s_cop2ClampConstsValid = false; + +#ifdef PCSX2_RECOMPILER_TESTS +u32 g_cop2ClampConstEstablishCount = 0; +#endif + +bool cop2ClampConstsValid() +{ + return s_cop2ClampConstsValid; +} + +void cop2ClampConstsSetValid(bool valid) +{ + s_cop2ClampConstsValid = valid; +} + +void cop2ClampConstsInvalidate() +{ + s_cop2ClampConstsValid = false; +} + +static void cop2EnsureClampConsts() +{ + if (s_cop2ClampConstsValid) + return; + armAsm->Dup(a64::v25.V4S(), a64::v8.V4S(), 0); // +FLT_MAX broadcast + armAsm->Dup(a64::v26.V4S(), a64::v9.V4S(), 0); // -FLT_MAX broadcast + s_cop2ClampConstsValid = true; +#ifdef PCSX2_RECOMPILER_TESTS + g_cop2ClampConstEstablishCount++; +#endif +} + // Clamp the result register to [-FLT_MAX, +FLT_MAX] (removes infinities and // NaNs). FMINNM/FMAXNM match x86 MINPS/MAXPS semantics: NaN → non-NaN operand. static void cop2ClampResultReg(const a64::VRegister& result) { - armAsm->Ldr(RQSCRATCH2, armCpuRegMem(&_cpuRegistersPack.cop2Rec.maxFloat)); - armAsm->Ldr(RQSCRATCH3, armCpuRegMem(&_cpuRegistersPack.cop2Rec.minFloat)); - - armAsm->Fminnm(result.V4S(), result.V4S(), RQSCRATCH2.V4S()); // clamp to +FLT_MAX - armAsm->Fmaxnm(result.V4S(), result.V4S(), RQSCRATCH3.V4S()); // clamp to -FLT_MAX + cop2EnsureClampConsts(); + armAsm->Fminnm(result.V4S(), result.V4S(), a64::v25.V4S()); // clamp to +FLT_MAX + armAsm->Fmaxnm(result.V4S(), result.V4S(), a64::v26.V4S()); // clamp to -FLT_MAX } static void cop2ClampResult() @@ -497,28 +567,14 @@ static void cop2ClampResult() cop2ClampResultReg(RQSCRATCH); } -// Single-temp variant of cop2ClampReg: clamps `qreg` to [-FLT_MAX, +FLT_MAX] -// using just one scratch register (it reloads the bound between the two -// clamps). Needed when pre-clamping a broadcast FMAC operand, where Fs/Ft -// already occupy two of the three q-scratch regs and only one is free. -static void cop2ClampRegOneTmp(const a64::VRegister& qreg, const a64::VRegister& tmp) -{ - armAsm->Ldr(tmp, armCpuRegMem(&_cpuRegistersPack.cop2Rec.maxFloat)); - armAsm->Fminnm(qreg.V4S(), qreg.V4S(), tmp.V4S()); // clamp to +FLT_MAX - armAsm->Ldr(tmp, armCpuRegMem(&_cpuRegistersPack.cop2Rec.minFloat)); - armAsm->Fmaxnm(qreg.V4S(), qreg.V4S(), tmp.V4S()); // clamp to -FLT_MAX -} - // Non-destructive clamp: dst = clamp(src) without modifying src (which may be -// a live VF-cache register). Same 4-insn cost as cop2ClampRegOneTmp — the -// first Fminnm is 3-operand, so preserving src is free. -static void cop2ClampInto(const a64::VRegister& dst, const a64::VRegister& src, - const a64::VRegister& tmp) +// a live VF-cache register) — the first Fminnm is 3-operand, so preserving +// src is free. +static void cop2ClampInto(const a64::VRegister& dst, const a64::VRegister& src) { - armAsm->Ldr(tmp, armCpuRegMem(&_cpuRegistersPack.cop2Rec.maxFloat)); - armAsm->Fminnm(dst.V4S(), src.V4S(), tmp.V4S()); - armAsm->Ldr(tmp, armCpuRegMem(&_cpuRegistersPack.cop2Rec.minFloat)); - armAsm->Fmaxnm(dst.V4S(), dst.V4S(), tmp.V4S()); + cop2EnsureClampConsts(); + armAsm->Fminnm(dst.V4S(), src.V4S(), a64::v25.V4S()); + armAsm->Fmaxnm(dst.V4S(), dst.V4S(), a64::v26.V4S()); } // ======================================================================== @@ -1066,6 +1122,16 @@ static const u8* cop2DynGenOneSyncStub(void (*syncFn)(), void (*finishFn)()) armReloadCycleDelta(); armReloadEEClobberedPins(); + // SL-13: the callees (and any VU0 micro they ran) clobber caller-saved + // q25/q26 — re-materialize the clamp-constant broadcasts so sites whose + // compile-time validity rides through this seam stay correct. Always + // sound: q25/q26 are pool-reserved and can hold nothing else, and the + // s8/s9 sources are callee-saved (low 64 bits). The fast path above + // touches no NEON, so validity rides it untouched. Pinned by + // EeVu0Cop2ClampResidency.SyncStubsReDupClampConsts. + armAsm->Dup(a64::v25.V4S(), a64::v8.V4S(), 0); + armAsm->Dup(a64::v26.V4S(), a64::v9.V4S(), 0); + armAsm->Ldr(a64::x30, a64::MemOperand(a64::sp, 48)); armAsm->Ldp(a64::x14, a64::x15, a64::MemOperand(a64::sp, 32)); armAsm->Ldp(a64::x6, a64::x7, a64::MemOperand(a64::sp, 16)); @@ -1084,6 +1150,24 @@ void cop2DynGenSyncStubs() s_cop2SyncStubs[kCop2SyncStubFinish] = cop2DynGenOneSyncStub(nullptr, _vu0FinishMicro); } +#ifdef PCSX2_RECOMPILER_TESTS +// SL-13 pin surface: emitted sync-stub code ranges, so tests can assert the +// taken path re-materializes the q25/q26 clamp broadcasts (the seam-survival +// invariant is emission-level — end-to-end runs only catch it when the C +// path happens to clobber q25/q26). Kind indexes follow emission order; the +// end of stub k is the start of stub k+1 (contiguous emission), and the last +// stub is bounded by the dispatcher's Perf-registered range — tests scan to +// the final Ret instead. +int cop2TestGetSyncStubCount() +{ + return kCop2SyncStubCount; +} +const u8* cop2TestGetSyncStub(int kind) +{ + return (kind >= 0 && kind < kCop2SyncStubCount) ? s_cop2SyncStubs[kind] : nullptr; +} +#endif + // Emit conditional VU0 sync: uses EEINST analysis flags when available, // falls back to runtime VPU_STAT check otherwise. // Implements the COP2_Interlock + mVUSyncVU0/mVUFinishVU0 sync protocol. @@ -1642,10 +1726,10 @@ static void cop2LoadBroadcast(const a64::VRegister& qreg, int vfReg, int bc) a64::VRegister mulA = fs; \ if (mulClamp) \ { \ - cop2ClampInto(RQSCRATCH, fs, RQSCRATCH3); \ + cop2ClampInto(RQSCRATCH, fs); \ mulA = RQSCRATCH; \ if (_XYZW_cop2 == 0xf) \ - cop2ClampRegOneTmp(RQSCRATCH2, RQSCRATCH3); \ + cop2ClampResultReg(RQSCRATCH2); /* in-place operand clamp */ \ } \ const a64::VRegister rd = cop2ResultReg(_Fd_cop2, _XYZW_cop2); \ armAsm->neonOp(rd.V4S(), mulA.V4S(), RQSCRATCH2.V4S()); \ @@ -1823,8 +1907,7 @@ void recCOP2_VMSUB() // a zero broadcast Ft must become FLT_MAX*0 = 0 rather than Inf*0 = NaN folded // to +/-FLT_MAX by the result clamp. MSUBx/y/z/w use mVU_FMACd (clampType=0, // no cFs) — that Fs divergence is shared/by-design, so MSUB keeps clampFs=false. -// The MADDw extras (cACC|cFt) are a separate concern. RQSCRATCH2/RQSCRATCH3 are -// free as the ±FLT_MAX bounds here (Ft/ACC are loaded after the clamp). +// The MADDw extras (cACC|cFt) are a separate concern. #define COP2_MADD_BC(name, addOp, bc, clampFs) \ void recCOP2_V##name() \ { \ @@ -1834,7 +1917,7 @@ void recCOP2_VMSUB() a64::VRegister mulA = fs; \ if (clampFs) \ { \ - cop2ClampInto(RQSCRATCH, fs, RQSCRATCH3); \ + cop2ClampInto(RQSCRATCH, fs); \ mulA = RQSCRATCH; \ } \ cop2LoadBroadcast(RQSCRATCH2, _Ft_cop2, bc); \ @@ -1970,10 +2053,10 @@ COP2_ACCUM_OP(MULA, Fmul) a64::VRegister mulA = fs; \ if (mulClamp) \ { \ - cop2ClampInto(RQSCRATCH, fs, RQSCRATCH3); \ + cop2ClampInto(RQSCRATCH, fs); \ mulA = RQSCRATCH; \ if (_XYZW_cop2 == 0xf) \ - cop2ClampRegOneTmp(RQSCRATCH2, RQSCRATCH3); \ + cop2ClampResultReg(RQSCRATCH2); /* in-place operand clamp */ \ } \ const a64::VRegister rdA = cop2ResultRegACC(_XYZW_cop2); \ armAsm->neonOp(rdA.V4S(), mulA.V4S(), RQSCRATCH2.V4S()); \ diff --git a/pcsx2/arm64/iCore-arm64.cpp b/pcsx2/arm64/iCore-arm64.cpp index 23eee4cd5b..9fe4f243b0 100644 --- a/pcsx2/arm64/iCore-arm64.cpp +++ b/pcsx2/arm64/iCore-arm64.cpp @@ -709,7 +709,21 @@ static constexpr u32 NEON_RESERVED_FPU_MAX = 8; static constexpr u32 NEON_RESERVED_FPU_MIN = 9; // (The callee-saved allocator range q10-q15 is declared in iCore-arm64.h — -// NEON_CALLEE_SAVED_START/END; indices 8/9 reserved above.) +// NEON_CALLEE_SAVED_START/END; indices 8/9 reserved above. SL-13 reserves +// q25/q26 the same way for the COP2 clamp-constant broadcasts — +// NEON_RESERVED_COP2_CLAMPMAX/MIN in iCore-arm64.h.) +static bool _isReservedNEONreg(u32 i) +{ + return i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN || + i == NEON_RESERVED_COP2_CLAMPMAX || i == NEON_RESERVED_COP2_CLAMPMIN; +} + +#ifdef PCSX2_RECOMPILER_TESTS +bool eeTestNeonRegIsReserved(int hostreg) +{ + return _isReservedNEONreg(static_cast(hostreg)); +} +#endif // Free-slot-only probe of a range: no eviction, -1 when the range is full. // Used by the FPR-class allocators to PREFER a call-surviving home (GE-15) @@ -719,7 +733,7 @@ static int _getFreeArm64NEONInRangeNoEvict(u32 minreg, u32 maxreg) { for (u32 i = minreg; i < maxreg; i++) { - if (i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN) + if (_isReservedNEONreg(i)) continue; if (!arm64neon[i].inuse) return static_cast(i); @@ -735,7 +749,7 @@ int _getFreeArm64NEON(u32 minreg, u32 maxreg) // Check for free registers for (u32 i = minreg; i < maxreg; i++) { - if (i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN) + if (_isReservedNEONreg(i)) continue; if (!arm64neon[i].inuse) return i; @@ -746,7 +760,7 @@ int _getFreeArm64NEON(u32 minreg, u32 maxreg) bestcount = 0xffff; for (u32 i = minreg; i < maxreg; i++) { - if (i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN) + if (_isReservedNEONreg(i)) continue; pxAssert(arm64neon[i].inuse); if (arm64neon[i].needed) @@ -787,7 +801,7 @@ int _getFreeArm64NEON(u32 minreg, u32 maxreg) bestcount = 0xffff; for (u32 i = minreg; i < maxreg; i++) { - if (i == NEON_RESERVED_FPU_MAX || i == NEON_RESERVED_FPU_MIN) + if (_isReservedNEONreg(i)) continue; pxAssert(arm64neon[i].inuse); if (arm64neon[i].needed) diff --git a/pcsx2/arm64/iCore-arm64.h b/pcsx2/arm64/iCore-arm64.h index 7b3bb8391a..5efc61217f 100644 --- a/pcsx2/arm64/iCore-arm64.h +++ b/pcsx2/arm64/iCore-arm64.h @@ -131,6 +131,17 @@ struct _arm64gprregs static constexpr u32 NEON_CALLEE_SAVED_START = 10; static constexpr u32 NEON_CALLEE_SAVED_END = 16; // exclusive +// SL-13: q25/q26 are dedicated to the COP2 macro clamp-constant broadcasts +// (q25 = maxFloat.4S = +FLT_MAX, q26 = minFloat.4S = -FLT_MAX) and excluded +// from the NEON allocator pool entirely, like q8/q9. They are lazily +// re-materialized per block from the pinned s8/s9 scalars (2 Dups, no memory) +// — see cop2EnsureClampConsts in iCOP2-arm64.cpp for the compile-time +// validity discipline. The COP2 macro-mode mVU pool excludes them too +// (microRegAlloc::reset(cop2mode)); micro-mode mVU may clobber them freely — +// every EE-side path back from micro execution re-materializes. +static constexpr u32 NEON_RESERVED_COP2_CLAMPMAX = 25; +static constexpr u32 NEON_RESERVED_COP2_CLAMPMIN = 26; + // x86 type aliases — used by shared analysis code (iR5900Analysis.cpp) #define XMMTYPE_TEMP NEONTYPE_TEMP #define XMMTYPE_GPRREG NEONTYPE_GPRREG diff --git a/pcsx2/arm64/iFPU-arm64.cpp b/pcsx2/arm64/iFPU-arm64.cpp index 5ad6797683..22725e28fa 100644 --- a/pcsx2/arm64/iFPU-arm64.cpp +++ b/pcsx2/arm64/iFPU-arm64.cpp @@ -447,11 +447,13 @@ static a64::VRegister fpuClampMinMaxOperand(const a64::VRegister& src, const a64 // positions must read as zero on hardware. This masks the low mantissa bits of // the smaller-exponent operand by the exponent difference, then does the single // op. It is the arm64 fast-path port of x86 FPU_ADD_SUB (iFPU.cpp:402). Both -// JITs apply this masking unconditionally (x86 FPU_ADD/FPU_SUB, iFPU.cpp) — -// games like True Crime NYC and Jak 3 misrender without it, and flagging them -// per-game proved impractical. It reproduces the masking already present in the -// DOUBLE path's FPU_ADD_SUB (iFPUd-arm64.cpp:200); the CHECK_FPU_FULL (double) -// config dispatches to that path instead and never reaches here. +// JITs gate this masking on the same CHECK_FPU_GUARDED option (x86 FPU_ADD/ +// FPU_SUB, iFPU.cpp) — ON by default (games like True Crime NYC and Jak 3 +// misrender without it, and per-game flagging proved impractical) but opt-out +// globally for EE-heavy titles that don't need it; see the early-out below. It +// reproduces the masking already present in the DOUBLE path's FPU_ADD_SUB +// (iFPUd-arm64.cpp:200); the CHECK_FPU_FULL (double) config dispatches to that +// path instead and never reaches here (Full mode guards unconditionally). // // When |expd - expt| <= 1 the mask clears zero bits, so that (common) case skips // straight to the plain op. Only |diff| >= 2 masks the smaller-exponent operand; @@ -475,6 +477,22 @@ static a64::VRegister fpuClampMinMaxOperand(const a64::VRegister& src, const a64 static void fpuEmitGuardedAddSub(const a64::VRegister& dst, const a64::VRegister& s, const a64::VRegister& t, bool issub) { + // Guard-bit emulation is ON by default (CHECK_FPU_GUARDED) but can be turned + // off globally via the fpuGuardedAddSub Recompiler INI bool for EE-FPU-heavy + // titles verified to render fine without it. Off = a plain single op, matching + // AetherSX2 / PCSX2 v1.0 and the x86 FPU_ADD/FPU_SUB guard-off branch + // (iFPU.cpp). Returns before the NEON-temp alloc and GPR-scratch use below so + // nothing is booked on the fast path. (Full clamp mode is unaffected either + // way: it runs the DOUBLE path, which masks guard bits itself — iFPUd-arm64.cpp.) + if (!CHECK_FPU_GUARDED) + { + if (issub) + armAsm->Fsub(dst, s, t); + else + armAsm->Fadd(dst, s, t); + return; + } + // Alloc the NEON temp FIRST, before any raw GPR scratch below goes live. // The alloc can emit a victim eviction whose address materialization uses // scratch (today only x16/x17 via armMoveAddressToReg); keeping w9/w10 diff --git a/pcsx2/arm64/iR3000A-arm64.cpp b/pcsx2/arm64/iR3000A-arm64.cpp index f3bb70fdd7..90643b12a3 100644 --- a/pcsx2/arm64/iR3000A-arm64.cpp +++ b/pcsx2/arm64/iR3000A-arm64.cpp @@ -1304,9 +1304,9 @@ static void iopRecRecompile(const u32 startpc) // and triggers SIGILL. Same fix as EE rec at iR5900-arm64.cpp:1751. const uptr block_fnptr = (uptr)armGetCurrentCodePointer(); - s_pCurBlockEx = recBlocks.Get(HWADDR(startpc)); - if (!s_pCurBlockEx || s_pCurBlockEx->startpc != HWADDR(startpc)) - s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); + // See the EE rec's equivalent: New() creates or re-binds, and publishes + // the owner for the link sites this block is about to register. + s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); psxbranch = 0; diff --git a/pcsx2/arm64/iR5900-arm64.cpp b/pcsx2/arm64/iR5900-arm64.cpp index 236225dcfc..356e8c90fe 100644 --- a/pcsx2/arm64/iR5900-arm64.cpp +++ b/pcsx2/arm64/iR5900-arm64.cpp @@ -672,6 +672,12 @@ void iFlushCall(int flushtype) // (Cop2VfCacheScope) so each fork emits its own writebacks. cop2VfCacheFlush(); + // SL-13: the callee may clobber the caller-saved q25/q26 clamp-constant + // broadcasts — pure compile-time invalidation (constants are clean by + // definition; the next clamp site re-materializes with 2 Dups). + // Unconditional on flushtype: ANY C call can clobber them. + cop2ClampConstsInvalidate(); + // Free caller-saved registers for (int i = 0; i < NUM_ARM_GPR_REGS; i++) { @@ -1613,10 +1619,12 @@ struct BranchCompileState u32 blockCycles; EEINST* instInfo; Cop2VfCacheState vfCache; + bool clampConstsValid; // SL-13: q25/q26 broadcast validity at the fork point void capture() { vfCache = cop2VfCacheGetState(); + clampConstsValid = cop2ClampConstsValid(); blockCycles = s_nBlockCycles; memcpy(constRegs, g_cpuConstRegs, sizeof(g_cpuConstRegs)); hasConstReg = g_cpuHasConstReg; @@ -1629,6 +1637,7 @@ struct BranchCompileState void restore() const { cop2VfCacheSetState(vfCache); + cop2ClampConstsSetValid(clampConstsValid); s_nBlockCycles = blockCycles; memcpy(g_cpuConstRegs, constRegs, sizeof(g_cpuConstRegs)); g_cpuHasConstReg = hasConstReg; @@ -3240,12 +3249,15 @@ static void recRecompile(const u32 startpc) // and triggers SIGILL. const uptr block_fnptr = (uptr)armGetCurrentCodePointer(); - s_pCurBlockEx = recBlocks.Get(HWADDR(startpc)); - if (!s_pCurBlockEx || s_pCurBlockEx->startpc != HWADDR(startpc)) - s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); + // New() both creates and re-binds: a startpc whose BASEBLOCKEX survived a + // straddled recClear is retargeted at the new code rather than left with + // a stale fnptr. It also publishes the block as the owner of every link + // site the emission below registers. + s_pCurBlockEx = recBlocks.New(HWADDR(startpc), block_fnptr); g_branch = 0; cop2VfCacheReset(); + cop2ClampConstsInvalidate(); // SL-13: q25/q26 state unknown at block entry s_pCurBlock->SetFnptr(block_fnptr); s_nBlockCycles = 0; diff --git a/pcsx2/arm64/iR5900-arm64.h b/pcsx2/arm64/iR5900-arm64.h index f179cd86a7..6f423a368e 100644 --- a/pcsx2/arm64/iR5900-arm64.h +++ b/pcsx2/arm64/iR5900-arm64.h @@ -737,16 +737,35 @@ Cop2VfCacheState cop2VfCacheGetState(); // fork-tail peek support void cop2VfCacheSetState(const Cop2VfCacheState&); bool cop2OpPreservesVfCache(u32 code); // classifier for recompileNextInstruction -// RAII: preserve the compile-time cache state across a branch-fork tail +// SL-13: compile-time validity of the q25/q26 clamp-constant broadcasts +// (defined in iCOP2-arm64.cpp — see cop2EnsureClampConsts for the full +// discipline). Invalidate at every real C-call seam (iFlushCall) and at +// block start; the flag joins BranchCompileState for forks/side exits; +// vtlbGetLiveRegisterMasks reads it to make fastmem thunks preserve q25/q26. +bool cop2ClampConstsValid(); +void cop2ClampConstsSetValid(bool valid); +void cop2ClampConstsInvalidate(); + +// RAII: preserve the compile-time COP2 residency state (VF cache + SL-13 +// clamp-const validity) across a branch-fork tail // (SetBranchImm/SetBranchImmCall/SetBranchReg) whose iFlushCall destructively // flushes — the sibling fork must re-emit its own writebacks from the same // pre-tail state, since the cached values stay register-resident along every -// runtime path. +// runtime path (and q25/q26 stay materialized on the not-taken path). struct Cop2VfCacheScope { Cop2VfCacheState state; - Cop2VfCacheScope() : state(cop2VfCacheGetState()) {} - ~Cop2VfCacheScope() { cop2VfCacheSetState(state); } + bool clampConstsValid; + Cop2VfCacheScope() + : state(cop2VfCacheGetState()) + , clampConstsValid(cop2ClampConstsValid()) + { + } + ~Cop2VfCacheScope() + { + cop2VfCacheSetState(state); + cop2ClampConstsSetValid(clampConstsValid); + } }; // COP2 macro-mode microVU0 state setup/teardown (defined in microVU-arm64.cpp). diff --git a/pcsx2/arm64/microVU-arm64.cpp b/pcsx2/arm64/microVU-arm64.cpp index 19c1dd7aad..b0d1f01736 100644 --- a/pcsx2/arm64/microVU-arm64.cpp +++ b/pcsx2/arm64/microVU-arm64.cpp @@ -2103,6 +2103,16 @@ bool mVUTestProbe_VIPoolUsable(int hostreg, bool cop2mode) return usable; } +// SL-13 twin: is host NEON reg q in the VF allocation pool under +// cop2mode? Macro mode must exclude q25/q26 (EE clamp-constant broadcasts). +bool mVUTestProbe_NeonPoolUsable(int hostreg, bool cop2mode) +{ + microVU0.regAlloc->reset(cop2mode); + const bool usable = microVU0.regAlloc->isUsableNeon(hostreg); + microVU0.regAlloc->reset(false); + return usable; +} + // waitMTVU stub-shape probe: returns the emitted VU1-sync thunk entry // (mVU.waitMTVU, generated once by mVUgenerateDispatchers) for VU `index`. // mVUaddrFix branches to this thunk when VU0/COP2 touches VU1 register space diff --git a/pcsx2/arm64/microVU_IR-arm64.h b/pcsx2/arm64/microVU_IR-arm64.h index e09d660a6d..6f349e8af2 100644 --- a/pcsx2/arm64/microVU_IR-arm64.h +++ b/pcsx2/arm64/microVU_IR-arm64.h @@ -60,6 +60,7 @@ protected: int counter; int neonWatermark; // see getNeonWatermark() int index; // VU0 or VU1 + bool neonCop2Mode; // SL-13: macro mode — q25/q26 unallocatable (EE clamp consts) VURegs& regs() const { return ::vuRegs[index]; } @@ -88,11 +89,19 @@ protected: armAsm->Dup(reg.V4S(), reg.V4S(), 0); // Broadcast to all lanes } + // SL-13: NEON pool gate — cop2mode excludes q25/q26 (see reset()). + __ri bool neonUsable(int i) const + { + return !neonCop2Mode || (i != 25 && i != 26); + } + // Find least-recently-used NEON reg (recursive, for eviction) int findFreeNeonRec(int startIdx) { for (int i = startIdx; i < neonAllocTotal; i++) { + if (!neonUsable(i)) + continue; if (!neonMap[i].isNeeded) { int x = findFreeNeonRec(i + 1); @@ -109,6 +118,8 @@ protected: // Prefer unoccupied temp regs for (int i = 0; i < neonAllocTotal; i++) { + if (!neonUsable(i)) + continue; if (!neonMap[i].isNeeded && neonMap[i].VFreg < 0) { neonWatermark = std::max(neonWatermark, i + 1); @@ -256,6 +267,15 @@ public: // EeVu0Cop2Macro.MacroModeVIPoolExcludesEEPinHosts. (x86's cop2mode // meaning — fastmem-base/text-pointer usability — doesn't apply here; // those bases are pinned outside the allocatable set.) + // + // SL-13: cop2mode likewise gates the q25/q26 NEON slots — in EE-block + // context they hold the COP2 clamp-constant broadcasts (see + // NEON_RESERVED_COP2_CLAMPMAX/MIN, iCore-arm64.h), and the clamp validity + // flag deliberately RIDES THROUGH the mVU-reuse macro wrappers (they emit + // no C call), so an mVU allocation landing there would silently corrupt + // the constants for every later clamp site in the block. Micro mode keeps + // both (micro programs run under the dispatcher; EE re-materializes on + // every path back). Pinned by EeVu0Cop2ClampResidency.MacroModeNeonPool*. void reset(bool cop2mode = false) { // Clear x26/x27 unconditionally so no VI binding survives a @@ -265,6 +285,7 @@ public: clearGPR(27); gprMap[26].usable = !cop2mode; gprMap[27].usable = !cop2mode; + neonCop2Mode = cop2mode; for (int i = 0; i < neonAllocTotal; i++) clearNeon(i); for (int i = 0; i < gprAllocCount; i++) @@ -874,6 +895,13 @@ public: return i >= 0 && i < gprAllocCount && gprMap[i].usable; } + // NEON twin of isUsableGPR (SL-13 q25/q26 clamp-const isolation) — see + // mVUTestProbe_NeonPoolUsable. + bool isUsableNeon(int i) const + { + return i >= 0 && i < neonAllocTotal && neonUsable(i); + } + // Move VI value into a specific GPR (for address computation etc.) void moveVIToGPR(const a64::Register& dstReg, int vi, bool signext = false) { diff --git a/pcsx2/arm64/recVTLB-arm64.cpp b/pcsx2/arm64/recVTLB-arm64.cpp index 3b9bc025cb..9ce584bd9b 100644 --- a/pcsx2/arm64/recVTLB-arm64.cpp +++ b/pcsx2/arm64/recVTLB-arm64.cpp @@ -227,6 +227,13 @@ static void vtlbGetLiveRegisterMasks(u32& gpr_bitmask, u32& fpr_bitmask) if (arm64neon[i].inuse) fpr_bitmask |= (1u << i); } + + // SL-13: the q25/q26 clamp-constant broadcasts are not allocator state, + // but when compile-time valid they must survive a backpatched slowmem + // thunk's C call like any live register — clamp sites after this access + // were compiled without re-materialization. + if (cop2ClampConstsValid()) + fpr_bitmask |= (1u << NEON_RESERVED_COP2_CLAMPMAX) | (1u << NEON_RESERVED_COP2_CLAMPMIN); } // Emit a single fastmem load instruction and register backpatch info. diff --git a/pcsx2/x86/iFPU.cpp b/pcsx2/x86/iFPU.cpp index 42fd33a750..dbc37122df 100644 --- a/pcsx2/x86/iFPU.cpp +++ b/pcsx2/x86/iFPU.cpp @@ -62,8 +62,10 @@ namespace DOUBLE #define FPUflagSO 0x00000010 #define FPUflagSU 0x00000008 -// Add/Sub opcodes produce the same results as the ps2 -#define FPU_CORRECT_ADD_SUB 1 +// Add/Sub guard-bit emulation (matching the PS2's missing mantissa guard bits) +// is gated at runtime on CHECK_FPU_GUARDED (the fpuGuardedAddSub Recompiler +// option, ON by default) so both JITs honor the same setting. The Full-mode +// DOUBLE path keeps its own unconditional guard (iFPUd.cpp). alignas(16) static const u32 s_neg[4] = {0x80000000, 0xffffffff, 0xffffffff, 0xffffffff}; alignas(16) static const u32 s_pos[4] = {0x7fffffff, 0xffffffff, 0xffffffff, 0xffffffff}; @@ -481,7 +483,7 @@ void FPU_ADD_SUB(int regd, int regt, int issub) void FPU_ADD(int regd, int regt) { - if (FPU_CORRECT_ADD_SUB) + if (CHECK_FPU_GUARDED) FPU_ADD_SUB(regd, regt, 0); else xADD.SS(xRegisterSSE(regd), xRegisterSSE(regt)); @@ -489,7 +491,7 @@ void FPU_ADD(int regd, int regt) void FPU_SUB(int regd, int regt) { - if (FPU_CORRECT_ADD_SUB) + if (CHECK_FPU_GUARDED) FPU_ADD_SUB(regd, regt, 1); else xSUB.SS(xRegisterSSE(regd), xRegisterSSE(regt)); diff --git a/tests/ctest/core/recompilers/CMakeLists.txt b/tests/ctest/core/recompilers/CMakeLists.txt index 9034893aea..2c5d2e5985 100644 --- a/tests/ctest/core/recompilers/CMakeLists.txt +++ b/tests/ctest/core/recompilers/CMakeLists.txt @@ -87,6 +87,7 @@ add_pcsx2_test(recompiler_tests ee_vu0_cfc2_ctc2_tests.cpp ee_vu0_qmfc2_qmtc2_tests.cpp ee_vu0_cop2_transfer_residency_tests.cpp + ee_vu0_cop2_clamp_residency_tests.cpp ee_vu0_cop2_macro_tests.cpp vu1_alu_upper_tests.cpp vu1_alu_lower_tests.cpp diff --git a/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp b/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp index d783252792..c9820d449a 100644 --- a/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp +++ b/tests/ctest/core/recompilers/arm64_baseblocks_link_tests.cpp @@ -97,3 +97,171 @@ TEST(Arm64BaseBlocksLink, RemoveRedirectStubIsPlainBAndRelinkKeepsBl) EXPECT_EQ(DecodeImm26Bytes(*site_bl), reinterpret_cast(new_entry) - reinterpret_cast(site_bl)); } + +// --------------------------------------------------------------------------- +// Link-map liveness. Entries are stamped with the block that emitted them, so +// New() can drop the ones stranded in code that has been removed or superseded +// instead of re-patching them forever. Regression cover for the Dirge of +// Cerberus blowup: 13,372 dead sites on one PC, 93.5% of the EE thread spent +// flushing icache for branches nothing can reach. +// --------------------------------------------------------------------------- + +namespace +{ +// A link registered while `owner_pc` was being compiled at `owner_entry`. +void CompileAndLink(Arm64BaseBlocks& bb, u32 owner_pc, u32* owner_entry, u32 dest_pc, + u32* site, bool call = false) +{ + bb.New(owner_pc, reinterpret_cast(owner_entry)); + bb.Link(dest_pc, site, call); +} +} // namespace + +TEST(Arm64BaseBlocksLink, NewRebindsAnExistingBlockToItsNewCode) +{ + alignas(64) static u32 code[64]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kPc = 0x1000; + u32* const first = &code[16]; + u32* const second = &code[24]; + u32* const site = &code[8]; + + bb.Link(kPc, site); + bb.New(kPc, reinterpret_cast(first)); + ASSERT_EQ(bb.Get(kPc)->fnptr, reinterpret_cast(first)); + + // Recompiling a startpc whose BASEBLOCKEX survived a straddled recClear + // must retarget the entry, not leave it aimed at the superseded compile. + bb.New(kPc, reinterpret_cast(second)); + EXPECT_EQ(bb.Get(kPc)->fnptr, reinterpret_cast(second)); + // And it must not have produced a duplicate array entry for the same pc. + EXPECT_EQ(bb.Index(kPc), 0); + // Callers follow the block to its new code instead of branching into the + // dead first compile. + EXPECT_EQ(DecodeImm26Bytes(*site), + reinterpret_cast(second) - reinterpret_cast(site)); +} + +TEST(Arm64BaseBlocksLink, StaleOwnerEntriesPrunedOnNew) +{ + alignas(64) static u32 code[64]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kOwner = 0x1000; + constexpr u32 kDest = 0x2000; + u32* const site_old = &code[8]; + + // Owner compiles and links to a destination that does not exist yet... + CompileAndLink(bb, kOwner, &code[16], kDest, site_old); + // ...then is recompiled into a shape that no longer branches there, so + // nothing re-registers a link for kDest and only New(kDest) can notice. + bb.New(kOwner, reinterpret_cast(&code[24])); + ASSERT_EQ(bb.LinkCount(kDest), 1u); + + const u32 stale_word = *site_old; + + // The destination compiles: the site left behind in the owner's dead + // first compile is dropped, not re-patched. + u32* const dest_entry = &code[40]; + bb.New(kDest, reinterpret_cast(dest_entry)); + + EXPECT_EQ(bb.LinkCount(kDest), 0u); + EXPECT_EQ(*site_old, stale_word); +} + +TEST(Arm64BaseBlocksLink, RemovedOwnerEntriesPruned) +{ + alignas(64) static u32 code[64]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kOwner = 0x1000; + constexpr u32 kDest = 0x2000; + u32* const site = &code[8]; + + CompileAndLink(bb, kOwner, &code[16], kDest, site); + ASSERT_EQ(bb.LinkCount(kDest), 1u); + + const int idx = bb.Index(kOwner); + ASSERT_GE(idx, 0); + bb.Remove(idx, idx); + + const u32 stale_word = *site; + bb.New(kDest, reinterpret_cast(&code[40])); + + // Owner is gone, so its site is unreachable code: dropped, never patched. + EXPECT_EQ(bb.LinkCount(kDest), 0u); + EXPECT_EQ(*site, stale_word); +} + +TEST(Arm64BaseBlocksLink, MapStaysBoundedUnderOwnerChurn) +{ + alignas(64) static u32 code[512]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kOwner = 0x1000; + constexpr u32 kDest = 0x2000; + constexpr int kRecompiles = 100; + + // The pathological shape: one block recompiled over and over, each compile + // re-registering its branch at a fresh address. Before the liveness + // filter this grew one entry per recompile forever (13,372 on one PC in + // Dirge of Cerberus). The list must stay at the single live caller the + // whole way through — the destination here is never recompiled, so + // nothing but Link() itself can do the reaping. + for (int i = 0; i < kRecompiles; i++) + { + CompileAndLink(bb, kOwner, &code[256 + i], kDest, &code[i]); + ASSERT_EQ(bb.LinkCount(kDest), 1u) << "grew at recompile " << i; + } + + const u32 first_word = code[0]; + u32* const dest_entry = &code[400]; + bb.New(kDest, reinterpret_cast(dest_entry)); + + // Exactly one live site survives — the one in the owner's current compile. + EXPECT_EQ(bb.LinkCount(kDest), 1u); + EXPECT_EQ(bb.TotalLinkCount(), 1u); + EXPECT_EQ(DecodeImm26Bytes(code[kRecompiles - 1]), + reinterpret_cast(dest_entry) - reinterpret_cast(&code[kRecompiles - 1])); + EXPECT_EQ(code[0], first_word); +} + +TEST(Arm64BaseBlocksLink, BlFormSurvivesPruning) +{ + alignas(64) static u32 code[64]; + std::memset(code, 0, sizeof(code)); + + Arm64BaseBlocks bb; + bb.SetJITCompile(&code[0]); + + constexpr u32 kOwner = 0x1000; + constexpr u32 kDest = 0x2000; + u32* const site_old = &code[8]; + u32* const site_new = &code[9]; + + CompileAndLink(bb, kOwner, &code[16], kDest, site_old, /*call=*/true); + CompileAndLink(bb, kOwner, &code[24], kDest, site_new, /*call=*/true); + + u32* const dest_entry = &code[40]; + bb.New(kDest, reinterpret_cast(dest_entry)); + + // The call-form tag rides through the liveness filter with the rest of + // the entry — the survivor is still a BL. + ASSERT_EQ(bb.LinkCount(kDest), 1u); + EXPECT_EQ(OpcodeBits(*site_new), kOpcBL); + EXPECT_EQ(DecodeImm26Bytes(*site_new), + reinterpret_cast(dest_entry) - reinterpret_cast(site_new)); +} diff --git a/tests/ctest/core/recompilers/ee_rec_fpu_guardbit_tests.cpp b/tests/ctest/core/recompilers/ee_rec_fpu_guardbit_tests.cpp index 2069ee7e42..b098e85eae 100644 --- a/tests/ctest/core/recompilers/ee_rec_fpu_guardbit_tests.cpp +++ b/tests/ctest/core/recompilers/ee_rec_fpu_guardbit_tests.cpp @@ -9,9 +9,12 @@ // zero on PS2 hardware. The recompiler reproduces this by masking the low // mantissa bits of the smaller-exponent operand by the exponent difference // before the op - x86 FPU_ADD_SUB (iFPU.cpp:402) and the arm64 -// fpuEmitGuardedAddSub (iFPU-arm64.cpp) — unconditionally in both JITs (games +// fpuEmitGuardedAddSub (iFPU-arm64.cpp). Both JITs gate it on the same +// CHECK_FPU_GUARDED / fpuGuardedAddSub option, which is ON by default (games // like True Crime NYC and Jak 3 misrender without it, and per-game flagging -// proved impractical). +// proved impractical) but can be turned off globally for EE-heavy titles that +// don't need it. These tests run under the default (ON); DisableEmitsPlainOp +// at the bottom pins the opt-out path. // // THESE ARE JIT-ONLY TESTS. The shared interpreter's ADD_S/SUB_S (FPU.cpp) is a // plain host float + float (fpuDouble() returns float and does no masking), @@ -206,3 +209,23 @@ TEST(EeRecFpuGuardBit, RandomizedMatchesX86Model) EXPECT_GT(checked, 1500) << "too many pairs skipped; the test is not exercising the mask"; } +// Opt-out path (guard OFF): the default-ON fpuGuardedAddSub option can be turned +// off globally for EE-heavy titles that don't need guard-bit accuracy. With it +// off, a guard-sensitive subtraction must emit a plain fsub — no masking — which +// makes the JIT bit-identical to the single-precision interpreter (interp never +// masked). This pins that the toggle actually gates AND that the fast path +// matches interp, so Run()'s JIT-vs-interp auto-diff holds. Same operands as +// SubMasksOneGuardBit, which (guard ON) asserts the masked 0x403fffff; here the +// bare/interp value 0x403ffffe is the result. +TEST(EeRecFpuGuardBit, DisableEmitsPlainOpMatchingInterp) +{ + EeRecTestHarness h; + h.EnableCop1(); + h.DisableFpuGuarded(); + h.SetFprBits(1, 0x40800000u); // 4.0 + h.SetFprBits(2, 0x3f800003u); // 1.0 + 3ulp + h.LoadProgram({ee::SUB_S(3, 1, 2)}); + h.Run(); + h.ExpectFpr(3, 0x403ffffeu); // bare == interp; masked (guard on) would be 0x403fffff +} + diff --git a/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp b/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp new file mode 100644 index 0000000000..bffc5777d2 --- /dev/null +++ b/tests/ctest/core/recompilers/ee_vu0_cop2_clamp_residency_tests.cpp @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: 2026 yaps2 Dev Team +// SPDX-License-Identifier: GPL-3.0+ + +// SL-13 — COP2 clamp-constant broadcast residency (q25/q26). +// +// The macro-mode FMAC clamp bounds (±FLT_MAX) live register-resident in +// q25/q26, lazily re-materialized per block from the pinned s8/s9 scalars +// (2 Dups, zero memory) instead of being reloaded from +// _cpuRegistersPack.cop2Rec at every clamp site. These are contract tests +// for the compile-time validity discipline (cop2EnsureClampConsts, +// iCOP2-arm64.cpp): +// +// - one establishment per straight-line clamp chain; +// - re-establishment after any real C-call seam (iFlushCall); +// - the VPU_STAT sync stubs re-materialize on their taken path, so +// validity may ride through sync seams; +// - q25/q26 are excluded from the EE NEON allocator pool and from the +// COP2 macro-mode mVU pool (nothing in EE-block emission can clobber +// them). +// +// The end-to-end value tests double as JIT-vs-interp oracles: an overflow +// FMAC must produce exactly ±FLT_MAX (0x7f7fffff), which fails if a clamp +// site ever sees garbage bounds. The emission-level pins (establishment +// counter, stub byte-scan, pool probes) catch policy regressions +// deterministically — the value tests alone would only fail when the C +// path happens to clobber q25/q26. + +#include "harness/EeRecTestHarness.h" + +#include "VU.h" +#include "VUmicro.h" +#include "Config.h" + +#include + +// SL-13 test hooks (PCSX2_RECOMPILER_TESTS builds). Global scope — defined +// outside namespaces in the arm64 sources. +extern u32 g_cop2ClampConstEstablishCount; // iCOP2-arm64.cpp +int cop2TestGetSyncStubCount(); // iCOP2-arm64.cpp +const u8* cop2TestGetSyncStub(int kind); // iCOP2-arm64.cpp +bool mVUTestProbe_NeonPoolUsable(int hostreg, bool cop2mode); // microVU-arm64.cpp +bool eeTestNeonRegIsReserved(int hostreg); // iCore-arm64.cpp + +namespace recompiler_tests { + +using namespace mips; +using namespace mips::ee; +using namespace vu; + +namespace { + +constexpr u32 mask_xyzw = 0xF; + +// FLT_MAX per lane; ~2^114 per lane. Product overflows to +inf, which the +// mandatory PS2 result clamp turns into exactly +FLT_MAX (0x7f7fffff). +constexpr u32 kFltMaxBits = 0x7f7fffffu; +constexpr u32 kBigBits = 0x78000000u; + +void SeedOverflowOperands(EeRecTestHarness& h, u32 fs, u32 ft) +{ + h.SeedVu0VfBits(fs, kFltMaxBits, kFltMaxBits, kFltMaxBits, kFltMaxBits); + h.SeedVu0VfBits(ft, kBigBits, kBigBits, kBigBits, kBigBits); + // The overflow FMACs set STATUS/MAC O-flags; with no consumer in the + // program the JIT's flag-liveness elision (EP-2a, vuFlagHack default) + // legitimately skips the architectural VI writes the interp performs. + // Flag behavior is pinned elsewhere — these tests pin clamp VALUES. + h.IgnoreVu0Vi(REG_STATUS_FLAG); + h.IgnoreVu0Vi(REG_MAC_FLAG); +} + +void ExpectClampedToFltMax(EeRecTestHarness& h, u32 vf) +{ + for (char l : {'x', 'y', 'z', 'w'}) + { + EXPECT_EQ(h.GetVu0VfBitsJit(vf, l), kFltMaxBits) << "lane " << l; + EXPECT_EQ(h.GetVu0VfBitsJit(vf, l), h.GetVu0VfBitsInterp(vf, l)) << "lane " << l; + } +} + +// Micro at PC 0: 32 NOP pairs (outlasts the 16-cycle sync kickstart window) +// then E-bit. Idempotent — pure NOPs, so JIT/interp replay divergence is +// impossible. Keeps VPU_STAT busy across the first post-VCALLMS sync seam. +void SeedLongNopMicro(EeRecTestHarness& h) +{ + u32 off = 0; + for (int i = 0; i < 32; i++, off += 8) + h.SeedVu0Microprogram(off, {NopPair()}); + h.SeedVu0Microprogram(off, { + EBitNopPair(), + NopPair(), // explicit E-bit delay pair — keep micro mem deterministic + }); +} + +} // namespace + +// ========================================================================= +// Emission policy — establishment counts +// ========================================================================= + +// A straight-line chain of clamping FMACs pays ONE establishment (2 Dups) +// for the whole block; every clamp site is then a bare Fminnm+Fmaxnm. +TEST(EeVu0Cop2ClampResidency, EstablishOncePerStraightLineChain) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + SeedOverflowOperands(h, 1, 2); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), + VMUL_C2(mask_xyzw, 4, 1, 2), + VMUL_C2(mask_xyzw, 5, 1, 2), + }); + const u32 before = g_cop2ClampConstEstablishCount; + h.Run(); + EXPECT_EQ(g_cop2ClampConstEstablishCount - before, 1u) + << "3 clamping FMACs in one block must establish q25/q26 exactly once"; + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); + ExpectClampedToFltMax(h, 5); +} + +// VCALLMS is a real C-call seam (iFlushCall(FLUSH_INTERPRETER)) — validity +// must NOT ride through it; the next clamp site re-establishes. +TEST(EeVu0Cop2ClampResidency, ReestablishAfterCCallSeam) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SeedVu0Vi(REG_VPU_STAT, 0); + SeedOverflowOperands(h, 1, 2); + // Trivial immediate-E micro: the VCALLMS is here purely as an in-block + // C-call seam. + h.SeedVu0Microprogram(0, { + EBitNopPair(), + NopPair(), + }); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), + VCALLMS(0), + VMUL_C2(mask_xyzw, 4, 1, 2), + }); + const u32 before = g_cop2ClampConstEstablishCount; + h.Run(); + EXPECT_EQ(g_cop2ClampConstEstablishCount - before, 2u) + << "the C-call seam must invalidate; the post-seam FMAC re-establishes"; + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); +} + +// ========================================================================= +// Runtime — clamp correctness through a TAKEN sync seam +// ========================================================================= + +// VCALLMS kicks a >16-cycle micro, so the following FMAC's analysis-marked +// sync seam (EEINST_COP2_FINISH_VU0 → SyncFinish stub) is TAKEN at runtime: +// the stub's C path drains the micro (arbitrary caller-saved NEON traffic), +// and the FMAC's clamp must still produce exactly ±FLT_MAX afterwards. +TEST(EeVu0Cop2ClampResidency, ClampCorrectAfterTakenSyncSeam) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SeedVu0Vi(REG_VPU_STAT, 0); + SeedOverflowOperands(h, 1, 2); + SeedLongNopMicro(h); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), // pre-kick clamp (establishes) + VCALLMS(0), // kick — VPU_STAT busy + VMUL_C2(mask_xyzw, 4, 1, 2), // sync seam TAKEN (drains micro), then clamp + VMUL_C2(mask_xyzw, 5, 1, 2), // rides the post-seam establishment + }); + h.Run(); + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); + ExpectClampedToFltMax(h, 5); +} + +// ========================================================================= +// Fork discipline — establishment state across branch arms +// ========================================================================= + +// The flag joins BranchCompileState: establishment before the branch is +// restored for the sibling fork, so neither arm re-establishes and both +// arms' clamps stay correct. Run both runtime paths. +TEST(EeVu0Cop2ClampResidency, ForkArmsShareEstablishment) +{ + for (const u64 t0 : {u64(0), u64(1)}) // not-taken / taken + { + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SetGpr64(reg::t0, t0); + SeedOverflowOperands(h, 1, 2); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), // establishes before the fork + BEQ(reg::t0, reg::zero, 2), // fork + VMUL_C2(mask_xyzw, 4, 1, 2), // delay slot — clamps on both paths + VMUL_C2(mask_xyzw, 5, 1, 2), // fall-through arm + VMUL_C2(mask_xyzw, 6, 1, 2), // branch target + }); + h.Run(); + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); + ExpectClampedToFltMax(h, 6); + if (t0 != 0) + ExpectClampedToFltMax(h, 5); // fall-through executed too + } +} + +// ========================================================================= +// Emission pins — sync stubs and allocator pools +// ========================================================================= + +// Every shared VU0-sync stub must re-materialize q25/q26 on its taken path: +// scan each stub's emitted words for the exact Dup pair before the taken +// path's terminating Ret. Encodings verified against the ARM ARM: +// dup v25.4s, v8.s[0] = 0x4E040519 +// dup v26.4s, v9.s[0] = 0x4E04053A +TEST(EeVu0Cop2ClampResidency, SyncStubsReDupClampConsts) +{ + // Any Run() (re)generates the dispatchers + stubs. + EeRecTestHarness h; + h.LoadProgram({NOP}); + h.Run(); + + constexpr u32 kDupMax = 0x4E040519u; + constexpr u32 kDupMin = 0x4E04053Au; + constexpr u32 kRet = 0xD65F03C0u; + + ASSERT_GT(cop2TestGetSyncStubCount(), 0); + for (int kind = 0; kind < cop2TestGetSyncStubCount(); kind++) + { + const u32* words = reinterpret_cast(cop2TestGetSyncStub(kind)); + ASSERT_NE(words, nullptr) << "stub " << kind; + // Stub layout: fast-path Ret first, taken path ends in the 2nd Ret. + bool sawDupMax = false, sawDupMin = false; + int rets = 0; + int i = 0; + for (; i < 96 && rets < 2; i++) + { + if (words[i] == kDupMax) + sawDupMax = true; + else if (words[i] == kDupMin) + sawDupMin = true; + else if (words[i] == kRet) + rets++; + } + EXPECT_EQ(rets, 2) << "stub " << kind << " shape drifted (scan window)"; + EXPECT_TRUE(sawDupMax) << "stub " << kind << " taken path lost the q25 re-Dup"; + EXPECT_TRUE(sawDupMin) << "stub " << kind << " taken path lost the q26 re-Dup"; + } +} + +// q25/q26 are reserved out of the EE NEON allocator pool (like q8/q9); the +// rest of the pool is untouched. +TEST(EeVu0Cop2ClampResidency, EeAllocatorReservesClampRegs) +{ + for (int reserved : {8, 9, 25, 26}) + EXPECT_TRUE(eeTestNeonRegIsReserved(reserved)) << "q" << reserved; + for (int usable : {0, 7, 10, 15, 16, 24, 27, 28}) + EXPECT_FALSE(eeTestNeonRegIsReserved(usable)) << "q" << usable; +} + +// COP2 macro mode runs mVU emitters (the mVU-reuse wrappers) inline in EE +// blocks WITHOUT a C-call seam, and clamp validity deliberately rides +// through them — so the macro-mode mVU NEON pool must exclude q25/q26. +// Micro mode keeps the full pool. +TEST(EeVu0Cop2ClampResidency, MacroModeNeonPoolExcludesClampRegs) +{ + EXPECT_FALSE(mVUTestProbe_NeonPoolUsable(25, /*cop2mode*/ true)); + EXPECT_FALSE(mVUTestProbe_NeonPoolUsable(26, /*cop2mode*/ true)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(25, /*cop2mode*/ false)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(26, /*cop2mode*/ false)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(0, /*cop2mode*/ true)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(24, /*cop2mode*/ true)); + EXPECT_TRUE(mVUTestProbe_NeonPoolUsable(27, /*cop2mode*/ true)); +} + +// The mVU-reuse wrappers must not disturb validity: FMAC → VMFIR (mVU-reuse +// wrapper, no C call) → FMAC pays ONE establishment, and the post-wrapper +// clamp is still exact. +TEST(EeVu0Cop2ClampResidency, ValidityRidesThroughMvuReuseWrapper) +{ + EeRecTestHarness h; + h.EnableVu0Capture(); + h.EnableCop1(); + h.SeedVu0Vi(5, 0x1234); + SeedOverflowOperands(h, 1, 2); + h.LoadProgram({ + VMUL_C2(mask_xyzw, 3, 1, 2), + VMFIR_C2(mask_xyzw, /*ft*/ 7, /*is*/ 5), // mVU-reuse wrapper op + VMUL_C2(mask_xyzw, 4, 1, 2), + }); + const u32 before = g_cop2ClampConstEstablishCount; + h.Run(); + EXPECT_EQ(g_cop2ClampConstEstablishCount - before, 1u) + << "the mVU-reuse wrapper must not invalidate (its pool excludes q25/q26)"; + ExpectClampedToFltMax(h, 3); + ExpectClampedToFltMax(h, 4); + for (char l : {'x', 'y', 'z', 'w'}) + EXPECT_EQ(h.GetVu0VfBitsJit(7, l), h.GetVu0VfBitsInterp(7, l)) << "lane " << l; +} + +} // namespace recompiler_tests diff --git a/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp b/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp index 059b089b1c..128bc119e6 100644 --- a/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp +++ b/tests/ctest/core/recompilers/harness/EeRecTestHarness.cpp @@ -61,6 +61,9 @@ EeRecTestHarness::~EeRecTestHarness() if (fpu_mul_hack_changed_) EmuConfig.Gamefixes.FpuMulHack = prev_fpu_mul_hack_; + + if (fpu_guarded_changed_) + EmuConfig.Cpu.Recompiler.fpuGuardedAddSub = prev_fpu_guarded_; } void EeRecTestHarness::SetGpr64(u32 reg_idx, u64 value) @@ -115,6 +118,16 @@ void EeRecTestHarness::EnableFpuMulHack() EmuConfig.Gamefixes.FpuMulHack = true; } +void EeRecTestHarness::DisableFpuGuarded() +{ + if (!fpu_guarded_changed_) + { + prev_fpu_guarded_ = EmuConfig.Cpu.Recompiler.fpuGuardedAddSub; + fpu_guarded_changed_ = true; + } + EmuConfig.Cpu.Recompiler.fpuGuardedAddSub = false; +} + void EeRecTestHarness::SetStatusBits(u32 mask) { cpuRegs.CP0.n.Status.val |= mask; } // EE vtlb_memWrite on a direct RAM hit bypasses Cpu->Clear — upstream relies diff --git a/tests/ctest/core/recompilers/harness/EeRecTestHarness.h b/tests/ctest/core/recompilers/harness/EeRecTestHarness.h index 56a728f0fa..f5ddaf5647 100644 --- a/tests/ctest/core/recompilers/harness/EeRecTestHarness.h +++ b/tests/ctest/core/recompilers/harness/EeRecTestHarness.h @@ -69,6 +69,12 @@ public: void EnableFpuFullMode(); void EnableFpuMulHack(); + // Turns OFF the (default-ON) fpuGuardedAddSub Recompiler option so the JIT + // emits a plain single-precision add/sub with no guard-bit masking — the + // opt-out perf path. Off makes the JIT bit-identical to the single-precision + // interpreter (which never masks). Restored to its previous value in the dtor. + void DisableFpuGuarded(); + // ---- Memory ---- void WriteU8 (u32 addr, u8 value); @@ -343,6 +349,8 @@ private: bool prev_fpu_full_mode_ = false; bool fpu_mul_hack_changed_ = false; bool prev_fpu_mul_hack_ = false; + bool fpu_guarded_changed_ = false; + bool prev_fpu_guarded_ = false; }; } // namespace recompiler_tests diff --git a/tools/perf/devices/mq65.env b/tools/perf/devices/mq65.env index 95fbe77bf0..86368b6436 100644 --- a/tools/perf/devices/mq65.env +++ b/tools/perf/devices/mq65.env @@ -13,7 +13,12 @@ # NOTE the part-id direction: 0x800 = Gold/A73 (the BIG cores, 4-7) — an earlier # memory note had this backwards. Pin to the golds. # -# Per-session protocol (no fan on this device — interleaving is the thermal control; +# Per-session protocol. The device HAS a fan (gpio_fan hwmon, on/off only), but it +# is thermal-framework-driven with an ACTIVE trip at 65degC (zone10 trip3; passive +# throttle 90degC) — a null-renderer codegen_ab run plateaus ~55-57degC and correctly +# never engages it, so no fan pin exists or is needed (verified 2026-07-20; an +# earlier note here claimed "no fan on this device", which was wrong). +# Interleaving is the thermal control; # codegen_ab logs SoC temp per run so a ramp is visible): # pin : for p in /sys/devices/system/cpu/cpufreq/policy{0,4}; do echo performance > $p/scaling_governor; done # restore: ... echo ondemand > $p/scaling_governor ... diff --git a/tools/perf/ee_code_census.py b/tools/perf/ee_code_census.py index fd98f34dc0..bf4da8e3d5 100644 --- a/tools/perf/ee_code_census.py +++ b/tools/perf/ee_code_census.py @@ -91,7 +91,10 @@ FPU_BASE, FPU_END = 1168, 1600 CATS = [ "ldr_gpr_pinned(reload)", "ldr_gpr_pinned(MISSED)", "ldr_gpr_unpinned", - "str_gpr_pinned(wthru)", "str_gpr_unpinned", "ldrstr_hilo", + "str_gpr_pinned(wthru)", "str_gpr_unpinned", + "neon_gpr_ld(q@GPR)", "neon_gpr_st(q@GPR)", # 128-bit MMI residency churn + "cop2_vf_ldst(@VU0)", "cop2_vu0_other", # x24-based VU0 macro traffic + "ldrstr_hilo", "state_branch", "state_pc", "state_cycle", "state_fpu", "state_other", "mov_from_pin", "mov_to_pin", "pin_maint(sxtw/bfi/lsr)", "pin_as_operand", "bl_call", "other", @@ -136,6 +139,14 @@ def classify_block(code, pin_gpr, pins): for k in range(n): (i,) = struct.unpack_from("> 5) & 31, (i >> 16) & 31 + if rn == 24: + # x24 = &VU0 pinned base in EE blocks — COP2 macro-mode VF/VI traffic. + m = MEM_TOPS.get(i & 0xFFC00000) + if m: + ld, w = m + off = ((i >> 10) & 0xFFF) * w + c["cop2_vf_ldst(@VU0)" if off < 512 else "cop2_vu0_other"] += 1 + continue if rn == RSTATE: m = MEM_TOPS.get(i & 0xFFC00000) if m: @@ -144,7 +155,14 @@ def classify_block(code, pin_gpr, pins): if off < 512: g = off // 16 lane0 = (off % 16) == 0 - if ld: + if w == 16: + # 128-bit GPR home traffic (MMI NEON residency churn); + # kept out of the scalar pin categories — rd is a + # v-reg number there, pin comparisons are meaningless. + if ld: + per_gpr_ld[g] += 1 + c["neon_gpr_ld(q@GPR)" if ld else "neon_gpr_st(q@GPR)"] += 1 + elif ld: per_gpr_ld[g] += 1 if g in pin_gpr and lane0: c["ldr_gpr_pinned(reload)" if rd == pin_gpr[g] else "ldr_gpr_pinned(MISSED)"] += 1