Tests: pin the VU div unit against the console

502 VU0 macro-mode cases of DIV, SQRT and RSQRT over zero, denormal,
normal, exponent-255 and saturated operands, scored on all four engines.
The three commits before this one each fixed one thing an engine had
wrong; this holds the whole grid rather than the operands that happened
to expose them.

DIV earns its place by separating the two rules those commits turned on:
its Invalid never comes from a sign, and its saturated quotient takes
the xor RSQRT's cannot. An RSQRT-only table would fit either rule.

Q is scored by class rather than by row, with exact per-engine tallies.
The arithmetic gap under it is a separate piece of work, and this is
where it gets a number to move.

vu_rsqrt_divisor_sign_tests states the same rules on hand-picked
witnesses, where they can be read instead of counted.
This commit is contained in:
pstef
2026-08-09 11:21:33 +02:00
parent bb3402401f
commit 20a4fc98d6
4 changed files with 1067 additions and 0 deletions
@@ -143,6 +143,8 @@ add_pcsx2_test(recompiler_tests
vu_madd_contract_console_tests.cpp
vu1_efu_console_conformance_tests.cpp
vu_sticky_console_conformance_tests.cpp
vu_rsqrt_divisor_sign_tests.cpp
vu_divunit_console_conformance_tests.cpp
vu_branch_console_conformance_tests.cpp
vu_pipeline_console_conformance_tests.cpp
vu_memory_xgkick_console_conformance_tests.cpp
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
// SPDX-FileCopyrightText: 2026 yaps2 Dev Team
// SPDX-License-Identifier: GPL-3.0+
// The VU div unit against a first-party console capture: VDIV, VSQRT and
// VRSQRT over zero, denormal, normal, exponent-255 and saturated operands,
// scoring STATUS and Q. 502 cases, table in autocases_vurs.h.
//
// The capture was taken in VU0 macro mode, where VU0's registers are
// EE-readable, so the macro engines are scored against it directly and the
// micro engines are scored on the D/I cause and sticky pair, which is the div
// unit's own output rather than the mode's bookkeeping.
//
// What it settles, and what it does not:
//
// STATUS. One rule fits all 502 rows. I comes from the operand's SIGN BIT
// for the two ops that contain a square root -- exponent field ignored, so
// -0 and the negative denormals raise it -- decided ahead of the zero test
// and independently of it. D comes from a zero divisor with a nonzero
// dividend; a zero dividend over a zero divisor raises I instead, and the
// two are exclusive. So VRSQRT over -0 is the one operand class where both
// causes stand together: the root's I and the division's D.
//
// VDIV is the control that says the sign clause belongs to the square root
// and not to the unit: a negative divisor raises nothing there. It is also
// where the two ops' quotient signs part company -- VDIV's saturated
// quotient takes the xor of the operand signs, VRSQRT's takes the dividend's
// alone, because its divisor is a square root and never negative.
//
// Q. Not settled, and not asserted row by row. PCSX2 saturates a binade low
// of the console (0x7F7FFFFF against 0x7FFFFFFF -- the VU clamp mode), and
// its DIV/SQRT/RSQRT go through a host divide rather than through the EE's
// divide-unit model in FPU.cpp. Both gaps are pinned here as exact tallies
// per engine so that neither can move unnoticed, and so that whoever ports
// eeDivide/eeSqrtBits to these call sites has a number to move.
#include <gtest/gtest.h>
#include "harness/EeRecTestHarness.h"
#include "harness/MipsEncode.h"
#include "harness/RecompilerTestEnvironment.h"
#include "harness/VuEncode.h"
#include "harness/VuTestHarness.h"
#include "VU.h"
#include <vector>
#include "autocases_vurs.h"
using namespace console_vurs;
namespace recompiler_tests
{
namespace
{
using namespace mips;
using namespace mips::ee;
constexpr u32 kFs = 4, kFt = 5;
constexpr u32 kRSeed = 20, kRStatus = 8, kRQ = 9;
// STATUS bits the div unit owns: the D/I cause pair and the two stickies they
// set. The ZSUO cause is the FMAC's and is zero throughout the capture.
constexpr u32 kDiMask = 0xC30u;
u32 MacroOp(const VursCase& c)
{
switch (c.op)
{
case VURS_DIV: return VDIV_C2(0, 0, kFs, kFt);
case VURS_SQRT: return VSQRT_C2(0, kFt);
default: return VRSQRT_C2(0, 0, kFs, kFt);
}
}
void BuildMacro(EeRecTestHarness& h, const VursCase& c)
{
h.EnableVu0Capture();
h.SeedVu0VfBits(kFs, c.fs, c.fs, c.fs, c.fs);
h.SeedVu0VfBits(kFt, c.ft, c.ft, c.ft, c.ft);
h.LoadProgram(std::vector<u32>{
ORI(kRSeed, 0, c.seed),
CTC2(kRSeed, REG_STATUS_FLAG),
MacroOp(c),
CFC2(kRStatus, REG_STATUS_FLAG),
CFC2(kRQ, REG_Q),
});
}
u32 MicroOp(const VursCase& c)
{
switch (c.op)
{
case VURS_DIV: return vu::VDIV_L(kFs, 0, kFt, 0);
case VURS_SQRT: return vu::VSQRT_L(kFt, 0);
default: return vu::VRSQRT_L(kFs, 0, kFt, 0);
}
}
// The div unit's flags reach STATUS up to 13 cycles downstream in micro mode
// (mVUanalyzeFDIV), so the program has to outrun that before either side can
// be read. Micro mode has no CTC2, so only the unseeded rows run here.
void BuildMicro(VuTestHarness& h, const VursCase& c)
{
h.SetVfBits(kFs, c.fs, c.fs, c.fs, c.fs);
h.SetVfBits(kFt, c.ft, c.ft, c.ft, c.ft);
std::vector<vu::VuOp> prog;
prog.push_back(vu::VuOp{MicroOp(c), vu::VNOP_U()});
for (int i = 0; i < 16; ++i)
prog.push_back(vu::NopPair());
prog.push_back(vu::VuOp{vu::VWAITQ_L(), vu::VNOP_U()});
prog.push_back(vu::EBitNopPair());
h.LoadProgram(prog);
}
// Where an engine's Q lands relative to the console's.
struct QTally
{
int ok = 0; // the console's word
int sat = 0; // the console saturated and the engine saturated a binade low
int unit = 0; // everything else: the divide unit's arithmetic
};
void ScoreQ(QTally& t, const VursCase& c, u32 got)
{
if (got == c.q)
t.ok++;
else if ((c.q & 0x7FFFFFFFu) == 0x7FFFFFFFu && got == ((c.q & 0x80000000u) | 0x7F7FFFFFu))
t.sat++;
else
t.unit++;
}
} // namespace
TEST(VuDivUnitConsole, MacroStatusMatchesConsoleOnEveryRow)
{
int checked = 0;
for (const VursCase& c : kVursCases)
{
SCOPED_TRACE(c.tag);
EeRecTestHarness hj;
BuildMacro(hj, c);
hj.RunJitNoDiff();
EXPECT_EQ(hj.GetGprJit(kRStatus) & 0xFFFu, c.status) << "[macro jit] STATUS";
EeRecTestHarness hi;
BuildMacro(hi, c);
hi.RunInterpOnly();
EXPECT_EQ(hi.GetGprInterp(kRStatus) & 0xFFFu, c.status) << "[macro interp] STATUS";
++checked;
}
EXPECT_EQ(checked, static_cast<int>(std::size(kVursCases)));
}
TEST(VuDivUnitConsole, MicroCauseAndStickyMatchConsole)
{
int checked = 0;
for (const VursCase& c : kVursCases)
{
if (c.seed != 0)
continue;
SCOPED_TRACE(c.tag);
VuTestHarness m(0);
m.IgnoreViInDiff(REG_Q); // scored separately, and it diverges by class
BuildMicro(m, c);
m.Run(); // also diffs micro JIT against micro interp
EXPECT_EQ(m.GetViJit(REG_STATUS_FLAG) & kDiMask, c.status & kDiMask)
<< "[micro jit] STATUS D/I";
EXPECT_EQ(m.GetViInterp(REG_STATUS_FLAG) & kDiMask, c.status & kDiMask)
<< "[micro interp] STATUS D/I";
++checked;
}
EXPECT_EQ(checked, 422);
}
// The console's saturated quotient is the EE maximum 0x7FFFFFFF; PCSX2's is
// FLT_MAX, one binade lower. The sign is not part of that difference, so the
// class is defined with the sign carried over -- which is what makes it a
// statement about the clamp and not a place for a sign bug to hide.
TEST(VuDivUnitConsole, QSaturatesABinadeLowOfTheConsole)
{
QTally mj, mi, uj, ui;
int consoleSaturated = 0;
for (const VursCase& c : kVursCases)
{
if ((c.q & 0x7FFFFFFFu) == 0x7FFFFFFFu)
++consoleSaturated;
EeRecTestHarness hj;
BuildMacro(hj, c);
hj.RunJitNoDiff();
ScoreQ(mj, c, hj.GetGprJit(kRQ));
EeRecTestHarness hi;
BuildMacro(hi, c);
hi.RunInterpOnly();
ScoreQ(mi, c, hi.GetGprInterp(kRQ));
if (c.seed != 0)
continue;
VuTestHarness m(0);
m.IgnoreViInDiff(REG_STATUS_FLAG);
m.IgnoreViInDiff(REG_Q);
BuildMicro(m, c);
m.Run();
ScoreQ(uj, c, m.GetViJit(REG_Q));
ScoreQ(ui, c, m.GetViInterp(REG_Q));
}
EXPECT_EQ(consoleSaturated, 226);
// Two rows saturate on the console and come back from the emulator as
// something other than the sign-matched FLT_MAX; they fall in `unit`
// below rather than being counted as clamp-mode misses.
EXPECT_EQ(mj.sat, 224);
EXPECT_EQ(mi.sat, 226);
EXPECT_EQ(uj.sat, 176);
EXPECT_EQ(ui.sat, 178);
// The arithmetic gap. The JIT is worse than the interpreter by 30 rows in
// each mode -- its clamp runs before anything else can look at the result.
EXPECT_EQ(mj.unit, 86);
EXPECT_EQ(mi.unit, 56);
EXPECT_EQ(uj.unit, 82);
EXPECT_EQ(ui.unit, 52);
EXPECT_EQ(mj.ok, 192);
EXPECT_EQ(mi.ok, 220);
EXPECT_EQ(uj.ok, 164);
EXPECT_EQ(ui.ok, 192);
EXPECT_EQ(mj.ok + mj.sat + mj.unit, static_cast<int>(std::size(kVursCases)));
EXPECT_EQ(uj.ok + uj.sat + uj.unit, 422);
}
// The two ops' saturated quotients take their sign by different rules, and the
// capture pins both. VDIV xors the operand signs; VRSQRT cannot, because its
// divisor is a square root. Stated on the smallest witnesses because the
// tallies above would still pass if the two rules were swapped.
TEST(VuDivUnitConsole, SaturatedQuotientSignsDifferBetweenDivAndRsqrt)
{
int div = 0, rsqrt = 0;
for (const VursCase& c : kVursCases)
{
if ((c.q & 0x7FFFFFFFu) != 0x7FFFFFFFu || (c.ft & 0x7F800000u) != 0)
continue;
const u32 sign = c.q & 0x80000000u;
if (c.op == VURS_DIV)
{
EXPECT_EQ(sign, (c.fs ^ c.ft) & 0x80000000u) << c.tag;
++div;
}
else if (c.op == VURS_RSQRT)
{
EXPECT_EQ(sign, c.fs & 0x80000000u) << c.tag;
++rsqrt;
}
}
// 80 VDIV rows: the 8 zero-exponent divisors over all 10 dividends. 130
// VRSQRT rows: the same 80, the seeded pass's 8 x 6, and the two rig-check
// repeats of the row the earlier capture already held.
EXPECT_EQ(div, 80);
EXPECT_EQ(rsqrt, 130);
}
} // namespace recompiler_tests
@@ -0,0 +1,264 @@
// SPDX-FileCopyrightText: 2026 yaps2 Dev Team
// SPDX-License-Identifier: GPL-3.0+
// The div unit's flag and quotient-sign rules on hand-picked witnesses, across
// `_vuRSQRT`/`_vuSQRT`, `recCOP2_VRSQRT`/`recCOP2_VSQRT` and
// `mVU_RSQRT`/`mVU_SQRT`. VuDivUnitConsole scores the same rules over a whole
// operand grid; this is the readable form, and it names which engine had each
// case wrong.
//
// Q's magnitude is the engines' 0x7F7FFFFF where the console gives 0x7FFFFFFF
// -- the VU clamp mode, left alone here as it is there. The sign is not part
// of that difference and is asserted against the console.
#include <gtest/gtest.h>
#include "harness/EeRecTestHarness.h"
#include "harness/MipsEncode.h"
#include "harness/RecompilerTestEnvironment.h"
#include "harness/VuEncode.h"
#include "harness/VuTestHarness.h"
#include "VU.h"
#include <vector>
namespace recompiler_tests
{
namespace
{
using namespace mips;
using namespace mips::ee;
using namespace vu;
inline VuOp LowerOnly(u32 lower) { return VuOp{lower, VNOP_U()}; }
inline VuOp WaitQPair() { return VuOp{VWAITQ_L(), VNOP_U()}; }
constexpr u32 kFs = 4, kFt = 5;
constexpr u32 kRStatus = 8, kRQ = 9;
// Cause D|I (0x30) plus the sticky pair they set (0xC00). Everything outside
// this mask is the ZSUO cause, which no div-unit op touches.
constexpr u32 kDiMask = 0xC30u;
constexpr u32 kI = 0x410u; // cause I + sticky I
constexpr u32 kD = 0x820u; // cause D + sticky D
struct Row
{
const char* what;
u32 fs;
u32 ft;
u32 di; // expected STATUS & kDiMask
u32 q;
};
// Every row is checked on all four engines. 0/0 goes to its own test below.
constexpr Row kRows[] = {
// Under xor both quotients would come back with the opposite sign, so this
// pair alone refutes it.
{"+1 / -0", 0x3F800000u, 0x80000000u, kI | kD, 0x7F7FFFFFu},
{"-1 / -0", 0xBF800000u, 0x80000000u, kI | kD, 0xFF7FFFFFu},
// Positive divisor, D alone: says the pair above moved for the sign bit
// and not because the whole zero branch changed.
{"+1 / +0", 0x3F800000u, 0x00000000u, kD, 0x7F7FFFFFu},
{"-1 / +0", 0xBF800000u, 0x00000000u, kD, 0xFF7FFFFFu},
// Zero exponent, nonzero mantissa: the VU has no denormals, so this
// reaches the zero branch too.
{"+1 / -denorm", 0x3F800000u, 0x80000001u, kI | kD, 0x7F7FFFFFu},
// Nonzero divisors: the sign bit still decides I, and nothing raises D.
{"+1 / -4", 0x3F800000u, 0xC0800000u, kI, 0x3F000000u},
{"-1 / -4", 0xBF800000u, 0xC0800000u, kI, 0xBF000000u},
{"+1 / +4", 0x3F800000u, 0x40800000u, 0, 0x3F000000u},
};
void BuildMacro(EeRecTestHarness& h, u32 fs, u32 ft)
{
h.EnableVu0Capture();
h.SeedVu0VfBits(kFs, fs, fs, fs, fs);
h.SeedVu0VfBits(kFt, ft, ft, ft, ft);
h.LoadProgram(std::vector<u32>{
CTC2(0, REG_STATUS_FLAG), // clear the sticky field the prologue left
VRSQRT_C2(0, 0, kFs, kFt),
CFC2(kRStatus, REG_STATUS_FLAG),
CFC2(kRQ, REG_Q),
});
}
// The FDIV flag reaches STATUS 13 cycles after an RSQRT (mVUanalyzeFDIV), so
// the program has to run past that before the JIT side can be read.
void BuildMicro(VuTestHarness& h, u32 fs, u32 ft)
{
h.SetVfBits(kFs, fs, fs, fs, fs);
h.SetVfBits(kFt, ft, ft, ft, ft);
std::vector<VuOp> prog;
prog.push_back(LowerOnly(VRSQRT_L(kFs, 0, kFt, 0)));
for (int i = 0; i < 16; ++i)
prog.push_back(NopPair());
prog.push_back(WaitQPair());
prog.push_back(EBitNopPair());
h.LoadProgram(prog);
}
} // namespace
// "+1 / -0" and "+1 / -denorm" were red on all three engines; the rest hold
// the surrounding behaviour still.
TEST(VuRsqrtDivisorSign, InvalidComesFromTheDivisorSignBitAlone)
{
for (const Row& r : kRows)
{
SCOPED_TRACE(r.what);
EeRecTestHarness hj;
BuildMacro(hj, r.fs, r.ft);
hj.RunJitNoDiff();
EXPECT_EQ(hj.GetGprJit(kRStatus) & kDiMask, r.di) << "[macro jit] STATUS";
EeRecTestHarness hi;
BuildMacro(hi, r.fs, r.ft);
hi.RunInterpOnly();
EXPECT_EQ(hi.GetGprInterp(kRStatus) & kDiMask, r.di) << "[macro interp] STATUS";
VuTestHarness m(0);
BuildMicro(m, r.fs, r.ft);
m.Run(); // also diffs micro JIT against micro interp
EXPECT_EQ(m.GetViJit(REG_STATUS_FLAG) & kDiMask, r.di) << "[micro jit] STATUS";
EXPECT_EQ(m.GetViInterp(REG_STATUS_FLAG) & kDiMask, r.di) << "[micro interp] STATUS";
}
}
// Asserted as the whole word so the magnitude is held still too, then as the
// sign alone, which is the half the console can arbitrate.
TEST(VuRsqrtDivisorSign, QuotientSignIsTheDividendsAlone)
{
for (const Row& r : kRows)
{
SCOPED_TRACE(r.what);
EeRecTestHarness hj;
BuildMacro(hj, r.fs, r.ft);
hj.RunJitNoDiff();
EXPECT_EQ(hj.GetGprJit(kRQ), r.q) << "[macro jit] Q";
EeRecTestHarness hi;
BuildMacro(hi, r.fs, r.ft);
hi.RunInterpOnly();
EXPECT_EQ(hi.GetGprInterp(kRQ), r.q) << "[macro interp] Q";
VuTestHarness m(0);
BuildMicro(m, r.fs, r.ft);
m.Run();
EXPECT_EQ(m.GetViJit(REG_Q), r.q) << "[micro jit] Q";
EXPECT_EQ(m.GetViInterp(REG_Q), r.q) << "[micro interp] Q";
EXPECT_EQ(r.q & 0x80000000u, r.fs & 0x80000000u) << "quotient sign is the dividend's";
}
}
// The row autocases_vusticky.h already held before the div-unit grid existed:
// cause and sticky D|I together, and a positive quotient over a negative zero.
TEST(VuRsqrtDivisorSign, MatchesTheConsoleRowForOneOverNegativeZero)
{
EeRecTestHarness h;
BuildMacro(h, 0x3F800000u, 0x80000000u);
h.RunJitNoDiff();
EXPECT_EQ(h.GetGprJit(kRStatus) & kDiMask, 0xC30u);
EXPECT_EQ(h.GetGprJit(kRQ) & 0x80000000u, 0x00000000u);
EXPECT_EQ(0x7FFFFFFFu & 0x80000000u, h.GetGprJit(kRQ) & 0x80000000u)
<< "console returned 0x7FFFFFFF; the magnitude is the VU clamp mode, the sign is not";
}
// Three of the four engines used to raise both causes here and return ±0.
TEST(VuRsqrtDivisorSign, ZeroOverZeroRaisesInvalidWithoutDivideByZero)
{
struct { const char* what; u32 fs; u32 ft; u32 q; } rows[] = {
{"+0 / +0", 0x00000000u, 0x00000000u, 0x7F7FFFFFu},
{"+0 / -0", 0x00000000u, 0x80000000u, 0x7F7FFFFFu},
{"-0 / +0", 0x80000000u, 0x00000000u, 0xFF7FFFFFu},
{"-0 / -0", 0x80000000u, 0x80000000u, 0xFF7FFFFFu},
};
for (const auto& r : rows)
{
SCOPED_TRACE(r.what);
EeRecTestHarness hj;
BuildMacro(hj, r.fs, r.ft);
hj.RunJitNoDiff();
EXPECT_EQ(hj.GetGprJit(kRStatus) & kDiMask, kI) << "[macro jit] STATUS";
EXPECT_EQ(hj.GetGprJit(kRQ), r.q) << "[macro jit] Q";
EeRecTestHarness hi;
BuildMacro(hi, r.fs, r.ft);
hi.RunInterpOnly();
EXPECT_EQ(hi.GetGprInterp(kRStatus) & kDiMask, kI) << "[macro interp] STATUS";
EXPECT_EQ(hi.GetGprInterp(kRQ), r.q) << "[macro interp] Q";
VuTestHarness m(0);
BuildMicro(m, r.fs, r.ft);
m.Run();
EXPECT_EQ(m.GetViJit(REG_STATUS_FLAG) & kDiMask, kI) << "[micro jit] STATUS";
EXPECT_EQ(m.GetViJit(REG_Q), r.q) << "[micro jit] Q";
EXPECT_EQ(m.GetViInterp(REG_STATUS_FLAG) & kDiMask, kI) << "[micro interp] STATUS";
EXPECT_EQ(m.GetViInterp(REG_Q), r.q) << "[micro interp] Q";
}
}
// VSQRT shared the defect: only mVU_SQRT tested the sign bit, so the other
// three lost I on -0 and on the denormals vuDouble flushes to it.
TEST(VuRsqrtDivisorSign, SqrtInvalidComesFromTheSignBitToo)
{
struct { const char* what; u32 ft; u32 di; } rows[] = {
{"sqrt -0", 0x80000000u, kI},
{"sqrt -denorm.min", 0x80000001u, kI},
{"sqrt -denorm.max", 0x807FFFFFu, kI},
{"sqrt +0", 0x00000000u, 0},
{"sqrt +denorm.max", 0x007FFFFFu, 0},
{"sqrt -4", 0xC0800000u, kI},
{"sqrt +4", 0x40800000u, 0},
};
for (const auto& r : rows)
{
SCOPED_TRACE(r.what);
EeRecTestHarness hj;
hj.EnableVu0Capture();
hj.SeedVu0VfBits(kFt, r.ft, r.ft, r.ft, r.ft);
hj.LoadProgram(std::vector<u32>{
CTC2(0, REG_STATUS_FLAG),
VSQRT_C2(0, kFt),
CFC2(kRStatus, REG_STATUS_FLAG),
});
hj.RunJitNoDiff();
EXPECT_EQ(hj.GetGprJit(kRStatus) & kDiMask, r.di) << "[macro jit] STATUS";
EeRecTestHarness hi;
hi.EnableVu0Capture();
hi.SeedVu0VfBits(kFt, r.ft, r.ft, r.ft, r.ft);
hi.LoadProgram(std::vector<u32>{
CTC2(0, REG_STATUS_FLAG),
VSQRT_C2(0, kFt),
CFC2(kRStatus, REG_STATUS_FLAG),
});
hi.RunInterpOnly();
EXPECT_EQ(hi.GetGprInterp(kRStatus) & kDiMask, r.di) << "[macro interp] STATUS";
VuTestHarness m(0);
m.SetVfBits(kFt, r.ft, r.ft, r.ft, r.ft);
std::vector<VuOp> prog;
prog.push_back(LowerOnly(VSQRT_L(kFt, 0)));
for (int i = 0; i < 16; ++i)
prog.push_back(NopPair());
prog.push_back(WaitQPair());
prog.push_back(EBitNopPair());
m.LoadProgram(prog);
m.Run();
EXPECT_EQ(m.GetViJit(REG_STATUS_FLAG) & kDiMask, r.di) << "[micro jit] STATUS";
EXPECT_EQ(m.GetViInterp(REG_STATUS_FLAG) & kDiMask, r.di) << "[micro interp] STATUS";
}
}
} // namespace recompiler_tests