diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock index b12e72a3..81ae3846 100644 --- a/.claude/scheduled_tasks.lock +++ b/.claude/scheduled_tasks.lock @@ -1 +1 @@ -{"sessionId":"4759827d-a890-479a-b9fd-8fb2951be225","pid":2159,"acquiredAt":1776363341090} \ No newline at end of file +{"sessionId":"4759827d-a890-479a-b9fd-8fb2951be225","pid":6894,"acquiredAt":1776385589030} \ No newline at end of file diff --git a/src/simulator/crates/mc-turn/src/gpu/fauna_encounter.wgsl b/src/simulator/crates/mc-turn/src/gpu/fauna_encounter.wgsl index 9a3805b4..6b66634f 100644 --- a/src/simulator/crates/mc-turn/src/gpu/fauna_encounter.wgsl +++ b/src/simulator/crates/mc-turn/src/gpu/fauna_encounter.wgsl @@ -61,11 +61,11 @@ fn mul64(al: u32, ah: u32, bl: u32, bh: u32) -> vec2 { } // SplitMix64 step matching hash_mix(state, 0xDEADBEEFCAFEBABE) in processor.rs. -// Combined addend: 0xDEADBEEFCAFEBABE + 0x9E3779B97F4A7C15 = 0x7CE538A94A493673 +// Combined addend: 0xDEADBEEFCAFEBABE + 0x9E3779B97F4A7C15 = 0x7CE538A94A4936D3 // (wrapping add in u64). fn smix_step(lo: u32, hi: u32) -> vec2 { // z = state + combined_const - let c_lo = 0x4A493673u; + let c_lo = 0x4A4936D3u; let c_hi = 0x7CE538A9u; var zl = lo + c_lo; var zh = hi + c_hi + select(0u, 1u, zl < lo); @@ -113,9 +113,9 @@ fn main(@builtin(global_invocation_id) gid: vec3) { if uid >= uni.n_units { return; } let tile = unit_tiles[uid]; - let meta = unit_meta[uid]; - let pi = (meta >> 1u) & 0x7Fu; - let fort = (meta & 1u) != 0u; + let um = unit_meta[uid]; + let pi = (um >> 1u) & 0x7Fu; + let fort = (um & 1u) != 0u; var rlo = player_rng[pi * 2u]; var rhi = player_rng[pi * 2u + 1u]; diff --git a/src/simulator/crates/mc-turn/src/gpu/mod.rs b/src/simulator/crates/mc-turn/src/gpu/mod.rs index c82a2061..0859efdf 100644 --- a/src/simulator/crates/mc-turn/src/gpu/mod.rs +++ b/src/simulator/crates/mc-turn/src/gpu/mod.rs @@ -462,6 +462,58 @@ mod inner { "GPU kill_flags must be byte-identical to CPU across {TURNS} turns with seed={SEED}"); } + /// Scalar parity: smix_step(lo, hi) == hash_mix(state, SALT) for N iterations. + /// Catches any constant or bit-shift mismatch between CPU and WGSL. + #[test] + fn smix_step_matches_cpu_hash_mix() { + let ctx = match GpuContext::try_init() { + Some(c) => c, + None => { + eprintln!("[gpu-test] No GPU adapter — skipping smix_step_matches_cpu_hash_mix"); + return; + } + }; + + // Drive 16 single-unit dispatches with no lairs so the only RNG output + // is the unchanged rng_state readback. We verify state evolves identically. + let cfg = LairCombatConfig::default(); + let empty_csr = LairIndexCsr { offsets: vec![0u32; 2], flat_lair_ids: vec![] }; + let lair_tiers: Vec = vec![]; + + let mut cpu_state: u64 = 0xDEAD_C0DE_1234_5678; + let mut gpu_state: u64 = cpu_state; + + for _ in 0..16 { + // CPU: one rand_unit call = one hash_mix step + let (_, next) = rand_unit(cpu_state); + cpu_state = next; + + // GPU: dispatch a single unit on a tile with no lairs. + // The kernel still calls smix_step once for the (skipped) encounter roll. + let unit = GpuUnit { tile_idx: 0, meta: 0 }; + // Patch csr so tile 0 has one lair entry (forcing one encounter roll). + let one_lair_csr = LairIndexCsr { + offsets: vec![0u32, 1u32, 1u32], + flat_lair_ids: vec![0u32], + }; + let one_tier: Vec = vec![1u32]; + ctx.dispatch_player_fauna( + &[unit], + 0, + &mut gpu_state, + &one_lair_csr, + &one_tier, + 2, + 1, + &cfg, + ); + } + + // After 16 identical steps the states must agree. + assert_eq!(cpu_state, gpu_state, + "smix_step must produce byte-identical output to CPU hash_mix after 16 steps"); + } + /// Fallback: when no GPU is available, dispatch returns empty without panicking. #[test] fn graceful_when_no_gpu() {