Skip to content

Keep the Gekko float pipeline in the runtime helpers - #11

Merged
siahisaforker merged 6 commits into
ExpansionPak:mainfrom
dougchansan:c-backend-float-semantics
Aug 4, 2026
Merged

Keep the Gekko float pipeline in the runtime helpers#11
siahisaforker merged 6 commits into
ExpansionPak:mainfrom
dougchansan:c-backend-float-semantics

Conversation

@dougchansan

Copy link
Copy Markdown
Contributor

The C backend inlines single- and double-precision floating point as plain C:

ctx->fpr[d] = (f64)(f32)(ctx->fpr[a] * ctx->fpr[c]);

That produces a plausible value and drops four things the hardware does, none of
them visible in the generated source.

What the inline form loses

The 25-bit C operand. ppc_fmuls runs its C operand through force_25bit_c
first — the Gekko truncates a multiply's C operand to a 25-bit mantissa. An
inlined f64 multiply is therefore numerically wrong on every instruction, not
only in edge cases.

ps1. fp_write_single writes ps1 as well as fpr. A single-precision
result occupies both halves of a paired-single register. The inline form leaves
ps1 holding whatever was there before, and the ps_* instructions go on to
read it.

FPSCR. FPRF, FI and FR are never updated.

NaN handling. Invalid-operation gating is skipped. emit_fcompare also
dropped the ordered flag entirely, so fcmpo and fcmpu compiled to
identical code even though only fcmpo signals on a NaN operand.

