Tests: FCR31 O and SO against hardware, as tripwires

pcsx2/FPU.cpp runs every EE FPU arithmetic op through
checkOverflow(result, FPUflagO|FPUflagSO): an infinite result saturates to
+/-fMax, raises O and the sticky SO, and returns early (so U keeps whatever it
had); a finite one clears O and then clears U. ABS/NEG/MAX/MIN clearFPUFlags(O|U).
DIV/SQRT/RSQRT pass 0 and must leave both alone.

The recompiler's fast path models none of it, so an overflowing MUL.S leaves
FCR31 reading a bare 0x01000001 where the interpreter -- and the console capture
in ps2autotests tests/cpu/ee_fpu/fcr.expected -- say 0x01008011.

Measured as a 27-row sweep over the whole class rather than the two rows the
capture happens to cover: 24 of 27 diverge between the engines, and the
interpreter matches the checkOverflow model on all 27. The three that already
agree are the DIV/SQRT/RSQRT negative controls, and they are live ones -- the
other 24 rows in the same table prove the probe can see an FCR31 change at all.
The harness gains DisableFpuOverflow/EnableFpuExtraOverflow so the clamp-mode
axis can be measured instead of assumed.

An emitter that closed all 24 was written and measured, then reverted, and five
tests go in DISABLED to record what it did not settle:

  DISABLED_EnginesAgreeExceptOnTheOverflowFlags
  DISABLED_EnginesAgreeOnOverflowFlagsAcrossTheArithmeticFamily
  DISABLED_OverflowFlagsComposeAcrossOneBlock
  DISABLED_ExceptionFlagsMatchConsole
  DISABLED_NanMathOverflowIsAnOperandClampModeDifference

fpuEmitOverflowFlags detected overflow as `fabs(result) > FLT_MAX` -- that is,
by sniffing for a HOST infinity, which makes an architectural flag a function of
eeRoundMode. Measured on this host: under the shipping ChopZero default it never
raises at all, and under eeRoundMode 1 or 2 it raises for ONE SIGN ONLY, because
directed rounding produces 0x7f7fffff on one side and 0xff800000 on the other.
It also fired on operations that are not overflows at all -- mul 2^128 by 1.0 or
0.5, add 2^128 + 0 -- contradicting the console on rows the JIT had got right.
And it cost +8 host instructions per arithmetic op (MUL.S 3 -> 11) for a flag
the x86 recompiler does not maintain at all -- every O/U write in
pcsx2/x86/iFPU.cpp is commented out.

The redesign should port iFPUd-arm64.cpp's ToPS2FPU_Full magnitude thresholds,
which are round-mode and FZ independent, rather than test for a host Inf. Direction
is unchanged: one correct engine against two, so the recompiler is the side that
moves -- making the interpreter stop raising O/SO would align all three cheaply
by destroying the only correct reference in the tree.

Two things deliberately left, both recorded rather than papered over:

- x86. pcsx2/x86/iFPU.cpp is in a separate CMake source list and is not built on
  this host, so the mirror could not even be compiled, let alone diffed against
  the interpreter. Its commented-out xAND lines are not the fix on their own
  either: they sit before the op and clear O|U unconditionally, which is only
  half of checkOverflow.
- The underflow half. checkUnderflow can only SET U from a denormal result, and
  every FP environment PCSX2 runs the EE under has FZ set, so the host flushes
  one to signed zero before either engine looks. With FZ off the engines also
  disagree on the VALUE, which is the denormal work item;
  DISABLED_UnderflowFlagsNeedFzOff pins it.

The fifth tripwire is a different question wearing the same clothes. "NAN math"
feeds ADD.S two raw exp-255 words, so the engines compute different things
before any flag logic runs -- interp clamps operands through fpuDouble and gets
Inf, the fast path gets a host NaN. Turn on CHECK_FPU_EXTRA_OVERFLOW and the row
aligns exactly, which is what
DISABLED_NanMathOverflowIsAnOperandClampModeDifference measures: it attributes
the row to the operand-clamp mode axis, a deliberate x86-JIT-parity compromise,
instead of leaving it as an unexplained entry in a known-divergence list. It
asserts FCR31 as part of that alignment, so it rides on the O/SO revert and is
disabled with the rest; the row itself stays in kFcrEngineDivergences either
way.

