Skip to content

fix(vu0): saturate VDIV/VRSQRT on a zero divisor and take the VSQRT/VRSQRT radicand magnitude - #198

Draft
smmathews wants to merge 2 commits into
ran-j:mainfrom
smmathews:feature/30-vu0-fdiv-saturation
Draft

fix(vu0): saturate VDIV/VRSQRT on a zero divisor and take the VSQRT/VRSQRT radicand magnitude#198
smmathews wants to merge 2 commits into
ran-j:mainfrom
smmathews:feature/30-vu0-fdiv-saturation

Conversation

@smmathews

Copy link
Copy Markdown
Contributor

fix(vu0): saturate macro-mode VDIV/VRSQRT on a zero divisor and take the VSQRT/VRSQRT radicand magnitude

Problem

The three VU0 macro-mode FDIV-unit translators in ps2xRecomp/src/lib/vu_translation_helpers.cpp write 0.0f where the hardware saturates or takes a magnitude.

  • VDIV and VRSQRT emit 0.0f on a zero divisor. The manual gives +MAX/-MAX.
  • VSQRT emits sqrtf(std::max(0.0f, ft)), so a radicand of -16.0 yields 0.0 instead of 4.0.
  • translateVU_VRSQRT never reads its fs operand and emits a bare reciprocal, substituting 1.0 for the numerator. An RSQRT Q, VFax, VFby whose numerator is not 1.0 therefore computes a wrong quotient on all but the operand pairs where the substitution happens not to show: a +0 numerator over a finite negative radicand, and a positive finite numerator over an infinite one, both give +0 either way.

Fix

  • ps2xRuntime/include/ps2_runtime_macros.h gains three static inline helpers behind PS2_VU_DIV_Q, PS2_VU_SQRT_Q and PS2_VU_RSQRT_Q, as it already places Ps2ExtractEpi32 behind PS2_EXTRACT_EPI32. One include is added, <limits>.
  • Ps2VuDivQ saturates to the signed float maximum on a zero divisor, signed by signbit(fs) != signbit(ft) — read from the bit, since -0.0f >= 0.0f is true in C++.
  • Ps2VuSqrtQ is sqrt(fabs(ft)). Ps2VuRsqrtQ composes the two, so its divisor is never negative and its saturation sign is signbit(fs) alone. The two operations therefore disagree on a negative-zero divisor.
  • The translators keep their component extraction and emit a PS2_* call, following translateVU_VADD/VSUB/VMUL in the same file. Format arguments are now indexed.
  • PS2_VDIV/PS2_VSQRT/PS2_VRSQRT are unchanged: no callers, and neither fits. PS2_VDIV is a packed four-lane _mm_div_ps; PS2_VRSQRT takes one operand where the instruction takes two.
  • Two commits: the VRSQRT operand read, then the saturating helpers. The first builds and passes on its own.

Hardware basis

  • §2.3 exception table: 0/0 and 0 division give +MAX/-MAX; √x (x < 0) gives √|x|; "0 is valid sign"; note, "D flag is not set for 0/0."
  • §2.4: "The VU does not support non-numerals, infinity, and non-normalized numbers" — so +MAX is not IEEE inf.
  • §3.3.2: D set by DIV/RSQRT on "0 division (except 0/0)", cleared by SQRT regardless of result; I raised by SQRT/RSQRT on a negative root.
  • Micro SQRT/RSQRT pages: Q = √|VF[ft]ftf|; RSQRT Q, VF10x, VF20y gives Q = VF10x ÷ √|VF20y|.
  • Macro VRSQRT page: "Divides the fsf field of VF[fs] by the square root of the ftf field of VF[ft]"; mnemonic VRSQRT Q, fsfsf, ftftf, two operands.
  • Macro VDIV/VSQRT/VRSQRT pages: "Same as the micro instruction". §5.3: macro-mode flags are the same as micro-mode.

This change implements §2.3's calculation-result column for the rows cited above and nothing else in that table. EVIDENCE.md tabulates what is quoted here and what is derived.

Testing

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS=-msse4.1 -DCMAKE_CXX_FLAGS=-msse4.1
cmake --build build
./build/ps2xTest/ps2x_tests
  • Emission tests in ps2xTest/src/code_generator_tests.cpp assert the entire emitted string per instruction, over operand tuples chosen to close constant and linear substitution of one field by another. They fail against the unmodified translators and are the regression gates for the codegen change.
  • Numeric tests in ps2xTest/src/ps2_vu_tests.cpp call the helpers directly. They pin the ordinary quotient at every sign combination, the zero-dividend quotient by magnitude and by sign bit, the saturation value and its sign, the magnitude root, and NaN and overflow behaviour on both paths.
  • Those numeric tests are new coverage rather than gates: against an unmodified tree they do not fail, they do not compile.
  • EVIDENCE.md carries the mutation table, the fail-before/pass-after record and the operand grids.

Risk and not in scope

  • Blast radius. A zero divisor used to write 0.0 and now writes the float maximum, so a later multiply that stayed at zero can reach an infinity. Saturating the rest of the pipeline is the follow-up; Floating-point precision issues in VU0 (macrocode/microcode) and VU1 #165 is the nearest open issue.
  • Integrator note. These are code-generator changes consumed by generated translation units. A runtime-only rebuild exercises none of this path, so seeing the change in a program needs the corpus regenerated. Otherwise the patch looks inert when it is not.
  • MAC/status/sticky flags (D/I/DS/IS) are not maintained, and no VU0 arithmetic macro translator maintains any today. A flag set but never cleared reads as permanently latched, so a partial model is worse than none. Guest code reads them through CCR[2,16] (§5.1.3) and CFC2.
  • §2.3's other rows — exponent overflow, exponent underflow, conversion overflow. An overflowing quotient still gives an IEEE infinity, an underflowing one is left to the host, and float-to-fixed is untouched. Honouring them means clamping every FMAC and FDIV result, since a clamp on Q alone would be undone by the next multiply.
  • EE COP1 div.s/rsqrt.s in fpu_translator.cpp have analogous problems, in a separate file and a separate change.
  • The VU1 micro-mode interpreter saturates and takes the magnitude already, but picks DIV's sign by >= 0.0f and saturates RSQRT unsigned. PS2Runtime::executeVU0Microprogram runs VU0 microprograms on it and copies its q into ctx->vu0_q, so one program can see +Fmax for DIV -0 / +0 in a VCALLMS and -Fmax for the same macro-mode VDIV. This change narrows that gap rather than widening it. fix(vu1): maintain MAC/STATUS/CLIP flag registers, correct the flag-reading lower-op table, and saturate FTOI #189 and refactor: refactor VU1 #191 are open against that file and touch neither sign rule.
  • HLE sceVu0DivVector/sceVu0DivVectorXYZ carry the same finite-zero guard as each other — a library-routine question. An existing test pins the singular form; both are unchanged.
  • Prior art. codegen: PS2-accurate divide-by-zero semantics for COP1 and VU0 FP + RSQRT operand fix #113 proposed this VDIV saturation construction first, and the sign rule is its author's. Closed unreviewed in a batch cleanup spanning elf_parser: clamp overlapping function boundaries from external sources #108 through codegen: force continuation on JAL/JALR resume-PC mismatch instead of aborting caller #117, it left VSQRT and both VRSQRT defects unaddressed.
