Skip to content

fix(vu1): maintain MAC/STATUS/CLIP flag registers, correct the flag-reading lower-op table, and saturate FTOI - #189

Draft
smmathews wants to merge 1 commit into
ran-j:mainfrom
smmathews:feature/26-vu1-flag-model
Draft

fix(vu1): maintain MAC/STATUS/CLIP flag registers, correct the flag-reading lower-op table, and saturate FTOI#189
smmathews wants to merge 1 commit into
ran-j:mainfrom
smmathews:feature/26-vu1-flag-model

Conversation

@smmathews

Copy link
Copy Markdown
Contributor

Summary

The VU1 interpreter declares a flag model it does not implement. VU1State has had mac,
status, and clip fields since it was introduced, and the lower-op instructions that read
them (FMAND, FMEQ, FMOR, FSEQ, FSAND, FSOR, FCEQ, FCAND, FCOR) have been present the whole
time, but nothing in the upper (FMAC) pipeline ever wrote mac, and STATUS was written by
exactly one instruction with an unsourced immediate extraction. Separately, the lower-op
table that FMEQ/FMAND/FMOR/FCGET live in has two of its four case bodies swapped, is missing
the fourth op entirely, and FTOI has no saturation, so out-of-range conversions are
undefined behavior.

This PR:

  • Adds MAC flag computation to every flag-raising FMAC instruction (arithmetic vf-writers
    and ACC-writers), gated per DEST lane, and folds it into a STATUS live/sticky pair.
    MAX/MINI, which run in the FMAC pipeline but raise no flags on hardware, are excluded.
  • Fixes the lower-op table: 0x18 is FMEQ, 0x1A is FMAND, 0x1B is FMOR (previously absent),
    0x1C is FCGET (previously absent; the slot instead ran the FMOR (OR) body, which belongs
    at 0x1B).
  • Fixes FSSET's immediate extraction and makes it write only the sticky half of STATUS.
  • Masks the CLIP shift-register accumulation to 24 bits.
  • Adds saturation to FTOI0/4/12/15.

Everything here is a flag/value fix with an immediate-commit model. This PR does not model
any pipeline latency (the FDIV/Q delay, the EFU/P delay, or a multi-cycle flag/CLIP commit
delay). Those are real and worth a follow-up, but they are unsourced by this project today
and belong in their own PR with their own citation, not folded into a value-correctness fix.

The MAC register was declared but never written

git grep -n "m_state\.mac" -- ps2xRuntime/src/lib/vu

returns three reads (FMAND, FMEQ, FMOR before this fix) and zero writes anywhere in the
upper pipeline. Every FMAC instruction computed a result and wrote it through applyDest
straight to the destination register; nothing ever touched m_state.mac. FMAND/FMEQ/FMOR
therefore always evaluated the flag register as zero.

The write path split: flag computation is a per-call-site property, not a mode flag

The previous shape for a runtime flag pipeline would be a mutable "am I inside an upper op"
flag. That is a state machine hiding a compile-time fact: whether an instruction computes
flags is fixed by which instruction it is, not by when it runs. So the destination-mask copy
(applyDest, renamed writeDestMasked) and the flag computation (computeFmacFlags) are
now two separate calls, and which call sites include the flag call is visible with a plain
grep:

  • FMAC arithmetic vf-writers (ADD/SUB/MADD/MSUB/MUL and their bc/q/i variants, plus
    OPMSUB): both calls.
  • MAX/MINI (and their bc/i variants): writeDestMasked only. They run in the FMAC
    pipeline but leave MAC and STATUS unchanged on hardware (PCSX2 routes them through
    applyMinMax, which performs no flag update), so they are excluded exactly like the
    unary ops below. A dedicated negative-control test runs a MAX between a producing FMAC
    and a flag read, and asserts MAC is unchanged by it.
  • FMAC ACC-writers (ADDA/SUBA/MADDA/MULA/... and OPMULA): both calls, since applyDestAcc's
    body now computes flags before writing to m_state.acc. MAC reflects the FMAC result
    whether the destination is a vf register or the accumulator.
  • The nine ITOF/FTOI/ABS unary ops, and the lower-op writers (LQ, MOVE, MR32, LQI, LQD,
    MFIR, MFP): writeDestMasked only. These are not FMAC ops and must not touch MAC. A
    dedicated negative-control test runs all three of ITOF0, FTOI0, and ABS between a
    producing FMAC and a flag read, and asserts MAC is unchanged by them.

The ACC-writer group is uniform (every ACC-writer raises flags, so applyDestAcc can
bundle both calls unconditionally); the vf-writer group is mixed (MAX/MINI and the unary
ops do not raise flags, arithmetic vf-writers do). Keeping the flag call as a separate line
at each vf-writer site, rather than bundling it into a single vf-write helper, is what makes
that per-op membership visible at the point of use instead of hidden behind a shared wrapper.

MAC and STATUS bit layout

