From 558d140fca600a78cbde8cf9b960fe6c04676cc3 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:05:34 +0200 Subject: [PATCH 1/9] Compute Math.sin/cos/pow/log10 as HotSpot does on the host architecture Rust's float functions use the platform libm, and vanilla's own Math results differ between x86-64 (Intel stubs, close to glibc) and AArch64 (fdlibm, with sin/cos evaluated using fused multiply-adds). jmath picks the same implementation HotSpot uses on the machine, so options, the world border's alpha and RandomPos match vanilla on an Apple silicon Mac as well as on Linux. The vanilla protocol client no longer records the game-time sync sent every 20 ticks: whether one arrives before the first chunk batch depends on when the client joined. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/horizon-client/src/border_render.rs | 2 +- crates/horizon-client/src/mobs/goals.rs | 8 +- crates/horizon-client/src/options_file.rs | 4 +- crates/horizon-core/Cargo.toml | 3 + crates/horizon-core/src/jmath.rs | 611 ++++++++++++++++++ crates/horizon-core/src/lib.rs | 1 + .../parity/VanillaProtocolClient.java | 7 +- 8 files changed, 631 insertions(+), 8 deletions(-) create mode 100644 crates/horizon-core/src/jmath.rs diff --git a/Cargo.lock b/Cargo.lock index 5a8da580f..b1fabec8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,6 +700,9 @@ dependencies = [ [[package]] name = "horizon-core" version = "0.1.0" +dependencies = [ + "libm", +] [[package]] name = "horizon-protocol" diff --git a/crates/horizon-client/src/border_render.rs b/crates/horizon-client/src/border_render.rs index 422989920..ff3e7c8b3 100644 --- a/crates/horizon-client/src/border_render.rs +++ b/crates/horizon-client/src/border_render.rs @@ -78,7 +78,7 @@ pub fn extract( || z > max_z + render_distance; if !deep_inside && !far_outside { let alpha = 1.0 - border.distance(x, z) / render_distance; - state.alpha = jclamp(alpha.powf(4.0), 0.0, 1.0); + state.alpha = jclamp(horizon_core::jmath::pow(alpha, 4.0), 0.0, 1.0); state.tint = status_color(border.status()); } else { state.alpha = 0.0; diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 4dcd401c1..ec3463f96 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -1,7 +1,7 @@ //! Vanilla's `GoalSelector` (priorities, MOVE/LOOK/JUMP/TARGET control flags, //! interruption) and the shared goals of `net.minecraft.world.entity.ai.goal`. use super::{Mob, MobEvent, MobWorld, Seen, Species, Who, world::TargetInfo}; -use horizon_core::{geometry::Vec3, random::RandomSource}; +use horizon_core::{geometry::Vec3, jmath, random::RandomSource}; use std::sync::Arc; pub const MOVE: u8 = 1; @@ -208,8 +208,8 @@ impl Mob { let yrad = center + f64::from(2.0 * self.random.next_float() - 1.0) * max_rad; let dist = (min + self.random.next_double().sqrt() * (max - min)) * f64::from(std::f32::consts::SQRT_2); - let xt = -dist * yrad.sin(); - let zt = dist * yrad.cos(); + let xt = -dist * jmath::sin(yrad); + let zt = dist * jmath::cos(yrad); if xt.abs() > max || zt.abs() > max { return None; } @@ -932,7 +932,7 @@ impl Goal for RandomLookAround { } fn start(&mut self, m: &mut Mob, _: &mut Cx) { let r = std::f64::consts::TAU * m.random.next_double(); - self.rel = [r.cos(), r.sin()]; + self.rel = [jmath::cos(r), jmath::sin(r)]; self.time = 20 + m.random.next_int_bound(20).unwrap_or(0); } fn every_tick(&self) -> bool { diff --git a/crates/horizon-client/src/options_file.rs b/crates/horizon-client/src/options_file.rs index fd606fd62..5c14450ec 100644 --- a/crates/horizon-client/src/options_file.rs +++ b/crates/horizon-client/src/options_file.rs @@ -298,7 +298,7 @@ fn double_range(v: f64, lo: f64, hi: f64) -> Option { (v.total_cmp(&lo).is_ge() && v.total_cmp(&hi).is_le() && !v.is_nan()).then_some(v) } fn log_mouse(value: i32) -> f64 { - 10f64.powf(f64::from(value) / 100.0) + horizon_core::jmath::pow(10.0, f64::from(value) / 100.0) } fn unit_alt(json: &Json) -> Option { codec_double(json) @@ -366,7 +366,7 @@ impl Spec { } (Spec::Fov, V::Int(x)) => (30..=110).contains(x).then(|| v.clone()), (Spec::WheelSensitivity, V::Double(x)) => { - let i = floor_int(x.log10() * 100.0); + let i = floor_int(horizon_core::jmath::log10(*x) * 100.0); (-200..=100).contains(&i).then(|| V::Double(log_mouse(i))) } (Spec::Unit, V::Double(x)) => (*x >= 0.0 && *x <= 1.0).then(|| v.clone()), diff --git a/crates/horizon-core/Cargo.toml b/crates/horizon-core/Cargo.toml index 655472a35..97c9356b8 100644 --- a/crates/horizon-core/Cargo.toml +++ b/crates/horizon-core/Cargo.toml @@ -10,6 +10,9 @@ description = "Horizon simulation foundations for the pinned vanilla Java Editio # Records the call sites of LegacyRandom draws (mob AI parity debugging). rng-trace = [] +[dependencies] +libm = "=0.2.16" + [[bin]] name = "horizon-parity" path = "src/bin/parity.rs" diff --git a/crates/horizon-core/src/jmath.rs b/crates/horizon-core/src/jmath.rs new file mode 100644 index 000000000..90bb9abfe --- /dev/null +++ b/crates/horizon-core/src/jmath.rs @@ -0,0 +1,611 @@ +//! `java.lang.Math` as HotSpot computes it on the machine Horizon runs on. +//! +//! Rust's float functions call the platform's libm, and those round +//! differently: glibc, Apple's libm and fdlibm disagree in the last bit for +//! a few percent of arguments. Vanilla's own results differ between machines +//! too, because HotSpot replaces some `Math` methods with intrinsics: +//! +//! - `atan2`, `atan`, `asin`, `acos` and `hypot` are `StrictMath`'s (fdlibm) +//! everywhere. +//! - On x86-64, `sin`, `cos`, `tan`, `exp`, `log`, `log10` and `pow` are +//! Intel's libm stubs, which round like glibc for nearly every argument; +//! there they use the platform libm, as before. +//! - On AArch64 only `sin` and `cos` are intrinsics: fdlibm's kernels +//! evaluated with fused multiply-adds (`macroAssembler_aarch64_trig.cpp`). +//! Everything else is `StrictMath`. +//! +//! So these functions return what vanilla returns on the same machine, on +//! Linux and on an Apple silicon Mac alike. + +/// `Math.sin`. +pub fn sin(x: f64) -> f64 { + if cfg!(target_arch = "aarch64") { + aarch64::sin(x) + } else { + x.sin() + } +} + +/// `Math.cos`. +pub fn cos(x: f64) -> f64 { + if cfg!(target_arch = "aarch64") { + aarch64::cos(x) + } else { + x.cos() + } +} + +/// `Math.tan`. +pub fn tan(x: f64) -> f64 { + if cfg!(target_arch = "aarch64") { + libm::tan(x) + } else { + x.tan() + } +} + +/// `Math.exp`. +pub fn exp(x: f64) -> f64 { + if cfg!(target_arch = "aarch64") { + libm::exp(x) + } else { + x.exp() + } +} + +/// `Math.log`. +pub fn log(x: f64) -> f64 { + if cfg!(target_arch = "aarch64") { + fdlibm::log(x) + } else { + x.ln() + } +} + +/// `Math.log10`. +pub fn log10(x: f64) -> f64 { + if cfg!(target_arch = "aarch64") { + fdlibm::log10(x) + } else { + x.log10() + } +} + +/// `Math.pow`. +pub fn pow(x: f64, y: f64) -> f64 { + if cfg!(target_arch = "aarch64") { + libm::pow(x, y) + } else { + x.powf(y) + } +} + +/// `Math.cbrt`. +pub fn cbrt(x: f64) -> f64 { + if cfg!(target_arch = "aarch64") { + fdlibm::cbrt(x) + } else { + x.cbrt() + } +} + +/// `Math.atan2`. +pub fn atan2(y: f64, x: f64) -> f64 { + libm::atan2(y, x) +} + +/// `Math.atan`. +pub fn atan(x: f64) -> f64 { + libm::atan(x) +} + +/// `Math.asin`. +pub fn asin(x: f64) -> f64 { + libm::asin(x) +} + +/// `Math.acos`. +pub fn acos(x: f64) -> f64 { + libm::acos(x) +} + +/// `Math.hypot`. +pub fn hypot(x: f64, y: f64) -> f64 { + fdlibm::hypot(x, y) +} + +fn hi(x: f64) -> i32 { + (x.to_bits() >> 32) as i32 +} + +fn with_hi(x: f64, high: i32) -> f64 { + f64::from_bits((x.to_bits() & 0xffff_ffff) | (u64::from(high as u32) << 32)) +} + +/// `java.lang.FdLibm` ports (`StrictMath`) of what the `libm` crate computes +/// differently. +#[allow(clippy::excessive_precision)] // fdlibm's constants, verbatim. +pub mod fdlibm { + use super::{hi, with_hi}; + + const TWO54: f64 = 1.80143985094819840000e+16; + + /// `FdLibm.Log`. + pub fn log(mut x: f64) -> f64 { + const LN2_HI: f64 = 6.93147180369123816490e-01; + const LN2_LO: f64 = 1.90821492927058770002e-10; + const LG1: f64 = 6.666666666666735130e-01; + const LG2: f64 = 3.999999999940941908e-01; + const LG3: f64 = 2.857142874366239149e-01; + const LG4: f64 = 2.222219843214978396e-01; + const LG5: f64 = 1.818357216161805012e-01; + const LG6: f64 = 1.531383769920937332e-01; + const LG7: f64 = 1.479819860511658591e-01; + let mut hx = hi(x); + let lx = x.to_bits() as u32; + let mut k = 0; + if hx < 0x0010_0000 { + if ((hx & 0x7fff_ffff) as u32 | lx) == 0 { + return -TWO54 / 0.0; + } + if hx < 0 { + return (x - x) / 0.0; + } + k -= 54; + x *= TWO54; + hx = hi(x); + } + if hx >= 0x7ff0_0000 { + return x + x; + } + k += (hx >> 20) - 1023; + hx &= 0x000f_ffff; + let i = (hx + 0x9_5f64) & 0x10_0000; + x = with_hi(x, hx | (i ^ 0x3ff0_0000)); + k += i >> 20; + let f = x - 1.0; + if (0x000f_ffff & (2 + hx)) < 3 { + if f == 0.0 { + if k == 0 { + return 0.0; + } + let dk = f64::from(k); + return dk * LN2_HI + dk * LN2_LO; + } + let r = f * f * (0.5 - 0.33333333333333333 * f); + if k == 0 { + return f - r; + } + let dk = f64::from(k); + return dk * LN2_HI - ((r - dk * LN2_LO) - f); + } + let s = f / (2.0 + f); + let dk = f64::from(k); + let z = s * s; + let mut i = hx - 0x6_147a; + let w = z * z; + let j = 0x6b851 - hx; + let t1 = w * (LG2 + w * (LG4 + w * LG6)); + let t2 = z * (LG1 + w * (LG3 + w * (LG5 + w * LG7))); + i |= j; + let r = t2 + t1; + if i > 0 { + let hfsq = 0.5 * f * f; + if k == 0 { + f - (hfsq - s * (hfsq + r)) + } else { + dk * LN2_HI - ((hfsq - (s * (hfsq + r) + dk * LN2_LO)) - f) + } + } else if k == 0 { + f - s * (f - r) + } else { + dk * LN2_HI - ((s * (f - r) - dk * LN2_LO) - f) + } + } + + /// `FdLibm.Log10`. + pub fn log10(mut x: f64) -> f64 { + const IVLN10: f64 = 4.34294481903251816668e-01; + const LOG10_2HI: f64 = 3.01029995663611771306e-01; + const LOG10_2LO: f64 = 3.69423907715893078616e-13; + let mut hx = hi(x); + let lx = x.to_bits() as u32; + let mut k = 0; + if hx < 0x0010_0000 { + if ((hx & 0x7fff_ffff) as u32 | lx) == 0 { + return -TWO54 / 0.0; + } + if hx < 0 { + return (x - x) / 0.0; + } + k -= 54; + x *= TWO54; + hx = hi(x); + } + if hx >= 0x7ff0_0000 { + return x + x; + } + k += (hx >> 20) - 1023; + let i = ((k as u32) >> 31) as i32; + hx = (hx & 0x000f_ffff) | ((0x3ff - i) << 20); + let y = f64::from(k + i); + x = with_hi(x, hx); + let z = y * LOG10_2LO + IVLN10 * log(x); + z + y * LOG10_2HI + } + + /// `FdLibm.Cbrt`. + pub fn cbrt(mut x: f64) -> f64 { + const B1: i32 = 715094163; + const B2: i32 = 696219795; + const C: f64 = 5.42857142857142815906e-01; + const D: f64 = -7.05306122448979611050e-01; + const E: f64 = 1.41428571428571436819e+00; + const F: f64 = 1.60714285714285720630e+00; + const G: f64 = 3.57142857142857150787e-01; + if x == 0.0 || !x.is_finite() { + return x; + } + let sign = if x < 0.0 { -1.0 } else { 1.0 }; + x = x.abs(); + let mut t = 0.0f64; + if x < f64::MIN_POSITIVE { + t = 18014398509481984.0; // 2^54 + t *= x; + t = with_hi(t, hi(t) / 3 + B2); + } else { + t = with_hi(t, hi(x) / 3 + B1); + } + let mut r = t * t / x; + let mut s = C + r * t; + t *= G + F / (s + E + D / s); + t = f64::from_bits(t.to_bits() & 0xffff_ffff_0000_0000); + t = with_hi(t, hi(t) + 1); + s = t * t; + r = x / s; + let w = t + t; + r = (r - t) / (w + r); + t += t * r; + sign * t + } + + /// `FdLibm.Hypot`. + pub fn hypot(x: f64, y: f64) -> f64 { + let mut a = x.abs(); + let mut b = y.abs(); + if !a.is_finite() || !b.is_finite() { + if a == f64::INFINITY || b == f64::INFINITY { + return f64::INFINITY; + } + return a + b; + } + if b > a { + std::mem::swap(&mut a, &mut b); + } + let mut ha = hi(a); + let mut hb = hi(b); + if ha - hb > 0x3c0_0000 { + return a + b; + } + let mut k = 0; + if a > f64::from_bits(0x5f30_0000_ffff_ffff) { + ha -= 0x2580_0000; + hb -= 0x2580_0000; + a *= f64::from_bits(0x1a70_0000_0000_0000); // 2^-600 + b *= f64::from_bits(0x1a70_0000_0000_0000); + k += 600; + } + if b < f64::from_bits(0x20b0_0000_0000_0000) { + // 2^-500 + if b < f64::MIN_POSITIVE { + if b == 0.0 { + return a; + } + let t1 = f64::from_bits(0x7fd0_0000_0000_0000); // 2^1022 + b *= t1; + a *= t1; + k -= 1022; + } else { + ha += 0x2580_0000; + hb += 0x2580_0000; + a *= f64::from_bits(0x6570_0000_0000_0000); // 2^600 + b *= f64::from_bits(0x6570_0000_0000_0000); + k -= 600; + } + } + let mut w = a - b; + if w > b { + let t1 = with_hi(0.0, ha); + let t2 = a - t1; + w = (t1 * t1 - (b * (-b) - t2 * (a + t1))).sqrt(); + } else { + a += a; + let y1 = with_hi(0.0, hb); + let y2 = b - y1; + let t1 = with_hi(0.0, ha + 0x0010_0000); + let t2 = a - t1; + w = (t1 * y1 - (w * (-w) - (t1 * y2 + t2 * b))).sqrt(); + } + if k != 0 { + f64::from_bits(((1023 + k) as u64) << 52) * w + } else { + w + } + } +} + +/// HotSpot's AArch64 `Math.sin` / `Math.cos` stubs +/// (`MacroAssembler::generate_dsin_dcos`): fdlibm with fused multiply-adds. +/// Exposed on every architecture so they can be tested anywhere. +#[allow(clippy::excessive_precision, clippy::unreadable_literal)] // HotSpot's tables, verbatim. +pub mod aarch64 { + use super::hi; + + const S: [f64; 6] = [ + -1.66666666666666324348e-01, + 8.33333333332248946124e-03, + -1.98412698298579493134e-04, + 2.75573137070700676789e-06, + -2.50507602534068634195e-08, + 1.58969099521155010221e-10, + ]; + const C: [f64; 6] = [ + 4.16666666666666019037e-02, + -1.38888888888741095749e-03, + 2.48015872894767294178e-05, + -2.75573143513906633035e-07, + 2.08757232129817482790e-09, + -1.13596475577881948265e-11, + ]; + const INVPIO2: f64 = 6.36619772367581382433e-01; + const PIO2_1: f64 = 1.57079632673412561417e+00; + const PIO2_1T: f64 = 6.07710050650619224932e-11; + const PIO2_2: f64 = 6.07710050630396597660e-11; + const PIO2_2T: f64 = 2.02226624879595063154e-21; + const PIO2_3: f64 = 2.02226624871116645580e-21; + const PIO2_3T: f64 = 8.47842766036889956997e-32; + const NPIO2_HW: [i32; 32] = [ + 0x3FF921FB, 0x400921FB, 0x4012D97C, 0x401921FB, 0x401F6A7A, 0x4022D97C, 0x4025FDBB, + 0x402921FB, 0x402C463A, 0x402F6A7A, 0x4031475C, 0x4032D97C, 0x40346B9C, 0x4035FDBB, + 0x40378FDB, 0x403921FB, 0x403AB41B, 0x403C463A, 0x403DD85A, 0x403F6A7A, 0x40407E4C, + 0x4041475C, 0x4042106C, 0x4042D97C, 0x4043A28C, 0x40446B9C, 0x404534AC, 0x4045FDBB, + 0x4046C6CB, 0x40478FDB, 0x404858EB, 0x404921FB, + ]; + + /// `generate_kernel_sin`. + fn kernel_sin(x: f64, y: f64, iy: bool) -> f64 { + let z = x * x; + let v = z * x; + let mut r = z.mul_add(S[5], S[4]); + r = z.mul_add(r, S[3]); + r = z.mul_add(r, S[2]); + r = z.mul_add(r, S[1]); + if !iy { + v.mul_add(z.mul_add(r, S[0]), x) + } else { + let t = (-v).mul_add(r, 0.5 * y); + let t = (-z).mul_add(t, y); + x + v.mul_add(S[0], t) + } + } + + /// `generate_kernel_cos`. `ix` is the high word of the *original* + /// argument: the stub reuses the register, so a reduced argument always + /// takes the `qx = 0.28125` branch. + fn kernel_cos(x: f64, y: f64, ix: i32) -> f64 { + let z = x * x; + let mut r = z.mul_add(C[5], C[4]); + r = z.mul_add(r, C[3]); + let xy = x * y; + r = z.mul_add(r, C[2]); + r = z.mul_add(r, C[1]); + let zz = z * z; + r = z.mul_add(r, C[0]); + if ix <= 0x3FD33333 { + let v = (-zz).mul_add(r, xy); + 1.0 - 0.5f64.mul_add(z, v) + } else { + let qx = if ix > 0x3fe90000 { + 0.28125 + } else { + f64::from_bits(u64::from((ix - 0x0020_0000) as u32) << 32) + }; + let zr_xy = zz.mul_add(r, -xy); + let h = 0.5f64.mul_add(z, -qx); + let a = 1.0 - qx; + a - (h - zr_xy) + } + } + + /// `generate__ieee754_rem_pio2` for |x| <= 2^19·π/2; `None` beyond. + fn rem_pio2(x: f64, ix: i32) -> Option<(i32, f64, f64)> { + if ix < 0x4002d97c { + return Some(if x.to_bits() as i64 >= 0 { + let z = x - PIO2_1; + if ix != 0x3ff921fb { + let y0 = z - PIO2_1T; + (1, y0, (z - y0) - PIO2_1T) + } else { + let z = z - PIO2_2; + let y0 = z - PIO2_2T; + (1, y0, (z - y0) - PIO2_2T) + } + } else { + let z = x + PIO2_1; + if ix != 0x3ff921fb { + let y0 = z + PIO2_1T; + (-1, y0, (z - y0) + PIO2_1T) + } else { + let z = z + PIO2_2; + let y0 = z + PIO2_2T; + (-1, y0, (z - y0) + PIO2_2T) + } + }); + } + if ix > 0x413921fb { + return None; + } + let t = x.abs(); + let fnv = t.mul_add(INVPIO2, 0.5).trunc(); + let n = fnv as i32; + let mut r = (-fnv).mul_add(PIO2_1, t); + let mut w = fnv * PIO2_1T; + let mut y0 = r - w; + if n > 32 || ix == NPIO2_HW[(n - 1) as usize] { + let j = ix >> 20; + let exponent = |y: f64| ((y.to_bits() << 1) >> 53) as i32; + if j - exponent(y0) > 16 { + let t = r; + let w2 = fnv * PIO2_2; + r = t - w2; + w = fnv.mul_add(PIO2_2T, w2 - (t - r)); + y0 = r - w; + if j - exponent(y0) > 49 { + let t = r; + let w3 = fnv * PIO2_3; + r = t - w3; + w = fnv.mul_add(PIO2_3T, w3 - (t - r)); + y0 = r - w; + } + } + } + let y1 = (r - y0) - w; + Some(if x > 0.0 { (n, y0, y1) } else { (-n, -y0, -y1) }) + } + + fn sin_cos(x: f64, is_cos: bool) -> f64 { + let ix = (hi(x) & 0x7fff_ffff) as i32; + if ix < 0x3e400000 { + return if is_cos { 1.0 } else { x }; + } + if ix <= 0x3fe921fb { + return if is_cos { + kernel_cos(x, 0.0, ix) + } else { + kernel_sin(x, 0.0, false) + }; + } + if ix >= 0x7ff00000 { + return f64::from_bits(0x7ff0_0000_0000_0001); + } + // Beyond 2^19·π/2 the stub's Payne–Hanek reduction isn't ported: + // StrictMath's is used instead. + let Some((n, y0, y1)) = rem_pio2(x, ix) else { + return if is_cos { libm::cos(x) } else { libm::sin(x) }; + }; + let (value, negate) = if (n & 1 == 1) != is_cos { + ( + kernel_cos(y0, y1, ix), + if is_cos { (n ^ (n >> 1)) & 1 } else { n & 2 }, + ) + } else { + ( + kernel_sin(y0, y1, true), + if is_cos { (n ^ (n >> 1)) & 1 } else { n & 2 }, + ) + }; + if negate != 0 { -value } else { value } + } + + /// `Math.sin` on AArch64 HotSpot. + pub fn sin(x: f64) -> f64 { + sin_cos(x, false) + } + + /// `Math.cos` on AArch64 HotSpot. + pub fn cos(x: f64) -> f64 { + sin_cos(x, true) + } +} + +#[cfg(test)] +mod tests { + use super::{aarch64, fdlibm}; + + fn check1(f: fn(f64) -> f64, (x, expected): (u64, u64)) { + assert_eq!(f(f64::from_bits(x)).to_bits(), expected, "{x:x}"); + } + + fn check2(f: fn(f64, f64) -> f64, (x, y, expected): (u64, u64, u64)) { + assert_eq!( + f(f64::from_bits(x), f64::from_bits(y)).to_bits(), + expected, + "{x:x} {y:x}" + ); + } + + #[test] + fn matches_hotspot_on_aarch64() { + // (argument bits, Math result bits) from HotSpot 25 on an Apple M4, each + // one where glibc rounds differently. + // sin + check1(aarch64::sin, (0x3fe63c5ab66b09f0, 0x3fe47d34215b54f7)); + check1(aarch64::sin, (0xc0569150fe105e6f, 0xbfe7bc19d65ab55c)); + check1(aarch64::sin, (0x4072c3f15b47b5d6, 0xbfef32be0f0a9124)); + check1(aarch64::sin, (0x3fee86c6ffce047e, 0x3fea1a47f46cbf28)); + // cos + check1(aarch64::cos, (0x3fbb10690eeadb00, 0x3fefd24375dbae28)); + check1(aarch64::cos, (0xc0496cc08a65245d, 0x3feab1a44a8022c4)); + check1(aarch64::cos, (0x40099262f89e73f8, 0xbfeff3aa148795ce)); + check1(aarch64::cos, (0x4001ce4e022387bc, 0xbfe37dd512be4d00)); + // log + check1(fdlibm::log, (0x40569150fe105e6f, 0x401202e102f27b78)); + check1(fdlibm::log, (0x3ff5dec70f518390, 0x3fd4009cda1f8856)); + check1(fdlibm::log, (0x3feb3d17325d493e, 0xbfc49ee8c4866f84)); + check1(fdlibm::log, (0x3fe735a40edf77ba, 0xbfd48e1816b18bf2)); + // log10 + check1(fdlibm::log10, (0x3ff5dec70f518390, 0x3fc15fb52aa5f051)); + check1(fdlibm::log10, (0x3feb3d17325d493e, 0xbfb1e933cae02f5f)); + check1(fdlibm::log10, (0x3fe735a40edf77ba, 0xbfc1da98cbb8974c)); + check1(fdlibm::log10, (0x3fe7febd9ca1bab0, 0xbfc000e8e3b0b56e)); + // cbrt + check1(fdlibm::cbrt, (0x401bb21f8568bd0c, 0x3ffe7ee30c7a56f9)); + check1(fdlibm::cbrt, (0x3fc59f3a45c8fb80, 0x3fe1b0791e0f8a74)); + check1(fdlibm::cbrt, (0xbfdd73cdc693ddab, 0xbfe8b4a6f43f1b73)); + check1(fdlibm::cbrt, (0xbfeb0715a8425e10, 0xbfee3f99498da6f6)); + // hypot + check2( + fdlibm::hypot, + (0xc0569150fe105e6f, 0xc0056b970bf3b41a, 0x405693db72f461a6), + ); + check2( + fdlibm::hypot, + (0x3fdc5999df9270f4, 0xc006c3ad990f72c0, 0x400709de26a1e40a), + ); + check2( + fdlibm::hypot, + (0x401bb21f8568bd0c, 0xbffeb93d1a2ef904, 0x401cbdbb623d20c3), + ); + check2( + fdlibm::hypot, + (0x4026d31c32ddb50b, 0xc00e7b2771d6867c, 0x40281027bb313aae), + ); + // pow + check2( + libm::pow, + (0x3fee04c313607ea6, 0xc00fd156138baf96, 0x3ff4a199ac2c8e9c), + ); + check2( + libm::pow, + (0x401bb21f8568bd0c, 0xbffeb93d1a2ef904, 0x3f98ecc16a60feee), + ); + check2( + libm::pow, + (0x407383ff427dadbd, 0x3ffdc5d178d00264, 0x40e56695c058d704), + ); + check2( + libm::pow, + (0x3fe0c95b66dd6030, 0xc00c9cfde259a3e6, 0x4024194449e9deca), + ); + // exp + check1(libm::exp, (0x3fdc5999df9270f4, 0x3ff8eacc53fdbf56)); + check1(libm::exp, (0x3fcffff502ecbd80, 0x3ff48b5c78bca894)); + check1(libm::exp, (0x3fc59f3a45c8fb80, 0x3ff2f1c6cb35728c)); + check1(libm::exp, (0x4056561bf10ba7f6, 0x47fdd21dc9013084)); + // tan + check1(libm::tan, (0x40023034f56881f0, 0xbff2e3f762ac9810)); + check1(libm::tan, (0xbfebf862877c88ec, 0xbff31f61e3e8539e)); + check1(libm::tan, (0xc00e37e234490564, 0xbfe79ca6eee66726)); + check1(libm::tan, (0xc075c085c04800b5, 0x3fea18b4a92fcf22)); + } +} diff --git a/crates/horizon-core/src/lib.rs b/crates/horizon-core/src/lib.rs index 79045910a..5b46ce2ef 100644 --- a/crates/horizon-core/src/lib.rs +++ b/crates/horizon-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod biome; pub mod carving; pub mod density; pub mod geometry; +pub mod jmath; pub mod noise; pub mod paletted; pub mod position; diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java index 3a21aaae2..468e0ee61 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java @@ -476,7 +476,12 @@ public final class VanillaProtocolClient { record("play", r.type + " " + json(p.reason())); break; } - if (ordered) { + // MinecraftServer.tickServer syncs the game time every 20 ticks + // (no clocks): whether one lands before the first chunk batch + // depends on when the client joined, so it isn't compared. + boolean periodicTime = packet instanceof net.minecraft.network.protocol.game.ClientboundSetTimePacket p + && p.clockUpdates().isEmpty(); + if (ordered && !periodicTime) { record("play-start", r.type + " " + summary(r)); } if (packet instanceof net.minecraft.network.protocol.game.ClientboundCommandsPacket p && counts.get(r.type) == 1) { From ab9109c7874c630b9cdd51339aaad64bdbf97269 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:06:48 +0200 Subject: [PATCH 2/9] Route world generation's and mob AI's Math calls through jmath Beardifier, density pow/log, Marsaglia's Gaussian, dripstone, ores, fancy trunks, icebergs, end spikes, strongholds, note pitches, sculk charge particles, the warden's and brains' weighted shuffles. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/animal_brain.rs | 2 +- crates/horizon-client/src/mobs/monster_brain.rs | 2 +- crates/horizon-client/src/mobs/villager_brain.rs | 4 ++-- crates/horizon-client/src/mobs/warden.rs | 4 ++-- crates/horizon-core/src/density/arithmetic.rs | 4 ++-- crates/horizon-core/src/density/beardifier.rs | 2 +- crates/horizon-core/src/density/interval.rs | 2 +- crates/horizon-core/src/random.rs | 2 +- crates/horizon-world/src/level/redstone.rs | 2 +- crates/horizon-world/src/level/sculk.rs | 3 ++- crates/horizon-world/src/worldgen/dripstone.rs | 6 +++--- crates/horizon-world/src/worldgen/end_spike.rs | 4 ++-- crates/horizon-world/src/worldgen/iceberg.rs | 10 +++++++--- crates/horizon-world/src/worldgen/ore_feature.rs | 5 ++++- .../horizon-world/src/worldgen/structure/stronghold.rs | 5 ++++- crates/horizon-world/src/worldgen/tree/trunk.rs | 9 +++++---- tools/parity_hosts.json | 7 +------ 17 files changed, 40 insertions(+), 33 deletions(-) diff --git a/crates/horizon-client/src/mobs/animal_brain.rs b/crates/horizon-client/src/mobs/animal_brain.rs index 9edf2accf..cf7862773 100644 --- a/crates/horizon-client/src/mobs/animal_brain.rs +++ b/crates/horizon-client/src/mobs/animal_brain.rs @@ -742,7 +742,7 @@ impl Gate { // current order, then a stable sort. for k in &mut self.kids { let r = self.random.next_float(); - k.2 = -f64::from(r).powf(f64::from(1.0_f32 / k.1 as f32)); + k.2 = -horizon_core::jmath::pow(f64::from(r), f64::from(1.0_f32 / k.1 as f32)); } self.kids.sort_by(|a, b| a.2.total_cmp(&b.2)); for k in &mut self.kids { diff --git a/crates/horizon-client/src/mobs/monster_brain.rs b/crates/horizon-client/src/mobs/monster_brain.rs index c770858a6..bc2cfe705 100644 --- a/crates/horizon-client/src/mobs/monster_brain.rs +++ b/crates/horizon-client/src/mobs/monster_brain.rs @@ -550,7 +550,7 @@ impl RunOne { // order, then a stable sort. for k in &mut self.kids { let r = self.random.next_float(); - k.2 = -f64::from(r).powf(f64::from(1.0_f32 / k.1 as f32)); + k.2 = -horizon_core::jmath::pow(f64::from(r), f64::from(1.0_f32 / k.1 as f32)); } self.kids.sort_by(|a, b| a.2.total_cmp(&b.2)); for k in &mut self.kids { diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 4d94ca2bb..63a112d0a 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -1101,7 +1101,7 @@ impl Gate { // current order, then a stable sort. for k in &mut self.kids { let r = self.random.next_float(); - k.2 = -f64::from(r).powf(f64::from(1.0_f32 / k.1 as f32)); + k.2 = -horizon_core::jmath::pow(f64::from(r), f64::from(1.0_f32 / k.1 as f32)); } self.kids.sort_by(|a, b| a.2.total_cmp(&b.2)); } @@ -2392,7 +2392,7 @@ impl B { } => { for k in kids.iter_mut() { let r = random.next_float(); - k.2 = -f64::from(r).powf(f64::from(1.0_f32 / k.1 as f32)); + k.2 = -horizon_core::jmath::pow(f64::from(r), f64::from(1.0_f32 / k.1 as f32)); } kids.sort_by(|a, b| a.2.total_cmp(&b.2)); for k in kids.iter_mut() { diff --git a/crates/horizon-client/src/mobs/warden.rs b/crates/horizon-client/src/mobs/warden.rs index 11394a011..44544a4f8 100644 --- a/crates/horizon-client/src/mobs/warden.rs +++ b/crates/horizon-client/src/mobs/warden.rs @@ -1061,8 +1061,8 @@ fn attack(m: &mut Mob, target: Who, cx: &mut Cx) { fn run_one(m: &mut Mob, st: &mut Warden, cx: &mut Cx) { let now = i64::from(m.age); // ShufflingList.shuffle: each entry sorts by -ln(random) / weight. - let stroll_key = -f64::from(m.random.next_float()).ln() / 2.0; - let pause_key = -f64::from(m.random.next_float()).ln(); + let stroll_key = -horizon_core::jmath::log(f64::from(m.random.next_float())) / 2.0; + let pause_key = -horizon_core::jmath::log(f64::from(m.random.next_float())); let pause_first = pause_key < stroll_key; let order = if pause_first { [false, true] diff --git a/crates/horizon-core/src/density/arithmetic.rs b/crates/horizon-core/src/density/arithmetic.rs index 296a071e8..f82b96ce2 100644 --- a/crates/horizon-core/src/density/arithmetic.rs +++ b/crates/horizon-core/src/density/arithmetic.rs @@ -8,7 +8,7 @@ pub(super) fn java_power(base: f64, exponent: f64) -> f64 { } else if exponent.is_nan() || (base.abs() == 1.0 && exponent.is_infinite()) { f64::NAN } else { - base.powf(exponent) + crate::jmath::pow(base, exponent) } } @@ -83,7 +83,7 @@ impl UnaryOp { Self::Square => value * value, Self::Cube => value * value * value, Self::Sqrt => (value as f64).sqrt() as f32, - Self::Log => (value as f64).ln() as f32, + Self::Log => crate::jmath::log(value as f64) as f32, Self::PowerBase(base) => java_power(base, value as f64) as f32, Self::PowerExponent(exponent) => java_power(value as f64, exponent) as f32, Self::Reciprocal => 1.0 / value, diff --git a/crates/horizon-core/src/density/beardifier.rs b/crates/horizon-core/src/density/beardifier.rs index 225f74714..73b94a066 100644 --- a/crates/horizon-core/src/density/beardifier.rs +++ b/crates/horizon-core/src/density/beardifier.rs @@ -208,7 +208,7 @@ fn kernel() -> &'static [f32; 13824] { let x = ((i / 24) % 24) as f64 - 12.; let y = (i % 24) as f64 - 12. + 0.5; let squared = (x * x + y * y) + z * z; - std::f64::consts::E.powf(-squared / 16.) as f32 + crate::jmath::pow(std::f64::consts::E, -squared / 16.) as f32 }) }) } diff --git a/crates/horizon-core/src/density/interval.rs b/crates/horizon-core/src/density/interval.rs index 0375dba6d..0e2652fe1 100644 --- a/crates/horizon-core/src/density/interval.rs +++ b/crates/horizon-core/src/density/interval.rs @@ -189,7 +189,7 @@ impl Interval { Ok(Self::NAI) } else { self.maximum(Self::exact(0.0)?) - .map_monotonic(|v| (v as f64).ln() as f32) + .map_monotonic(|v| crate::jmath::log(v as f64) as f32) } } pub fn power(self, exponent: Self) -> Self { diff --git a/crates/horizon-core/src/random.rs b/crates/horizon-core/src/random.rs index 89de3e1f1..609cf59be 100644 --- a/crates/horizon-core/src/random.rs +++ b/crates/horizon-core/src/random.rs @@ -300,7 +300,7 @@ pub fn marsaglia_polar(cache: &mut Option, mut next_double: impl FnMut() -> if radius >= 1.0 || radius == 0.0 { continue; } - let multiplier = (-2.0 * radius.ln() / radius).sqrt(); + let multiplier = (-2.0 * crate::jmath::log(radius) / radius).sqrt(); *cache = Some(y * multiplier); return x * multiplier; } diff --git a/crates/horizon-world/src/level/redstone.rs b/crates/horizon-world/src/level/redstone.rs index 6e4722844..92f9059e5 100644 --- a/crates/horizon-world/src/level/redstone.rs +++ b/crates/horizon-world/src/level/redstone.rs @@ -1092,7 +1092,7 @@ impl Sim<'_, L> { let pitch = if mob_head { 1.0 } else { - 2f64.powf(f64::from(note - 12) / 12.0) as f32 + horizon_core::jmath::pow(2.0, f64::from(note - 12) / 12.0) as f32 }; let sound = match instrument.as_str() { "custom_head" => "minecraft:ui.button.click".to_owned(), diff --git a/crates/horizon-world/src/level/sculk.rs b/crates/horizon-world/src/level/sculk.rs index a831f2151..46073cf5e 100644 --- a/crates/horizon-world/src/level/sculk.rs +++ b/crates/horizon-world/src/level/sculk.rs @@ -315,7 +315,8 @@ impl Sim<'_, L> { if charge > 0 && let Some(faces) = faces { - let particles = ((f64::from(charge) + 1.0).ln() as f32 / 2.3) as i32 + 1; + let particles = + (horizon_core::jmath::log(f64::from(charge) + 1.0) as f32 / 2.3) as i32 + 1; let data = (particles << 6) + pack_faces(&faces); self.level_event(3006, pos, data); } diff --git a/crates/horizon-world/src/worldgen/dripstone.rs b/crates/horizon-world/src/worldgen/dripstone.rs index 2358bd723..8b67e0eca 100644 --- a/crates/horizon-world/src/worldgen/dripstone.rs +++ b/crates/horizon-world/src/worldgen/dripstone.rs @@ -545,9 +545,9 @@ fn speleothem_height(distance: f64, radius: f64, scale: f64, bluntness: f64) -> distance }; let r = distance / radius * 0.384; - let part1 = 0.75 * r.powf(1.3333333333333333); - let part2 = r.powf(0.6666666666666666); - let part3 = 0.3333333333333333 * r.ln(); + let part1 = 0.75 * horizon_core::jmath::pow(r, 1.3333333333333333); + let part2 = horizon_core::jmath::pow(r, 0.6666666666666666); + let part3 = 0.3333333333333333 * horizon_core::jmath::log(r); let height = (scale * (part1 - part2 - part3)).max(0.0); height / 0.384 * radius } diff --git a/crates/horizon-world/src/worldgen/end_spike.rs b/crates/horizon-world/src/worldgen/end_spike.rs index a2c00ee08..e76982853 100644 --- a/crates/horizon-world/src/worldgen/end_spike.rs +++ b/crates/horizon-world/src/worldgen/end_spike.rs @@ -38,8 +38,8 @@ pub fn spikes_for_seed(seed: i64) -> Vec { 2.0 * (-std::f64::consts::PI + (std::f64::consts::PI / 10.0) * f64::from(i)); let size = sizes[i as usize]; EndSpike { - center_x: (42.0 * angle.cos()).floor() as i32, - center_z: (42.0 * angle.sin()).floor() as i32, + center_x: (42.0 * horizon_core::jmath::cos(angle)).floor() as i32, + center_z: (42.0 * horizon_core::jmath::sin(angle)).floor() as i32, radius: 2 + size / 3, height: 76 + size * 3, guarded: size == 1 || size == 2, diff --git a/crates/horizon-world/src/worldgen/iceberg.rs b/crates/horizon-world/src/worldgen/iceberg.rs index 2bfeff29a..3a7409ad3 100644 --- a/crates/horizon-world/src/worldgen/iceberg.rs +++ b/crates/horizon-world/src/worldgen/iceberg.rs @@ -29,7 +29,7 @@ fn ceil(v: f32) -> i32 { f64::from(v).ceil() as i32 } fn pow2(v: i32) -> f64 { - f64::from(v).powf(2.0) + horizon_core::jmath::pow(f64::from(v), 2.0) } impl IcebergFeature { pub(crate) fn decode( @@ -380,7 +380,11 @@ impl IcebergFeature { fn signed_ellipse(xo: i32, zo: i32, origin: [i32; 3], a: i32, c: i32, angle: f64) -> f64 { let dx = f64::from(xo - origin[0]); let dz = f64::from(zo - origin[2]); - ((dx * angle.cos() - dz * angle.sin()) / f64::from(a)).powf(2.0) - + ((dx * angle.sin() + dz * angle.cos()) / f64::from(c)).powf(2.0) + let (sin, cos) = ( + horizon_core::jmath::sin(angle), + horizon_core::jmath::cos(angle), + ); + horizon_core::jmath::pow((dx * cos - dz * sin) / f64::from(a), 2.0) + + horizon_core::jmath::pow((dx * sin + dz * cos) / f64::from(c), 2.0) - 1.0 } diff --git a/crates/horizon-world/src/worldgen/ore_feature.rs b/crates/horizon-world/src/worldgen/ore_feature.rs index c4cbe447e..9d723ae40 100644 --- a/crates/horizon-world/src/worldgen/ore_feature.rs +++ b/crates/horizon-world/src/worldgen/ore_feature.rs @@ -155,7 +155,10 @@ impl OreFeature { let spread_xy = self.size as f32 / 8.0; let max_radius = ((self.size as f32 / 16.0 * 2.0 + 1.0) / 2.0).ceil() as i32; let (ox, oz) = (f64::from(origin[0]), f64::from(origin[2])); - let (sin, cos) = (f64::from(dir).sin(), f64::from(dir).cos()); + let (sin, cos) = ( + horizon_core::jmath::sin(f64::from(dir)), + horizon_core::jmath::cos(f64::from(dir)), + ); let spread = f64::from(spread_xy); let x0 = ox + sin * spread; let x1 = ox - sin * spread; diff --git a/crates/horizon-world/src/worldgen/structure/stronghold.rs b/crates/horizon-world/src/worldgen/structure/stronghold.rs index 95fcd679c..eb31e0627 100644 --- a/crates/horizon-world/src/worldgen/structure/stronghold.rs +++ b/crates/horizon-world/src/worldgen/structure/stronghold.rs @@ -30,7 +30,10 @@ pub fn ring_positions( let dist = f64::from(4 * distance + distance * circle * 6) + (random.next_double() - 0.5) * (f64::from(distance) * 2.5); let round = |v: f64| (v + 0.5).floor() as i64 as i32; - let (x, z) = (round(angle.cos() * dist), round(angle.sin() * dist)); + let (x, z) = ( + round(horizon_core::jmath::cos(angle) * dist), + round(horizon_core::jmath::sin(angle) * dist), + ); let mut search = random.fork(); let (cx, cz) = ((x * 16 + 8) >> 2, (z * 16 + 8) >> 2); let mut found = 0; diff --git a/crates/horizon-world/src/worldgen/tree/trunk.rs b/crates/horizon-world/src/worldgen/tree/trunk.rs index b88e88bfc..741f0e2a7 100644 --- a/crates/horizon-world/src/worldgen/tree/trunk.rs +++ b/crates/horizon-world/src/worldgen/tree/trunk.rs @@ -690,8 +690,9 @@ impl TrunkPlacer { let height = tree_height + 2; let trunk_height = (f64::from(height) * 0.618).floor() as i32; Self::place_below_trunk(run, tree, tb::offset(origin, 0, -1, 0))?; - let clusters_per_y = - 1.min((1.382 + (1.0 * f64::from(height) / 13.0).powf(2.0)).floor() as i32); + let clusters_per_y = 1.min( + (1.382 + horizon_core::jmath::pow(1.0 * f64::from(height) / 13.0, 2.0)).floor() as i32, + ); let trunk_top = origin[1].wrapping_add(trunk_height); let mut relative_y = height - 5; // (attachment position, branch base y) @@ -704,8 +705,8 @@ impl TrunkPlacer { let radius = 1.0 * f64::from(shape) * (f64::from(run.random.next_float()) + 0.328); let angle = f64::from(run.random.next_float() * 2.0f32) * std::f64::consts::PI; - let x = radius * angle.sin() + 0.5; - let z = radius * angle.cos() + 0.5; + let x = radius * horizon_core::jmath::sin(angle) + 0.5; + let z = radius * horizon_core::jmath::cos(angle) + 0.5; let check_start = tb::offset(origin, x.floor() as i32, relative_y - 1, z.floor() as i32); let check_end = tb::offset(check_start, 0, 5, 0); diff --git a/tools/parity_hosts.json b/tools/parity_hosts.json index 5347c9257..4d5bd1bcd 100644 --- a/tools/parity_hosts.json +++ b/tools/parity_hosts.json @@ -9,12 +9,7 @@ "python": "/opt/homebrew/bin/python3.13", "rsync": "/opt/homebrew/bin/rsync", "jobs": 14, - "exclude": { - "options": "Rust's f64::powf uses the platform libm, which rounds differently on macOS", - "chunk_generation": "one ocean ruined portal differs on macOS (platform float math)", - "world_border_render": "the wall's alpha differs by one ulp on macOS (platform float math)", - "mob_ai": "mob movement differs on macOS (platform float math in random positions)" - } + "exclude": {} }, "thinkpad": { "ssh": "thinkpad", From 6aa875c6c2a037d61cb59e2ae7cc0afc3bed01b5 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:12:59 +0200 Subject: [PATCH 3/9] Compute the breeze's long-jump vector with jmath; run every harness on the Mac The Mac mini no longer excludes options, chunk_generation, world_border_render and mob_ai: their float maths now matches vanilla's on Apple silicon. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/breeze.rs | 10 +++++----- docs/parity-workflow.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/mobs/breeze.rs b/crates/horizon-client/src/mobs/breeze.rs index 4a8484835..5d78fa8ab 100644 --- a/crates/horizon-client/src/mobs/breeze.rs +++ b/crates/horizon-client/src/mobs/breeze.rs @@ -6,7 +6,7 @@ use super::{ anim::{Poses, State}, keyframes::Animation, }; -use horizon_core::{geometry::Vec3, random::RandomSource}; +use horizon_core::{geometry::Vec3, jmath, random::RandomSource}; use std::collections::HashMap; // `variant3` is the pose, `variant` the pose the animations last saw, @@ -96,9 +96,9 @@ fn jump_vector(m: &Mob, target: Vec3, max_velocity: f32, angle: i32) -> Option Option`, whose `.local` links to the vanilla data in `~/code/horizon-parity/shared-local`, and starts `tools/parity.py --worker` over ssh. The worker builds the probes (about 80 s the first time) and runs what it is sent under the same tracer, admitting harnesses against its own free memory. One queue, longest first, feeds every machine, remote ones first: the Mac takes up to 14 harnesses, the desktop 3 and only those under 800 MB. Each pass comes back with the content hash of every file it depended on, so it is keyed on exactly what ran, and logs come back to `.local/parity-runner/logs/`; state, sharing and `gate.py` work as before. If a host can't be reached, or drops out, its harnesses run here. -Harnesses a host can't run are listed in the file: `local_only` (`save_roundtrip` runs the game on a virtual screen) and each host's `exclude`. On macOS, `options` and `chunk_generation` differ because Rust's float functions (`f64::powf` and the like) use the platform libm, which rounds differently from glibc; every other harness gave the same result on the Mac as on Linux. The ThinkPad helps too, gently: Linux, 2 harnesses at `nice 19`, since it hosts the Claude account pool; it finds ALSA through `~/code/horizon-parity/alsa-dev` like the desktop. +Harnesses a host can't run are listed in the file: `local_only` (`save_roundtrip` runs the game on a virtual screen) and each host's `exclude`. The Mac excludes nothing: vanilla's `Math.sin`, `pow`, `log` and the like don't round the same on an Apple silicon Mac as on Linux (HotSpot uses fdlibm there, Intel's stubs on x86-64), and Rust's float functions round like neither (they call the platform libm), so code that mirrors a vanilla `Math` call uses `horizon_core::jmath`, which computes what HotSpot computes on the host architecture. The ThinkPad helps too, gently: Linux, 2 harnesses at `nice 19`, since it hosts the Claude account pool; it finds ALSA through `~/code/horizon-parity/alsa-dev` like the desktop. ### Shards and Rust scopes From 71e677ed6a978274a76d3d783522aafc9ec09f54 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:40:50 +0200 Subject: [PATCH 4/9] dedicated_server: settle the generated world in game ticks, without random ticks The world was saved 20 wall-clock seconds after force-loading the area, so on a busy machine chunks were still generating or their fluids flowing, and random ticks changed blocks while the client was being sent its chunks: which chunks differed varied from run to run. Wait until every force-loaded chunk is loaded and 600 game ticks have passed, with random_tick_speed 0. Co-Authored-By: Claude Opus 5.5 --- tools/test_dedicated_server_parity.py | 37 ++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tools/test_dedicated_server_parity.py b/tools/test_dedicated_server_parity.py index 044d75bee..e7bac561d 100644 --- a/tools/test_dedicated_server_parity.py +++ b/tools/test_dedicated_server_parity.py @@ -163,6 +163,36 @@ class Server: self.log.close() +def settle(server, ticks=600, timeout=600): + """Waits until every force-loaded chunk is loaded, then for `ticks` game + ticks, so their fluids have finished flowing however slow the machine.""" + deadline = time.time() + timeout + offsets = range(-80, 81, 16) + for poll in range(1_000_000): + start = len(server.lines) + for dx in offsets: + for dz in offsets: + server.console(f'execute unless loaded ~{dx} ~ ~{dz} run say unloaded') + server.console(f'say poll {poll}') + server.wait_for(rf'\[Server\] poll {poll}$', timeout=max(1, deadline - time.time())) + if not any(l.endswith('[Server] unloaded') for l in server.lines[start:]): + break + time.sleep(2) + + def game_time(): + start = len(server.lines) + server.console('time query gametime') + while time.time() < deadline: + for line in server.lines[start:]: + if m := re.search(r'The time is (\d+)', line): + return int(m.group(1)) + time.sleep(0.2) + raise AssertionError(f'{server.log_path.name}: no game time in {timeout} s') + until = game_time() + ticks + while game_time() < until: + time.sleep(2) + + def legacy_ping(port): """The reply to a 1.6 client's 0xFE 0x01 0xFA MC|PingHost query (LegacyQueryHandler).""" host = 'localhost' @@ -330,9 +360,14 @@ def main(): # Vanilla keeps no spawn chunks: force-load the area the client is # sent and its neighbours (the console stands at the world spawn) so # both servers load full chunks instead of finishing proto-chunks. + # Random ticks change blocks while the client is being sent its + # chunks, more of them the busier the machine: none, so both servers + # send the world as it was saved. + server.console('gamerule random_tick_speed 0') + server.wait_for(r'random_tick_speed', timeout=30) server.console('forceload add ~-80 ~-80 ~80 ~80') server.wait_for(r'chunks in the Overworld are now force loaded|forceloaded|force loaded', timeout=60) - time.sleep(20) + settle(server) server.console('forceload remove all') server.console('save-all flush') server.wait_for(r'Saved the game', timeout=120) From 7322bba2b59895e3afc8c878b1a5ccec832dc945 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:51:40 +0200 Subject: [PATCH 5/9] dedicated_server: read the game time as the console prints it Co-Authored-By: Claude Opus 5.5 --- tools/test_dedicated_server_parity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_dedicated_server_parity.py b/tools/test_dedicated_server_parity.py index e7bac561d..0f27d599c 100644 --- a/tools/test_dedicated_server_parity.py +++ b/tools/test_dedicated_server_parity.py @@ -184,7 +184,7 @@ def settle(server, ticks=600, timeout=600): server.console('time query gametime') while time.time() < deadline: for line in server.lines[start:]: - if m := re.search(r'The time is (\d+)', line): + if m := re.search(r'The (?:game )?time is (\d+)', line): return int(m.group(1)) time.sleep(0.2) raise AssertionError(f'{server.log_path.name}: no game time in {timeout} s') From b15db4749f1f3b8061834ff3a001fc8c4705b75d Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:04:17 +0200 Subject: [PATCH 6/9] Net harness client: wait for each chat reply instead of 1.5 s On a busy machine the /list reply sometimes came after the client had left. Each step now waits for a new chat line, up to 15 s. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/net_harness.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/net_harness.rs b/crates/horizon-client/src/game/net_harness.rs index 755a4c562..d9bb6a74b 100644 --- a/crates/horizon-client/src/game/net_harness.rs +++ b/crates/horizon-client/src/game/net_harness.rs @@ -21,6 +21,7 @@ impl Game<'_> { let mut quiet_since = Instant::now(); let mut step = 0; let mut step_at = Instant::now(); + let mut chat_at = 0; let mut position = None; let mut chunks = 0; let mut error = None; @@ -44,6 +45,10 @@ impl Game<'_> { last_chunks = count; quiet_since = now; } + // Each step waits for what it sent to be answered (its chat line, + // the /list reply), however busy the machine, up to 15 s. + let shown = self.remote.as_ref().map_or(0, |r| r.chat.len()); + let answered = shown > chat_at || now - step_at > Duration::from_secs(15); match step { 0 if spawned_at.is_some() && count > 0 @@ -53,13 +58,15 @@ impl Game<'_> { self.send_chat("hello from the horizon client"); step = 1; step_at = now; + chat_at = shown; } - 1 if now - step_at > Duration::from_millis(1500) => { + 1 if answered => { self.send_chat("/list"); step = 2; step_at = now; + chat_at = shown; } - 2 if now - step_at > Duration::from_millis(1500) => { + 2 if answered => { step = 3; break; } From 7826a221c5c9d01d6e251a5cd3a78d09875c3a08 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:32:48 +0200 Subject: [PATCH 7/9] jmath: satisfy clippy (fdlibm's constants and NaN idiom, verbatim) Co-Authored-By: Claude Opus 5.5 --- crates/horizon-core/src/jmath.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/horizon-core/src/jmath.rs b/crates/horizon-core/src/jmath.rs index 90bb9abfe..6d94b835b 100644 --- a/crates/horizon-core/src/jmath.rs +++ b/crates/horizon-core/src/jmath.rs @@ -124,7 +124,7 @@ fn with_hi(x: f64, high: i32) -> f64 { /// `java.lang.FdLibm` ports (`StrictMath`) of what the `libm` crate computes /// differently. -#[allow(clippy::excessive_precision)] // fdlibm's constants, verbatim. +#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::eq_op)] // fdlibm's constants and NaN, verbatim. pub mod fdlibm { use super::{hi, with_hi}; @@ -337,7 +337,7 @@ pub mod fdlibm { /// HotSpot's AArch64 `Math.sin` / `Math.cos` stubs /// (`MacroAssembler::generate_dsin_dcos`): fdlibm with fused multiply-adds. /// Exposed on every architecture so they can be tested anywhere. -#[allow(clippy::excessive_precision, clippy::unreadable_literal)] // HotSpot's tables, verbatim. +#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::unreadable_literal)] // HotSpot's tables, verbatim. pub mod aarch64 { use super::hi; @@ -474,7 +474,7 @@ pub mod aarch64 { } fn sin_cos(x: f64, is_cos: bool) -> f64 { - let ix = (hi(x) & 0x7fff_ffff) as i32; + let ix = hi(x) & 0x7fff_ffff; if ix < 0x3e400000 { return if is_cos { 1.0 } else { x }; } From 4ce4e983e70eacbeacda304d2fd3646e369a183e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:33:03 +0200 Subject: [PATCH 8/9] jmath: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/horizon-core/src/jmath.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/horizon-core/src/jmath.rs b/crates/horizon-core/src/jmath.rs index 6d94b835b..c88a8c81d 100644 --- a/crates/horizon-core/src/jmath.rs +++ b/crates/horizon-core/src/jmath.rs @@ -124,7 +124,8 @@ fn with_hi(x: f64, high: i32) -> f64 { /// `java.lang.FdLibm` ports (`StrictMath`) of what the `libm` crate computes /// differently. -#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::eq_op)] // fdlibm's constants and NaN, verbatim. +// fdlibm's constants and NaN, verbatim. +#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::eq_op)] pub mod fdlibm { use super::{hi, with_hi}; @@ -337,7 +338,8 @@ pub mod fdlibm { /// HotSpot's AArch64 `Math.sin` / `Math.cos` stubs /// (`MacroAssembler::generate_dsin_dcos`): fdlibm with fused multiply-adds. /// Exposed on every architecture so they can be tested anywhere. -#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::unreadable_literal)] // HotSpot's tables, verbatim. +// HotSpot's tables, verbatim. +#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::unreadable_literal)] pub mod aarch64 { use super::hi; From b1a560264d40028184aac1d30d9a646292b1e8ff Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:33:20 +0200 Subject: [PATCH 9/9] jmath: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/horizon-core/src/jmath.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/horizon-core/src/jmath.rs b/crates/horizon-core/src/jmath.rs index c88a8c81d..57775c674 100644 --- a/crates/horizon-core/src/jmath.rs +++ b/crates/horizon-core/src/jmath.rs @@ -339,7 +339,11 @@ pub mod fdlibm { /// (`MacroAssembler::generate_dsin_dcos`): fdlibm with fused multiply-adds. /// Exposed on every architecture so they can be tested anywhere. // HotSpot's tables, verbatim. -#[allow(clippy::excessive_precision, clippy::approx_constant, clippy::unreadable_literal)] +#[allow( + clippy::excessive_precision, + clippy::approx_constant, + clippy::unreadable_literal +)] pub mod aarch64 { use super::hi;