Evidence — mutation table, saturation grids, and reproduction commands

Evidence

Build and run

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS=-msse4.1 -DCMAKE_CXX_FLAGS=-msse4.1
cmake --build build
./build/ps2xTest/ps2x_tests
Constraint Reason
-msse4.1 on both C and C++ flags ps2_runtime.h uses _mm_extract_epi32, which fails to inline without them
Analyzer and studio targets stay enabled ps2x_tests links ps2_analyzer_lib and fails to link without it
./build/ps2xTest/ps2x_tests is the entry point ctest registers no tests in this project
Baseline On an unmodified checkout of this branch the build succeeds and the run reports Failed: 0 with exit code 0. Reproduced before any change was made, and again after each mutation row below was restored.

What is quoted and what is derived

Fact used Status Source, or basis for the derivation
0/0 and 0 division give +MAX/-MAX Quoted §2.3 exception table
√x (x < 0) gives √|x| Quoted §2.3; the micro SQRT page states the operation directly as Q = √|VF[ft]ftf|
0 carries a valid sign, so -0/+0 differs from +0/+0 Quoted §2.3, "0 is valid sign"
D is not set for 0/0 Quoted §2.3 note; §3.3.2, "0 division (except 0/0)"
Macro mode inherits the micro-mode operation and flag behaviour Quoted Macro VDIV/VSQRT/VRSQRT pages, "Same as the micro instruction"; §5.3
VRSQRT takes two operands Quoted Macro VRSQRT page text and its VRSQRT Q, fsfsf, ftftf mnemonic; micro RSQRT example Q = VF10x ÷ √|VF20y|
+MAX is not IEEE inf Quoted §2.4, non-numerals and infinity unsupported
+MAX is std::numeric_limits<float>::max() Derived, and a host-format compromise The manual gives no bit pattern. §2.1.1 makes E = 255 an ordinary VU value at every mantissa, F = 0 included, so the VU format reaches 0x7FFFFFFF (about 6.81e38) — a binade above the host maximum 0x7F7FFFFF (about 3.40e38). A host float cannot hold 1.F × 2^(+128), so the largest finite host float is the nearest stand-in and saturates a factor of two low. §2.4's exclusion of infinity is what makes that top binade ordinary; it does not move +MAX down.
Saturation sign signbit(fs) != signbit(ft) Derived The manual gives no prose sign formula for the +MAX/-MAX case. This is the ordinary quotient-sign rule, consistent with §2.3.
VRSQRT's saturation sign is signbit(fs) alone Derived from quoted parts §2.3's √|x| rule and §3.3.2's placement of RSQRT among root-taking instructions make the divisor √|ft|, which is never negative and is +0 for a -0 radicand. The radicand's sign is gone before the divide.
VDIV and VRSQRT disagree at a -0 divisor Consequence of the two rows above VDIV 1 / -0 saturates negative; VRSQRT 3 / -0 saturates positive

The test set

Emission layer — ps2xTest/src/code_generator_tests.cpp

Test Pins
VDIV emits exactly the statements for a saturating divide of fs by ft E1
VSQRT emits exactly the statements for the magnitude square root of ft E2
VRSQRT emits exactly the statements that read fs and ft from their own register and selector E3
Tuple fs fsf ft ftf
A 11 1 7 2
B 24 3 5 0
C 6 2 19 3
Mutation family Free coefficients Tuples needed to close Closed here
Constant substitution 2 2 Yes — M23, M24
Linear in one other field 3 3 Yes — M27, M28
Affine in two other fields 4 4 No
Affine in all three other fields 5 5 No
Arbitrary expression unbounded no finite count suffices No
Note Detail
Why whole-string equality translateInstruction returns each translator's fmt::format result verbatim for these three opcodes, so the emitted string is fixed by the register and selector pair. Each test asserts the whole string with ==, which no appended statement and no alternate spelling survives.
E3's history Present from the operand-read commit at tuples A and B, pinning that commit's emitted text. The saturation commit updates the expected strings and adds tuple C, without renaming the test.
Field values The six register values are pairwise distinct and none collides with a selector value.
Why tuple C reuses selectors fsf and ftf are 2-bit fields and tuples A and B already spend all four legal values, so C necessarily reuses two. C is chosen so that for every ordered pair of fields the three points are non-collinear.
Why two tuples cannot suffice Fitting a·y + b = x across two tuples is two equations in two unknowns, unsolvable only when the source field repeats while the target differs — which pairwise-distinct tuples rule out. Two tuples therefore always leave a linear survivor for every ordered pair of fields.
VSQRT Formats only two fields, so for E2 the third tuple closes the linear family outright.

Numeric layer — ps2xTest/src/ps2_vu_tests.cpp

Calls the three helpers directly on real operands and asserts the computed float.

Test Pins
VDIV divides ordinarily on any non-zero divisor, a zero dividend included, and saturates to the signed float maximum on a zero divisor N1
VDIV saturation is chosen by sign bits, so signed zero operands are honoured N2
VSQRT returns the root of the radicand magnitude N3
VRSQRT divides fs by the root of the radicand magnitude at every combination of the operands' sign bits, a zero dividend included N4
VRSQRT saturates on a zero radicand with the dividend's sign alone N5
VU0 FDIV-unit results are finite across the zero and float-maximum operand set, a non-finite operand propagates on the ordinary path, the zero-divisor branch saturates by sign bit alone, and neither end of the range is clamped N6
Note Detail
Why the volatile round-trip Operands pass through it first, so the compiler cannot fold a call into a constant and leave the assertion comparing two literals. Without it the helper bodies never execute at run time.
Why zero-valued cells carry two assertions MiniTest::Equals is exact ==, which cannot separate +0 from -0, so every cell whose expected value is a zero carries a paired std::signbit assertion.
Why N6's name is scoped to its operand set Every result the probe set {±Fmax, ±0, ±1} produces under the three operations is finite — the largest quotient it builds is Fmax / 1 — so it cannot construct an overflowing quotient and cannot support a general finiteness claim. Finiteness is the property the argument needs; the set is not closed set-theoretically, since SQRT(Fmax) and DIV(1, Fmax) both land outside it.
What N6 adds beyond that set NaN and infinity propagation at both signs in each of five operand positions; an infinite dividend over an infinite divisor producing a NaN; overflow to an infinity on both sides; an underflowing quotient keeping the dividend's sign, unclamped.
Why only an infinite radicand exposes a clamp on the root The root of any finite float is at most about 1.84e19. Both signs of the infinite radicand are asserted.