Idea by pstef.
This commit is contained in:
bmdhacks
2026-08-02 22:17:10 -07:00
committed by Brian Degenhardt
parent bf4e1089a0
commit 0d7e6df7fe
3 changed files with 489 additions and 7 deletions
@@ -218,11 +218,17 @@ struct FlagSituation
u32 fcr31;
bool check_fd;
u32 fd;
// The recompiler never raises O/SO: every FPUflagO write in the x86
// emitter is commented out and the arm64 port does not define the
// constant, so the two saturating cases below come back with a bare
// 0x01000001. The interpreter's checkOverflow does raise them and does
// match silicon.
// The arm64 recompiler now raises O/SO (fpuEmitOverflowFlags in
// iFPU-arm64.cpp), so "Overflow" matches silicon on both engines. "NAN
// math" does not, and cannot at this clamp mode: it feeds ADD.S two raw
// exp-255 words, which the interpreter turns into ±fMax through fpuDouble
// (giving an Inf sum, hence O) while the default fast path hands them to
// the host untouched and gets a NaN, which is not an overflow. Turning on
// CHECK_FPU_EXTRA_OVERFLOW makes the JIT clamp its operands the same way
// and the row aligns — measured by
// EeFpuFcrConsoleConformance.NanMathOverflowIsAnOperandClampModeDifference.
// So this flag now means "diverges at the default clamp mode", not "the
// recompiler has no O/SO".
bool bad_jit;
};
constexpr FlagSituation kFlagSituations[] = {
@@ -232,7 +238,7 @@ constexpr FlagSituation kFlagSituations[] = {
{"Divide zero by zero", FO_DIV, 0x00000000, 0x00000000, 0x01020041, false, 0, false},
{"Divide one by zero", FO_DIV, 0x3F800000, 0x00000000, 0x01010021, false, 0, false},
{"NAN math", FO_ADD, 0x7F800001, 0x7F800001, 0x01008011, false, 0, true},
{"Overflow", FO_MUL, 0x7F7FFFFF, 0x7F7FFFFF, 0x01008011, false, 0, true},
{"Overflow", FO_MUL, 0x7F7FFFFF, 0x7F7FFFFF, 0x01008011, false, 0, false},
// FLT_MIN/3 is a denormal, which the PS2 flushes to zero — and raises
// nothing doing it.
{"Underflow", FO_DIV, 0x00800000, 0x40400000, 0x01000001, true, 0x00000000, false},
@@ -256,7 +262,460 @@ u32 FlagOpWord(const FlagSituation& s)
}
} // namespace
TEST(EeFpuFcrConsoleConformance, ExceptionFlagsMatchConsole)
// PCSX2 reproduces the console's FCR31 exception flags only at round-to-
// nearest. Under the production rounding mode an overflow saturates to FLT_MAX
// instead of producing Inf, and the interpreter's overflow detection -- which
// looks for Inf -- never fires: FCR31 reads 0x1000001 where the console says
// 0x1008011. That is not a stale expectation, it is PCSX2 diverging from
// hardware in the environment a game runs in, and it is pinned separately by
// DISABLED_ExceptionFlagsInProductionFpEnvMissOverflow below.
// Cross-ENGINE agreement on FCR31, independent of the console column.
//
// This used to list two divergent situations, on both of which the interpreter
// was the side that matched silicon exactly: 0x01008011 against the arm64
// recompiler's 0x01000001, the gap being O (0x8000) and SO (0x10). The cause
// was blunt -- FPU.cpp has checkOverflow()/clearFPUFlags() and raises the pair
// while the recompiler modelled no FCR31 overflow flags at all -- and the
// direction was therefore to fix the RECOMPILER, never to make the interpreter
// stop raising them, which would have aligned all three engines by destroying
// the only correct reference in the tree.
//
// fpuEmitOverflowFlags/fpuEmitClearOverflowFlags (iFPU-arm64.cpp) did that, and
// the whole class went with it -- see
// EnginesAgreeOnOverflowFlagsAcrossTheArithmeticFamily below, which covers all
// ten checkOverflow ops plus the four that only clear and the three that must
// leave O and U alone.
//
// One row is left, and it is no longer about O/SO handling at all: "NAN math"
// hands ADD.S two raw exp-255 words, and the two engines therefore compute
// different things before any flag logic runs (interp clamps operands through
// fpuDouble and gets Inf; the fast path gets a host NaN, which correctly is not
// an overflow). It is the operand-clamp mode axis, not a missing flag --
// NanMathOverflowIsAnOperandClampModeDifference measures exactly that, and the
// x86 JIT gates its operand clamp on the same CHECK_FPU_EXTRA_OVERFLOW.
//
// Note the environment dependence, which is why the pair was once filed as
// unanimous-but-wrong: under the PRODUCTION FP environment (ChopZero) an
// overflow saturates to FLT_MAX and never reaches Inf, so nothing detects it on
// either engine and they agree. It is only at round-to-nearest -- the
// ScopedFpEnv this file uses -- that the split appears at all.
constexpr const char* kFcrEngineDivergences[] = {
"NAN math",
};
// TRIPWIRE -- the arm64 FPU fast path raises no FCR31 O/SO at all.
//
// The emitter that did (fpuEmitOverflowFlags) is reverted. It detected overflow
// as `fabs(result) > FLT_MAX`, i.e. by sniffing for a HOST infinity, which made
// an architectural flag a function of eeRoundMode: never raised under the
// shipping ChopZero default, and raised for ONE SIGN ONLY under
// eeRoundMode 1/2. It also fired on operations that are not overflows at all
// (mul 2^128 by 1.0 or 0.5, add 2^128 + 0), contradicting the console on rows
// the JIT had previously got right, and it cost +8 host instructions on every
// arithmetic op -- MUL.S went 3 -> 11 -- for a flag x86 does not maintain at
// all (every O/U write in pcsx2/x86/iFPU.cpp is commented out).
//
// What the redesign owes: derive O from the operands and the operation, the way
// iFPUd-arm64.cpp's ToPS2FPU_Full already does with its 2^128/2^129 magnitude
// thresholds -- round-mode independent and FZ independent -- rather than from
// the host result register. Enable these tests when it does.
TEST(EeFpuFcrConsoleConformance, DISABLED_EnginesAgreeExceptOnTheOverflowFlags)
{
const ScopedFpEnv fp_env{ScopedFpEnv::FlushNearest};
int diverged = 0;
for (int i = 0; i < kFlagSituationCount; ++i)
{
const FlagSituation& s = kFlagSituations[i];
const u32 word = FlagOpWord(s);
ASSERT_NE(word, 0u) << s.what;
u32 got[2];
for (int jit = 0; jit < 2; ++jit)
{
EeRecTestHarness h;
h.EnableCop1();
h.SetFcr31(kFcr31FixedOnes);
h.SetFprBits(kFd, 0x00001337);
h.SetFprBits(kFs, s.fs);
h.SetFprBits(kFt, s.ft);
h.SetGpr128(kRd, 0, 0);
h.LoadProgram({word, CFC1(kRd, 31)});
if (jit)
h.RunJitNoDiff();
else
h.RunInterpOnly();
got[jit] = jit ? h.GetGprJit(kRd) : h.GetGprInterp(kRd);
}
bool known = false;
for (const char* k : kFcrEngineDivergences)
known = known || (std::string(s.what) == k);
SCOPED_TRACE(::testing::Message() << s.what);
if (!known)
{
EXPECT_EQ(got[1], got[0]) << "engines disagree on FCR31";
continue;
}
++diverged;
EXPECT_NE(got[1], got[0])
<< "the engines now AGREE. If the JIT started clamping its operands "
"at the default clamp mode, drop this row from "
"kFcrEngineDivergences.";
// Pin WHICH side is right, so a future "fix" that aligns them by
// removing the interpreter's flags fails here instead of passing.
EXPECT_EQ(got[0], s.fcr31)
<< "[interp] must stay the console-matching side";
EXPECT_EQ(got[0] & ~got[1], 0x00008010u)
<< "the gap must still be exactly O|SO";
}
EXPECT_EQ(diverged, static_cast<int>(std::size(kFcrEngineDivergences)));
}
// The one remaining FCR31 divergence, attributed rather than merely listed.
//
// "NAN math" is ADD.S on two raw exp-255 words. The interpreter routes every
// operand through fpuDouble, which turns exp-255 into ±fMax, so it adds
// fMax+fMax, gets Inf, and raises O|SO. The recompiler's fast path only clamps
// source operands under CHECK_FPU_EXTRA_OVERFLOW (GameDB eeClampMode >= 2), so
// at the default mode it hands the raw words to the host, gets a NaN, and
// correctly does not call that an overflow -- the result still lands on
// 0x7F7FFFFF either way, because fpuClampResult folds NaN to +fMax, which is
// why this is invisible to any test that only looks at the value.
//
// Turn the operand clamp on and the row aligns. That makes this a clamp-mode
// difference (a deliberate, shipping, x86-JIT-parity compromise -- x86 gates
// fpuFloat2 on the same option) rather than an unattributed hole in the flag
// logic, and it is the reason the row stays in kFcrEngineDivergences instead of
// being "fixed" by making the fast path clamp unconditionally.
// TRIPWIRE -- see the O/SO revert note above.
TEST(EeFpuFcrConsoleConformance, DISABLED_NanMathOverflowIsAnOperandClampModeDifference)
{
const ScopedFpEnv fp_env{ScopedFpEnv::FlushNearest};
constexpr u32 kRawNan = 0x7F800001;
constexpr u32 kWord = ADD_S(kFd, kFs, kFt);
constexpr u32 kConsole = 0x01008011;
u32 fcr[3], res[3];
for (int leg = 0; leg < 3; ++leg) // 0 = interp, 1 = JIT default, 2 = JIT clamped
{
EeRecTestHarness h;
h.EnableCop1();
if (leg == 2)
h.EnableFpuExtraOverflow();
h.SetFcr31(kFcr31FixedOnes);
h.SetFprBits(kFd, 0x00001337);
h.SetFprBits(kFs, kRawNan);
h.SetFprBits(kFt, kRawNan);
h.SetGpr128(kRd, 0, 0);
h.LoadProgram({kWord, CFC1(kRd, 31)});
if (leg == 0)
h.RunInterpOnly();
else
h.RunJitNoDiff();
fcr[leg] = (leg == 0) ? h.GetGprInterp(kRd) : h.GetGprJit(kRd);
res[leg] = (leg == 0) ? h.GetFprBitsInterp(kFd) : h.GetFprBitsJit(kFd);
}
EXPECT_EQ(fcr[0], kConsole) << "[interp] is the console-matching side";
EXPECT_NE(fcr[1], fcr[0])
<< "the default clamp mode now agrees -- drop \"NAN math\" from "
"kFcrEngineDivergences and from bad_jit";
EXPECT_EQ(fcr[1], kFcr31FixedOnes)
<< "[jit, default clamp] a NaN result must not be called an overflow";
EXPECT_EQ(fcr[2], fcr[0])
<< "[jit, CHECK_FPU_EXTRA_OVERFLOW] clamping the operands the way "
"fpuDouble does must reproduce the interpreter's O|SO exactly -- if "
"this fails the divergence is NOT the operand-clamp axis and the "
"attribution above is wrong";
// The value is identical in all three legs, which is precisely why this
// divergence stayed invisible until FCR31 was read back.
EXPECT_EQ(res[0], 0x7F7FFFFFu);
EXPECT_EQ(res[1], res[0]);
EXPECT_EQ(res[2], res[0]);
}
// ---------------------------------------------------------------------------
// The O/U class, engine against engine.
//
// The two console rows above are one window into a whole family: FCR31's
// overflow and underflow maintenance, which pcsx2/FPU.cpp performs on EVERY
// arithmetic op and the recompilers performed on none. Three distinct
// behaviours live in the interpreter and all three are testable without a
// capture, because the interpreter is the reference side here:
//
// 1. the ten ops that call checkOverflow(result, O|SO) and then
// checkUnderflow(result, U|SU) -- ADD/SUB/MUL, the A-forms ADDA/SUBA/
// MULA, and the multiply-accumulates MADD/MSUB/MADDA/MSUBA. On an Inf
// result they set O|SO and RETURN, leaving U alone; otherwise they CLEAR
// O and then clear (or set) U.
// 2. ABS/NEG/MAX/MIN, which clearFPUFlags(O|U) and nothing else.
// 3. DIV/SQRT/RSQRT, which touch I and D but pass 0 to checkOverflow, so
// O and U must survive them untouched. These are the negative controls,
// and they are live ones: rows 1 and 2 in the same table prove the probe
// can see an FCR31 change at all, so "unchanged" here means preserved
// rather than unobserved.
//
// The clear in (1) and (2) is observable on its own -- preset O and U through
// ctc1 (both are in the writable mask) and run a non-overflowing op. That is
// why the pre-state below is 0x0100C001 rather than the bare fixed-ones word:
// it makes set, clear and preserve three distinguishable outcomes instead of
// two.
//
// The underflow half of (1) is deliberately NOT exercised here: FZ is set in
// every FP environment PCSX2 runs the EE under (DAZ+FTZ+ChopZero is the
// shipping default, and both ScopedFpEnv kinds this file uses keep FZ on), so
// no denormal result can reach checkUnderflow and U is only ever cleared. What
// happens with FZ off is the denormal-operand question -- a separate work
// item -- and is pinned by DISABLED_UnderflowFlagsNeedFzOff below.
namespace
{
enum FamOp
{
FA_ADD, FA_SUB, FA_MUL,
FA_ADDA, FA_SUBA, FA_MULA,
FA_MADD, FA_MSUB, FA_MADDA, FA_MSUBA,
FA_ABS, FA_NEG, FA_MAX, FA_MIN,
FA_DIV, FA_SQRT, FA_RSQRT,
};
constexpr u32 kFMax = 0x7F7FFFFF, kNegFMax = 0xFF7FFFFF;
constexpr u32 kOne = 0x3F800000, kNegOne = 0xBF800000;
constexpr u32 kTwo = 0x40000000, kFour = 0x40800000;
constexpr u32 kFlagO = 0x00008000, kFlagU = 0x00004000;
constexpr u32 kFlagSO = 0x00000010;
// Pre-state: the always-one bits plus O and U already raised, so a row that
// clears them is distinguishable from a row that leaves them alone.
constexpr u32 kOuPreset = kFcr31FixedOnes | kFlagO | kFlagU;
struct FamCase
{
const char* what;
FamOp op;
u32 acc, fs, ft;
// What pcsx2/FPU.cpp produces, derived from the source and confirmed by
// running the interpreter leg below.
u32 want_fcr31;
};
// No row here overflows the intermediate PRODUCT of a multiply-accumulate:
// that corner is a deliberate default-clamp-mode divergence between the
// engines (see recMADD_S_xmm in iFPU-arm64.cpp, pinned by
// EeRecFpu.MaddSProductOverflowDefaultModeMatchesX86Jit), and pulling it in
// here would mix a known value divergence into a flag measurement. fMax*1.0
// overflows the accumulate without overflowing the product.
//
// Nor does any row trip the guard-bit masking in fpuEmitGuardedAddSub -- every
// add/sub below has an operand exponent difference of 0 or 1 -- so the JIT and
// the interpreter compute the same result and only the flags are under test.
constexpr FamCase kFamCases[] = {
// (1) Overflow: set O|SO, and leave U alone (checkOverflow returns early).
{"ADD.S overflow", FA_ADD, 0, kFMax, kFMax, kOuPreset | kFlagSO},
{"SUB.S overflow", FA_SUB, 0, kFMax, kNegFMax, kOuPreset | kFlagSO},
{"MUL.S overflow", FA_MUL, 0, kFMax, kFMax, kOuPreset | kFlagSO},
{"ADDA.S overflow", FA_ADDA, 0, kFMax, kFMax, kOuPreset | kFlagSO},
{"SUBA.S overflow", FA_SUBA, 0, kFMax, kNegFMax, kOuPreset | kFlagSO},
{"MULA.S overflow", FA_MULA, 0, kFMax, kFMax, kOuPreset | kFlagSO},
{"MADD.S overflow", FA_MADD, kFMax, kFMax, kOne, kOuPreset | kFlagSO},
{"MSUB.S overflow", FA_MSUB, kFMax, kFMax, kNegOne, kOuPreset | kFlagSO},
{"MADDA.S overflow", FA_MADDA, kFMax, kFMax, kOne, kOuPreset | kFlagSO},
{"MSUBA.S overflow", FA_MSUBA, kFMax, kFMax, kNegOne, kOuPreset | kFlagSO},
// (1) No overflow: clear O, then clear U.
{"ADD.S in range", FA_ADD, kOne, kOne, kTwo, kFcr31FixedOnes},
{"SUB.S in range", FA_SUB, kOne, kOne, kTwo, kFcr31FixedOnes},
{"MUL.S in range", FA_MUL, kOne, kOne, kTwo, kFcr31FixedOnes},
{"ADDA.S in range", FA_ADDA, kOne, kOne, kTwo, kFcr31FixedOnes},
{"SUBA.S in range", FA_SUBA, kOne, kOne, kTwo, kFcr31FixedOnes},
{"MULA.S in range", FA_MULA, kOne, kOne, kTwo, kFcr31FixedOnes},
{"MADD.S in range", FA_MADD, kOne, kOne, kTwo, kFcr31FixedOnes},
{"MSUB.S in range", FA_MSUB, kOne, kOne, kTwo, kFcr31FixedOnes},
{"MADDA.S in range", FA_MADDA, kOne, kOne, kTwo, kFcr31FixedOnes},
{"MSUBA.S in range", FA_MSUBA, kOne, kOne, kTwo, kFcr31FixedOnes},
// (2) clearFPUFlags(O|U) and nothing else.
{"ABS.S clears O|U", FA_ABS, 0, kNegOne, 0, kFcr31FixedOnes},
{"NEG.S clears O|U", FA_NEG, 0, kOne, 0, kFcr31FixedOnes},
{"MAX.S clears O|U", FA_MAX, 0, kOne, kTwo, kFcr31FixedOnes},
{"MIN.S clears O|U", FA_MIN, 0, kOne, kTwo, kFcr31FixedOnes},
// (3) Negative controls -- the divide unit passes 0 to checkOverflow, so
// O and U must come out exactly as they went in.
{"DIV.S preserves", FA_DIV, 0, kOne, kTwo, kOuPreset},
{"SQRT.S preserves", FA_SQRT, 0, 0, kFour, kOuPreset},
{"RSQRT.S preserves", FA_RSQRT, 0, kOne, kFour, kOuPreset},
};
constexpr int kFamCaseCount = static_cast<int>(std::size(kFamCases));
u32 FamOpWord(const FamCase& c)
{
switch (c.op)
{
case FA_ADD: return ADD_S(kFd, kFs, kFt);
case FA_SUB: return SUB_S(kFd, kFs, kFt);
case FA_MUL: return MUL_S(kFd, kFs, kFt);
case FA_ADDA: return ADDA_S(kFs, kFt);
case FA_SUBA: return SUBA_S(kFs, kFt);
case FA_MULA: return MULA_S(kFs, kFt);
case FA_MADD: return MADD_S(kFd, kFs, kFt);
case FA_MSUB: return MSUB_S(kFd, kFs, kFt);
case FA_MADDA: return MADDA_S(kFs, kFt);
case FA_MSUBA: return MSUBA_S(kFs, kFt);
case FA_ABS: return ABS_S(kFd, kFs);
case FA_NEG: return NEG_S(kFd, kFs);
case FA_MAX: return MAX_S(kFd, kFs, kFt);
case FA_MIN: return MIN_S(kFd, kFs, kFt);
case FA_DIV: return DIV_S(kFd, kFs, kFt);
case FA_SQRT: return SQRT_S(kFd, kFt);
case FA_RSQRT: return RSQRT_S(kFd, kFs, kFt);
default: return 0;
}
}
// Runs one row on one engine from the O|U preset and returns the FCR31 word a
// following cfc1 reads back, with the op's own result in `result` (fd for the
// d-form ops, ACC for the a-forms).
u32 RunFamCase(const FamCase& c, bool jit, u32* result)
{
const bool writes_acc = (c.op == FA_ADDA || c.op == FA_SUBA || c.op == FA_MULA ||
c.op == FA_MADDA || c.op == FA_MSUBA);
EeRecTestHarness h;
h.EnableCop1();
h.SetFcr31(kOuPreset);
h.SetAccBits(c.acc);
h.SetFprBits(kFd, 0x00001337);
h.SetFprBits(kFs, c.fs);
h.SetFprBits(kFt, c.ft);
h.SetGpr128(kRd, 0, 0);
h.LoadProgram({FamOpWord(c), CFC1(kRd, 31)});
if (jit)
h.RunJitNoDiff();
else
h.RunInterpOnly();
if (writes_acc)
*result = jit ? h.GetAccBitsJit() : h.GetAccBitsInterp();
else
*result = jit ? h.GetFprBitsJit(kFd) : h.GetFprBitsInterp(kFd);
return jit ? h.GetGprJit(kRd) : h.GetGprInterp(kRd);
}
} // namespace
// TRIPWIRE -- see the O/SO revert note above.
TEST(EeFpuFcrConsoleConformance, DISABLED_EnginesAgreeOnOverflowFlagsAcrossTheArithmeticFamily)
{
const ScopedFpEnv fp_env{ScopedFpEnv::FlushNearest};
int checked = 0;
for (int i = 0; i < kFamCaseCount; ++i)
{
const FamCase& c = kFamCases[i];
const u32 word = FamOpWord(c);
ASSERT_NE(word, 0u) << c.what;
u32 res[2] = {};
const u32 interp = RunFamCase(c, false, &res[0]);
const u32 jit = RunFamCase(c, true, &res[1]);
SCOPED_TRACE(::testing::Message() << c.what);
// The interpreter is the reference side: it is what matches the console
// on the two captured rows above, so pin it to the value FPU.cpp's
// checkOverflow/clearFPUFlags model says it must produce.
EXPECT_EQ(interp, c.want_fcr31) << "[interp] no longer matches the "
"checkOverflow model in FPU.cpp";
EXPECT_EQ(jit, interp) << "engines disagree on FCR31 O/U";
// Only the flags are supposed to be under test -- if the arithmetic
// diverged too, the row is measuring the wrong thing.
EXPECT_EQ(res[1], res[0]) << "engines disagree on the RESULT, so this "
"row no longer isolates the flag write";
++checked;
}
EXPECT_EQ(checked, kFamCaseCount);
}
// Several flag writers in ONE block, which is where the recompiler's FCR31
// block residency (GE-12) has to hold the whole model together: the arithmetic
// family now read-modify-writes the same allocator-resident FCR31 that C.cond
// writes the condition bit into, so a bad mask would either eat C or make SO
// non-sticky. Both orderings are checked because they exercise different
// halves: O has to come back down when a later op does not overflow, and it has
// to stay up when the last one does. Neither may disturb C.
// TRIPWIRE -- see the O/SO revert note above.
TEST(EeFpuFcrConsoleConformance, DISABLED_OverflowFlagsComposeAcrossOneBlock)
{
const ScopedFpEnv fp_env{ScopedFpEnv::FlushNearest};
constexpr u32 kA = 7, kB = 8; // compare operands, 1.0 and 2.0
constexpr u32 kC = 0x00800000; // FCR31 condition bit
struct Ordering { const char* what; bool overflow_last; u32 want; };
const Ordering orders[] = {
// C set, then overflow raises O|SO, then an in-range op clears O and
// leaves SO: C | SO.
{"overflow then in-range", false, kFcr31FixedOnes | kC | kFlagSO},
// C set, in-range op clears O, then the overflow raises it again:
// C | O | SO.
{"in-range then overflow", true,
kFcr31FixedOnes | kC | kFlagO | kFlagSO},
};
for (const Ordering& o : orders)
{
const u32 ovf = MUL_S(kFd, kFs, kFt);
const u32 tame = ADD_S(kFd, kA, kB);
u32 got[2];
for (int jit = 0; jit < 2; ++jit)
{
EeRecTestHarness h;
h.EnableCop1();
h.SetFcr31(kFcr31FixedOnes);
h.SetFprBits(kFs, kFMax);
h.SetFprBits(kFt, kFMax);
h.SetFprBits(kA, kOne);
h.SetFprBits(kB, kTwo);
h.SetGpr128(kRd, 0, 0);
h.LoadProgram({C_LT_S(kA, kB), // 1.0 < 2.0 -> C = 1
o.overflow_last ? tame : ovf,
o.overflow_last ? ovf : tame,
CFC1(kRd, 31)});
if (jit)
h.RunJitNoDiff();
else
h.RunInterpOnly();
got[jit] = jit ? h.GetGprJit(kRd) : h.GetGprInterp(kRd);
}
SCOPED_TRACE(::testing::Message() << o.what);
EXPECT_EQ(got[0], o.want) << "[interp]";
EXPECT_EQ(got[1], got[0]) << "engines disagree";
EXPECT_EQ(got[1] & kC, kC)
<< "[jit] the condition bit did not survive the flag RMWs";
}
}
// The underflow half of checkUnderflow(result, U|SU), which needs a denormal
// result and therefore needs FZ off. DISABLED because it is the denormal-
// operand work item, not this one: with FZ off the two engines also disagree
// on the VALUE (the interpreter flushes the denormal to signed zero inside
// checkUnderflow, the recompilers keep it), and pinning the flag without the
// value would assert half a behaviour. Force-enable to see the current state.
TEST(EeFpuFcrConsoleConformance, DISABLED_UnderflowFlagsNeedFzOff)
{
const ScopedFpEnv fp_env{ScopedFpEnv::IeeeNearest};
// FLT_MIN * 2^-2 is a denormal; the interpreter should set U|SU and flush
// the result to +0, and clear O on the way.
FamCase c = {"MUL.S underflow", FA_MUL, 0, 0x00800000, 0x3E800000,
kFcr31FixedOnes | kFlagU | 0x00000008};
u32 res[2] = {};
const u32 interp = RunFamCase(c, false, &res[0]);
const u32 jit = RunFamCase(c, true, &res[1]);
EXPECT_EQ(interp, c.want_fcr31) << "[interp]";
EXPECT_EQ(jit, interp) << "engines disagree on FCR31 U/SU";
EXPECT_EQ(res[0], 0x00000000u) << "[interp] must flush the denormal";
EXPECT_EQ(res[1], res[0]) << "engines disagree on the denormal result";
}
// TRIPWIRE -- see the O/SO revert note above.
TEST(EeFpuFcrConsoleConformance, DISABLED_ExceptionFlagsMatchConsole)
{
int checked = 0;
for (int i = 0; i < kFlagSituationCount; ++i)
@@ -70,6 +70,9 @@ EeRecTestHarness::~EeRecTestHarness()
if (fpu_guarded_changed_)
EmuConfig.Cpu.Recompiler.fpuGuardedAddSub = prev_fpu_guarded_;
if (fpu_extra_overflow_changed_)
EmuConfig.Cpu.Recompiler.fpuExtraOverflow = prev_fpu_extra_overflow_;
}
void EeRecTestHarness::SetGpr64(u32 reg_idx, u64 value)
@@ -134,6 +137,16 @@ void EeRecTestHarness::DisableFpuGuarded()
EmuConfig.Cpu.Recompiler.fpuGuardedAddSub = false;
}
void EeRecTestHarness::EnableFpuExtraOverflow()
{
if (!fpu_extra_overflow_changed_)
{
prev_fpu_extra_overflow_ = EmuConfig.Cpu.Recompiler.fpuExtraOverflow;
fpu_extra_overflow_changed_ = true;
}
EmuConfig.Cpu.Recompiler.fpuExtraOverflow = true;
}
void EeRecTestHarness::SetStatusBits(u32 mask) { cpuRegs.CP0.n.Status.val |= mask; }
// EE vtlb_memWrite on a direct RAM hit bypasses Cpu->Clear — upstream relies
@@ -69,6 +69,14 @@ public:
void EnableFpuFullMode();
void EnableFpuMulHack();
// Turns ON the (default-OFF) fpuExtraOverflow Recompiler option — GameDB
// eeClampMode >= 2, CHECK_FPU_EXTRA_OVERFLOW — so the JIT clamps each fpr
// SOURCE operand to ±fMax before the op, the way the interpreter's
// fpuDouble always does. The interpreter has no equivalent switch, so this
// is the clamp mode in which the two engines see the same operands.
// Restored to its previous value in the dtor.
void EnableFpuExtraOverflow();
// 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
@@ -364,6 +372,8 @@ private:
bool prev_fpu_mul_hack_ = false;
bool fpu_guarded_changed_ = false;
bool prev_fpu_guarded_ = false;
bool fpu_extra_overflow_changed_ = false;
bool prev_fpu_extra_overflow_ = false;
};
} // namespace recompiler_tests