From f24eef96d2a0432e52cfc3b3b240711208d4a180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trung=20L=C3=AA?= <8@tle.id.au> Date: Tue, 30 Jun 2026 00:46:40 +1000 Subject: [PATCH 1/3] ppc64-QVM: zero-extend cached OP_LOAD1/LOAD2 instead of sign-extend The register-caching fast path reduced an already-cached wider value to a byte/halfword with EXTSB/EXTSH (sign extend). OP_LOAD1/OP_LOAD2 are unsigned loads, so any cached byte >= 0x80 (or halfword >= 0x8000) came back negative, diverging from the interpreter and the fresh-load path (LBZ/LHZ). This corrupted byte-sized cgame values and made HUD/item icons disappear under the ppc64le JIT (CPMA, some OpenArena mods). Use CLRLDI to zero-extend, matching reduce_map_size() unsigned constant folding and the aarch64 JIT UXTB/UXTH. The genuine OP_SEX8/OP_SEX16 sign-extends are unaffected. Fixes ec-/Quake3e#411 --- code/qcommon/vm_powerpc.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/qcommon/vm_powerpc.c b/code/qcommon/vm_powerpc.c index b6760bef2e..23ec4bdf3b 100644 --- a/code/qcommon/vm_powerpc.c +++ b/code/qcommon/vm_powerpc.c @@ -2177,12 +2177,12 @@ qboolean VM_Compile( vm_t *vm, vmHeader_t *header ) switch ( ci->op ) { case OP_LOAD1: if ( reg->ext != Z_EXT8 ) { - emit( PPC_EXTSB( rx[0], rx[0] ) ); // RX = (unsigned byte) RX + emit( PPC_CLRLDI( rx[0], rx[0], 56 ) ); // RX = (unsigned byte) RX (zero-extend) reduce_map_size( reg, 1 ); } break; case OP_LOAD2: if ( reg->ext != Z_EXT16 ) { - emit( PPC_EXTSH( rx[0], rx[0] ) ); // RX = (unsigned short) RX + emit( PPC_CLRLDI( rx[0], rx[0], 48 ) ); // RX = (unsigned short) RX (zero-extend) reduce_map_size( reg, 2 ); } break; case OP_LOAD4: From ecfcc6d1df6f3170c5fca20ed6e00833b4a2c3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trung=20L=C3=AA?= <8@tle.id.au> Date: Tue, 30 Jun 2026 08:55:51 +1000 Subject: [PATCH 2/3] ppc64-QVM: do not clobber an aliased register when reducing a cached load The cached OP_LOAD1/LOAD2 fast path zero-extended the cached register in place. If that register was still a live value elsewhere on the opStack (e.g. a wider constant reused across stores of different sizes, or a prior load result still pending), the in-place reduction corrupted that value - the JIT returned a result differing from the interpreter by a power of two. Mirror the clone-on-alias guard already used by load_rx_opstack/finish_rx: when search_opstack() shows the register is live elsewhere, zero-extend a copy and leave the cached register untouched; otherwise reduce in place as before. OP_LOAD4 is unchanged. Found and verified with a JIT-vs-interpreter differential fuzzer: reproduced at iteration 17966 before, 0 divergences over 5,000,000 iterations after. --- code/qcommon/vm_powerpc.c | 42 ++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/code/qcommon/vm_powerpc.c b/code/qcommon/vm_powerpc.c index 23ec4bdf3b..65d6135078 100644 --- a/code/qcommon/vm_powerpc.c +++ b/code/qcommon/vm_powerpc.c @@ -2173,23 +2173,33 @@ qboolean VM_Compile( vm_t *vm, vmHeader_t *header ) default: var.size = 4; break; } if ( (reg = find_rx_var( &rx[0], &var )) != NULL ) { - // already cached in some register, do zero extension if needed - switch ( ci->op ) { - case OP_LOAD1: - if ( reg->ext != Z_EXT8 ) { - emit( PPC_CLRLDI( rx[0], rx[0], 56 ) ); // RX = (unsigned byte) RX (zero-extend) - reduce_map_size( reg, 1 ); - } break; - case OP_LOAD2: - if ( reg->ext != Z_EXT16 ) { - emit( PPC_CLRLDI( rx[0], rx[0], 48 ) ); // RX = (unsigned short) RX (zero-extend) - reduce_map_size( reg, 2 ); - } break; - case OP_LOAD4: - reg->ext = Z_NONE; - break; + // already cached in some register + if ( ci->op == OP_LOAD4 ) { + reg->ext = Z_NONE; + mask_rx( rx[0] ); + } else { + const ext_t need = ( ci->op == OP_LOAD1 ) ? Z_EXT8 : Z_EXT16; + const int bits = ( ci->op == OP_LOAD1 ) ? 56 : 48; + if ( reg->ext == need ) { + // already zero-extended to the requested width + mask_rx( rx[0] ); + } else if ( search_opstack( TYPE_RX, rx[0] ) ) { + // the cached register is still a live value elsewhere + // on the opStack (e.g. a wider constant reused across + // stores of different sizes). Zero-extend a *copy* so + // the in-place reduction can't clobber that value - + // mirrors the clone-on-alias guard in finish_rx(). + mask_rx( rx[0] ); // clone_rx unmasks the source + rx[0] = clone_rx( rx[0] ); // new masked reg = copy + emit( PPC_CLRLDI( rx[0], rx[0], bits ) ); // (unsigned) zero-extend copy + set_rx_ext( rx[0], need ); + } else { + // sole owner: zero-extend in place and update the cache + emit( PPC_CLRLDI( rx[0], rx[0], bits ) ); // RX = (unsigned) RX + reduce_map_size( reg, ( ci->op == OP_LOAD1 ) ? 1 : 2 ); + mask_rx( rx[0] ); + } } - mask_rx( rx[0] ); } else { // not found in vars, perform load rx[0] = alloc_rx( R3 ); // allocate target register From 4dc2ff1676cd5db2c203810a754c4dc0cec88d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trung=20L=C3=AA?= <8@tle.id.au> Date: Tue, 30 Jun 2026 01:22:45 +1000 Subject: [PATCH 3/3] ppc64-QVM: honor IEEE unordered semantics for float <= and >= OP_LEF/OP_GEF mapped to a single CR0 test (branch if !GT / !LT). PPC FCMPU sets only CR0[SO] when an operand is NaN, leaving LT/GT/EQ clear, so those conditions wrongly took the branch on unordered operands - diverging from the interpreter and aarch64 (where NaN <= / >= is false). Fold the unordered bit into the tested bit via cror before the branch (GT |= SO for <=, LT |= SO for >=) so a NaN operand does not branch. Ordered comparisons and the other four float compares are unaffected; the integer LE/GE paths are untouched. Verified with a differential harness (JIT vs interpreter): NaN <= / >= 1.0 returned 1 on the JIT before and 0 after, matching the interpreter. --- code/qcommon/vm_powerpc.c | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/code/qcommon/vm_powerpc.c b/code/qcommon/vm_powerpc.c index 65d6135078..71c18f37e9 100644 --- a/code/qcommon/vm_powerpc.c +++ b/code/qcommon/vm_powerpc.c @@ -580,6 +580,11 @@ static inline uint32_t _ppc_chk_ui16( int32_t v, const char *opname ) // bns target (not SO) #define PPC_BNS(off) PPC_BC(BO_FALSE, BI_SO, off) +// cror bt, ba, bb (CR[bt] = CR[ba] | CR[bb]) XL-form, xo=449 +#define PPC_CROR(bt, ba, bb) \ + ( (19u<<26) | (((unsigned)(bt)&0x1F)<<21) | (((unsigned)(ba)&0x1F)<<16) | \ + (((unsigned)(bb)&0x1F)<<11) | (449u<<1) ) + // -- Branch to LR/CTR (XL-form) -- // blr (branch to LR) #define PPC_BLR() PPC_XL(19, BO_ALWAYS, 0, 16, 0) @@ -2116,7 +2121,16 @@ qboolean VM_Compile( vm_t *vm, vmHeader_t *header ) sx[0] = load_sx_opstack( F0 | RCONST ); dec_opstack(); // F0 = *opstack; opstack -= 4 flush_nonvolatile(); emit( PPC_FCMPU( 0, sx[0], sx[1] ) ); - // emit_branchConditional( vm, ci, ci->op ); + // IEEE unordered (NaN) handling: FCMPU sets only CR0[SO] when an + // operand is NaN, leaving LT/GT/EQ clear. For <= / >= the single-bit + // condition from get_branch_cond would then wrongly take the branch, + // so fold the unordered bit into the tested bit: a NaN operand must + // NOT branch (matching the interpreter and aarch64). The other float + // compares (eq,ne,lt,gt) are already correct under NaN. + if ( ci->op == OP_LEF ) + emit( PPC_CROR( BI_GT, BI_GT, BI_SO ) ); // GT |= unordered + else if ( ci->op == OP_GEF ) + emit( PPC_CROR( BI_LT, BI_LT, BI_SO ) ); // LT |= unordered emit_branchConditionalShort( vm, ci ); unmask_sx( sx[1] ); unmask_sx( sx[0] );