Fail before, pass after

Each row was green — the whole suite passing with the mutation applied — against the tree named, and fails against the delivered tree.

Escape Site Green against Now fails Row
std::signbit(fs) rewritten to (fs < 0.0f) Ps2VuRsqrtQ the tree before the closing assertions landed site removed — the helper is now the composition and carries no sign test of its own
if (ft != 0.0f) rewritten to if (ft > 0.0f) Ps2VuDivQ the tree before the closing assertions landed N1, N6 M7
if (ft != 0.0f) rewritten to if (ft < 0.0f || ft > 0.0f) Ps2VuDivQ the tree before the closing assertions landed N6 M22
appends if (ft < 0.0) ctx->vu0_q = 0.0; translateVU_VDIV the tree before the closing assertions landed E1 M20
appends if (ft < 0.0) ctx->vu0_q = 0.0; translateVU_VSQRT the tree before the closing assertions landed E2 M21
sign test rewritten to (std::signbit(fs) || std::signbit(ft)) Ps2VuDivQ the tree before the closing assertions landed N2 M26
fsf format argument rewritten as 3 - ftf translateVU_VDIV tuples A and B E1, at tuple C M27
ftf format argument rewritten as ft_reg - 5 translateVU_VSQRT tuples A and B E2, at tuple C M28
guard rewritten to if (ft != 0.0f && fs != 0.0f) Ps2VuDivQ the tree before the closing assertions landed N1, N4 M30
ordinary return rewritten to (fs == 0.0f) ? fs : (fs / ft) Ps2VuDivQ the tree before the closing assertions landed N1 M31
if (fs == 0.0f) return fs; before the saturation return Ps2VuDivQ the tree before the closing assertions landed N2, N5 M32
quotient of two negatively-signed operands negated Ps2VuDivQ the tree before the closing assertions landed N1 M33
(signbit(fs) && signbit(ft)) ? -div : div Ps2VuRsqrtQ the tree before the closing assertions landed N4, N5 M36
clamp bolted onto the result Ps2VuRsqrtQ the tree before the closing assertions landed N6 M38
NaN dividend swallowed Ps2VuRsqrtQ the tree before the closing assertions landed N5, N6 M39
infinite radicand special-cased, sign Ps2VuRsqrtQ delivered tree minus the two rows it gates N6 M51
infinite radicand special-cased, magnitude Ps2VuRsqrtQ delivered tree minus the two rows it gates N6 M52
infinity saturated by the dividend's own sign Ps2VuDivQ delivered tree minus the grid assertions N2 M53
negatively-signed NaN radicand swallowed Ps2VuRsqrtQ delivered tree minus the row it gates N6 M55
negatively-signed NaN divisor swallowed Ps2VuDivQ delivered tree minus the row it gates N6 M56
negatively-signed NaN radicand swallowed at the root Ps2VuSqrtQ delivered tree minus the row it gates N6 M57
negatively-signed NaN dividend swallowed Ps2VuDivQ delivered tree minus the row it gates N6 M58

Mutation table — measured

One production mutation per row, applied to the finished tree, rebuilt, and run. Predicted was written before the run; Actually observed is what it reported.

