eerunner: narrow --rec-fallback to a single VU macro op

Once a hunt reaches `cop2vu` it stops: that group is one dispatch bit
covering the whole VU macro-mode instruction set, and there was no next
axis. Finding which of them miscompiles meant hand-editing the classifier
and rebuilding per hypothesis.

Two additions close that gap. `--rec-fallback cop2vu:<mnemonic>` selects
individual macro ops by name, over a flat 256-entry id space covering all
three dispatch tables (BC2 by rt, SPECIAL1 by funct, SPECIAL2 by its
packed index). And a compile-time census, printed after --mkstate, lists
the macro ops the run actually emitted — an op that never compiles cannot
be the bug, so it turns a 100-way search into a bisect over the handful a
given game really uses.

On NASCAR Thunder 2002 the census reported 58 distinct ops and the bisect
reached one of them in eleven runs, no rebuilds.
This commit is contained in:
Brian Degenhardt
2026-07-28 21:01:12 -07:00
parent 37ce96996c
commit 06445dd641
4 changed files with 190 additions and 9 deletions
+15 -2
View File
@@ -152,6 +152,7 @@ static std::vector<SetOverride> s_set_overrides; // --set Section/Key=Value (rep
static u32 s_rec_fallback_groups = 0; // --rec-fallback <groups>: EE opcode groups forced to interp
#if defined(ARCH_ARM64)
static u32 s_rec_fallback_reg_masks[EERecFallback::kCop2MoveOpCount] = {~0u, ~0u, ~0u, ~0u}; // per-COP2-move-op register filters
static u64 s_rec_fallback_vu_mask[EERecFallback::kCop2VuIdCount / 64] = {~0ull, ~0ull, ~0ull, ~0ull}; // per-VU-macro-op filter
#endif
bool EERunner::InitializeConfig()
@@ -554,7 +555,11 @@ static void PrintCommandLineHelp(const char* progname)
std::fprintf(stderr, " the group that makes the symptom disappear contains the bug. Groups: fpu, cop2,\n");
std::fprintf(stderr, " mmi, multdiv, shift, arith, loadstore, move, cop0, branch (plus all / none).\n");
std::fprintf(stderr, " cop2 narrows further into cop2move (QMFC2/CFC2/QMTC2/CTC2), cop2vu (the VU\n");
std::fprintf(stderr, " macro ops) and cop2ls (LQC2/SQC2).\n");
std::fprintf(stderr, " macro ops) and cop2ls (LQC2/SQC2). The four move ops take a ':<reg>' filter\n");
std::fprintf(stderr, " (ctc2:27, ctc2:16:21); cop2vu takes a ':<mnemonic>' one (cop2vu:vmaddaw,\n");
std::fprintf(stderr, " cop2vu:vmulax:vmaddaz) — cop2vu is ~100 emitters, so don't stop the funnel\n");
std::fprintf(stderr, " there. --mkstate prints a COP2VU CENSUS of the macro ops actually compiled;\n");
std::fprintf(stderr, " bisect over that list, since an op never compiled cannot be the bug.\n");
std::fprintf(stderr, " Pairs well with --mkstate --renderer sw for a visual oracle. arm64 only.\n");
std::fprintf(stderr, " --gsdump <path>[:<frames>]: with --liverun, record a .gs dump of the GIF stream (default 1\n");
std::fprintf(stderr, " frame) starting after --gsdump-at frames (default 30, so the scene has settled).\n");
@@ -784,13 +789,15 @@ bool EERunner::ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& pa
std::string err;
u32 mask = 0;
u32 reg_masks[EERecFallback::kCop2MoveOpCount] = {};
if (!EERecFallback::ParseGroups(list, &mask, reg_masks, &err))
u64 vu_mask[EERecFallback::kCop2VuIdCount / 64] = {};
if (!EERecFallback::ParseGroups(list, &mask, reg_masks, vu_mask, &err))
{
Console.Error(err.c_str());
return false;
}
s_rec_fallback_groups = mask;
std::memcpy(s_rec_fallback_reg_masks, reg_masks, sizeof(reg_masks));
std::memcpy(s_rec_fallback_vu_mask, vu_mask, sizeof(vu_mask));
#else
Console.Error("--rec-fallback is implemented for the arm64 EE recompiler only.");
return false;
@@ -1094,6 +1101,7 @@ void EERunner::SettingsOverride()
// before any block is compiled, and stays put for the whole run.
EERecFallback::g_groups = s_rec_fallback_groups;
std::memcpy(EERecFallback::g_cop2RegMask, s_rec_fallback_reg_masks, sizeof(s_rec_fallback_reg_masks));
std::memcpy(EERecFallback::g_cop2VuMask, s_rec_fallback_vu_mask, sizeof(s_rec_fallback_vu_mask));
if (s_rec_fallback_groups != 0)
{
Console.WriteLn(fmt::format("EE REC FALLBACK: forcing to interpreter -> {}",
@@ -3027,6 +3035,11 @@ static int RunMkState()
VMManager::WaitForSaveStateFlush();
Console.WriteLn(fmt::format("MKSTATE: {} after {} frames -> {}",
ok ? "wrote state" : "FAILED", s_frames, s_mkstate_path));
#if defined(ARCH_ARM64)
// Which VU macro ops this run actually compiled — the candidate list for a
// `--rec-fallback cop2vu:<mnemonic>` bisect.
Console.WriteLn(fmt::format("COP2VU CENSUS: {}", EERecFallback::DescribeCop2VuCensus()));
#endif
return ok ? EXIT_SUCCESS : EXIT_FAILURE;
}
+146 -4
View File
@@ -1695,6 +1695,92 @@ namespace EERecFallback
{
u32 g_groups = 0;
u32 g_cop2RegMask[kCop2MoveOpCount] = {~0u, ~0u, ~0u, ~0u};
u64 g_cop2VuMask[kCop2VuIdCount / 64] = {~0ull, ~0ull, ~0ull, ~0ull};
// Mnemonics parallel to recCOP2_BC2t / recCOP2SPECIAL1t / recCOP2SPECIAL2t
// (iR5900Misc-arm64.cpp). nullptr marks a hole in the encoding.
static const char* const kBc2Names[4] = {"vbc2f", "vbc2t", "vbc2fl", "vbc2tl"};
static const char* const kSpec1Names[64] = {
"vaddx", "vaddy", "vaddz", "vaddw", "vsubx", "vsuby", "vsubz", "vsubw",
"vmaddx", "vmaddy", "vmaddz", "vmaddw", "vmsubx", "vmsuby", "vmsubz", "vmsubw",
"vmaxx", "vmaxy", "vmaxz", "vmaxw", "vminix", "vminiy", "vminiz", "vminiw",
"vmulx", "vmuly", "vmulz", "vmulw", "vmulq", "vmaxi", "vmuli", "vminii",
"vaddq", "vmaddq", "vaddi", "vmaddi", "vsubq", "vmsubq", "vsubi", "vmsubi",
"vadd", "vmadd", "vmul", "vmax", "vsub", "vmsub", "vopmsub", "vmini",
"viadd", "visub", "viaddi", nullptr, "viand", "vior", nullptr, nullptr,
"vcallms", "vcallmsr", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
};
static const char* const kSpec2Names[128] = {
"vaddax", "vadday", "vaddaz", "vaddaw", "vsubax", "vsubay", "vsubaz", "vsubaw",
"vmaddax", "vmadday", "vmaddaz", "vmaddaw", "vmsubax", "vmsubay", "vmsubaz", "vmsubaw",
"vitof0", "vitof4", "vitof12", "vitof15", "vftoi0", "vftoi4", "vftoi12", "vftoi15",
"vmulax", "vmulay", "vmulaz", "vmulaw", "vmulaq", "vabs", "vmulai", "vclip",
"vaddaq", "vmaddaq", "vaddai", "vmaddai", "vsubaq", "vmsubaq", "vsubai", "vmsubai",
"vadda", "vmadda", "vmula", nullptr, "vsuba", "vmsuba", "vopmula", "vnop",
"vmove", "vmr32", nullptr, nullptr, "vlqi", "vsqi", "vlqd", "vsqd",
"vdiv", "vsqrt", "vrsqrt", "vwaitq", "vmtir", "vmfir", "vilwr", "viswr",
"vrnext", "vrget", "vrinit", "vrxor", nullptr, nullptr, nullptr, nullptr,
// 0x48-0x7F are holes.
};
int Cop2VuOpId(u32 code)
{
if ((code >> 26) != 0x12)
return -1;
const u32 rs = (code >> 21) & 0x1F;
if (rs == 0x08) // BC2
return static_cast<int>((code >> 16) & 0x1F);
if (rs < 0x10) // QMFC2/CFC2/QMTC2/CTC2 and holes are not VU macro ops
return -1;
const u32 funct = code & 0x3F;
if (funct >= 0x3C) // SPECIAL2 escape
return 0x80 + static_cast<int>((code & 0x3) | ((code >> 4) & 0x7C));
return 0x40 + static_cast<int>(funct);
}
const char* Cop2VuOpName(int id)
{
if (id < 0 || id >= kCop2VuIdCount)
return nullptr;
if (id < 0x20)
return (id < 4) ? kBc2Names[id] : nullptr;
if (id >= 0x40 && id < 0x80)
return kSpec1Names[id - 0x40];
if (id >= 0x80)
return kSpec2Names[id - 0x80];
return nullptr;
}
static u32 s_cop2VuCensus[kCop2VuIdCount];
void NoteCop2VuCompiled(u32 code)
{
const int id = Cop2VuOpId(code);
if (id >= 0)
s_cop2VuCensus[id]++;
}
std::string DescribeCop2VuCensus()
{
std::string s;
u32 total = 0;
for (int id = 0; id < kCop2VuIdCount; id++)
{
if (s_cop2VuCensus[id] == 0)
continue;
total += s_cop2VuCensus[id];
const char* name = Cop2VuOpName(id);
if (!s.empty())
s += " ";
s += fmt::format("{}={}", name ? name : fmt::format("id0x{:02x}", id), s_cop2VuCensus[id]);
}
if (s.empty())
return "no VU macro ops compiled";
return fmt::format("{} (total {})", s, total);
}
bool Selected(u32 code)
{
@@ -1773,6 +1859,15 @@ namespace EERecFallback
break;
}
if (group == Cop2Vu)
{
// Per-op filter: an unnamed/unmapped encoding is never selected, so a
// mnemonic filter can't accidentally widen to the whole group.
const int id = Cop2VuOpId(code);
if (id < 0 || (g_cop2VuMask[id >> 6] & (1ull << (id & 63))) == 0)
return false;
}
return group != 0 && (g_groups & group) != 0;
}
@@ -1797,10 +1892,13 @@ namespace EERecFallback
}
}
bool ParseGroups(const std::string_view& list, u32* out, u32* reg_masks, std::string* error)
bool ParseGroups(const std::string_view& list, u32* out, u32* reg_masks,
u64* cop2vu_mask, std::string* error)
{
u32 local_reg_masks[kCop2MoveOpCount] = {~0u, ~0u, ~0u, ~0u};
bool reg_filtered[kCop2MoveOpCount] = {false, false, false, false};
u64 local_vu_mask[kCop2VuIdCount / 64] = {~0ull, ~0ull, ~0ull, ~0ull};
bool vu_filtered = false;
u32 mask = 0;
size_t pos = 0;
while (pos <= list.size())
@@ -1842,7 +1940,47 @@ namespace EERecFallback
found = true;
mask |= g.bit;
if (!regs.empty())
if (!regs.empty() && g.bit == Cop2Vu)
{
// "cop2vu:vmulaw:vmaddaz" — narrow to named macro ops.
size_t rp = 0;
while (rp <= regs.size())
{
const size_t rc = regs.find(':', rp);
const size_t rend = (rc == std::string_view::npos) ? regs.size() : rc;
const std::string_view mnem = regs.substr(rp, rend - rp);
int id = -1;
for (int i = 0; i < kCop2VuIdCount; i++)
{
const char* n = Cop2VuOpName(i);
if (n && mnem == n)
{
id = i;
break;
}
}
if (id < 0)
{
if (error)
{
*error = fmt::format("unknown VU macro op '{}' in EE rec fallback "
"filter '{}'", std::string(mnem), std::string(tok));
}
return false;
}
if (!vu_filtered)
{
for (auto& w : local_vu_mask)
w = 0;
vu_filtered = true;
}
local_vu_mask[id >> 6] |= (1ull << (id & 63));
if (rc == std::string_view::npos)
break;
rp = rc + 1;
}
}
else if (!regs.empty())
{
const int slot = MoveOpSlotForGroup(g.bit);
if (slot < 0)
@@ -1850,7 +1988,8 @@ namespace EERecFallback
if (error)
{
*error = fmt::format("EE rec fallback group '{}' does not take a register "
"filter (only qmfc2/cfc2/qmtc2/ctc2 do)", std::string(name));
"filter (only qmfc2/cfc2/qmtc2/ctc2 take ':<reg>', cop2vu takes "
"':<mnemonic>')", std::string(name));
}
return false;
}
@@ -1891,7 +2030,8 @@ namespace EERecFallback
*error = fmt::format("unknown EE rec fallback group '{}'; valid: none, all, "
"fpu, cop2, mmi, multdiv, shift, arith, loadstore, move, cop0, branch, "
"cop2move, cop2vu, cop2ls, qmfc2, cfc2, qmtc2, ctc2 (the last four accept "
"a ':<reg>' filter, e.g. ctc2:27)",
"a ':<reg>' filter, e.g. ctc2:27; cop2vu accepts a ':<mnemonic>' filter, "
"e.g. cop2vu:vmaddaw)",
std::string(name));
}
return false;
@@ -1907,6 +2047,8 @@ namespace EERecFallback
*out = mask;
for (int i = 0; i < kCop2MoveOpCount; i++)
reg_masks[i] = local_reg_masks[i];
for (int i = 0; i < kCop2VuIdCount / 64; i++)
cop2vu_mask[i] = local_vu_mask[i];
return true;
}
+26 -3
View File
@@ -962,14 +962,37 @@ namespace EERecFallback
enum Cop2MoveOp { kQmfc2 = 0, kCfc2, kQmtc2, kCtc2, kCop2MoveOpCount };
extern u32 g_cop2RegMask[kCop2MoveOpCount];
// Per-VU-macro-op filter, the next bisect axis once `cop2vu` is implicated.
// That group is 100+ distinct emitters, so narrowing it by name is the
// difference between one run and a hand-written binary search. Ids form a
// flat 256-entry space so one bitset covers all three dispatch tables:
// 0x00-0x1F BC2 (by rt), 0x40-0x7F SPECIAL1 (by funct), 0x80-0xFF SPECIAL2
// (by its packed index). All bits set (the default) means "every op".
inline constexpr int kCop2VuIdCount = 256;
extern u64 g_cop2VuMask[kCop2VuIdCount / 64];
// Canonical id for a VU macro op, or -1 if `code` is not one.
int Cop2VuOpId(u32 code);
// Mnemonic for an id ("vmulaw"), or nullptr for an unassigned slot.
const char* Cop2VuOpName(int id);
// Compile-time census of the VU macro ops the recompiler actually emitted.
// An op that is never compiled cannot be the bug, so this prunes a 100-way
// bisect down to the handful a given game really uses.
void NoteCop2VuCompiled(u32 code);
std::string DescribeCop2VuCensus();
// True when `code` belongs to a selected group (and passes its register filter).
bool Selected(u32 code);
// Parse a comma-separated group list ("fpu,mmi" / "all" / "none"). A COP2 move
// group may carry a register filter: "ctc2:27" or "ctc2:16:21" selects only
// those fs values. Returns false and leaves the outputs untouched on a parse
// error. `reg_masks` must point at kCop2MoveOpCount entries.
bool ParseGroups(const std::string_view& list, u32* out, u32* reg_masks, std::string* error);
// those fs values; "cop2vu" takes a mnemonic filter: "cop2vu:vmulaw:vmaddaz".
// Returns false and leaves the outputs untouched on a parse error.
// `reg_masks` must point at kCop2MoveOpCount entries, `cop2vu_mask` at
// kCop2VuIdCount/64.
bool ParseGroups(const std::string_view& list, u32* out, u32* reg_masks,
u64* cop2vu_mask, std::string* error);
// Human-readable rendering of a mask, for run banners.
std::string DescribeGroups(u32 groups);
+3
View File
@@ -426,6 +426,9 @@ static void recCOP2_SPEC2() { recCOP2SPECIAL2t[(cpuRegs.code & 0x3) | ((cpuRegs.
void recCOP2()
{
#ifdef PCSX2_RECOMPILER_TESTS
EERecFallback::NoteCop2VuCompiled(cpuRegs.code);
#endif
recCOP2t[_Rs_]();
}