At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the rarest and most valuable outcome silently became an instant 1.00x loss. At u=2 it produced a 2.1-billion-times payout the house could never cover, which would have left settlement failing and the player unpaid. The crash point is now capped at the largest multiplier the curve can express, which is unreachable anyway since the round hits its tick ceiling first. FromInt now panics outside the Q32.32 integer range instead of wrapping a positive input into a negative value. Raises coverage to 88% overall; adds a Makefile with db-reset, since the append-only ledger steadily consumes bridge headroom across test runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
3.4 KiB
Go
139 lines
3.4 KiB
Go
// Package fixed provides deterministic Q32.32 fixed-point arithmetic.
|
|
//
|
|
// No floating-point operation appears anywhere in this package. Results must be
|
|
// bit-identical across architectures and between native and WASM builds, which
|
|
// is what allows a player's browser to independently replay a round and reach
|
|
// exactly the same outcome as the server.
|
|
package fixed
|
|
|
|
import (
|
|
"math/bits"
|
|
"strconv"
|
|
)
|
|
|
|
// F is a Q32.32 fixed-point number: an int64 with 32 fractional bits.
|
|
type F int64
|
|
|
|
// One is the fixed-point representation of 1.0.
|
|
const One F = 1 << 32
|
|
|
|
const fracBits = 32
|
|
|
|
// MaxInt is the largest whole number representable in Q32.32. Values beyond
|
|
// it cannot be held in the 32 integer bits.
|
|
const MaxInt int64 = 1<<31 - 1
|
|
|
|
// MinInt is the smallest whole number representable in Q32.32.
|
|
const MinInt int64 = -(1 << 31)
|
|
|
|
// FromInt converts a whole number to fixed-point.
|
|
//
|
|
// It panics outside [MinInt, MaxInt] rather than wrapping. A silent wrap here
|
|
// produced a negative multiplier from a positive input, which is exactly the
|
|
// kind of fault that is invisible until it corrupts a payout.
|
|
func FromInt(v int64) F {
|
|
if v > MaxInt || v < MinInt {
|
|
panic("fixed: " + strconv.FormatInt(v, 10) + " is outside the Q32.32 integer range")
|
|
}
|
|
return F(v << fracBits)
|
|
}
|
|
|
|
// Int truncates toward negative infinity and returns the whole part.
|
|
func (a F) Int() int64 { return int64(a) >> fracBits }
|
|
|
|
func (a F) Add(b F) F { return a + b }
|
|
func (a F) Sub(b F) F { return a - b }
|
|
|
|
// Mul multiplies via a 128-bit intermediate so no precision is lost before the
|
|
// shift back down. A naive (a*b)>>32 overflows for operands above roughly 2^15.
|
|
func (a F) Mul(b F) F {
|
|
neg := false
|
|
x, y := int64(a), int64(b)
|
|
if x < 0 {
|
|
x, neg = -x, !neg
|
|
}
|
|
if y < 0 {
|
|
y, neg = -y, !neg
|
|
}
|
|
hi, lo := bits.Mul64(uint64(x), uint64(y))
|
|
res := int64(lo>>fracBits | hi<<(64-fracBits))
|
|
if neg {
|
|
res = -res
|
|
}
|
|
return F(res)
|
|
}
|
|
|
|
// Div divides via a 128-bit intermediate for the same reason as Mul.
|
|
func (a F) Div(b F) F {
|
|
if b == 0 {
|
|
panic("fixed: division by zero")
|
|
}
|
|
neg := false
|
|
x, y := int64(a), int64(b)
|
|
if x < 0 {
|
|
x, neg = -x, !neg
|
|
}
|
|
if y < 0 {
|
|
y, neg = -y, !neg
|
|
}
|
|
hi := uint64(x) >> (64 - fracBits)
|
|
lo := uint64(x) << fracBits
|
|
q, _ := bits.Div64(hi, lo, uint64(y))
|
|
res := int64(q)
|
|
if neg {
|
|
res = -res
|
|
}
|
|
return F(res)
|
|
}
|
|
|
|
// Sqrt returns the fixed-point square root using integer Newton iteration.
|
|
// It converges in well under the iteration cap for the full int64 range.
|
|
func Sqrt(a F) F {
|
|
if a < 0 {
|
|
panic("fixed: sqrt of negative")
|
|
}
|
|
if a == 0 {
|
|
return 0
|
|
}
|
|
// Initial guess: half the bit length puts us within a factor of two.
|
|
shift := uint(bits.Len64(uint64(a))+fracBits) / 2
|
|
x := F(1) << shift
|
|
for i := 0; i < 64; i++ {
|
|
next := (x + a.Div(x)) / 2
|
|
if next == x || next == x-1 {
|
|
x = next
|
|
break
|
|
}
|
|
x = next
|
|
}
|
|
// Newton can land one ulp high; step down while the square exceeds the input.
|
|
for x > 0 && x.Mul(x) > a {
|
|
x--
|
|
}
|
|
return x
|
|
}
|
|
|
|
// String renders the value with six fractional digits, using integer math only.
|
|
func (a F) String() string {
|
|
neg := a < 0
|
|
if neg {
|
|
a = -a
|
|
}
|
|
whole := int64(a) >> fracBits
|
|
frac := int64(a) & (int64(One) - 1)
|
|
micros := (frac * 1_000_000) >> fracBits
|
|
s := strconv.FormatInt(whole, 10) + "." + pad6(micros)
|
|
if neg {
|
|
return "-" + s
|
|
}
|
|
return s
|
|
}
|
|
|
|
func pad6(v int64) string {
|
|
s := strconv.FormatInt(v, 10)
|
|
for len(s) < 6 {
|
|
s = "0" + s
|
|
}
|
|
return s
|
|
}
|