Method Detail
Legend E1E3 and N1N6 are the emission and numeric tests named above, in that order
Restore between rows Each row was restored from a cp-made backup and confirmed byte-identical with cmp before the next was applied. cmp reported identity every time, and a full rebuild afterward reproduced the clean baseline pass.
Assertion-level claims MiniTest names each failing assertion, so the assertion sets quoted in the notes were read directly off the runs, and re-measured against the tree as delivered rather than against the tree each row was first written for.
# Mutation Predicted Actually observed Match
M1 translateVU_VDIV: fs extraction reads the ft register and ftf selector E1 only E1 only Yes
M2 translateVU_VSQRT: reads inst.rd instead of ft_reg E2 only E2 only Yes
M3 translateVU_VSQRT: reads fsf instead of ftf E2 only E2 only Yes
M4 translateVU_VDIV: emits ctx->vu0_q = PS2_VU_DIV_Q(fs, ft) * 2.0f; E1 only E1 only Yes — a call is a primary expression and cannot absorb a trailing operator, so the emitted line no longer matches
M5 translateVU_VRSQRT: fs extraction reads the ft register and ftf selector E3 only E3 only Yes
M6 translateVU_VSQRT: appends if (ft < 0.0f) ctx->vu0_q = 0.0f; E2 only E2 only Yes
M7 Ps2VuDivQ: guard if (ft != 0.0f) replaced with if (ft > 0.0f) N1 only N1, N6 No — see the note below the table
M8 Ps2VuDivQ: sign test (std::signbit(fs) != std::signbit(ft)) replaced with (fs < 0.0f) N2, N5, N6 N2, N5, N6 Yes — the mutant and the true rule disagree at both NaN saturation cells the new rows reach, DIV(NaN, -0) in N6 and DIV(NaN⁻, +0) in N2
M9 Ps2VuDivQ: guard if (ft != 0.0f) replaced with if (true) N1, N2, N5, N6 N1, N2, N5, N6 Yes
M10 Ps2VuDivQ: both saturation returns replaced with -1.0f/1.0f N1, N2, N5, N6 N1, N2, N5, N6 Yes — the new rows expect ±kFmax from the saturation branch on non-finite dividends; the mutant returns ±1.0f
M11 Ps2VuSqrtQ: std::sqrt(std::fabs(ft)) replaced with std::sqrt(std::max(0.0f, ft)) N3, N4 N3, N4, N6 No — see the note below the table
M12 Ps2VuSqrtQ: std::fabs dropped, std::sqrt(ft) N3, N4, N5, N6 N3, N4, N5, N6 Yes
M13 Ps2VuSqrtQ: root dropped, return std::fabs(ft); N3, N4 N3, N4 Yes
M14 Ps2VuDivQ: sign XOR inverted (!= to ==) N1, N2, N5, N6 N1, N2, N5, N6 Yes — same mechanism as M10, with the sign inverted
M15 Ps2VuDivQ: ordinary-path return reversed to ft / fs N1, N4, N6 N1, N4, N6 Yes
M16 Ps2VuRsqrtQ: arguments transposed to Ps2VuDivQ(Ps2VuSqrtQ(ft), fs) N4, N5, N6 N4, N5, N6 Yes — N6's overflow row sees Ps2VuDivQ(1e-15, kFmax) ≈ 0, finite
M17 Ps2VuRsqrtQ: root dropped, return Ps2VuDivQ(fs, ft); N4, N5, N6 N4, N5, N6 Yes — Ps2VuDivQ(fs, ft) without the root disagrees with the composition at RSQRT(NaN, -0)
M18 PS2_VU_RSQRT_Q alias transposed to Ps2VuRsqrtQ((ft), (fs)) N4, N5, N6 N4, N5, N6 Yes — N6's overflow row sees 1e-30 / sqrt(kFmax) ≈ 0, finite
M19 PS2_VU_DIV_Q alias transposed to Ps2VuDivQ((ft), (fs)) N1, N2, N6 N1, N2, N6 Yes
M20 (mandatory — appended-zero-write escape, VDIV) translateVU_VDIV: appends if (ft < 0.0) ctx->vu0_q = 0.0; E1 only E1 only Yes — whole-string equality cannot absorb an appended statement; caught at all three tuples
M21 (mandatory — appended-zero-write escape, VSQRT) translateVU_VSQRT: appends if (ft < 0.0) ctx->vu0_q = 0.0; E2 only E2 only Yes — as M20
M22 (mandatory — N1 escape) Ps2VuDivQ: guard if (ft != 0.0f) replaced with if (ft < 0.0f || ft > 0.0f) N6 only N6 only Yes — the rewritten guard agrees with the original on every non-NaN input, so N1–N5 are structurally unreachable; N6's four NaN-propagation assertions are the only place the difference is observable
M23 translateVU_VDIV: the fsf format argument replaced with the literal 1 E1 only E1 only Yes — the literal equals tuple A's fsf, so tuples B and C are what catch it
M24 translateVU_VRSQRT: the ft_reg format argument replaced with the literal 7 E3 only E3 only Yes — the literal equals tuple A's ft_reg, so tuples B and C are what catch it
M25 Ps2VuDivQ: ordinary path wrapped in a clamp, return std::clamp(fs / ft, -max, max); N6 only N6 only Yes
M26 (mandatory — the sign-test OR escape) Ps2VuDivQ: sign test (std::signbit(fs) != std::signbit(ft)) replaced with (std::signbit(fs) || std::signbit(ft)) N2 only N2 only Yes — N1 has no (1,1) row, N4/N5 are structurally unreachable because fabs pins signbit(den) false, and N6 stays finite either way
M27 (mandatory — affine survivor, VDIV) translateVU_VDIV: fsf format argument replaced with (uint8_t)(3 - ftf) E1 only E1 only Yes — survives tuples A and B (3−2=1, 3−0=3), caught by tuple C (3−3=0 ≠ 2)
M28 (mandatory — affine survivor, VSQRT) translateVU_VSQRT: ftf format argument replaced with (uint8_t)(ft_reg - 5) E2 only E2 only Yes — survives tuples A and B (7−5=2, 5−5=0), caught by tuple C (19−5=14 ≠ 3)
M29 PS2_VU_SQRT_Q macro redefined to dispatch to Ps2VuRsqrtQ(1.0f, (ft)) N3, N6 N3, N6 Yes — N4/N5 still reach the root through the function, not this macro. N6 fails too, because Ps2VuRsqrtQ(1.0f, (ft)) does not reproduce Ps2VuSqrtQ's magnitude-then-root behaviour at an infinite ft. M42 is the discriminating row for N3.
M30 (mandatory — the zero-dividend guard escape) Ps2VuDivQ: guard if (ft != 0.0f) replaced with if (ft != 0.0f && fs != 0.0f) N1, N4 N1, N4 Yes — N1's zero-dividend Equals(0.0f) rows see ±Fmax, and N4's zero-dividend rows route a zero dividend with a non-zero divisor into the divide, where the mutant saturates
M31 (mandatory — the zero-quotient sign escape) Ps2VuDivQ: ordinary return replaced with return (fs == 0.0f) ? fs : (fs / ft); N1 only N1 only Yes — caught by N1's signbit rows at the zero-dividend, negative-divisor cells, which the paired Equals(0.0f) rows cannot see; the mutant returns the dividend unchanged, so its sign is the dividend's where the true quotient's is the exclusive-or
M32 (mandatory — the early-zero-return escape) Ps2VuDivQ: if (fs == 0.0f) { return fs; } inserted before the saturation return N2, N5 N2, N5 Yes — caught by N2's four Equals(±kFmax) rows and by all four of N5's zero-dividend saturation rows, two of them at a positively-signed zero. This is the row that justifies the signbit-to-Equals upgrades.
M33 (mandatory — the same-sign-quotient negation escape) Ps2VuDivQ: ordinary return replaced with return (std::signbit(fs) && std::signbit(ft)) ? -(fs / ft) : (fs / ft); N1 only N1 only Yes — caught by N1's two-negative-operand row (-8,-2) = 4.0f, where the mutant returns -4.0f, and by its (-0,-2) signbit row, whose sign the negation flips
M34 Ps2VuRsqrtQ: return Ps2VuDivQ(fs, Ps2VuSqrtQ(ft) * 2.0f); N4 only N4 only Yes — caught by N4's ordinary-divide rows, whose expected quotients assume the unmodified root. N5 is unreachable because a doubled zero radicand is still zero; N1/N2/N3 call the divide or the macro directly; N6 stays finite. N4's overflow probe uses a radicand small enough that doubling, halving or dropping the root all still overflow.
M35 Ps2VuRsqrtQ: return (Ps2VuSqrtQ(ft) == 0.0f) ? std::numeric_limits<float>::max() : Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)); — unsigned saturation, matching the discarded prior-art form N5, N6 N5, N6 Yes — caught by all seven of N5's negative-dividend, zero-radicand rows, four finite and three non-finite, each pinning -kFmax against the mutant's +kFmax. N4 is unreachable because non-zero radicands never reach the saturation branch. N6's zero-radicand row for a negative infinite dividend sees it too. M37 is the discriminating row for N5.
M36 (mandatory — the reciprocal-root sign escape) Ps2VuRsqrtQ: return (std::signbit(fs) && std::signbit(ft)) ? -Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)) : Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)); N4, N5 N4, N5 Yes — caught by N4's (-8,-4) = -4 and (-0,-4) sign rows, and by all four of N5's two-negatively-signed-operand rows (-3,-0), (-0,-0), (-inf,-0) and (NaN⁻,-0). It shows both halves of the outer helper's sign rule closed, the ordinary quotient and the saturation.
M37 Ps2VuRsqrtQ: return (fs == 0.0f) ? fs : Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)); N5 only N5 only Yes — caught by all four of N5's zero-dividend saturation rows. M32's mirror at the outer body.
M38 (mandatory — mirror of M25) Ps2VuRsqrtQ: return std::clamp(Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)), -std::numeric_limits<float>::max(), std::numeric_limits<float>::max()); N6 only N6 only Yes — a two-sided clamp truncates every non-finite result the reciprocal root produces, so it fails both overflow rows RSQRT(±kFmax, 1e-30) and both infinite-dividend rows RSQRT(±inf, 4), and nothing else. M25 closed the clamp family on the divide and left it open one level up.
M39 (mandatory — mirror of the NaN rows) Ps2VuRsqrtQ: return std::isnan(fs) ? 0.0f : Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)); N5, N6 N5, N6 Yes — it fails N5's two negatively-signed-NaN saturation rows RSQRT(NaN⁻, ±0), which pin -kFmax where the mutant returns a zero, plus N6's two NaN-propagation rows and both positively-signed NaN-dividend saturation rows. N6 fed NaN to the reciprocal root's radicand and never to its dividend at the discriminating sign.
M40 Ps2VuSqrtQ: return (ft == 0.0f && !std::signbit(ft)) ? -0.0f : std::sqrt(std::fabs(ft)); N3, N5, N6 N3, N5, N6 Yes — N3 and N5 catch it directly. A -0 root also reaches Ps2VuDivQ as a negatively-signed divisor, flipping the RSQRT(NaN, 0) and RSQRT(-inf, 0) saturation rows, so N6 sees it too.
M41 translateVU_VRSQRT: appends if (ft < 0.0) ctx->vu0_q = 0.0; E3 only E3 only Yes — the third leg of M20/M21
M42 PS2_VU_SQRT_Q macro redefined to Ps2VuSqrtQ((ft) * 0.25f) N3 only N3 only Yes — the discriminating row against M29. N4/N5 reach the root through the function, not this macro; every N6 value through the macro stays finite, NaN still propagates and both infinite radicands still give an infinity, so N6 cannot see it.
M43 (mandatory — result-sign at the clamp, low side) Ps2VuDivQ: ordinary path clamped on the low side only, float q = fs / ft; return (q < -max) ? -max : q; N6 only N6 only Yes — N1–N5 construct no overflowing quotient. Four of N6's rows fail: the negative-overflow equalities DIV(-kFmax, 0.5) and RSQRT(-kFmax, 1e-30), and the negative-infinity rows DIV(-inf, 2) and RSQRT(-inf, 4).
M44 (mandatory — mirror of M43 on the composition) Ps2VuRsqrtQ: result clamped on the low side only, float q = Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)); return (q < -max) ? -max : q; N6 only N6 only Yes — the low-side half of M38. It fails RSQRT(-inf, 4) and RSQRT(-kFmax, 1e-30). The ternary spelling is required: written with std::fmax this would be a different mutation, because std::fmax returns its non-NaN argument and so suppresses a NaN as well as clamping.
M45 (mandatory — NaN on the zero-divisor path) Ps2VuDivQ: if (std::isnan(fs) || std::isnan(ft)) { return fs + ft; } inserted before the saturation return N2, N5, N6 N2, N5, N6 Yes — every pre-existing positively-signed-NaN row pairs the NaN with a non-zero second operand and never reaches the branch. It fails N2's DIV(NaN⁻, ±0), N5's RSQRT(NaN⁻, ±0), and N6's four positively-signed-NaN saturation rows DIV(NaN, ±0) and RSQRT(NaN, ±0).
M46 (mandatory — the infinity mirror of M45) Ps2VuDivQ: if (std::isinf(fs)) { return fs; } inserted before the saturation return N2, N5, N6 N2, N5, N6 Yes — it fails N2's DIV(+inf, -0) and DIV(-inf, -0), N5's RSQRT(+inf, ±0) and RSQRT(-inf, -0), and N6's DIV(±inf, 0) and RSQRT(-inf, 0)
M47 Ps2VuSqrtQ: float r = std::sqrt(std::fabs(ft)); return (!std::signbit(ft) && r > max) ? max : r; N6 only N6 only Yes — no finite radicand can drive the root out of range, so only an infinite one exposes a clamp on it. It fails SQRT(+inf) and RSQRT(1, +inf) and nothing else. Spelled with std::fmin this would also suppress a NaN and be caught for an unrelated reason.
M48 Ps2VuDivQ: an underflowed quotient saturated, float q = fs / ft; return (q == 0.0f && fs != 0.0f && std::isfinite(ft)) ? std::copysign(max, q) : q; N6 only N6 only Yes — N1's zero-quotient rows all have a zero dividend, so none reaches a genuine underflow. It fails exactly the three underflow-magnitude rows DIV(±1e-30, 1e20) and RSQRT(-1e-30, kFmax). The std::isfinite(ft) conjunct keeps it off the infinite-divisor rows, which pin a different property.
M49 Ps2VuDivQ: an underflowed quotient's sign dropped, float q = fs / ft; return (q == 0.0f && fs != 0.0f && std::isfinite(ft)) ? 0.0f : q; N6 only N6 only Yes — it fails exactly the two underflow-sign rows DIV(-1e-30, 1e20) and RSQRT(-1e-30, kFmax); == cannot separate the two zeros, so the std::signbit half is the only thing that can see it
M50 Ps2VuSqrtQ: float r = std::sqrt(std::fabs(ft)); return (std::signbit(ft) && r > max) ? max : r; N6 only N6 only Yes — the mirror of M47. It fails SQRT(-inf) and the equality half of RSQRT(1, -inf) and nothing else: clamping the root at a negative infinite radicand leaves a finite maximum, and the composition divides into it, giving a nonzero denormal where a zero is expected, so the signbit half still passes. SQRT(-inf) and that equality are jointly rather than individually necessary against this table; SQRT(-inf) is kept because it asserts the root's magnitude rule directly and sits in the identical position to SQRT(+inf) under M47.
M51 Ps2VuRsqrtQ: return (std::isinf(ft) && std::signbit(ft)) ? -0.0f : Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)); N6 only N6 only Yes — it fails exactly the signbit half of the RSQRT(1, -inf) pair; the equality half cannot see it, because == cannot separate +0 from -0
M52 Ps2VuRsqrtQ: return (std::isinf(ft) && std::signbit(ft)) ? 1.0f : Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)); N6 only N6 only Yes — it fails exactly the equality half of the same pair. With M51 this is why both lines of that pair are kept: neither line catches the other's mutant.
M53 Ps2VuDivQ: if (std::isinf(fs)) { return std::copysign(std::numeric_limits<float>::max(), fs); } inserted before the saturation return N2 only N2 only Yes — it fails exactly DIV(+inf, -0) and DIV(-inf, -0): the mutant saturates an infinite dividend by its own sign instead of by the exclusive-or, and the two forms agree at every positively-signed divisor. VRSQRT cannot see it, because the inner divisor is Ps2VuSqrtQ(±0), whose sign bit is always clear.
M54 Ps2VuDivQ: if (std::isnan(fs)) { return std::copysign(std::numeric_limits<float>::max(), ft); } inserted before the saturation return N2, N5 N2, N5 Yes — it fails exactly DIV(NaN⁻, ±0) in N2 and RSQRT(NaN⁻, ±0) in N5, and nothing in N6: the mutant saturates a NaN dividend by the divisor's sign instead of the dividend's, and the two forms agree at a positively-signed NaN. It shows the NaN family closed at both helpers and at both signs of the dividend.
M55 Ps2VuRsqrtQ: return (std::isnan(ft) && std::signbit(ft)) ? 0.0f : Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)); N6 only N6 only Yes — it fails exactly the negatively-signed NaN radicand row through the reciprocal root. The NaN counterpart of M51 and M52.
M56 Ps2VuDivQ: ordinary return replaced with return (std::isnan(ft) && std::signbit(ft)) ? 0.0f : (fs / ft); N6 only N6 only Yes — it fails exactly the negatively-signed NaN divisor row. The composition cannot see it, because std::fabs clears the radicand's sign bit before the root, so the inner divisor is never a negatively-signed NaN.
M57 Ps2VuSqrtQ: return (std::isnan(ft) && std::signbit(ft)) ? 0.0f : std::sqrt(std::fabs(ft)); N6 only N6 only Yes — it fails the negatively-signed NaN radicand row through the root and through the composition, which share the helper, and nothing else. N3 asserts no NaN radicand.
M58 Ps2VuDivQ: ordinary return replaced with return (std::isnan(fs) && std::signbit(fs)) ? 0.0f : (fs / ft); N6 only N6 only Yes — it fails the negatively-signed NaN dividend row through the divide and through the reciprocal root, which routes its dividend to the same helper unchanged, and nothing else

