Interpreter: honor the guest's FPU rounding mode and flush-to-zero

Every JIT backend puts the host FPU into the mode fcr31 asks for (bits 0-1 and
24) before running emulated code, and takes it back out before calling any host
code. The plain interpreter did none of that, so all its float math rounded to
nearest with denormals intact no matter what the game had set - cpu/fpu/fpu
fails under -i and passes under the JIT on exactly this.

Move the helpers the IR interpreter already had for this out of IRInterpreter
and into MIPS.cpp as ApplyHostRoundingMode/RestoreHostRoundingMode, and use them
around the interpreter's run loop and single step, restoring around syscalls and
replacement functions, which are host code. ctc1 re-applies immediately, since
the interpreter has no block boundary to defer it to.

round.w.s changes with it: it was floorf(x + 0.5f), which is half-away-from-zero
rather than the half-to-even every JIT produces, and the add would now pick up
the guest's rounding mode on top of that. round_ieee_754 is both correct and
mode-independent, and is what cvt.w.s already used for the same rounding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
This commit is contained in:
Henrik Rydgård
2026-08-27 16:43:06 +02:00
co-authored by Claude Opus 5
parent adccd302e5
commit 3fa67f22ed
6 changed files with 120 additions and 87 deletions
+2 -83
View File
@@ -3,10 +3,6 @@
#include "ppsspp_config.h"
#if PPSSPP_PLATFORM(WINDOWS) && PPSSPP_ARCH(ARM64)
#include <arm64intr.h>
#endif
#include "Common/BitSet.h"
#include "Common/BitScan.h"
#include "Common/Common.h"
@@ -28,32 +24,6 @@
#include "Core/System.h"
#include "Core/MIPS/MIPSTracer.h"
#if PPSSPP_ARCH(ARM64)
// TODO: This should be put in some common header.
static inline u64 ARM64ReadFPCR() {
#if PPSSPP_PLATFORM(WINDOWS)
return _ReadStatusReg(ARM64_FPCR);
#else
// TODO: Try __builtin_arm_get_fpcr()
u64 fpcr; // not really 64-bit, just to match the register size.
asm volatile ("mrs %0, fpcr" : "=r" (fpcr));
return fpcr;
#endif
}
static inline void ARM64WriteFPCR(u64 fpcr) {
#if PPSSPP_PLATFORM(WINDOWS)
_WriteStatusReg(ARM64_FPCR, fpcr);
#else
// TODO: Try __builtin_arm_set_fpcr()
// Write back the modified FPCR
asm volatile ("msr fpcr, %0" : : "r" (fpcr));
#endif
}
#endif
#ifdef mips
// Why do MIPS compilers define something so generic? Try to keep defined, at least...
#undef mips
@@ -110,57 +80,6 @@ u32 IRRunMemCheck(u32 pc, u32 addr) {
return coreState != CORE_RUNNING_CPU ? 1 : 0;
}
void IRApplyRounding(MIPSState *mips) {
u32 fcr1Bits = mips->fcr31 & 0x01000003;
// If these are 0, we just leave things as they are.
if (fcr1Bits) {
int rmode = fcr1Bits & 3;
bool ftz = (fcr1Bits & 0x01000000) != 0;
#if PPSSPP_ARCH(SSE2)
u32 csr = _mm_getcsr() & ~0x6000;
// Translate the rounding mode bits to X86, the same way as in Asm.cpp.
if (rmode & 1) {
rmode ^= 2;
}
csr |= rmode << 13;
if (ftz) {
// Flush to zero
csr |= 0x8000;
}
_mm_setcsr(csr);
#elif PPSSPP_ARCH(ARM64)
u64 fpcr = ARM64ReadFPCR();
// Translate MIPS to ARM rounding mode
static const u8 lookup[4] = {0, 3, 1, 2};
fpcr &= ~(3 << 22); // Clear bits [23:22]
fpcr |= ((u64)lookup[rmode] << 22);
if (ftz) {
fpcr |= 1 << 24;
}
ARM64WriteFPCR(fpcr);
#endif
}
}
void IRRestoreRounding() {
#if PPSSPP_ARCH(SSE2)
// TODO: We should avoid this if we didn't apply rounding in the first place.
// In the meantime, clear out FTZ and rounding mode bits.
u32 csr = _mm_getcsr();
csr &= ~(7 << 13);
_mm_setcsr(csr);
#elif PPSSPP_ARCH(ARM64)
u64 fpcr = ARM64ReadFPCR(); // not really 64-bit, just to match the regsiter size.
fpcr &= ~(7 << 22); // Clear bits [23:22] for rounding, 24 for FTZ
// Write back the modified FPCR
ARM64WriteFPCR(fpcr);
#endif
}
u32 IRInterpret(MIPSState *mips, const IRInst *inst) {
while (true) {
switch (inst->op) {
@@ -1257,10 +1176,10 @@ u32 IRInterpret(MIPSState *mips, const IRInst *inst) {
break;
case IROp::ApplyRoundingMode:
IRApplyRounding(mips);
ApplyHostRoundingMode(mips);
break;
case IROp::RestoreRoundingMode:
IRRestoreRounding();
RestoreHostRoundingMode();
break;
case IROp::UpdateRoundingMode:
// TODO: Implement
-3
View File
@@ -11,9 +11,6 @@ u32 IRRunBreakpoint(u32 pc);
u32 IRRunMemCheck(u32 pc, u32 addr);
u32 IRInterpret(MIPSState *ms, const IRInst *inst);
void IRApplyRounding();
void IRRestoreRounding();
template <uint32_t alignment>
u32 RunValidateAddress(u32 pc, u32 addr, u32 isWrite) {
static_assert(alignment <= 16);
+19 -1
View File
@@ -82,6 +82,8 @@ int MIPS_InterpretSingleStep(MIPSState *mips) {
return 0;
}
MIPSOpcode op = Memory::Read_Opcode_JIT(mips->pc); // now unchecked
// Same reason as the run loop in MIPSInterpret_RunUntil - see ApplyHostRoundingMode.
ApplyHostRoundingMode(mips);
if (mips->inDelaySlot) {
MIPSInterpret(mips, op);
if (mips->inDelaySlot) {
@@ -91,6 +93,7 @@ int MIPS_InterpretSingleStep(MIPSState *mips) {
} else {
MIPSInterpret(mips, op);
}
RestoreHostRoundingMode();
return 1;
}
@@ -235,7 +238,10 @@ namespace MIPSInt {
mips->pc += 4;
}
mips->inDelaySlot = false;
// HLE code is host code - it must not run under the guest's rounding mode.
RestoreHostRoundingMode();
CallSyscallWithPC(op, syscallPC);
ApplyHostRoundingMode(mips);
}
void Int_Sync(MIPSState *mips, MIPSOpcode op) {
@@ -718,6 +724,13 @@ namespace MIPSInt {
if (MIPSComp::jit) {
// In case of DISABLE, we need to tell jit we updated FCR31.
MIPSComp::jit->UpdateFCR31();
} else {
// The interpreter emulates the rounding mode by putting the host FPU in it,
// so it has to switch right here rather than at the next block boundary.
// Restore first: the new value may be back to the default, which Apply
// deliberately doesn't write.
RestoreHostRoundingMode();
ApplyHostRoundingMode(mips);
}
} else {
WARN_LOG_REPORT(Log::CPU, "WriteFCR: Unexpected reg %d (value %08x)", fs, value);
@@ -1100,7 +1113,9 @@ namespace MIPSInt {
}
switch (op & 0x3f)
{
case 12: FsI(fd) = (int)floorf(F(fs)+0.5f); break; //round.w.s
// round.w.s is round-half-to-even, not half-away-from-zero - and its mode is fixed,
// so unlike cvt.w.s below it must not follow fcr31. round_ieee_754 is both.
case 12: FsI(fd) = (int)round_ieee_754(F(fs)); break; //round.w.s
case 13: //trunc.w.s
if (F(fs) >= 0.0f) {
FsI(fd) = (int)floorf(F(fs));
@@ -1247,7 +1262,10 @@ namespace MIPSInt {
int index = op.encoding & 0xFFFFFF;
const ReplacementTableEntry *entry = GetReplacementFunc(index);
if (entry && entry->replaceFunc && (entry->flags & REPFLAG_DISABLED) == 0) {
// Like a syscall, a replacement function is host code - see Int_Syscall.
RestoreHostRoundingMode();
int cycles = entry->replaceFunc();
ApplyHostRoundingMode(mips);
if (entry->flags & (REPFLAG_HOOKENTER | REPFLAG_HOOKEXIT)) {
// Interpret the original instruction under the hook.
+82
View File
@@ -20,8 +20,14 @@
#include <mutex>
#include <utility>
#include "ppsspp_config.h"
#if PPSSPP_PLATFORM(WINDOWS) && PPSSPP_ARCH(ARM64)
#include <arm64intr.h>
#endif
#include "Common/CommonTypes.h"
#include "Common/Math/SIMDHeaders.h"
#include "Common/Serialize/Serializer.h"
#include "Common/Serialize/SerializeFuncs.h"
#include "Core/ConfigValues.h"
@@ -42,6 +48,82 @@ MIPSState *currentMIPS = &mipsr4k;
MIPSDebugInterface debugr4k(&mipsr4k);
MIPSDebugInterface *currentDebugMIPS = &debugr4k;
#if PPSSPP_ARCH(ARM64)
static inline u64 ARM64ReadFPCR() {
#if PPSSPP_PLATFORM(WINDOWS)
return _ReadStatusReg(ARM64_FPCR);
#else
// TODO: Try __builtin_arm_get_fpcr()
u64 fpcr; // not really 64-bit, just to match the register size.
asm volatile ("mrs %0, fpcr" : "=r" (fpcr));
return fpcr;
#endif
}
static inline void ARM64WriteFPCR(u64 fpcr) {
#if PPSSPP_PLATFORM(WINDOWS)
_WriteStatusReg(ARM64_FPCR, fpcr);
#else
// TODO: Try __builtin_arm_set_fpcr()
// Write back the modified FPCR
asm volatile ("msr fpcr, %0" : : "r" (fpcr));
#endif
}
#endif
void ApplyHostRoundingMode(const MIPSState *mips) {
u32 fcr1Bits = mips->fcr31 & 0x01000003;
// If these are 0, we just leave things as they are.
if (fcr1Bits) {
int rmode = fcr1Bits & 3;
bool ftz = (fcr1Bits & 0x01000000) != 0;
#if PPSSPP_ARCH(SSE2)
u32 csr = _mm_getcsr() & ~0x6000;
// Translate the rounding mode bits to X86, the same way as in Asm.cpp.
if (rmode & 1) {
rmode ^= 2;
}
csr |= rmode << 13;
if (ftz) {
// Flush to zero
csr |= 0x8000;
}
_mm_setcsr(csr);
#elif PPSSPP_ARCH(ARM64)
u64 fpcr = ARM64ReadFPCR();
// Translate MIPS to ARM rounding mode
static const u8 lookup[4] = {0, 3, 1, 2};
fpcr &= ~(3 << 22); // Clear bits [23:22]
fpcr |= ((u64)lookup[rmode] << 22);
if (ftz) {
fpcr |= 1 << 24;
}
ARM64WriteFPCR(fpcr);
#endif
}
}
void RestoreHostRoundingMode() {
// TODO: We should avoid this if we didn't apply rounding in the first place.
// In the meantime, clear out FTZ and rounding mode bits.
#if PPSSPP_ARCH(SSE2)
u32 csr = _mm_getcsr();
csr &= ~(7 << 13);
_mm_setcsr(csr);
#elif PPSSPP_ARCH(ARM64)
u64 fpcr = ARM64ReadFPCR(); // not really 64-bit, just to match the register size.
fpcr &= ~(7 << 22); // Clear bits [23:22] for rounding, 24 for FTZ
// Write back the modified FPCR
ARM64WriteFPCR(fpcr);
#endif
}
u8 voffset[128];
u8 fromvoffset[128];
+9
View File
@@ -293,3 +293,12 @@ extern MIPSDebugInterface *currentDebugMIPS;
extern MIPSState mipsr4k;
extern const float cst_constants[32];
// The guest's rounding mode and flush-to-zero flag (fcr31 bits 0-1 and 24) are emulated by putting
// the *host* FPU into the matching mode, since we do the arithmetic with plain host float ops.
// That mode must not be left on while running anything that isn't emulating a guest instruction -
// HLE syscalls, replacement functions, the GPU - so an emulation loop applies it on entry and
// restores it before calling out, the way the JITs do (see Jit::ApplyRoundingMode).
// Apply is cheap when the guest is in the default mode (by far the common case): it does nothing.
void ApplyHostRoundingMode(const MIPSState *mips);
void RestoreHostRoundingMode();
+8
View File
@@ -1272,6 +1272,12 @@ int MIPSInterpret_RunUntil(MIPSState *mips, u64 globalTicks) {
while (coreState == CORE_RUNNING_CPU) {
CoreTiming::Advance(mips);
// The emulated instructions below do their float math with plain host float ops, so the
// host FPU has to be in the guest's rounding mode while they run - and back in the normal
// one whenever we're not running them, which is what the JITs do too. Calls out from
// inside the run loops (syscalls, replacement functions) restore it themselves.
ApplyHostRoundingMode(mips);
uint64_t ticksLeft = globalTicks - CoreTiming::GetTicks(mips);
if (g_breakpoints.HasBreakPoints() || g_breakpoints.HasMemChecks() || g_breakpoints.HasRegBreakpoints() || ticksLeft <= mips->downcount) {
RunUntilDowncountZeroWithChecks(mips, globalTicks);
@@ -1279,6 +1285,8 @@ int MIPSInterpret_RunUntil(MIPSState *mips, u64 globalTicks) {
RunUntilDowncountZeroFast(mips);
}
RestoreHostRoundingMode();
if (CoreTiming::GetTicks(mips) > globalTicks) {
// DEBUG_LOG(Log::CPU, "Hit the max ticks, bailing 1 : %llu, %llu", globalTicks, CoreTiming::GetTicks(mips));
return 1;