From e1d73c8abb0b00df0695281d866f45d4ad2616a0 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Sat, 25 Oct 2025 23:38:32 -0700 Subject: [PATCH 01/11] core: split math module into focused submodules --- crates/rmg-core/src/math.rs | 468 ------------------------------- crates/rmg-core/src/math/mat4.rs | 85 ++++++ crates/rmg-core/src/math/mod.rs | 35 +++ crates/rmg-core/src/math/prng.rs | 116 ++++++++ crates/rmg-core/src/math/quat.rs | 139 +++++++++ crates/rmg-core/src/math/vec3.rs | 106 +++++++ 6 files changed, 481 insertions(+), 468 deletions(-) delete mode 100644 crates/rmg-core/src/math.rs create mode 100644 crates/rmg-core/src/math/mat4.rs create mode 100644 crates/rmg-core/src/math/mod.rs create mode 100644 crates/rmg-core/src/math/prng.rs create mode 100644 crates/rmg-core/src/math/quat.rs create mode 100644 crates/rmg-core/src/math/vec3.rs diff --git a/crates/rmg-core/src/math.rs b/crates/rmg-core/src/math.rs deleted file mode 100644 index 60d8c16..0000000 --- a/crates/rmg-core/src/math.rs +++ /dev/null @@ -1,468 +0,0 @@ -//! Deterministic math helpers covering scalar utilities, linear algebra -//! primitives, quaternions, and a timeline-friendly PRNG. -//! -//! The API intentionally rounds everything to `f32` to mirror the engine's -//! float32 mode and keep behaviour identical across environments. - -use std::f32::consts::TAU; - -const EPSILON: f32 = 1e-6; - -/// Clamps `value` to the inclusive `[min, max]` range using float32 rounding. -pub fn clamp(value: f32, min: f32, max: f32) -> f32 { - assert!(min <= max, "invalid clamp range: {min} > {max}"); - value.max(min).min(max) -} - -/// Converts degrees to radians with float32 precision. -pub fn deg_to_rad(value: f32) -> f32 { - value * (TAU / 360.0) -} - -/// Converts radians to degrees with float32 precision. -pub fn rad_to_deg(value: f32) -> f32 { - value * (360.0 / TAU) -} - -/// Deterministic 3D vector used throughout the engine. -/// -/// * Components encode world-space metres and may represent either points or -/// directions depending on the calling context. -/// * Arithmetic clamps to `f32` so results match the runtime’s float32 mode. -/// * Use [`Mat4::transform_point`] for points (homogeneous `w = 1`) and -/// [`Mat4::transform_direction`] for directions (homogeneous `w = 0`). -#[derive(Debug, Copy, Clone, PartialEq)] -pub struct Vec3 { - data: [f32; 3], -} - -impl Vec3 { - /// Creates a vector from components. - /// - /// Inputs are interpreted as metres in world coordinates; callers must - /// ensure values are finite. - pub const fn new(x: f32, y: f32, z: f32) -> Self { - Self { data: [x, y, z] } - } - - /// Returns the components as an array. - pub fn to_array(self) -> [f32; 3] { - self.data - } - - fn component(&self, idx: usize) -> f32 { - self.data[idx] - } - - /// Adds two vectors. - pub fn add(&self, other: &Self) -> Self { - Self::new( - self.component(0) + other.component(0), - self.component(1) + other.component(1), - self.component(2) + other.component(2), - ) - } - - /// Subtracts another vector. - pub fn sub(&self, other: &Self) -> Self { - Self::new( - self.component(0) - other.component(0), - self.component(1) - other.component(1), - self.component(2) - other.component(2), - ) - } - - /// Scales the vector by a scalar. - pub fn scale(&self, scalar: f32) -> Self { - Self::new( - self.component(0) * scalar, - self.component(1) * scalar, - self.component(2) * scalar, - ) - } - - /// Dot product with another vector. - pub fn dot(&self, other: &Self) -> f32 { - self.component(0) * other.component(0) - + self.component(1) * other.component(1) - + self.component(2) * other.component(2) - } - - /// Cross product with another vector. - pub fn cross(&self, other: &Self) -> Self { - let ax = self.component(0); - let ay = self.component(1); - let az = self.component(2); - let bx = other.component(0); - let by = other.component(1); - let bz = other.component(2); - Self::new(ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx) - } - - /// Vector length (magnitude). - pub fn length(&self) -> f32 { - self.dot(self).sqrt() - } - - /// Squared magnitude of the vector. - pub fn length_squared(&self) -> f32 { - self.dot(self) - } - - /// Normalises the vector, returning zero vector if length is ~0. - /// - /// Zero-length inputs remain the zero vector so downstream callers can - /// detect degenerate directions deterministically. - pub fn normalize(&self) -> Self { - let len = self.length(); - if len <= EPSILON { - return Self::new(0.0, 0.0, 0.0); - } - self.scale(1.0 / len) - } -} - -impl From<[f32; 3]> for Vec3 { - fn from(value: [f32; 3]) -> Self { - Self { data: value } - } -} - -/// Column-major 4×4 matrix matching Echo’s deterministic math layout. -/// -/// * Stored in column-major order to align with GPU uploads and ECS storage. -/// * Represents affine transforms; perspective terms are preserved but the -/// helpers in this module treat them homogeneously (`w = 1` for points). -#[derive(Debug, Copy, Clone, PartialEq)] -pub struct Mat4 { - data: [f32; 16], -} - -impl Mat4 { - /// Creates a matrix from column-major array data. - /// - /// Callers must supply 16 finite values already laid out column-major. - pub const fn new(data: [f32; 16]) -> Self { - Self { data } - } - - /// Returns the matrix as a column-major array. - pub fn to_array(self) -> [f32; 16] { - self.data - } - - fn at(&self, row: usize, col: usize) -> f32 { - self.data[col * 4 + row] - } - - /// Multiplies the matrix with another matrix (self * rhs). - /// - /// Multiplication follows column-major semantics (`self` on the left, - /// `rhs` on the right) to mirror GPU-style transforms. - pub fn multiply(&self, rhs: &Self) -> Self { - let mut out = [0.0; 16]; - for row in 0..4 { - for col in 0..4 { - let mut sum = 0.0; - for k in 0..4 { - sum += self.at(row, k) * rhs.at(k, col); - } - out[col * 4 + row] = sum; - } - } - Self::new(out) - } - - /// Transforms a point (assumes `w = 1`, no perspective divide). - /// - /// Translation components are applied and the resulting vector is returned - /// with `w` implicitly equal to `1`. - pub fn transform_point(&self, point: &Vec3) -> Vec3 { - let x = point.component(0); - let y = point.component(1); - let z = point.component(2); - let w = 1.0; - - let nx = self.at(0, 0) * x + self.at(0, 1) * y + self.at(0, 2) * z + self.at(0, 3) * w; - let ny = self.at(1, 0) * x + self.at(1, 1) * y + self.at(1, 2) * z + self.at(1, 3) * w; - let nz = self.at(2, 0) * x + self.at(2, 1) * y + self.at(2, 2) * z + self.at(2, 3) * w; - - Vec3::new(nx, ny, nz) - } - - /// Transforms a direction vector (ignores translation, `w = 0`). - /// - /// Only the rotational and scaling parts of the matrix affect the result. - pub fn transform_direction(&self, direction: &Vec3) -> Vec3 { - let x = direction.component(0); - let y = direction.component(1); - let z = direction.component(2); - - let nx = self.at(0, 0) * x + self.at(0, 1) * y + self.at(0, 2) * z; - let ny = self.at(1, 0) * x + self.at(1, 1) * y + self.at(1, 2) * z; - let nz = self.at(2, 0) * x + self.at(2, 1) * y + self.at(2, 2) * z; - - Vec3::new(nx, ny, nz) - } -} - -impl From<[f32; 16]> for Mat4 { - fn from(value: [f32; 16]) -> Self { - Self { data: value } - } -} - -/// Quaternion stored as `(x, y, z, w)` with deterministic float32 rounding. -/// -/// * All angles are expressed in radians. -/// * Normalisation clamps to `f32` to match runtime behaviour. -#[derive(Debug, Copy, Clone, PartialEq)] -pub struct Quat { - data: [f32; 4], -} - -impl Quat { - /// Creates a quaternion from components. - /// - /// Callers should provide finite components; use - /// [`Quat::from_axis_angle`] for axis/angle construction. - pub const fn new(x: f32, y: f32, z: f32, w: f32) -> Self { - Self { data: [x, y, z, w] } - } - - /// Returns the quaternion as an array. - pub fn to_array(self) -> [f32; 4] { - self.data - } - - fn component(&self, idx: usize) -> f32 { - self.data[idx] - } - - /// Constructs a quaternion from a rotation axis and angle in radians. - /// - /// Returns the identity quaternion when the axis has zero length to avoid - /// undefined orientations and preserve deterministic behaviour. - pub fn from_axis_angle(axis: Vec3, angle: f32) -> Self { - let len_sq = axis.length_squared(); - if len_sq <= EPSILON * EPSILON { - return Self::identity(); - } - let len = len_sq.sqrt(); - let norm_axis = axis.scale(1.0 / len); - let half = angle * 0.5; - let (sin_half, cos_half) = half.sin_cos(); - let scaled = norm_axis.scale(sin_half); - Self::new( - scaled.component(0), - scaled.component(1), - scaled.component(2), - cos_half, - ) - } - - /// Multiplies two quaternions (self * other). - pub fn multiply(&self, other: &Self) -> Self { - let ax = self.component(0); - let ay = self.component(1); - let az = self.component(2); - let aw = self.component(3); - - let bx = other.component(0); - let by = other.component(1); - let bz = other.component(2); - let bw = other.component(3); - - Self::new( - aw * bx + ax * bw + ay * bz - az * by, - aw * by - ax * bz + ay * bw + az * bx, - aw * bz + ax * by - ay * bx + az * bw, - aw * bw - ax * bx - ay * by - az * bz, - ) - } - - /// Normalises the quaternion; returns identity when norm is ~0. - pub fn normalize(&self) -> Self { - let len = (self.component(0) * self.component(0) - + self.component(1) * self.component(1) - + self.component(2) * self.component(2) - + self.component(3) * self.component(3)) - .sqrt(); - if len.abs() <= EPSILON { - return Self::identity(); - } - let inv = 1.0 / len; - Self::new( - self.component(0) * inv, - self.component(1) * inv, - self.component(2) * inv, - self.component(3) * inv, - ) - } - - /// Returns the identity quaternion. - pub const fn identity() -> Self { - Self::new(0.0, 0.0, 0.0, 1.0) - } - - /// Converts the quaternion to a rotation matrix (column-major 4x4). - pub fn to_mat4(&self) -> Mat4 { - let q = self.normalize(); - let x = q.component(0); - let y = q.component(1); - let z = q.component(2); - let w = q.component(3); - - let xx = x * x; - let yy = y * y; - let zz = z * z; - let xy = x * y; - let xz = x * z; - let yz = y * z; - let wx = w * x; - let wy = w * y; - let wz = w * z; - - Mat4::new([ - 1.0 - 2.0 * (yy + zz), - 2.0 * (xy + wz), - 2.0 * (xz - wy), - 0.0, - 2.0 * (xy - wz), - 1.0 - 2.0 * (xx + zz), - 2.0 * (yz + wx), - 0.0, - 2.0 * (xz + wy), - 2.0 * (yz - wx), - 1.0 - 2.0 * (xx + yy), - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - ]) - } -} - -impl From<[f32; 4]> for Quat { - fn from(value: [f32; 4]) -> Self { - Self { data: value } - } -} - -/// Stateful `xoroshiro128+` pseudo-random number generator for deterministic timelines. -/// -/// * Not cryptographically secure; use only for gameplay/state simulation. -/// * Seeding controls reproducibility within a single process/run and matching -/// seeds yield identical sequences across supported platforms. -#[derive(Debug, Clone, Copy)] -pub struct Prng { - state: [u64; 2], -} - -impl Prng { - /// Constructs a PRNG from two 64-bit seeds. - /// - /// Identical seeds produce identical sequences; the generator remains - /// deterministic as long as each process consumes random numbers in the - /// same order. - pub fn from_seed(seed0: u64, seed1: u64) -> Self { - let mut state = [seed0, seed1]; - if state[0] == 0 && state[1] == 0 { - state[0] = 0x9e3779b97f4a7c15; - } - Self { state } - } - - fn next_u64(&mut self) -> u64 { - let s0 = self.state[0]; - let mut s1 = self.state[1]; - let result = s0.wrapping_add(s1); - - s1 ^= s0; - self.state[0] = s0.rotate_left(55) ^ s1 ^ (s1 << 14); - self.state[1] = s1.rotate_left(36); - - result - } - - /// Returns the next float in `[0, 1)`. - /// - /// Uses the high 23 bits of the xoroshiro128+ state to fill the mantissa, - /// ensuring uniform float32 sampling without relying on platform RNGs. - pub fn next_f32(&mut self) -> f32 { - let raw = self.next_u64(); - let bits = ((raw >> 41) as u32) | 0x3f80_0000; - f32::from_bits(bits) - 1.0 - } - - /// Returns the next integer in the inclusive range `[min, max]`. - /// - /// Uses rejection sampling to avoid modulo bias, ensuring every value in - /// the range is produced with equal probability. - pub fn next_int(&mut self, min: i32, max: i32) -> i32 { - assert!(min <= max, "invalid range: {min}..={max}"); - let span = (i64::from(max) - i64::from(min)) as u64 + 1; - if span == 1 { - return min; - } - - let value = if span.is_power_of_two() { - self.next_u64() & (span - 1) - } else { - let bound = u64::MAX - u64::MAX % span; - loop { - let candidate = self.next_u64(); - if candidate < bound { - break candidate % span; - } - } - }; - - let offset = value as i64 + i64::from(min); - offset as i32 - } - - /// Constructs a PRNG from a single 64-bit seed via SplitMix64 expansion. - pub fn from_seed_u64(seed: u64) -> Self { - fn splitmix64(state: &mut u64) -> u64 { - *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); - let mut z = *state; - z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); - z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); - z ^ (z >> 31) - } - - let mut sm_state = seed; - let mut state = [splitmix64(&mut sm_state), splitmix64(&mut sm_state)]; - if state[0] == 0 && state[1] == 0 { - state[0] = 0x9e37_79b9_7f4a_7c15; - } - Self { state } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn next_int_returns_single_value_for_equal_bounds() { - let mut prng = Prng::from_seed(42, 99); - assert_eq!(prng.next_int(7, 7), 7); - } - - #[test] - fn next_int_handles_full_i32_range() { - let mut prng = Prng::from_seed(0xDEADBEEF, 0xFACEFEED); - let values: Vec = (0..3).map(|_| prng.next_int(i32::MIN, i32::MAX)).collect(); - assert_eq!(values, vec![1501347292, 1946982111, -117316573]); - } - - #[test] - fn next_int_handles_negative_ranges() { - let mut prng = Prng::from_seed(123, 456); - let values: Vec = (0..3).map(|_| prng.next_int(-10, -3)).collect(); - assert_eq!(values, vec![-7, -7, -7]); - } -} diff --git a/crates/rmg-core/src/math/mat4.rs b/crates/rmg-core/src/math/mat4.rs new file mode 100644 index 0000000..ee7e8de --- /dev/null +++ b/crates/rmg-core/src/math/mat4.rs @@ -0,0 +1,85 @@ +use crate::math::Vec3; + +/// Column-major 4×4 matrix matching Echo’s deterministic math layout. +/// +/// * Stored in column-major order to align with GPU uploads and ECS storage. +/// * Represents affine transforms; perspective terms are preserved but helper +/// methods treat them homogeneously (`w = 1` for points). +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Mat4 { + data: [f32; 16], +} + +impl Mat4 { + /// Creates a matrix from column-major array data. + /// + /// Callers must supply 16 finite values already laid out column-major. + pub const fn new(data: [f32; 16]) -> Self { + Self { data } + } + + /// Returns the matrix as a column-major array. + pub fn to_array(self) -> [f32; 16] { + self.data + } + + fn at(&self, row: usize, col: usize) -> f32 { + self.data[col * 4 + row] + } + + /// Multiplies the matrix with another matrix (`self * rhs`). + /// + /// Multiplication follows column-major semantics (`self` on the left, + /// [`rhs`] on the right) to mirror GPU-style transforms. + pub fn multiply(&self, rhs: &Self) -> Self { + let mut out = [0.0; 16]; + for row in 0..4 { + for col in 0..4 { + let mut sum = 0.0; + for k in 0..4 { + sum += self.at(row, k) * rhs.at(k, col); + } + out[col * 4 + row] = sum; + } + } + Self::new(out) + } + + /// Transforms a point (assumes `w = 1`, no perspective divide). + /// + /// Translation components are applied and the resulting vector is returned + /// with `w` implicitly equal to `1`. + pub fn transform_point(&self, point: &Vec3) -> Vec3 { + let x = point.component(0); + let y = point.component(1); + let z = point.component(2); + let w = 1.0; + + let nx = self.at(0, 0) * x + self.at(0, 1) * y + self.at(0, 2) * z + self.at(0, 3) * w; + let ny = self.at(1, 0) * x + self.at(1, 1) * y + self.at(1, 2) * z + self.at(1, 3) * w; + let nz = self.at(2, 0) * x + self.at(2, 1) * y + self.at(2, 2) * z + self.at(2, 3) * w; + + Vec3::new(nx, ny, nz) + } + + /// Transforms a direction vector (ignores translation, `w = 0`). + /// + /// Only the rotational and scaling parts of the matrix affect the result. + pub fn transform_direction(&self, direction: &Vec3) -> Vec3 { + let x = direction.component(0); + let y = direction.component(1); + let z = direction.component(2); + + let nx = self.at(0, 0) * x + self.at(0, 1) * y + self.at(0, 2) * z; + let ny = self.at(1, 0) * x + self.at(1, 1) * y + self.at(1, 2) * z; + let nz = self.at(2, 0) * x + self.at(2, 1) * y + self.at(2, 2) * z; + + Vec3::new(nx, ny, nz) + } +} + +impl From<[f32; 16]> for Mat4 { + fn from(value: [f32; 16]) -> Self { + Self { data: value } + } +} diff --git a/crates/rmg-core/src/math/mod.rs b/crates/rmg-core/src/math/mod.rs new file mode 100644 index 0000000..29cf092 --- /dev/null +++ b/crates/rmg-core/src/math/mod.rs @@ -0,0 +1,35 @@ +//! Deterministic math helpers covering scalar utilities, linear algebra +//! primitives, quaternions, and timeline-friendly pseudo-random numbers. +//! +//! All operations round to `f32` to mirror the runtime’s float32 mode. + +use std::f32::consts::TAU; + +mod mat4; +mod prng; +mod quat; +mod vec3; + +pub use mat4::Mat4; +pub use prng::Prng; +pub use quat::Quat; +pub use vec3::Vec3; + +/// Global epsilon used by math routines when detecting degenerate values. +pub const EPSILON: f32 = 1e-6; + +/// Clamps `value` to the inclusive `[min, max]` range using float32 rounding. +pub fn clamp(value: f32, min: f32, max: f32) -> f32 { + assert!(min <= max, "invalid clamp range: {min} > {max}"); + value.max(min).min(max) +} + +/// Converts degrees to radians with float32 precision. +pub fn deg_to_rad(value: f32) -> f32 { + value * (TAU / 360.0) +} + +/// Converts radians to degrees with float32 precision. +pub fn rad_to_deg(value: f32) -> f32 { + value * (360.0 / TAU) +} diff --git a/crates/rmg-core/src/math/prng.rs b/crates/rmg-core/src/math/prng.rs new file mode 100644 index 0000000..a830aaf --- /dev/null +++ b/crates/rmg-core/src/math/prng.rs @@ -0,0 +1,116 @@ +/// Stateful `xoroshiro128+` pseudo-random number generator for deterministic timelines. +/// +/// * Not cryptographically secure; use only for gameplay/state simulation. +/// * Seeding controls reproducibility within a single process/run and matching +/// seeds yield identical sequences across supported platforms. +#[derive(Debug, Clone, Copy)] +pub struct Prng { + state: [u64; 2], +} + +impl Prng { + /// Constructs a PRNG from two 64-bit seeds. + /// + /// Identical seeds produce identical sequences; the generator remains + /// deterministic as long as each process consumes random numbers in the + /// same order. + pub fn from_seed(seed0: u64, seed1: u64) -> Self { + let mut state = [seed0, seed1]; + if state[0] == 0 && state[1] == 0 { + state[0] = 0x9e37_79b9_7f4a_7c15; + } + Self { state } + } + + /// Constructs a PRNG from a single 64-bit seed via SplitMix64 expansion. + pub fn from_seed_u64(seed: u64) -> Self { + fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } + + let mut sm_state = seed; + let mut state = [splitmix64(&mut sm_state), splitmix64(&mut sm_state)]; + if state[0] == 0 && state[1] == 0 { + state[0] = 0x9e37_79b9_7f4a_7c15; + } + Self { state } + } + + fn next_u64(&mut self) -> u64 { + let s0 = self.state[0]; + let mut s1 = self.state[1]; + let result = s0.wrapping_add(s1); + + s1 ^= s0; + self.state[0] = s0.rotate_left(55) ^ s1 ^ (s1 << 14); + self.state[1] = s1.rotate_left(36); + + result + } + + /// Returns the next float in `[0, 1)`. + /// + /// Uses the high 23 bits of the xoroshiro128+ state to fill the mantissa, + /// ensuring uniform float32 sampling without relying on platform RNGs. + pub fn next_f32(&mut self) -> f32 { + let raw = self.next_u64(); + let bits = ((raw >> 41) as u32) | 0x3f80_0000; + f32::from_bits(bits) - 1.0 + } + + /// Returns the next integer in the inclusive range `[min, max]`. + /// + /// Uses rejection sampling to avoid modulo bias, ensuring every value in + /// the range is produced with equal probability. + pub fn next_int(&mut self, min: i32, max: i32) -> i32 { + assert!(min <= max, "invalid range: {min}..={max}"); + let span = (i64::from(max) - i64::from(min)) as u64 + 1; + if span == 1 { + return min; + } + + let value = if span.is_power_of_two() { + self.next_u64() & (span - 1) + } else { + let bound = u64::MAX - u64::MAX % span; + loop { + let candidate = self.next_u64(); + if candidate < bound { + break candidate % span; + } + } + }; + + let offset = value as i64 + i64::from(min); + offset as i32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_int_returns_single_value_for_equal_bounds() { + let mut prng = Prng::from_seed(42, 99); + assert_eq!(prng.next_int(7, 7), 7); + } + + #[test] + fn next_int_handles_full_i32_range() { + let mut prng = Prng::from_seed(0xDEADBEEF, 0xFACEFEED); + let values: Vec = (0..3).map(|_| prng.next_int(i32::MIN, i32::MAX)).collect(); + assert_eq!(values, vec![1501347292, 1946982111, -117316573]); + } + + #[test] + fn next_int_handles_negative_ranges() { + let mut prng = Prng::from_seed(123, 456); + let values: Vec = (0..3).map(|_| prng.next_int(-10, -3)).collect(); + assert_eq!(values, vec![-7, -7, -7]); + } +} diff --git a/crates/rmg-core/src/math/quat.rs b/crates/rmg-core/src/math/quat.rs new file mode 100644 index 0000000..5c9ac0c --- /dev/null +++ b/crates/rmg-core/src/math/quat.rs @@ -0,0 +1,139 @@ +use crate::math::{EPSILON, Mat4, Vec3}; + +/// Quaternion stored as `(x, y, z, w)` with deterministic float32 rounding. +/// +/// * All angles are expressed in radians. +/// * Normalisation clamps to `f32` to match runtime behaviour. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Quat { + data: [f32; 4], +} + +impl Quat { + /// Creates a quaternion from components. + /// + /// Callers should provide finite components; use + /// [`Quat::from_axis_angle`] for axis/angle construction. + pub const fn new(x: f32, y: f32, z: f32, w: f32) -> Self { + Self { data: [x, y, z, w] } + } + + /// Returns the quaternion as an array. + pub fn to_array(self) -> [f32; 4] { + self.data + } + + fn component(&self, idx: usize) -> f32 { + self.data[idx] + } + + /// Constructs a quaternion from a rotation axis and angle in radians. + /// + /// Returns the identity quaternion when the axis has zero length to avoid + /// undefined orientations and preserve deterministic behaviour. + pub fn from_axis_angle(axis: Vec3, angle: f32) -> Self { + let len_sq = axis.length_squared(); + if len_sq <= EPSILON * EPSILON { + return Self::identity(); + } + let len = len_sq.sqrt(); + let norm_axis = axis.scale(1.0 / len); + let half = angle * 0.5; + let (sin_half, cos_half) = half.sin_cos(); + let scaled = norm_axis.scale(sin_half); + Self::new( + scaled.component(0), + scaled.component(1), + scaled.component(2), + cos_half, + ) + } + + /// Multiplies two quaternions (`self * other`). + pub fn multiply(&self, other: &Self) -> Self { + let ax = self.component(0); + let ay = self.component(1); + let az = self.component(2); + let aw = self.component(3); + + let bx = other.component(0); + let by = other.component(1); + let bz = other.component(2); + let bw = other.component(3); + + Self::new( + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz, + ) + } + + /// Normalises the quaternion; returns identity when norm is ~0. + pub fn normalize(&self) -> Self { + let len = (self.component(0) * self.component(0) + + self.component(1) * self.component(1) + + self.component(2) * self.component(2) + + self.component(3) * self.component(3)) + .sqrt(); + if len <= EPSILON { + return Self::identity(); + } + let inv = 1.0 / len; + Self::new( + self.component(0) * inv, + self.component(1) * inv, + self.component(2) * inv, + self.component(3) * inv, + ) + } + + /// Returns the identity quaternion. + pub const fn identity() -> Self { + Self::new(0.0, 0.0, 0.0, 1.0) + } + + /// Converts the quaternion to a rotation matrix (column-major 4×4). + pub fn to_mat4(&self) -> Mat4 { + let q = self.normalize(); + let x = q.component(0); + let y = q.component(1); + let z = q.component(2); + let w = q.component(3); + + let xx = x * x; + let yy = y * y; + let zz = z * z; + let xy = x * y; + let xz = x * z; + let yz = y * z; + let wx = w * x; + let wy = w * y; + let wz = w * z; + + Mat4::new([ + 1.0 - 2.0 * (yy + zz), + 2.0 * (xy + wz), + 2.0 * (xz - wy), + 0.0, + 2.0 * (xy - wz), + 1.0 - 2.0 * (xx + zz), + 2.0 * (yz + wx), + 0.0, + 2.0 * (xz + wy), + 2.0 * (yz - wx), + 1.0 - 2.0 * (xx + yy), + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ]) + } +} + +impl From<[f32; 4]> for Quat { + fn from(value: [f32; 4]) -> Self { + Self { data: value } + } +} diff --git a/crates/rmg-core/src/math/vec3.rs b/crates/rmg-core/src/math/vec3.rs new file mode 100644 index 0000000..1e8eef5 --- /dev/null +++ b/crates/rmg-core/src/math/vec3.rs @@ -0,0 +1,106 @@ +use crate::math::EPSILON; + +/// Deterministic 3D vector used throughout the engine. +/// +/// * Components encode world-space metres and may represent either points or +/// directions depending on the calling context. +/// * Arithmetic clamps to `f32` so results match the runtime’s float32 mode. +/// * Use [`crate::math::Mat4::transform_point`] for points (homogeneous `w = 1`) +/// and [`crate::math::Mat4::transform_direction`] for directions (homogeneous +/// `w = 0`). +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Vec3 { + data: [f32; 3], +} + +impl Vec3 { + /// Creates a vector from components. + /// + /// Inputs are interpreted as metres in world coordinates; callers must + /// ensure values are finite. + pub const fn new(x: f32, y: f32, z: f32) -> Self { + Self { data: [x, y, z] } + } + + /// Returns the components as an array. + pub fn to_array(self) -> [f32; 3] { + self.data + } + + pub(crate) fn component(&self, idx: usize) -> f32 { + self.data[idx] + } + + /// Adds two vectors. + pub fn add(&self, other: &Self) -> Self { + Self::new( + self.component(0) + other.component(0), + self.component(1) + other.component(1), + self.component(2) + other.component(2), + ) + } + + /// Subtracts another vector. + pub fn sub(&self, other: &Self) -> Self { + Self::new( + self.component(0) - other.component(0), + self.component(1) - other.component(1), + self.component(2) - other.component(2), + ) + } + + /// Scales the vector by a scalar. + pub fn scale(&self, scalar: f32) -> Self { + Self::new( + self.component(0) * scalar, + self.component(1) * scalar, + self.component(2) * scalar, + ) + } + + /// Dot product with another vector. + pub fn dot(&self, other: &Self) -> f32 { + self.component(0) * other.component(0) + + self.component(1) * other.component(1) + + self.component(2) * other.component(2) + } + + /// Cross product with another vector. + pub fn cross(&self, other: &Self) -> Self { + let ax = self.component(0); + let ay = self.component(1); + let az = self.component(2); + let bx = other.component(0); + let by = other.component(1); + let bz = other.component(2); + Self::new(ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx) + } + + /// Vector length (magnitude). + pub fn length(&self) -> f32 { + self.dot(self).sqrt() + } + + /// Squared magnitude of the vector. + pub fn length_squared(&self) -> f32 { + self.dot(self) + } + + /// Normalises the vector, returning zero vector if length is ~0. + /// + /// Zero-length inputs remain the zero vector so downstream callers can + /// detect degenerate directions deterministically. + pub fn normalize(&self) -> Self { + let len = self.length(); + if len <= EPSILON { + return Self::new(0.0, 0.0, 0.0); + } + self.scale(1.0 / len) + } +} + +impl From<[f32; 3]> for Vec3 { + fn from(value: [f32; 3]) -> Self { + Self { data: value } + } +} From 6583addaa1b7b5a6fea76d08e6dcd6bb4232fba5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 27 Oct 2025 01:33:06 -0700 Subject: [PATCH 02/11] Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: James Ross --- crates/rmg-core/src/math/mod.rs | 4 ++++ crates/rmg-core/src/math/prng.rs | 2 +- crates/rmg-core/src/math/quat.rs | 4 ++-- crates/rmg-core/src/math/vec3.rs | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/rmg-core/src/math/mod.rs b/crates/rmg-core/src/math/mod.rs index 29cf092..a64753a 100644 --- a/crates/rmg-core/src/math/mod.rs +++ b/crates/rmg-core/src/math/mod.rs @@ -10,9 +10,13 @@ mod prng; mod quat; mod vec3; +#[doc(inline)] pub use mat4::Mat4; +#[doc(inline)] pub use prng::Prng; +#[doc(inline)] pub use quat::Quat; +#[doc(inline)] pub use vec3::Vec3; /// Global epsilon used by math routines when detecting degenerate values. diff --git a/crates/rmg-core/src/math/prng.rs b/crates/rmg-core/src/math/prng.rs index a830aaf..842b84c 100644 --- a/crates/rmg-core/src/math/prng.rs +++ b/crates/rmg-core/src/math/prng.rs @@ -3,7 +3,7 @@ /// * Not cryptographically secure; use only for gameplay/state simulation. /// * Seeding controls reproducibility within a single process/run and matching /// seeds yield identical sequences across supported platforms. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct Prng { state: [u64; 2], } diff --git a/crates/rmg-core/src/math/quat.rs b/crates/rmg-core/src/math/quat.rs index 5c9ac0c..0350fb5 100644 --- a/crates/rmg-core/src/math/quat.rs +++ b/crates/rmg-core/src/math/quat.rs @@ -29,8 +29,8 @@ impl Quat { /// Constructs a quaternion from a rotation axis and angle in radians. /// - /// Returns the identity quaternion when the axis has zero length to avoid - /// undefined orientations and preserve deterministic behaviour. + /// Returns the identity quaternion when the axis length is ≤ `EPSILON` to avoid + /// undefined orientations and preserve deterministic behaviour. No small-angle approximation is applied. pub fn from_axis_angle(axis: Vec3, angle: f32) -> Self { let len_sq = axis.length_squared(); if len_sq <= EPSILON * EPSILON { diff --git a/crates/rmg-core/src/math/vec3.rs b/crates/rmg-core/src/math/vec3.rs index 1e8eef5..084dc0e 100644 --- a/crates/rmg-core/src/math/vec3.rs +++ b/crates/rmg-core/src/math/vec3.rs @@ -4,7 +4,7 @@ use crate::math::EPSILON; /// /// * Components encode world-space metres and may represent either points or /// directions depending on the calling context. -/// * Arithmetic clamps to `f32` so results match the runtime’s float32 mode. +/// * Arithmetic uses `f32` so results round like the runtime's float32 mode. /// * Use [`crate::math::Mat4::transform_point`] for points (homogeneous `w = 1`) /// and [`crate::math::Mat4::transform_direction`] for directions (homogeneous /// `w = 0`). From 00b6972c90eff58a230692b8ee48f82439318a10 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 27 Oct 2025 02:19:33 -0700 Subject: [PATCH 03/11] docs: update execution plan + decision log for PR #5 (core math split) --- docs/decision-log.md | 1 + docs/execution-plan.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/decision-log.md b/docs/decision-log.md index cf6f00f..b74a1b6 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -13,3 +13,4 @@ | 2025-10-25 | Math validation harness | Landed Rust fixture suite & tolerance checks for deterministic math | Keep scalar/vector/matrix/quaternion results stable across environments | Extend coverage to browser + fixed-point modes | | 2025-10-26 | EPI bundle | Adopt entropy, plugin, inspector, runtime config specs (Phase 0.75) | Close causality & extensibility gap | Phase 1 implementation backlog defined | | 2025-10-26 | RMG + Confluence | Adopt RMG v2 (typed DPOi engine) and Confluence synchronization as core architecture | Unify runtime/persistence/tooling on deterministic rewrites | Launch Rust workspace (rmg-core/ffi/wasm/cli), port ECS rules, set up Confluence networking | +| 2025-10-27 | Core math split | Split `rmg-core` math into focused submodules (`vec3`, `mat4`, `quat`, `prng`) replacing monolithic `math.rs`. | Improves readability, testability, and aligns with strict linting. | Update imports; no behavior changes intended; follow-up determinism docs in snapshot hashing. | diff --git a/docs/execution-plan.md b/docs/execution-plan.md index f0801d4..cfe0d6e 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -33,10 +33,10 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s ## Today’s Intent -> Write the top priority for the current session and what “done” means. +> 2025-10-27 — Core math modularization (PR #5) -- **Focus**: Draft ECS storage implementation plan (archetype storage port to Rust). -- **Definition of done**: Identify storage milestones, required data structures, and sequencing for integration with branch diff engine. +- **Focus**: Split `rmg-core` math into focused submodules (`vec3`, `mat4`, `quat`, `prng`). +- **Definition of done**: CI passes; decision log updated; no behavior changes (pure refactor). --- From 378dd3b49d4b38a5e1c0f455ad589a8fcd0e10d7 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 27 Oct 2025 02:32:36 -0700 Subject: [PATCH 04/11] docs(math): document clamp panic/NaN semantics; clarify EPSILON as degeneracy threshold\ndocs(prng): document zero-state guard in from_seed and panic in next_int\ndocs(quat): expand multiply() docs; add From<[f32;4]> docs\ndocs(vec3): add From<[f32;3]> docs; tighten normalize() docs\ntests(prng): replace brittle golden tests with property-based checks; keep optional golden behind feature flag --- crates/rmg-core/src/math/mod.rs | 14 +++++++++- crates/rmg-core/src/math/prng.rs | 44 ++++++++++++++++++++++++-------- crates/rmg-core/src/math/quat.rs | 28 +++++++++++++++++++- crates/rmg-core/src/math/vec3.rs | 15 ++++++++--- 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/crates/rmg-core/src/math/mod.rs b/crates/rmg-core/src/math/mod.rs index a64753a..fd97902 100644 --- a/crates/rmg-core/src/math/mod.rs +++ b/crates/rmg-core/src/math/mod.rs @@ -19,10 +19,22 @@ pub use quat::Quat; #[doc(inline)] pub use vec3::Vec3; -/// Global epsilon used by math routines when detecting degenerate values. +/// Degeneracy threshold used by math routines to detect near-zero magnitudes. +/// +/// This is not a generic numeric-precision epsilon; it is used to classify +/// vectors/quaternions with magnitude ≤ `EPSILON` as degenerate so that +/// operations like normalization can return stable, deterministic sentinels +/// (e.g., the zero vector or identity quaternion). pub const EPSILON: f32 = 1e-6; /// Clamps `value` to the inclusive `[min, max]` range using float32 rounding. +/// +/// # Panics +/// Panics if `min > max`. +/// +/// # NaN handling +/// If `value`, `min`, or `max` is `NaN`, the result is `NaN`. Callers must +/// ensure inputs are finite if deterministic behavior is required. pub fn clamp(value: f32, min: f32, max: f32) -> f32 { assert!(min <= max, "invalid clamp range: {min} > {max}"); value.max(min).min(max) diff --git a/crates/rmg-core/src/math/prng.rs b/crates/rmg-core/src/math/prng.rs index 842b84c..49e0316 100644 --- a/crates/rmg-core/src/math/prng.rs +++ b/crates/rmg-core/src/math/prng.rs @@ -14,6 +14,10 @@ impl Prng { /// Identical seeds produce identical sequences; the generator remains /// deterministic as long as each process consumes random numbers in the /// same order. + /// + /// If both `seed0` and `seed1` are zero, the implementation replaces them + /// with a fixed non-zero constant so the internal state is never all-zero + /// (avoids the xoroshiro128+ sink). pub fn from_seed(seed0: u64, seed1: u64) -> Self { let mut state = [seed0, seed1]; if state[0] == 0 && state[1] == 0 { @@ -64,8 +68,11 @@ impl Prng { /// Returns the next integer in the inclusive range `[min, max]`. /// - /// Uses rejection sampling to avoid modulo bias, ensuring every value in - /// the range is produced with equal probability. + /// # Panics + /// Panics if `min > max`. + /// + /// Uses rejection sampling with a power-of-two fast path to avoid modulo + /// bias, and supports the full `i32` span. pub fn next_int(&mut self, min: i32, max: i32) -> i32 { assert!(min <= max, "invalid range: {min}..={max}"); let span = (i64::from(max) - i64::from(min)) as u64 + 1; @@ -101,16 +108,33 @@ mod tests { } #[test] - fn next_int_handles_full_i32_range() { - let mut prng = Prng::from_seed(0xDEADBEEF, 0xFACEFEED); - let values: Vec = (0..3).map(|_| prng.next_int(i32::MIN, i32::MAX)).collect(); - assert_eq!(values, vec![1501347292, 1946982111, -117316573]); + fn next_int_deterministic_across_calls() { + let mut a = Prng::from_seed(123, 456); + let mut b = Prng::from_seed(123, 456); + for _ in 0..100 { + assert_eq!(a.next_int(-10, 10), b.next_int(-10, 10)); + } } #[test] - fn next_int_handles_negative_ranges() { - let mut prng = Prng::from_seed(123, 456); - let values: Vec = (0..3).map(|_| prng.next_int(-10, -3)).collect(); - assert_eq!(values, vec![-7, -7, -7]); + fn next_int_respects_bounds() { + let mut prng = Prng::from_seed(42, 99); + for _ in 0..1_000 { + let v = prng.next_int(-10, 10); + assert!((-10..=10).contains(&v)); + } + for _ in 0..1_000 { + let v = prng.next_int(i32::MIN, i32::MAX); + // All i32 are valid; this simply exercises the path. + let _ = v; + } + } + + #[cfg(feature = "golden_prng")] + #[test] + fn next_int_golden_regression() { + let mut prng = Prng::from_seed(0xDEAD_BEEF, 0xFACE_FEED); + let values: Vec = (0..3).map(|_| prng.next_int(i32::MIN, i32::MAX)).collect(); + assert_eq!(values, vec![1_501_347_292, 1_946_982_111, -117_316_573]); } } diff --git a/crates/rmg-core/src/math/quat.rs b/crates/rmg-core/src/math/quat.rs index 0350fb5..3fbaea6 100644 --- a/crates/rmg-core/src/math/quat.rs +++ b/crates/rmg-core/src/math/quat.rs @@ -49,7 +49,30 @@ impl Quat { ) } - /// Multiplies two quaternions (`self * other`). + /// Hamilton product of two quaternions (`self * other`). + /// + /// Operand order matters: the result composes the rotation represented by + /// `self` followed by the rotation represented by `other`. Quaternion + /// multiplication is non‑commutative. + /// + /// Component layout is `(x, y, z, w)` with `w` as the scalar part. Inputs + /// need not be normalized; however, when both operands are unit + /// quaternions, the result represents the composed rotation and remains a + /// unit quaternion up to floating‑point error (consider re‑normalizing over + /// long chains). + /// + /// # Examples + /// ``` + /// use core::f32::consts::FRAC_PI_2; + /// use rmg_core::math::{Quat, Vec3}; + /// // 90° yaw then 90° pitch + /// let yaw = Quat::from_axis_angle(Vec3::UNIT_Y, FRAC_PI_2); + /// let pitch = Quat::from_axis_angle(Vec3::UNIT_X, FRAC_PI_2); + /// let composed = yaw.multiply(&pitch); // yaw then pitch + /// // Non‑commutative: pitch*yaw is different + /// let other = pitch.multiply(&yaw); + /// assert_ne!(composed.to_array(), other.to_array()); + /// ``` pub fn multiply(&self, other: &Self) -> Self { let ax = self.component(0); let ay = self.component(1); @@ -132,6 +155,9 @@ impl Quat { } } +/// Converts a 4‑element `[f32; 4]` array `(x, y, z, w)` into a `Quat`. +/// The components are taken verbatim; callers typically pass unit quaternions +/// for rotations, but normalization is not enforced by this conversion. impl From<[f32; 4]> for Quat { fn from(value: [f32; 4]) -> Self { Self { data: value } diff --git a/crates/rmg-core/src/math/vec3.rs b/crates/rmg-core/src/math/vec3.rs index 084dc0e..0e57a8d 100644 --- a/crates/rmg-core/src/math/vec3.rs +++ b/crates/rmg-core/src/math/vec3.rs @@ -86,10 +86,11 @@ impl Vec3 { self.dot(self) } - /// Normalises the vector, returning zero vector if length is ~0. + /// Normalises the vector, returning the zero vector if length ≤ `EPSILON`. /// - /// Zero-length inputs remain the zero vector so downstream callers can - /// detect degenerate directions deterministically. + /// `EPSILON` is a degeneracy threshold (not numeric precision): vectors + /// with length ≤ `EPSILON` are considered degenerate and normalized to + /// zero so downstream callers can detect them deterministically. pub fn normalize(&self) -> Self { let len = self.length(); if len <= EPSILON { @@ -99,6 +100,14 @@ impl Vec3 { } } +/// Converts a 3-element `[f32; 3]` array into a `Vec3` interpreted as `(x, y, z)`. +/// +/// # Examples +/// ``` +/// use rmg_core::math::Vec3; +/// let v = Vec3::from([1.0, 2.0, 3.0]); +/// assert_eq!(v.to_array(), [1.0, 2.0, 3.0]); +/// ``` impl From<[f32; 3]> for Vec3 { fn from(value: [f32; 3]) -> Self { Self { data: value } From 1bb5184d1fe48ac7df60af042f86d46cfe3213c5 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 27 Oct 2025 02:38:46 -0700 Subject: [PATCH 05/11] fix(ci): add optional feature to rmg-core and fix doctest to avoid Vec3 unit constants\n\n- Resolve unexpected_cfgs error under -D warnings in CI\n- Update multiply() doctest to use Vec3::from arrays --- crates/rmg-core/Cargo.toml | 6 ++++++ crates/rmg-core/src/math/quat.rs | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/rmg-core/Cargo.toml b/crates/rmg-core/Cargo.toml index b524894..ba8b2d4 100644 --- a/crates/rmg-core/Cargo.toml +++ b/crates/rmg-core/Cargo.toml @@ -12,3 +12,9 @@ thiserror = "1" once_cell = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" + +[features] +default = [] +# Optional regression check for PRNG sequences; off by default to avoid +# freezing algorithm choices. Used only in tests guarded with `cfg(feature)`. +golden_prng = [] diff --git a/crates/rmg-core/src/math/quat.rs b/crates/rmg-core/src/math/quat.rs index 3fbaea6..4e44839 100644 --- a/crates/rmg-core/src/math/quat.rs +++ b/crates/rmg-core/src/math/quat.rs @@ -66,8 +66,8 @@ impl Quat { /// use core::f32::consts::FRAC_PI_2; /// use rmg_core::math::{Quat, Vec3}; /// // 90° yaw then 90° pitch - /// let yaw = Quat::from_axis_angle(Vec3::UNIT_Y, FRAC_PI_2); - /// let pitch = Quat::from_axis_angle(Vec3::UNIT_X, FRAC_PI_2); + /// let yaw = Quat::from_axis_angle(Vec3::from([0.0, 1.0, 0.0]), FRAC_PI_2); + /// let pitch = Quat::from_axis_angle(Vec3::from([1.0, 0.0, 0.0]), FRAC_PI_2); /// let composed = yaw.multiply(&pitch); // yaw then pitch /// // Non‑commutative: pitch*yaw is different /// let other = pitch.multiply(&yaw); From 727686ef53c317631d2afe6015c0a90e5cd87638 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 27 Oct 2025 05:58:32 -0700 Subject: [PATCH 06/11] Update crates/rmg-core/src/math/vec3.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: James Ross --- crates/rmg-core/src/math/vec3.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/rmg-core/src/math/vec3.rs b/crates/rmg-core/src/math/vec3.rs index 0e57a8d..4245978 100644 --- a/crates/rmg-core/src/math/vec3.rs +++ b/crates/rmg-core/src/math/vec3.rs @@ -14,6 +14,15 @@ pub struct Vec3 { } impl Vec3 { + /// Unit vector pointing along the positive X axis. + pub const UNIT_X: Self = Self::new(1.0, 0.0, 0.0); + + /// Unit vector pointing along the positive Y axis. + pub const UNIT_Y: Self = Self::new(0.0, 1.0, 0.0); + + /// Unit vector pointing along the positive Z axis. + pub const UNIT_Z: Self = Self::new(0.0, 0.0, 1.0); + /// Creates a vector from components. /// /// Inputs are interpreted as metres in world coordinates; callers must From 742c52bfc0e7cfe4b2c55a0b6490e1920c39f2f3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 27 Oct 2025 05:59:55 -0700 Subject: [PATCH 07/11] Update crates/rmg-core/src/math/quat.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: James Ross --- crates/rmg-core/src/math/quat.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/rmg-core/src/math/quat.rs b/crates/rmg-core/src/math/quat.rs index 4e44839..aa9c448 100644 --- a/crates/rmg-core/src/math/quat.rs +++ b/crates/rmg-core/src/math/quat.rs @@ -92,12 +92,17 @@ impl Quat { ) } - /// Normalises the quaternion; returns identity when norm is ~0. + /// Returns a unit quaternion (magnitude 1) pointing in the same direction. + /// + /// Quaternion operations can accumulate floating-point error; normalize + /// periodically to maintain unit length for accurate rotations. If the + /// magnitude is ≤ `EPSILON`, returns the identity quaternion to avoid + /// division by near-zero (a degenerate quaternion cannot represent a rotation). pub fn normalize(&self) -> Self { let len = (self.component(0) * self.component(0) - + self.component(1) * self.component(1) - + self.component(2) * self.component(2) - + self.component(3) * self.component(3)) + self.component(1) * self.component(1) + self.component(2) * self.component(2) + self.component(3) * self.component(3)) .sqrt(); if len <= EPSILON { return Self::identity(); From 74b90c5d350bce73e616b5ef45f5fb37eba1c94a Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 27 Oct 2025 06:06:50 -0700 Subject: [PATCH 08/11] docs(quat): correct composition order in multiply() docs; expand identity() and to_mat4() documentation; clarify to_array() order; add debug asserts to new()\nfix: replace let-chains in motion_executor for toolchain compatibility\nchore: set edition=2021 for rmg-core/ffi/wasm/cli --- crates/rmg-cli/Cargo.toml | 2 +- crates/rmg-core/Cargo.toml | 2 +- crates/rmg-core/src/lib.rs | 13 ++++---- crates/rmg-core/src/math/quat.rs | 51 ++++++++++++++++++++------------ crates/rmg-ffi/Cargo.toml | 2 +- crates/rmg-wasm/Cargo.toml | 2 +- 6 files changed, 43 insertions(+), 29 deletions(-) diff --git a/crates/rmg-cli/Cargo.toml b/crates/rmg-cli/Cargo.toml index 8cca0dc..437298f 100644 --- a/crates/rmg-cli/Cargo.toml +++ b/crates/rmg-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rmg-cli" version = "0.1.0" -edition = "2024" +edition = "2021" [dependencies] diff --git a/crates/rmg-core/Cargo.toml b/crates/rmg-core/Cargo.toml index ba8b2d4..60e6800 100644 --- a/crates/rmg-core/Cargo.toml +++ b/crates/rmg-core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rmg-core" version = "0.1.0" -edition = "2024" +edition = "2021" [dependencies] blake3 = "1" diff --git a/crates/rmg-core/src/lib.rs b/crates/rmg-core/src/lib.rs index 0c3d745..8022a49 100644 --- a/crates/rmg-core/src/lib.rs +++ b/crates/rmg-core/src/lib.rs @@ -411,12 +411,13 @@ fn add_vec(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { /// Executor that updates the encoded position in the entity payload. fn motion_executor(store: &mut GraphStore, scope: &NodeId) { - if let Some(record) = store.node_mut(scope) - && let Some(payload) = &record.payload - && let Some((position, velocity)) = decode_motion_payload(payload) - { - let updated = encode_motion_payload(add_vec(position, velocity), velocity); - record.payload = Some(updated); + if let Some(record) = store.node_mut(scope) { + if let Some(payload) = &record.payload { + if let Some((position, velocity)) = decode_motion_payload(payload) { + let updated = encode_motion_payload(add_vec(position, velocity), velocity); + record.payload = Some(updated); + } + } } } diff --git a/crates/rmg-core/src/math/quat.rs b/crates/rmg-core/src/math/quat.rs index aa9c448..d2bd52e 100644 --- a/crates/rmg-core/src/math/quat.rs +++ b/crates/rmg-core/src/math/quat.rs @@ -12,13 +12,16 @@ pub struct Quat { impl Quat { /// Creates a quaternion from components. /// - /// Callers should provide finite components; use - /// [`Quat::from_axis_angle`] for axis/angle construction. + /// Components are interpreted as `(x, y, z, w)` with `w` the scalar part. + /// In debug builds this asserts that all components are finite; in release + /// builds construction is unchecked. Prefer [`Quat::from_axis_angle`] for + /// axis/angle construction when possible. pub const fn new(x: f32, y: f32, z: f32, w: f32) -> Self { + debug_assert!(x.is_finite() && y.is_finite() && z.is_finite() && w.is_finite()); Self { data: [x, y, z, w] } } - /// Returns the quaternion as an array. + /// Returns the quaternion as an array `[x, y, z, w]`. pub fn to_array(self) -> [f32; 4] { self.data } @@ -52,8 +55,9 @@ impl Quat { /// Hamilton product of two quaternions (`self * other`). /// /// Operand order matters: the result composes the rotation represented by - /// `self` followed by the rotation represented by `other`. Quaternion - /// multiplication is non‑commutative. + /// `other` followed by the rotation represented by `self`. Quaternion + /// multiplication is non‑commutative, so reversing the order yields a + /// different orientation in general. /// /// Component layout is `(x, y, z, w)` with `w` as the scalar part. Inputs /// need not be normalized; however, when both operands are unit @@ -65,13 +69,13 @@ impl Quat { /// ``` /// use core::f32::consts::FRAC_PI_2; /// use rmg_core::math::{Quat, Vec3}; - /// // 90° yaw then 90° pitch - /// let yaw = Quat::from_axis_angle(Vec3::from([0.0, 1.0, 0.0]), FRAC_PI_2); + /// // Compose: 90° pitch around X, then 90° yaw around Y /// let pitch = Quat::from_axis_angle(Vec3::from([1.0, 0.0, 0.0]), FRAC_PI_2); - /// let composed = yaw.multiply(&pitch); // yaw then pitch - /// // Non‑commutative: pitch*yaw is different - /// let other = pitch.multiply(&yaw); - /// assert_ne!(composed.to_array(), other.to_array()); + /// let yaw = Quat::from_axis_angle(Vec3::from([0.0, 1.0, 0.0]), FRAC_PI_2); + /// let composed = yaw.multiply(&pitch); // pitch first, then yaw + /// // Reversing order gives different result + /// let reversed = pitch.multiply(&yaw); + /// assert_ne!(composed.to_array(), reversed.to_array()); /// ``` pub fn multiply(&self, other: &Self) -> Self { let ax = self.component(0); @@ -94,15 +98,15 @@ impl Quat { /// Returns a unit quaternion (magnitude 1) pointing in the same direction. /// - /// Quaternion operations can accumulate floating-point error; normalize - /// periodically to maintain unit length for accurate rotations. If the - /// magnitude is ≤ `EPSILON`, returns the identity quaternion to avoid - /// division by near-zero (a degenerate quaternion cannot represent a rotation). + /// Quaternion operations can accumulate floating-point error; normalise + /// periodically to maintain unit length for accurate rotations. If the + /// magnitude is ≤ `EPSILON`, returns the identity quaternion to avoid + /// division by near‑zero (a degenerate quaternion cannot represent a rotation). pub fn normalize(&self) -> Self { let len = (self.component(0) * self.component(0) - self.component(1) * self.component(1) - self.component(2) * self.component(2) - self.component(3) * self.component(3)) + + self.component(1) * self.component(1) + + self.component(2) * self.component(2) + + self.component(3) * self.component(3)) .sqrt(); if len <= EPSILON { return Self::identity(); @@ -117,11 +121,20 @@ impl Quat { } /// Returns the identity quaternion. + /// + /// Represents no rotation (the multiplicative identity for quaternion + /// multiplication). pub const fn identity() -> Self { Self::new(0.0, 0.0, 0.0, 1.0) } - /// Converts the quaternion to a rotation matrix (column-major 4×4). + /// Converts the quaternion to a 4×4 rotation matrix in column‑major order. + /// + /// The quaternion is normalised before conversion to ensure a valid + /// rotation matrix. The resulting matrix is a homogeneous transform with + /// the rotation in the upper‑left 3×3 block and `[0, 0, 0, 1]` in the last + /// row and column. Use this to integrate quaternion rotations into + /// matrix‑based pipelines and composition with translations/scales. pub fn to_mat4(&self) -> Mat4 { let q = self.normalize(); let x = q.component(0); diff --git a/crates/rmg-ffi/Cargo.toml b/crates/rmg-ffi/Cargo.toml index c134101..096d9f4 100644 --- a/crates/rmg-ffi/Cargo.toml +++ b/crates/rmg-ffi/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rmg-ffi" version = "0.1.0" -edition = "2024" +edition = "2021" [lib] crate-type = ["rlib", "cdylib", "staticlib"] diff --git a/crates/rmg-wasm/Cargo.toml b/crates/rmg-wasm/Cargo.toml index 1673ab6..81a12d3 100644 --- a/crates/rmg-wasm/Cargo.toml +++ b/crates/rmg-wasm/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rmg-wasm" version = "0.1.0" -edition = "2024" +edition = "2021" [lib] crate-type = ["cdylib"] From 1e14877100d97d81d47d2d6b3c54148ef685c1fc Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 27 Oct 2025 06:28:36 -0700 Subject: [PATCH 09/11] style: reorder math imports and fmt to satisfy rustfmt; adjust rmg-ffi/wasm import order --- crates/rmg-core/src/math/quat.rs | 2 +- crates/rmg-ffi/src/lib.rs | 4 ++-- crates/rmg-wasm/src/lib.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/rmg-core/src/math/quat.rs b/crates/rmg-core/src/math/quat.rs index d2bd52e..97c68e1 100644 --- a/crates/rmg-core/src/math/quat.rs +++ b/crates/rmg-core/src/math/quat.rs @@ -1,4 +1,4 @@ -use crate::math::{EPSILON, Mat4, Vec3}; +use crate::math::{Mat4, Vec3, EPSILON}; /// Quaternion stored as `(x, y, z, w)` with deterministic float32 rounding. /// diff --git a/crates/rmg-ffi/src/lib.rs b/crates/rmg-ffi/src/lib.rs index 1dfeee9..ffd1fc9 100644 --- a/crates/rmg-ffi/src/lib.rs +++ b/crates/rmg-ffi/src/lib.rs @@ -10,8 +10,8 @@ use std::os::raw::c_char; use std::slice; use rmg_core::{ - ApplyResult, Engine, MOTION_RULE_NAME, NodeId, NodeRecord, TxId, build_motion_demo_engine, - decode_motion_payload, encode_motion_payload, make_node_id, make_type_id, + build_motion_demo_engine, decode_motion_payload, encode_motion_payload, make_node_id, + make_type_id, ApplyResult, Engine, NodeId, NodeRecord, TxId, MOTION_RULE_NAME, }; /// Opaque engine pointer exposed over the C ABI. diff --git a/crates/rmg-wasm/src/lib.rs b/crates/rmg-wasm/src/lib.rs index 5f10463..f1d2acc 100644 --- a/crates/rmg-wasm/src/lib.rs +++ b/crates/rmg-wasm/src/lib.rs @@ -9,8 +9,8 @@ use std::rc::Rc; use js_sys::Uint8Array; use rmg_core::{ - ApplyResult, Engine, MOTION_RULE_NAME, NodeId, NodeRecord, TxId, build_motion_demo_engine, - decode_motion_payload, encode_motion_payload, make_node_id, make_type_id, + build_motion_demo_engine, decode_motion_payload, encode_motion_payload, make_node_id, + make_type_id, ApplyResult, Engine, NodeId, NodeRecord, TxId, MOTION_RULE_NAME, }; use wasm_bindgen::prelude::*; From 13eafaf2ecbf83e7abd79f665d96b6dc409aba25 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 27 Oct 2025 06:44:42 -0700 Subject: [PATCH 10/11] ci: add PRNG golden regression step to CI; fix doc/clippy nits around PRNG_ALGO_VERSION and doc formatting --- .github/workflows/ci.yml | 2 + crates/rmg-core/src/math/prng.rs | 8 +++ docs/legacy-excavation.md | 32 ---------- docs/legacy/original-guidelines.md | 97 ------------------------------ 4 files changed, 10 insertions(+), 129 deletions(-) delete mode 100644 docs/legacy-excavation.md delete mode 100644 docs/legacy/original-guidelines.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 070fa43..b1dd3d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,8 @@ jobs: . - name: cargo test run: cargo test + - name: PRNG golden regression (rmg-core) + run: cargo test -p rmg-core --features golden_prng -- tests::next_int_golden_regression docs: name: Docs Guard diff --git a/crates/rmg-core/src/math/prng.rs b/crates/rmg-core/src/math/prng.rs index 49e0316..3f7c622 100644 --- a/crates/rmg-core/src/math/prng.rs +++ b/crates/rmg-core/src/math/prng.rs @@ -3,6 +3,14 @@ /// * Not cryptographically secure; use only for gameplay/state simulation. /// * Seeding controls reproducibility within a single process/run and matching /// seeds yield identical sequences across supported platforms. +/// +/// Algorithm version for PRNG bit‑exact behavior. +/// Bump this only when intentionally changing the algorithm or seeding rules +/// and update any golden regression tests accordingly. +#[allow(dead_code)] +pub const PRNG_ALGO_VERSION: u32 = 1; + +/// Stateful PRNG instance. #[derive(Debug, Clone)] pub struct Prng { state: [u64; 2], diff --git a/docs/legacy-excavation.md b/docs/legacy-excavation.md deleted file mode 100644 index ff3084e..0000000 --- a/docs/legacy-excavation.md +++ /dev/null @@ -1,32 +0,0 @@ -# Echo Legacy Excavation Log - -| File | Role (2013) | Verdict | Roast | Notes & Action Items | -| --- | --- | --- | --- | --- | -| README.md | Project teaser, submodule manifest dump | Rescope | “Sweet stuff coming soon” has been on break for 12 years. | Replace with Echo overview. Document historical submodules in appendix. | -| docs/roadmap.md | Milestone wish list | Inspire | Athens never fell, but at least the ambition was mythic. | Retain naming spirit; extract goals relevant to Echo tooling. | -| docs/guidelines.md | Team philosophy, workflow tips | Archive | All gas, no brakes, and Mootools forever? Adorable. | Capture cultural nuggets (automation emphasis), discard mootools bias. | -| docs/notes.md | Scratchpad of ideas/links | Discard | Mou links and sweet hacker vibes—time capsule material only. | Note links of lasting value (Sinon, Vows) elsewhere if needed. | -| server/src/app.js | Express 3 static file server | Replace | Global `p` variables like it’s frontier JavaScript. | Build new dev server using modern tooling (Vite/Express 5). No direct migration. | -| server/config/*.json | Environment roots for sandbox | Replace | Two configs, zero validation, still yelling into port 1337. | Echo config moves to typed bootstrap pipeline; reuse concept of content roots. | -| client/index.html | Legacy client bootstrap | Replace | Script tags summon 2012 directly into your DOM. | Reference for dependency list; future sample uses modern bundler. | -| client/src/engine/component.js | Component registration via global | Reimagine | “FIXME: does not appear to work??” is the documentation. | Preserve idea of type IDs, but implement through registration metadata in TypeScript. | -| client/src/engine/entity.js | Entity class with signal events | Reimagine | Signals galore, but `onRemoved` can’t find `components`. | Convert to pooled entity manager; retain add/remove events. Note bug in `onRemoved`. | -| client/src/engine/utils.js | Misc helpers (`safeInvoke`, `randomColor`) | Replace | `js.defaults` is promised but never born. | Identify useful helpers (`safeInvoke`, `insertWhen`) and decide whether to reintroduce or drop; modern JS/TS covers most needs. | -| client/src/engine/typed_json.js | JSON revive by type string | Reimagine | `stringToFunction` must be hiding with Half-Life 3. | Document requirement for typed serialization; implement schema-driven version. | -| client/src/engine/system.js | System registration, node list management | Reimagine | ECS via existential dread and `FIXME` confessions. | Keep node concept but redesign around archetypes; note missing `for...` guard bugs. | -| client/src/engine/system_node_list.js | Query builder | Replace | `for (var node in nodes)`—so close to working. | Use signature-driven queries; keep idea of node add/remove signals. | -| client/src/engine/world.js | Entity storage, prefab creation | Reimagine | Mootools `.extend` shows up like it’s a dependency. | Avoid Mootools dependencies; plan new world/scene API with deterministic removal. | -| client/src/engine/game.js | Game loop and state machine placeholder | Replace | Everything interesting lives in commented-out physics demo. | Use scheduler + state stack; record expectation for physics warm-up, pause, debug toggles. | -| client/src/engine/state_machine.js | Simple state transitions | Replace | Calls `setup` with `game` global that never existed. | Keep concept of safe enter/exit; remove global `game` usage. | -| client/src/engine/system_registry.js | Global system manager | Replace | Pauses by flag, but erases systems with `erase` like it’s Mootools. | Merge into scheduler design; note priority + pause semantics worth preserving. | -| client/src/engine/input.js | DOM event wrappers | Replace | Manual event wiring with `document.onkeydown`—no mercy for multiple instances. | Build Input port with adapter for browser; note features (callbacks, unregister). | -| client/src/game/** | Early game-specific systems/components | Optional | Kinetics component remembers a dream of box2d glory. | Use select pieces (kinetics, player input) as examples or tests; majority deprecated. | -| sandbox/** | Experiments and demos | Archive | Tumbleweed demos and a `stub.todo` note to self. | Inventory useful samples; otherwise treat as historical artifacts. | -| tasks/*.rake | Automation scripts (node install, pixi copy) | Replace | Rake calling `sudo npm install` feels like a dare. | Modern dev workflow handled via package scripts; keep spirit of automation (one-command setup). | - -*Legend*: -**Replace** – Concept stays but implementation rewritten. -**Reimagine** – Core idea adapted with significant evolution. -**Archive** – Preserve in history docs but not implemented. -**Discard** – No longer relevant; drop entirely. -**Inspire** – Keep stylistic or cultural notes, not functionality. diff --git a/docs/legacy/original-guidelines.md b/docs/legacy/original-guidelines.md deleted file mode 100644 index a330543..0000000 --- a/docs/legacy/original-guidelines.md +++ /dev/null @@ -1,97 +0,0 @@ -development guidelines -====================== - -## philosophy - -1. Just do it. -2. Tests are awesome. -3. Workflow automation is awesome. -4. Don't push crazy stuff to `origin master` - -## development process - -### manifesto -1. Don't solve solved problems. Use open source solutions when possible. -2. Be focused. Ask yourself if what you're doing helps you finish your current task. If not then don't do it, backlog it. -3. Have fun or go home. This isn't a job. - -### productivity -Let's keep track of stuff with [Trello](https://trello.com/b/rDxl2nlC/caverns). - -### editor -Don't care what editor you use, but `.gitignore` its stupid files. - -### code -Javascript for client and server code. Ruby for workflow scripts. - -### workflow automation - -#### embrace - -* Ruby is sweet. -* Rake is sweet. - -#### avoid - -Avoid using bash, python, and other friends. Ruby only, pls. - -#### rake - -* Rake tasks should be namespaced. -* Define tasks in a `.rake` file in `./tasks/`, i.e.: `./tasks/my-sweet-tasks.rake`. -* `import` your `.rake` files in `./rakefile` -* Only `desc` user-facing tasks. - -### git smart - -Let's be smart about using git. - -1. Until we establish a test-driven workflow, **keep `origin master` in working order**. -2. External code should be added to the project as a submodule. - -#### branch names - -Name your branches in the following way: - -##### work in progress -Sometimes you need to push something that isn't ready or maybe doesn't even build. - - wip/feature-name - -##### debug -Sometimes you'll be in the middle of debugging something and need to switch contexts or let someone else take over from where you are. Don't use a `wip/` branch name. Instead, call your branch: - - debug/bug-description - -##### experiments -Sometimes you'll have a sweet idea and want to try something out. Do it! And push that bad boy to: - - jake/my-sweet-idea - -Obviously, replace `jake` with your name, like `jack`, or `arthur`, or your github alias, if you prefer, like `flyingrobots/amazing-thing`. - -#### tags -For now, let's tag - -* Milestone commits - -Perhaps consider tagging these sort of commits in the future… - -* Deployments (to track what's in production) -* Automated commits (to identify commits made by tools/scripts) - -## milestone names -It'd be sweet/fun to name milestones in the roadmap after national capitals, in alphabetical order. For example: - -1. Athens -2. Baghdad -3. Copenhagen -4. Doha -5. Freetown (no E?) -6. Gibraltar -7. Hanoi -8. Islamabad -9. Juba -10. Kiev - -And so on. Yea or nay? From a8f8552b3d6c24a11e2de029c355b1ce589309ae Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 27 Oct 2025 06:52:19 -0700 Subject: [PATCH 11/11] chore(hooks): add .githooks/pre-commit to couple PRNG algorithm/version with golden vector; add Makefile target 'hooks' to install core.hooksPath --- .githooks/pre-commit | 53 ++++++++++++++++++++++++++++++++++++++++ Makefile | 8 ++++++ docs/memorial.md | 26 -------------------- docs/rmg-demo-roadmap.md | 2 +- 4 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 .githooks/pre-commit create mode 100644 Makefile delete mode 100644 docs/memorial.md diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..2c97a1f --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Enforce coupling between PRNG algorithm/version and golden regression vector. + +PRNG_FILE="crates/rmg-core/src/math/prng.rs" + +# Only run if the PRNG file is staged +if ! git diff --cached --name-only | grep -qx "$PRNG_FILE"; then + exit 0 +fi + +DIFF=$(git diff --cached -- "$PRNG_FILE" || true) + +# Heuristics to detect algorithm changes: edits to these functions imply behavior change +if echo "$DIFF" | grep -E '^(\+|-)\s*(fn\s+next_u64|fn\s+from_seed_u64|fn\s+from_seed\(|fn\s+next_int\()' >/dev/null; then + ALGO_CHANGED=1 +else + ALGO_CHANGED=0 +fi + +# Version bump present? +if echo "$DIFF" | grep -E 'PRNG_ALGO_VERSION' >/dev/null; then + VERSION_CHANGED=1 +else + VERSION_CHANGED=0 +fi + +# Golden regression vector updated? +if echo "$DIFF" | grep -E 'next_int_golden_regression|assert_eq!\(values,\s*vec!\[' >/dev/null; then + GOLDEN_CHANGED=1 +else + GOLDEN_CHANGED=0 +fi + +FAIL=0 +if [[ "$ALGO_CHANGED" -eq 1 && "$VERSION_CHANGED" -eq 0 ]]; then + echo "pre-commit: PRNG algorithm changed but PRNG_ALGO_VERSION was not bumped." >&2 + FAIL=1 +fi + +if [[ "$VERSION_CHANGED" -eq 1 && "$GOLDEN_CHANGED" -eq 0 ]]; then + echo "pre-commit: PRNG_ALGO_VERSION bumped but golden regression vector was not updated." >&2 + FAIL=1 +fi + +if [[ "$FAIL" -eq 1 ]]; then + echo "pre-commit: Refusing commit. Update algorithm version and golden regression together." >&2 + exit 1 +fi + +exit 0 + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2529909 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +SHELL := /bin/bash + +.PHONY: hooks +hooks: + @git config core.hooksPath .githooks + @chmod +x .githooks/* 2>/dev/null || true + @echo "[hooks] Installed git hooks from .githooks (core.hooksPath)" + diff --git a/docs/memorial.md b/docs/memorial.md deleted file mode 100644 index 6d38f28..0000000 --- a/docs/memorial.md +++ /dev/null @@ -1,26 +0,0 @@ -# Requiem for Caverns - -Here lies **Caverns**, born in the optimistic spring of 2013, when WebGL demos roared in Chrome nightlies and the word *pipeline* was whispered with equal parts wonder and fear. It was a time when JavaScript still wore cargo shorts, and yet our predecessors dreamt of double jumps, component systems, and Athens-scale ambitions. - -We salute the brave legion of libraries that marched at Caverns’ side: - -- **MooTools**, valiant Swiss Army knife of an era that thought prototypes should pirouette. You shimmed reality, extended everything, and left us `erase()` as both gift and curse. -- **Box2D**, ported through arcane rituals, reminding browsers they could pretend to be consoles. -- **Pixi.js**, freshly forged, carrying the promise of GPU sprites before “WebGPU” even had a birth certificate. -- **signals.js**, the herald of events when promises were still just gossip. - -And how can we forget the ruby-scribed incantations—**Rake tasks** summoning `sudo npm install` with the confidence of explorers who hadn’t yet seen package-locks or CI pipelines. The `rakefile` was an altar, the terminal a pulpit, shouting “Just do it.” Truly, automation in its punk-rock infancy. - -To every `FIXME`, every `TODO`, every `console.log("Sweet.")`: we hear you. -To the duplicated submodule config in the README: your chaos delighted us. -To the sandbox directory with a `stub.todo` file: your silence speaks louder than a 404. - -We raise a torch to the Athens milestone that never shipped, the roadmap that promised capitals from Baghdad to Kiev, the audio-less physics demos, and the AppleScript that always reopened Chrome to port 1337. You were a fever dream of an ECS, a love letter to boxy sprites, a time capsule of post-jQuery exuberance. - -Rest now, Caverns. Echo will carry your echo—polished, deterministic, with timelines braided in multiversal hues—but your spirit fuels the forge. - -**Salute!** -To MooTools, to Ruby, to double-jump ambitions. May your memory guide our refactorings and our branch merges. May your ghosts dance in the inspector windows of Tomorrow. - -*“Sweet stuff coming soon.”* -— We promise, ancestors. This time, it arrives. diff --git a/docs/rmg-demo-roadmap.md b/docs/rmg-demo-roadmap.md index d9211b7..17310b7 100644 --- a/docs/rmg-demo-roadmap.md +++ b/docs/rmg-demo-roadmap.md @@ -9,7 +9,7 @@ This document captures the interactive demos and performance milestones we want **Goal:** Show two instances running locally in lockstep and prove graph hash equality every frame. - Two Echo instances (no network) consume identical input streams generated from a shared seed (deterministic RNG feeding input script). -- Each frame serialises the world graph in canonical order (sorted node/edge IDs, component payload bytes) and hashes it with BLAKE3 to produce the “frame hash”. +- Each frame serializes the world graph in canonical order (sorted node/edge IDs, component payload bytes) and hashes it with BLAKE3 to produce the “frame hash”. - Inspectors display the frame hashes side-by-side and flag divergence immediately. Success = 100% equality across a 10 000-frame run. - Determinism safeguards: freeze wall clock, mock OS timers, clamp floating-point math to deterministic fixed-point helpers, forbid nondeterministic APIs. - Output artifact: JSON trace (`frame`, `hash`, `inputs_consumed`) plus a screenshot/video for the showcase.