Every row is caught, and every row except the two below matched its prediction exactly.

The two rows that came out wider than predicted

Row Predicted Observed Cause
M7 N1 only N1, N6 NaN > 0.0f is false, so a NaN divisor saturates instead of propagating, and so does every negative divisor. In N6 that fails the NaN divisor through the divide and the NaN radicand through the composition, at both signs, plus the negative infinite divisor — alongside N1's negative-divisor assertions.
M11 N3, N4 N3, N4, N6 std::max(0.0f, NaN) returns 0.0f, since 0.0f < NaN is false, so Ps2VuSqrtQ(NaN) becomes 0.0f instead of propagating. N6's four NaN-radicand rows fail. The same substitution sends a negative infinite radicand to 0.0f, so SQRT(-inf) and the equality half of RSQRT(1, -inf) fail too — the two assertions M50 also reaches.

Neither is a coverage gap. N6 reaches them because it asserts non-finite behaviour rather than only finiteness.

Operand-space enumeration

A cell is enumerated over the parameters of the helper being mutated, never credited to it from a sibling's test. "Structurally unreachable" means no input to that function's own parameters produces the cell. These spaces exclude the non-finite classes; the grids below cover those.

Helper Space Cells Coverage Structurally unreachable
Ps2VuDivQ(fs, ft) dividend class × divisor class over {+0, -0, positive, negative} 16 N1 covers 10: the four quotient cells; the four zero-dividend cells, each an Equals(0.0f) line plus a std::signbit line, two assertion lines to one cell; and the two (positive|negative) × +0 saturation cells, whose ±Fmax expectation pins magnitude and sign at once. N2 covers the other 6: the (positive|negative) × -0 pair and the whole (+0|-0) × (+0|-0) quadrant. None — a free two-argument function; every cell is constructible with a literal
Ps2VuSqrtQ(ft) radicand class 4 All four pinned by magnitude and sign. N3 asserts the +0 cell directly rather than relying on N5's saturation rows to pin it jointly. None
Ps2VuRsqrtQ(fs, ft) dividend class × radicand class 16 All sixteen pinned by magnitude and sign at the outer body, derived from this helper's own parameters rather than inherited from the divide None at this helper. At the inner Ps2VuDivQ(fs, den) call, den is Ps2VuSqrtQ(ft) and can only be +0 or positive — a fact about that call site, not about this helper's operand space. signbit(ft) is erased between the outer body and the inner call, so it is live in the outer body and a mutant placed there reads it.