dolrecomp_f32_to_bits / dolrecomp_f32_from_bits had likewise become a cast
plus a reinterpret. They are restored to the bit-exact conversions (the same
shape as Dolphin's ConvertToSingle / ConvertToDouble), which matters for
denormals and for values that are not already single-representable, and the
(f32)/(f64) casts at the call sites are removed so the conversions see their
real inputs.

The change

Route the affected instructions back through the helpers: fadds, fsubs,
fmuls, fdivs, fadd, fsub, fmul, fdiv, frsp, fcmpo, fcmpu, and
the ps_* group. One file, src/backend/emitter.c.

Why it shows up as graphics corruption

Paired-single is the Gekko's SIMD unit, so this is the path vertex and matrix
maths take. Feeding it a stale ps1 corrupts skinning.

On Skyward Sword (NTSC-U, SOUE01) character models lost limbs and burst into
stray triangles as soon as they animated. A GameCube title in the same tree
looked fine by eye, which is worth noting: the symptom depends on how much the
game leans on paired-single, so a clean-looking screenshot is not evidence the
arithmetic is right.

Verification

Measured by diffing the emitted C per guest instruction against a build from
before the regression, over 1,269,248 instructions:

Stage Differing instructions
Before this change 143,795
After restoring the bit-exact conversions 42,600
After restoring the scalar helpers 8,937
After restoring ps_* and the compares 0

The recompiled module then renders byte-identical frames to the known-good
build, from a fixed savestate with scripted input, compared by hash rather than
by eye.

ctest is 14/14, including c_execute.

Not affected

The CFG work is untouched — loop outlining, direct back-edges and routed local
returns are all still emitted, 819 outlined loops in this title before and
after. This is a correctness fix, not a performance trade.

The C backend inlines single- and double-precision floating point as plain C:

    ctx->fpr[d] = (f64)(f32)(ctx->fpr[a] * ctx->fpr[c]);

That computes a plausible value and drops four things the hardware does, none
of which are visible in the generated source:

  * ppc_fmuls runs its C operand through force_25bit_c first. The Gekko
    truncates a multiply's C operand to a 25-bit mantissa, so an inlined f64
    multiply is numerically wrong on every instruction, not just edge cases.
  * fp_write_single writes ps1 as well as fpr. Single-precision results occupy
    both halves of a paired-single register; the inline form leaves ps1 holding
    whatever was there before, and the ps_* instructions go on to read it.
  * FPRF, FI and FR in FPSCR are never updated.
  * NaN and invalid-operation gating is skipped. emit_fcompare also dropped the
    ordered flag entirely, so fcmpo and fcmpu compiled to identical code even
    though only fcmpo signals on a NaN operand.

dolrecomp_f32_to_bits and dolrecomp_f32_from_bits had likewise become a cast
plus a reinterpret. They are restored to the bit-exact conversions (the same
shape as Dolphin's ConvertToSingle / ConvertToDouble), which matters for
denormals and for values that are not already single-representable, and the
(f32)/(f64) casts at the call sites are removed so the conversions actually see
their inputs.

Route the affected instructions back through the helpers: fadds, fsubs, fmuls,
fdivs, fadd, fsub, fmul, fdiv, frsp, fcmpo, fcmpu, and the ps_* group.

Paired-single is the Gekko's SIMD unit, so this is the path vertex and matrix
maths take. The visible symptom on Skyward Sword (NTSC-U, SOUE01) was character
models losing limbs and bursting into stray triangles as soon as they animated;
the title renders correctly with a stale build and incorrectly with this one.
After the change the recompiled module renders byte-identical frames to that
known-good build, from a fixed savestate driven by scripted input.

No change to the CFG work: loop outlining, direct back-edges and the routed
local returns are all still emitted -- 819 outlined loops in this title before
and after. ctest is 14/14.
@siahisaforker

Copy link
Copy Markdown
Contributor

The new C emitter calls ppc_fcmp, but that helper uses:

 cpu->fpscr |= compare << 12;

A less-than comparison followed by equal produces FPCC 0xA instead of replacing it with 0x2. Clear the
four FPCC bits first while preserving the fifth FPRF classification bit, then optionally add a regression test.

ppc_fcmp OR-ed its result into FPSCR, so a less-than followed by an equal
left FPCC at 0xA rather than 0x2. Clear the four FPCC bits first and keep
bit 16, the C class bit, which a compare does not modify.

test_fpscr covers consecutive compares of differing results and the
preserved C bit; it reports FPCC 0xA without the fix.
@dougchansan

Copy link
Copy Markdown
Contributor Author

Fixed in 2515808. ppc_fcmp now replaces the four FPCC bits instead of OR-ing into them, preserving bit 16 — the C class bit, which a compare does not modify.

tests/test_fpscr.c covers consecutive compares of differing results and the preserved C bit. Without the change it reports FPCC 0xA for less-than followed by equal, exactly as you described. ctest 14/14.

test_float_semantics counts divergence per dropped behaviour instead of
checking the helpers against themselves, so the numbers can be read off
rather than taken on trust.

It also corrects a claim in this PR's description. The 25-bit truncation
of a multiply's C operand is not wrong on every instruction: a C operand
that came from lfs is already single-representable, its low 29 mantissa
bits are zero, and truncation is a no-op. Over 100,000 fixed-seed pairs
those agree exactly, 0/100000. Only a C operand carrying more than 25
mantissa bits diverges, and then in 12,545/100000 cases -- the final
narrowing to single absorbs most of the difference.
@dougchansan

Copy link
Copy Markdown
Contributor Author

Added tests/test_float_semantics.c in 4d68b7e. It counts divergence per dropped behaviour over 100,000 fixed-seed operand pairs instead of checking the helpers against themselves, so the numbers can be read off a run:

25-bit C operand : C from lfs   0/100000 differ (0.0%)
                 : C full f64   12545/100000 differ (12.5%)
ps1              : inline leaves it stale=yes, helper writes it=yes
FPRF             : inline 0x00, helper 0x04
fcmpo vs fcmpu   : VXVC ordered=0x00080000 unordered=0x00000000

That first line corrects something I claimed in the description above. I wrote that an inlined f64 multiply is "numerically wrong on every instruction, not only in edge cases." That is not right, and the test is what showed it. A C operand that came from lfs is already single-representable, so its low 29 mantissa bits are zero and force_25_bit is a no-op — those multiplies agree exactly, 0 out of 100,000. Divergence needs a C operand carrying more than 25 mantissa bits, from lfd or from double-precision arithmetic, and even then it is 12.5% rather than all of them, because the final narrowing to single absorbs most of the truncation.

So the scope of that one is narrower than I stated: fmuls fed by lfs is unaffected. It does not change the other three, and ps1 is still the one that reaches the screen.

ctest 16/16.

@dougchansan

Copy link
Copy Markdown
Contributor Author

Render evidence for the ps1 half of this, since "lost limbs" was the one claim above with nothing behind it but a description.

Skyward Sword (NTSC-U, SOUE01), same savestate, same scene, same shot index, two C-backend modules that differ only in whether the float work is routed through the runtime helpers.

Without the fix — arms gone, head detached and floating near the desk, torso separated from the legs:

without the fix

With it — Link intact:

with the fix

Everything else in the frame is identical: room, furniture, HUD, the shadow on the rug. The defect is confined to the skinned character mesh, and it moves between consecutive shots as the idle animation plays, which is what a stale ps1 feeding the paired-single path looks like.

Two caveats on what this does and does not show.

The framerate says nothing. Both arms held ~30 FPS with speed between 0.999 and 1.007 — pinned to the limiter. A capped run cannot measure what the helpers cost, so nothing here contradicts the description above saying this trades performance for correctness. It is also a reminder that a framerate check would have called the broken module healthy: the corrupted arm ran at a clean 30.

The unfixed module is a cached build from an earlier tree, and the fixed one predates the last two commits on this branch (the FPCC change and the tests). So this covers the helper routing that the PR is built on, not those two.

Images are on a pr11-render-evidence branch of the fork rather than in this PR, so they stay out of the diff.

@siahisaforker

Copy link
Copy Markdown
Contributor

The previous FPCC issue is fixed, but I found a few remaining gaps in the emitted float path.

ps_cmp* still performs an inline comparison that only updates CR. It does not update FPSCR FPCC ordistinguish the ordered and unordered NaN exception behavior.

The scalar helpers now used for fadd(s), fsub(s), fmul(s), and fdiv(s) still write their results
unconditionally. Cases such as 0 * infinity and 0 / 0 therefore do not set the appropriate FPSCR exception
or honor VE as claimed. FI and FR also remain incomplete.

The record forms of those operations, along with frsp., do not update CR1 when Rc=1.
Finally, the new emit_ps_merge correctly snapshots inputs to handle destination aliasing, but it passes
both values through dolrecomp_ps_round. The ps_merge* instructions are moves and should preserve the
selected values without narrowing them to single precision.

good luck!

dougchansan and others added 3 commits August 3, 2026 17:33
The counts were printed but only asserted non-zero, so truncation could
have collapsed from 12,545 divergent products to 3 and still passed.
The LCG is fixed and the arithmetic is IEEE-defined, so both counts are
exact: 0/100000 for a C operand from lfs, 12545/100000 for a full
mantissa. Pinned.

The ps_* path had no coverage at all, which is awkward given it is the
one that reaches the screen. The corruption is not in the instruction
that drops ps1 -- it is in the paired-single instruction that reads ps1
afterwards, so the test now runs that chain: poison ps1, produce the
register with fmuls, consume it from ps_add, and check both lanes. Run
again with the inline form standing in for fmuls and only the ps1 lane
goes wrong, ps0 stays correct. That asymmetry is why this shows up as
geometry rather than as an obviously broken number.
@siahisaforker
siahisaforker merged commit 48c4ef1 into ExpansionPak:main Aug 4, 2026
4 checks passed
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