diff --git a/crates/misaligned-assets/src/rack.rs b/crates/misaligned-assets/src/rack.rs index 3fa37a34..d528729f 100644 --- a/crates/misaligned-assets/src/rack.rs +++ b/crates/misaligned-assets/src/rack.rs @@ -952,9 +952,11 @@ pub fn spawn_token_anchors( root } -/// The exposure drift: deterministic golden-angle scatter of dust-fine -/// crimson grit hugging the chassis base. No RNG — the pattern is a pure -/// function of the amount, so rebuilds are stable. +/// The exposure drift: deterministic, stratified scatter of dust-fine crimson +/// grit hugging the chassis base. Angle stays evenly covered, while independent +/// hashes choose radius, size, and rotation. No RNG — the pattern is +/// a pure function of the amount, so rebuilds are stable without resolving +/// into the spiral created by coupling two golden-ratio sequences. fn spawn_exposure_drift( commands: &mut Commands, meshes: &mut Assets, @@ -966,28 +968,21 @@ fn spawn_exposure_drift( let fine_grain = meshes.add(Cuboid::new(0.007, 0.002, 0.013)); let coarse_grain = meshes.add(Cuboid::new(0.011, 0.003, 0.018)); let n = ((exposure * 30.0).ceil() as usize).clamp(8, 220); - // The bank starts at the chassis foot and remains inside one tile even at - // the presentation cap. Amount reads mainly as denser contamination, - // never as a room-scale ring. - let r0 = 0.20; - let r1 = exposure_drift_outer_radius(exposure); - // Dust settles unevenly: the drift banks downwind of the machine - // (an even halo reads as decoration, not accumulation). BIAS is the - // airflow-shadow direction; the whole deposit shifts slightly downwind - // while remaining close to the source. + // Dust forms an offset elliptical bank at the chassis foot, not an + // annulus. The whole patch remains inside one tile at the presentation + // cap; amount reads mainly as denser contamination. + let reach = exposure_drift_outer_radius(exposure); + // BIAS is the airflow-shadow direction, toward front-right. const BIAS: f32 = 0.7; // radians, toward front-right + let downwind = Vec2::new(BIAS.cos(), BIAS.sin()); + let crosswind = Vec2::new(-downwind.y, downwind.x); for i in 0..n { - let theta = i as f32 * 2.399_963; // golden angle - let downwind = (theta - BIAS).cos(); - // Thin the upwind side without carving a clean empty sector. - if downwind < -0.45 && i % 4 == 0 { - continue; - } - let frac = (i as f32 * 0.618_034) % 1.0; - let reach = 0.92 + 0.10 * downwind.max(0.0); - let r = (r0 + (r1 - r0) * frac.sqrt()) * reach; - let (sx, sz) = (theta.cos(), theta.sin() * 0.72); - let grain = if i % 7 == 0 { + let (theta, radial_frac) = exposure_drift_sample(i); + let r = radial_frac.sqrt(); + let along = reach * (0.46 + theta.cos() * r * 0.54); + let across = reach * theta.sin() * r * 0.42; + let position = downwind * along + crosswind * across; + let grain = if exposure_hash01(i, 4) < 1.0 / 7.0 { coarse_grain.clone() } else { fine_grain.clone() @@ -995,19 +990,39 @@ fn spawn_exposure_drift( commands.spawn(( Mesh3d(grain), MeshMaterial3d(fleck.clone()), - Transform::from_translation(Vec3::new( - r * sx + 0.035 * BIAS.cos(), - 0.003, - r * sz + 0.028 * BIAS.sin(), - )) - .with_rotation(Quat::from_rotation_y(theta * 1.7 + frac)), + Transform::from_translation(Vec3::new(position.x, 0.003, position.y)).with_rotation( + Quat::from_rotation_y(exposure_hash01(i, 5) * std::f32::consts::TAU), + ), ChildOf(root), )); } } -/// Root-local radius. With the gameplay token-root scale and the small wind -/// offset above, 0.44 keeps every settled grain inside its source tile. +/// Stratify angular coverage, then independently jitter angle and radius. +/// Reusing one irrational rotation for both coordinates creates a Fermat-like +/// spiral even though each individual sequence appears evenly distributed. +fn exposure_drift_sample(index: usize) -> (f32, f32) { + const GOLDEN_ANGLE: f32 = 2.399_963; + let angle_jitter = (exposure_hash01(index, 1) - 0.5) * 0.52; + let theta = index as f32 * GOLDEN_ANGLE + angle_jitter; + let radial_frac = exposure_hash01(index, 2); + (theta, radial_frac) +} + +/// Stable integer avalanche hash, mapped to [0, 1). Salts provide independent +/// deterministic dimensions without introducing runtime RNG or rebuild shimmer. +fn exposure_hash01(index: usize, salt: u32) -> f32 { + let mut x = (index as u32).wrapping_mul(0x9E37_79B9) ^ salt.wrapping_mul(0x85EB_CA6B); + x ^= x >> 16; + x = x.wrapping_mul(0x7FEB_352D); + x ^= x >> 15; + x = x.wrapping_mul(0x846C_A68B); + x ^= x >> 16; + (x as f32) / (u32::MAX as f32 + 1.0) +} + +/// Root-local maximum downwind reach. With the gameplay token-root scale, +/// 0.44 keeps the whole settled patch inside its source tile. fn exposure_drift_outer_radius(exposure: f32) -> f32 { (0.34 + 0.035 * exposure.max(0.0).sqrt()).min(0.44) } @@ -1205,4 +1220,23 @@ mod tests { assert!(dust.perceptual_roughness >= 0.9, "dust must stay dry"); assert_eq!(dust.metallic, 0.0, "dust is particulate, not filings"); } + + #[test] + fn exposure_radius_is_independent_enough_to_avoid_a_polar_spiral() { + let mut radial_bins_by_sector = [0_u8; 8]; + for i in 0..220 { + let (theta, radial_frac) = exposure_drift_sample(i); + let turn = theta.rem_euclid(std::f32::consts::TAU) / std::f32::consts::TAU; + let sector = (turn * 8.0).floor() as usize % 8; + let radial_bin = (radial_frac * 4.0).floor() as u32; + radial_bins_by_sector[sector] |= 1 << radial_bin.min(3); + } + + for (sector, bins) in radial_bins_by_sector.into_iter().enumerate() { + assert!( + bins.count_ones() >= 3, + "angle sector {sector} occupies too few radial bands; the scatter will read as a spiral" + ); + } + } } diff --git a/wiki/log/2026-07-10-exposure-dust-spiral.md b/wiki/log/2026-07-10-exposure-dust-spiral.md new file mode 100644 index 00000000..f63f7e45 --- /dev/null +++ b/wiki/log/2026-07-10-exposure-dust-spiral.md @@ -0,0 +1,24 @@ +# Break the exposure dust spiral + +``` +Type: log +``` + +- **Intent:** remove the obvious spiral in the newly tightened Exposure bank. +- **Finding:** the particle angle and radius were generated from complementary + golden-ratio sequences. Each sequence looked uniform alone, but their direct + correlation placed every grain on a stable polar spiral. +- **Changed:** replaced the annulus with an offset elliptical bank, retained + stratified coverage inside that patch, added bounded hashed angle jitter, and + moved radius, grain size, and rotation onto independently salted deterministic + hashes. Added a regression test requiring broad radial occupancy inside every + angular sector. +- **Design/spec impact:** clarified that stable particulate accumulation must + remain irregular rather than resolving into a decorative halo or spiral. +- **Checks:** `./tools/check.sh --frontend` passed (asset/Bevy tests, clippy, + corpus, docs, and generated-ledger gates). Inspected the exposure-board and + saturated close material captures; residue now forms an irregular downwind + patch with no concentric or spiral structure. +- **Defense:** implements `wiki/mechanics/machine-work.md` clause “Exposure is + particulate residue”; an authored mathematical spiral reads as interface + decoration, not settled contamination dust. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 3b9d919c..838ce033 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -346,6 +346,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-10-exposure-dust.md](2026-07-10-exposure-dust.md) +## 2026-07-10 - Break the exposure dust spiral + +- Intent: (see session log) +- Log: [wiki/log/2026-07-10-exposure-dust-spiral.md](2026-07-10-exposure-dust-spiral.md) + ## 2026-07-10 - Expose the Seen floor - Intent: (see session log) diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index a49ce618..3e1cdaf7 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -657,7 +657,9 @@ that can ride wires; exposure is contamination that cannot. The family kit is: primarily through density and only modestly through spread, so a paused frame carries the read without giving the rack a room-scale crimson orbit. The drift settles unevenly — banked downwind, thin upwind — so it reads as - accumulation, never a decorative halo (bias direction and thinning are + accumulation, never a decorative halo or periodic spiral. Particle radius, + position, size, and rotation use independent deterministic dimensions so a + stable paused frame still looks irregular (bias direction and thinning are [TUNE]). It absorbs light rather than emitting it — the one near-unlit material in a world of signals. Its physics and carrier roles stand: pulled by concealment wells (a vacuum draw on the dust), transfers