The non-finite saturation grid

Reached only when the divisor is exactly zero. Product: non-finite class × dividend sign × divisor sign × which helper carries the branch.

Helper Dividend Divisor / radicand Result Asserted in
Ps2VuDivQ NaN, sign bit clear +0 +Fmax N6
Ps2VuDivQ NaN, sign bit clear -0 -Fmax N6
Ps2VuDivQ NaN, sign bit set +0 -Fmax N2
Ps2VuDivQ NaN, sign bit set -0 +Fmax N2
Ps2VuDivQ +inf +0 +Fmax N6
Ps2VuDivQ +inf -0 -Fmax N2
Ps2VuDivQ -inf +0 -Fmax N6
Ps2VuDivQ -inf -0 +Fmax N2
Ps2VuRsqrtQ NaN, sign bit clear +0 +Fmax N6
Ps2VuRsqrtQ NaN, sign bit clear -0 +Fmax N6
Ps2VuRsqrtQ NaN, sign bit set +0 -Fmax N5
Ps2VuRsqrtQ NaN, sign bit set -0 -Fmax N5
Ps2VuRsqrtQ +inf +0 +Fmax N5
Ps2VuRsqrtQ +inf -0 +Fmax N5
Ps2VuRsqrtQ -inf +0 -Fmax N6
Ps2VuRsqrtQ -inf -0 -Fmax N5
Note Detail
Ps2VuSqrtQ contributes no cells It is unary and has no saturation branch. It carries every non-finite radicand down its ordinary path, where SQRT(NaN), SQRT(+inf) and SQRT(-inf) are asserted.
The NaN's sign bit is a live axis point std::numeric_limits<float>::quiet_NaN() has that bit clear, so the negatively-signed NaN is built with std::copysign, and both its isnan and its signbit are asserted as premises where it is used.
Reachability Both helpers take plain floats with nothing filtering either argument, so every row above is constructible with a literal.
The one unreachability belongs to a call site At the inner Ps2VuDivQ(fs, den) call, den is Ps2VuSqrtQ(±0), which is +0 at either sign of the radicand, so that site never occupies a -0-divisor cell. The divide's two -0-divisor infinity cells therefore cannot be reached through VRSQRT at any radicand, which is why they are asserted on the divide directly; the reciprocal root's own -0-radicand cells are asserted at the outer body, where signbit(ft) is still live.
Closed by rows, not by the sign rule's algebra M53 moves the two Ps2VuDivQ infinity cells at a negatively-signed divisor and nothing else; M54 moves the negatively-signed NaN cells at both helpers and nothing else; M46 moves every infinity cell of both helpers; M45 does the same for the NaN class. Each is a rule a reader could plausibly write.

The non-finite ordinary-path grid

The product is: which of five operand positions the NaN occupies × the NaN's sign bit × which path the operand pair selects.