MAC is 16 bits: Z[3:0], S[7:4], U[11:8], O[15:12], each nibble ordered x,y,z,w from
the high bit to the low bit. Z and U are not mutually exclusive: Z fires on a +/-0.0 result
or on a denormal result (a denormal is an underflow that PCSX2 flushes to zero, so it raises
both U and Z together, matching PCSX2's 0x0101<<shift); U fires when the result's exponent
field is 0 with a nonzero mantissa (denormal); O fires when the exponent field is all ones
(inf or NaN; this project does not distinguish them, matching the sourced reference). S
mirrors the IEEE754 sign bit unconditionally. Lanes outside DEST are not evaluated and read 0
in every group.

STATUS is modelled as a 12-bit register: a live half (bits 5:0) that is replaced whole by
every FMAC instruction, and a sticky half (bits 11:6) that only ever accumulates (bitwise
OR) so FSAND/FSOR can answer "has this ever happened". Each half only carries four of its
six bits (Z, S, U, O); the I (invalid) and D (divide-by-zero-adjacent) bits that real
hardware also tracks in STATUS are not modelled here and are left zero. This PR does not
claim to model them; a future PR that adds them can do so without touching this shape.

FSSET's immediate extraction was sourced from PCSX2 and does not match any of the three
formulas that had been circulating for this codebase. It writes only the sticky half and
leaves the live half exactly as the most recent FMAC left it.

The lower-op table was transposed, not just mislabeled

Before this fix, case 0x18 was labeled FMAND but its body computed AND at the wrong opcode
slot (FMAND is really at 0x1A); case 0x1A was labeled FMEQ but its body computed EQ, also at
the wrong slot (FMEQ is really at 0x18); case 0x1C was labeled FMOR and computed OR, but
0x1C is FCGET and FMOR belongs at 0x1B, which had no case at all. Sourced against the
reference VU interpreter's lower-op dispatch table: 0x18=FMEQ, 0x19 unassigned, 0x1A=FMAND,
0x1B=FMOR, 0x1C=FCGET (reads CLIP's low 12 bits into the destination register, guarded on a
nonzero destination). Each op's body implements the op its old comment named; the bug was
that the comment and the opcode slot disagreed with the reference table.

CLIP's shift-register accumulation was never masked

m_state.clip = (m_state.clip << 6) | flags; accumulates 6 new bits every CLIP instruction
with no mask. FCSET does mask its own write to 24 bits, so it is not accurate to say CLIP is
never masked; the precise claim is that the shift-register accumulation is unmasked. Once
five or more CLIP instructions have run, bits above bit 23 are permanently set and FCOR's
== 0xFFFFFF comparison can never go true again, because CLIP's actual value now always
exceeds what a 24-bit compare can match, regardless of what the low 24 bits are. This PR
adds & 0xFFFFFFu after the shift, matching the reference implementation's own mask at the
same point. A test runs five CLIPs that would overflow the 24-bit window, then confirms FCOR
can go true again afterward.

FTOI had no saturation, and the plain cast was undefined behavior

FTOI0/4/12/15 cast a (possibly pre-scaled) float straight to int32_t with
static_cast<int32_t>. Casting a float that does not fit in the target integer type is
undefined behavior in C++; in practice, on x86 this compiles to cvttss2si, whose
documented behavior on an out-of-range or NaN input is to return the "integer indefinite"
value 0x80000000, regardless of the input's sign. That means a large positive
out-of-range float silently produced INT_MIN, not INT_MAX, pre-fix; only the
already-correct-by-coincidence negative case matched what saturation should produce. This PR
adds explicit saturation: a scaled magnitude of 2^31 or more (checked on the exponent field,
covering inf and NaN) clamps to 0x7fffffff or 0x80000000 by sign bit, otherwise the
existing cast is used unchanged.

The decode cache is unaffected

#158 added a decode cache keyed on PS2Memory::getVU1CodeGeneration(). It caches the
decode of an instruction pair (opcode fields, iBit/eBit, execution order), not any
execution-time result, so none of this PR's flag state interacts with it: two runs of the
same cached instruction still compute fresh MAC/STATUS/CLIP values from the current
operands every time.

Reproduction

cmake -S . -B build -DCMAKE_CXX_FLAGS="-msse4.1 -include cstdint" -DCMAKE_C_FLAGS=-msse4.1
cmake --build build --target ps2x_tests
./build/ps2xTest/ps2x_tests

The pre-fix defects can be reproduced directly against a commit before this one:

git grep -n "m_state\.mac" -- ps2xRuntime/src/lib/vu       # 3 reads, 0 writes
git grep -n "m_state\.status = (instr >> 6)" -- ps2xRuntime/src/lib/vu   # the old FSSET line

Full sourcing and the one-line-mutation proof for every pinned property are in EVIDENCE.md.

…eading lower-op table, and saturate FTOI

The VU1 interpreter declared mac/status/clip fields and the lower-op instructions
that read them, but the upper FMAC pipeline never wrote mac, and STATUS was
written by exactly one instruction using an unsourced immediate extraction. Every
arithmetic FMAC vf-writer and ACC-writer now computes MAC over its DEST lanes and
folds it into a STATUS live/sticky pair; MAX/MINI (which the PS2 FMAC pipeline
runs without any flag update, matching PCSX2's applyMinMax) and the nine
ITOF/FTOI/ABS unary ops and the plain dest-mask lower ops (LQ, MOVE, MR32, LQI,
LQD, MFIR, MFP) are explicitly excluded from the flag path.

Separately, the flag-reading lower-op table at 0x18-0x1C had two of its four case
bodies swapped (0x18/0x1A) and was missing FMOR (0x1B) and FCGET (0x1C, which ran
a duplicate FMOR body instead). FSSET's immediate extraction is replaced with the
formula sourced from PCSX2's reference VU interpreter, since none of the formulas
previously in circulation for this codebase matched it. CLIP's shift-register
accumulation is now masked to 24 bits, since without it FCOR permanently loses
the ability to compare true once five or more CLIP instructions have run. FTOI0/
4/12/15 now saturate on overflow instead of relying on undefined behavior, which
on x86 silently produced INT_MIN for large positive values too.

All of this is sourced against PCSX2 pcsx2/VUops.cpp and pcsx2/VUflags.cpp (see
EVIDENCE.md); pipeline latency (FDIV/Q, EFU/P, and any multi-cycle flag commit
delay) is out of scope here and left for a follow-up with its own citation.
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.

1 participant