NaN position Ordinary path Saturating branch Gating row
Ps2VuDivQ dividend asserted at both signs asserted — see the saturation grid M58
Ps2VuDivQ divisor asserted at both signs unreachable: a NaN compares unequal to zero M56
Ps2VuSqrtQ radicand asserted at both signs unreachable: unary, no saturating branch M57
Ps2VuRsqrtQ radicand asserted at both signs unreachable: Ps2VuSqrtQ(NaN) is a NaN, so the inner divide takes the ordinary path. This rests on NaN's comparison behaviour, a different argument from the -0 unreachability above, which rests on fabs. M55
Ps2VuRsqrtQ dividend, read at the outer body asserted at both signs asserted — see the saturation grid see disclosures
Count Cells
Total 20
Unreachable by proof, all on the saturating branch 6
Asserted 14 — four on the saturating branch, which the grid above expands over the divisor's sign into eight NaN rows, and ten on the ordinary path

The macro layer

The three PS2_VU_*_Q macros are pass-throughs to identically-shaped functions, a transposition surface distinct from the functions themselves.

Macro Exercised by
PS2_VU_DIV_Q M19, transposed arguments
PS2_VU_SQRT_Q M29, redirected to the wrong helper; M42, argument scaled
PS2_VU_RSQRT_Q M18, transposed arguments

Reciprocal-root equivalence

Ps2VuRsqrtQ is Ps2VuDivQ(fs, Ps2VuSqrtQ(ft)), and the two forms agree on every input.

Claim Basis
The root is never negative and never -0 std::fabs clears the sign bit unconditionally
The ordinary path agrees Both forms evaluate the same division of the same two values
The saturation path agrees signbit(fs) != signbit(den) degenerates to signbit(fs), because signbit(den) is always false
Non-finite radicands agree NaN and infinite radicands take the ordinary path in both forms and produce the same bits
Checked, not only argued The sweep printed below reports no mismatch over every ft bit pattern at each of thirteen dividends
What it does not establish It is a proof about the implementation and says nothing about which operands the tests construct, which is why each helper is enumerated over its own parameters above

The sweep

Save the program below as vu_mirror_sweep.cpp in the clone root, then compile and run it from there with the commands that follow it. It is a one-off harness and is not carried in the tree, so it is reproduced here in full. It compiles against the header under test rather than a transcription of it. The third form is wrong on purpose: it saturates unsigned, so its disagreement count has to come out non-zero. That is what shows the comparison runs instead of being folded away at compile time.

// Checks Ps2VuRsqrtQ against a direct fs / sqrt(|ft|) form that saturates by
// signbit(fs) alone, over every ft bit pattern at each of 13 dividends.
// The third form is a deliberately wrong control: it must report mismatches,
// which is what shows the comparison is live rather than folded away.
#include "ps2_runtime_macros.h"

#include <bit>
#include <cstdint>
#include <cstdio>

static const float kMax = std::numeric_limits<float>::max();

static float DirectRsqrt(float fs, float ft)
{
    const float den = std::sqrt(std::fabs(ft));
    if (den != 0.0f)
    {
        return fs / den;
    }
    return std::signbit(fs) ? -kMax : kMax;
}

static float UnsignedRsqrt(float fs, float ft) // control: must not agree
{
    const float den = std::sqrt(std::fabs(ft));
    return (den != 0.0f) ? (fs / den) : kMax;
}

int main()
{
    const float fsSet[] = {
        0.0f, -0.0f, 1.0f, -1.0f, 3.0f,
        kMax, -kMax, 1.0e-30f, -1.0e-30f,
        std::numeric_limits<float>::infinity(),
        -std::numeric_limits<float>::infinity(),
        std::numeric_limits<float>::quiet_NaN(),
        std::copysign(std::numeric_limits<float>::quiet_NaN(), -1.0f),
    };
    const int kDividends = (int)(sizeof(fsSet) / sizeof(fsSet[0]));

    volatile uint32_t sink = 0;
    uint64_t compared = 0, mismatch = 0, control = 0;

    for (uint64_t pattern = 0; pattern <= 0xFFFFFFFFull; ++pattern)
    {
        sink = (uint32_t)pattern;
        const float ft = std::bit_cast<float>((uint32_t)sink);
        for (int i = 0; i < kDividends; ++i)
        {
            const float fs = fsSet[i];
            const uint32_t a = std::bit_cast<uint32_t>(Ps2VuRsqrtQ(fs, ft));
            const uint32_t b = std::bit_cast<uint32_t>(DirectRsqrt(fs, ft));
            const uint32_t c = std::bit_cast<uint32_t>(UnsignedRsqrt(fs, ft));
            if (a != b)
            {
                if (mismatch < 4)
                {
                    std::printf("mismatch fs=%a ft=%a composed=%08x direct=%08x\n",
                                fs, ft, a, b);
                }
                ++mismatch;
            }
            control += (a != c);
            ++compared;
        }
    }

    std::printf("compared %llu\n", (unsigned long long)compared);
    std::printf("mismatches vs direct form: %llu\n", (unsigned long long)mismatch);
    std::printf("control (unsigned saturation) disagreements: %llu\n",
                (unsigned long long)control);
    return mismatch == 0 && control > 0 ? 0 : 1;
}
g++ -O2 -msse4.1 -std=c++20 -Ips2xRuntime/include -Ips2xIOP/include vu_mirror_sweep.cpp -o vu_mirror_sweep
./vu_mirror_sweep

Output, after about fifty seconds of CPU time:

compared 55834574848
mismatches vs direct form: 0
control (unsigned saturation) disagreements: 12

The control's twelve are the two zero radicands against the six negatively-signed dividends, which is where an unsigned saturation is wrong.

Mirror axes swept

Axis Values
Sign of each operand positive, negative
Sign of the result a separate axis wherever a clamp or saturation sits between operands and result
Magnitude class of each operand zero, ordinary, near-maximum, near-minimum, non-finite — the last split into NaN, +inf, -inf
Which helper the property is asserted on Ps2VuDivQ, Ps2VuSqrtQ, Ps2VuRsqrtQ
For the composition, where it is asserted inner helper, outer body, or both

These are the axes the assertions vary, not a product asserted in full. The magnitude axis is one of the open ones, and the result-sign axis is open too at a NaN result. The near-minimum class appears only as ±1e-30, in the underflow rows and as the reciprocal root's overflowing radicand: absent at Ps2VuSqrtQ, absent as a Ps2VuDivQ divisor, and present at one sign apiece of the two Ps2VuRsqrtQ operands. What stays open, below, records why no finite operand set closes the magnitude axis and why a NaN result's sign is left unasserted. Two cells that no operand can construct are covered by proof instead.

Unreachable cell Proof
An overflow of the root at a finite radicand The root of any finite float is at most about 1.84e19. The infinite radicands that do overflow are asserted at both signs with a mutation row apiece, so the clamp family is closed on the root by assertion rather than by this argument.
A negative result from the root std::fabs yields a non-negative value or a NaN, std::sqrt of a non-negative value is non-negative, and std::sqrt of a NaN is a NaN. Both zeros are asserted positively signed.

Standalone commit verification

The first commit's tree — the VRSQRT operand read alone — builds and reports Failed: 0 on its own, under the build and run commands at the top of this document.

Prior art — #113

Record
Overlap Proposed the largest-finite-float-plus-sign-bit construction for VU0 VDIV's zero divisor. The sign rule used here is its author's, and he reached the operand semantics first on the sibling instruction. A single-file change whose bulk is divide-by-zero saturation, for EE COP1 div.s/rsqrt.s and for VU0 VDIV/VRSQRT.
Also carried An EE COP1 rsqrt.s operand fix, fd = fs / sqrt(ft), on the same "the emitter ignored ft entirely" reasoning applied here to VU0 VRSQRT
Left open, corrected here VU0 VRSQRT never read fs; VRSQRT saturated unsigned, dropping sign(fs); the ft > 0.0f guard stayed, so every negative radicand still saturated where fs / sqrt(|ft|) is an ordinary finite result; VSQRT was untouched and still clamped to zero
How it closed A batch cleanup spanning #108 through #117 — ten closed in one pass with a byte-identical comment, three of which touch VU0 — described as cleanup after #107 landed. #113 was already based on #107's merge commit. None of the ten was reviewed by a maintainer, and #113 has not been reopened.

VU1 micro-mode interpreter — divergences left open

PS2Runtime::executeVU0Microprogram runs VU0 microprograms on this interpreter and copies its q into ctx->vu0_q, the register these translators write.

Operation Interpreter Here What it takes to provoke a divergence
DIV, zero divisor sign by >= 0.0f sign by signbit(fs) != signbit(ft) A zero divisor, which is the only input either form saturates on. Over a +0 divisor the two forms agree at every dividend except a -0 and a positively-signed NaN; over a -0 divisor they agree at exactly those two dividends and diverge at every other one. So a -0 operand is not what the divergence needs: DIV NaN⁺ / +0 gives +Fmax here and -Fmax in the interpreter, with no -0 anywhere in the pair.
RSQRT, zero radicand saturates unsigned saturates with signbit(fs) Any negative dividend; no signed zero needed. RSQRT of a negative dividend by an ordinary +0 radicand gives +Fmax in a microprogram and -Fmax in macro mode.
ERLENG saturates unsigned no macro-mode counterpart Cannot diverge: a literal 1.0f numerator, and a divisor that is the root of a sum of squares, never a negative zero
ERCPR saturates unsigned no macro-mode counterpart A -0 operand only; the numerator is a literal 1.0f
ERLENG and ERCPR are EFU operations, so unlike DIV they have no second implementation here to disagree with

Open against that file: #189 reworks the VU1 flag registers and the flag-reading lower-op table, #191 the status-flag lower ops. Neither touches these sign rules.

Compilation of the emitted expression

Claim Basis
Header availability is not a derivation function_emitter.cpp and ps2_recompiler.cpp emit ps2_runtime_macros.h into every generated translation unit that contains translated instructions. A stub-only unit gets a shorter include list without it, and contains no translated instruction that could call these helpers.
The helpers compile everywhere the tree builds They are compiled into the runtime library and into the test binary on every CI platform, and the numeric layer executes them directly — a stronger guarantee than confirming the generator-side string formatting compiles

An anomaly, observed and ruled out

Observation Disposition
While the M22 mutation was applied, one run of the mutated but unrebuilt binary reported an extra failure unrelated to the mutation — a concurrency and scheduling test whose name includes "wake handoff" Two immediately subsequent runs reproduced only the expected N6 failure, and it has not recurred. It is not a defect in this change, whose translator output is static text and whose runtime helpers are pure functions with no threading.

What stays open

  • Any rule keyed on operand magnitude rather than sign or zero-ness is not closed at any of the three helpers. No finite operand set can close it, because any finite set can be interpolated around.
  • Whole-string equality does not pin an expression affine in two or more of the other fields, or a non-affine function of a single other field, since three points admit a quadratic. Closing those needs a fourth and fifth tuple, and neither is a plausible slip.
  • The Special2 decode is not pinned: the emission fixture is a hand-written inverse of it and the two could be changed in step. The fixture carries a comment saying so.
  • The denormal band is host-dependent. A host that flushes denormals gives §2.3's +0/-0 answer and one that keeps them does not, so this is stated rather than asserted.
  • Float-to-fixed conversion has no behaviour in these helpers, so there is nothing here to assert about §2.3's conversion-overflow row.
  • The ordinary-path NaN cells assert std::isnan of the result and not its sign bit. IEEE 754 leaves a NaN result's sign unspecified, so a sign assertion there would pin host behaviour rather than the helpers' rule.
  • Dropping a macro's inner parentheses leaves the suite green, and that is an equivalent mutant rather than a gap. Each parameter is substituted into a function-call argument slot, and an argument containing a top-level comma is split into two macro arguments by the preprocessor before substitution, so no input distinguishes the two forms.
  • A further NaN site has no mutation row: a negatively-signed NaN dividend swallowed at the reciprocal root's outer body on the ordinary path alone. Its only mutant must be gated on a non-zero radicand, which makes it built backwards from the assertion; it was reproduced green before the row landed and fails after.
  • SQRT(-inf) has no row that fails it alone, for the same reason. Redefining PS2_VU_SQRT_Q to special-case a negative infinite radicand would do it, since VRSQRT never routes through that macro, but a mutant tailored to one assertion demonstrates nothing the assertion does not already state.

VRSQRT divides fs by the square root of ft, but the translator only read
ft and emitted a bare reciprocal, silently substituting 1.0 for the
numerator. Read fs and its fsf component selector, which the decoder
already populates for this opcode.
…RSQRT radicand magnitude

VDIV, VSQRT and VRSQRT wrote 0.0 for a zero divisor and for a negative
radicand. The manual's exception table gives +MAX/-MAX for a zero divisor and
the root of the magnitude for a negative radicand. That magnitude rule covers
VRSQRT as well as VSQRT: the previous ft > 0.0f guard sent every negative
radicand to 0.0, and VRSQRT now divides fs by the root of the radicand's
magnitude. The VU's own float format holds one binade more than a host float
does, so the largest finite float stands in for +MAX rather than equalling
it. Put the three Q-producing results behind inline runtime helpers and have
the translators emit calls to them, following the arithmetic translators in
that file, which emit PS2_VADD, PS2_VSUB, PS2_VMUL and PS2_VBLEND rather than
intrinsics. Not every translator there does this - the min/max forms emit
_mm_min_ps and _mm_max_ps, the integer forms emit raw C operators, and the
load/store forms emit the READ/WRITE macros - so this follows the arithmetic
precedent rather than a universal one.
@ran-j

ran-j commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Before you put more time on this, I'm working a big refactor on vu1 and vf1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants