diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-22 16:11:21 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-22 16:11:21 +0300 |
| commit | 0b23cf48e7aba160b2786d8359e8cfb4ca13da07 (patch) | |
| tree | fe76daf7987d78fae1537c07cb1bddc96e79876f | |
| parent | 7356238ffbdb8d0e1229124eff23295cf3f410e2 (diff) | |
| download | fparkan-0b23cf48e7aba160b2786d8359e8cfb4ca13da07.tar.xz fparkan-0b23cf48e7aba160b2786d8359e8cfb4ca13da07.zip | |
fix: use canonical sha256 world hashes
| -rw-r--r-- | Cargo.lock | 3 | ||||
| -rw-r--r-- | crates/fparkan-world/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/fparkan-world/src/lib.rs | 162 |
3 files changed, 145 insertions, 21 deletions
@@ -287,6 +287,9 @@ dependencies = [ [[package]] name = "fparkan-world" version = "0.1.0" +dependencies = [ + "fparkan-binary", +] [[package]] name = "miniz_oxide" diff --git a/crates/fparkan-world/Cargo.toml b/crates/fparkan-world/Cargo.toml index e336d07..a9ae709 100644 --- a/crates/fparkan-world/Cargo.toml +++ b/crates/fparkan-world/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true repository.workspace = true [dependencies] +fparkan-binary = { path = "../fparkan-binary" } [lints] workspace = true diff --git a/crates/fparkan-world/src/lib.rs b/crates/fparkan-world/src/lib.rs index 9059a23..26ed5ad 100644 --- a/crates/fparkan-world/src/lib.rs +++ b/crates/fparkan-world/src/lib.rs @@ -1,8 +1,11 @@ #![forbid(unsafe_code)] //! Deterministic world identity, queue, lifecycle, and snapshots. +use fparkan_binary::sha256; use std::collections::VecDeque; +const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0"; + /// Object handle with generation. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct ObjectHandle { @@ -418,24 +421,36 @@ where /// Computes canonical state hash. #[must_use] pub fn canonical_state_hash(world: &World) -> StateHash { - let mut state = 0xcbf2_9ce4_8422_2325_u64; - hash_u64(&mut state, world.tick.0); + StateHash(sha256(&canonical_state_bytes(world))) +} + +fn canonical_state_bytes(world: &World) -> Vec<u8> { + let mut out = Vec::new(); + out.extend_from_slice(WORLD_STATE_HASH_SCHEMA); + push_u64(&mut out, world.tick.0); + push_u64(&mut out, world.next_sequence); + push_u64(&mut out, world.next_registration_sequence); + push_len(&mut out, world.slots.len()); for (idx, slot) in world.slots.iter().enumerate() { - hash_u64(&mut state, idx as u64); - hash_u64(&mut state, u64::from(slot.generation)); - hash_u64(&mut state, u64::from(u8::from(slot.live))); - hash_u64(&mut state, u64::from(u8::from(slot.registered))); - hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0))); - hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0))); - hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0))); - hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX)); - } - let mut out = [0; 32]; - out[..8].copy_from_slice(&state.to_le_bytes()); - out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes()); - out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes()); - out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes()); - StateHash(out) + push_len(&mut out, idx); + push_u32(&mut out, slot.generation); + push_bool(&mut out, slot.live); + push_bool(&mut out, slot.registered); + push_optional_u32(&mut out, slot.original_id.map(|id| id.0)); + push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0)); + push_optional_u16(&mut out, slot.owner_id.map(|id| id.0)); + push_optional_u64(&mut out, slot.registration_sequence); + } + push_len(&mut out, world.queue.len()); + for command in &world.queue { + push_u64(&mut out, command.sequence); + push_optional_handle(&mut out, command.target); + } + push_len(&mut out, world.deferred_delete.len()); + for handle in &world.deferred_delete { + push_handle(&mut out, *handle); + } + out } /// Creates a fixed-step clock. @@ -552,13 +567,59 @@ pub fn shutdown(mut world: World) -> ShutdownReport { } } -fn hash_u64(state: &mut u64, value: u64) { - for byte in value.to_le_bytes() { - *state ^= u64::from(byte); - *state = state.wrapping_mul(0x0000_0100_0000_01b3); +fn push_len(out: &mut Vec<u8>, value: usize) { + push_u64(out, u64::try_from(value).unwrap_or(u64::MAX)); +} + +fn push_u64(out: &mut Vec<u8>, value: u64) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn push_u32(out: &mut Vec<u8>, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn push_u16(out: &mut Vec<u8>, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn push_bool(out: &mut Vec<u8>, value: bool) { + out.push(u8::from(value)); +} + +fn push_optional_u64(out: &mut Vec<u8>, value: Option<u64>) { + push_bool(out, value.is_some()); + if let Some(value) = value { + push_u64(out, value); + } +} + +fn push_optional_u32(out: &mut Vec<u8>, value: Option<u32>) { + push_bool(out, value.is_some()); + if let Some(value) = value { + push_u32(out, value); } } +fn push_optional_u16(out: &mut Vec<u8>, value: Option<u16>) { + push_bool(out, value.is_some()); + if let Some(value) = value { + push_u16(out, value); + } +} + +fn push_optional_handle(out: &mut Vec<u8>, handle: Option<ObjectHandle>) { + push_bool(out, handle.is_some()); + if let Some(handle) = handle { + push_handle(out, handle); + } +} + +fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) { + push_u32(out, handle.generation); + push_u32(out, handle.slot); +} + fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> { let slot = world .slots @@ -837,6 +898,34 @@ mod tests { } #[test] + fn state_hash_uses_canonical_sha256_instead_of_legacy_rotated_fnv() { + let mut world = new(WorldConfig); + let handle = construct_object( + &mut world, + ObjectDraft { + original_id: Some(OriginalObjectId(42)), + }, + ) + .expect("object"); + set_mirror_original(&mut world, handle, Some(OriginalObjectId(420))).expect("mirror"); + set_owner(&mut world, handle, Some(OwnerId(9))).expect("owner"); + register_object(&mut world, handle).expect("register"); + enqueue( + &mut world, + WorldCommand { + sequence: 999, + target: Some(handle), + }, + ) + .expect("enqueue"); + + let snapshot = step(&mut world, &InputSnapshot).expect("step"); + + assert_ne!(snapshot.hash, legacy_rotated_fnv_hash(&world)); + assert_ne!(snapshot.hash, canonical_state_hash(&new(WorldConfig))); + } + + #[test] fn fixed_step_pause_and_long_determinism_are_stable() { let config = FixedStepConfig { step_millis: 20, @@ -966,4 +1055,35 @@ mod tests { } } } + + fn legacy_rotated_fnv_hash(world: &World) -> StateHash { + let mut state = 0xcbf2_9ce4_8422_2325_u64; + legacy_hash_u64(&mut state, world.tick.0); + for (idx, slot) in world.slots.iter().enumerate() { + legacy_hash_u64( + &mut state, + u64::try_from(idx).expect("slot index should fit"), + ); + legacy_hash_u64(&mut state, u64::from(slot.generation)); + legacy_hash_u64(&mut state, u64::from(u8::from(slot.live))); + legacy_hash_u64(&mut state, u64::from(u8::from(slot.registered))); + legacy_hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0))); + legacy_hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0))); + legacy_hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0))); + legacy_hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX)); + } + let mut out = [0; 32]; + out[..8].copy_from_slice(&state.to_le_bytes()); + out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes()); + out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes()); + out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes()); + StateHash(out) + } + + fn legacy_hash_u64(state: &mut u64, value: u64) { + for byte in value.to_le_bytes() { + *state ^= u64::from(byte); + *state = state.wrapping_mul(0x0000_0100_0000_01b3); + } + } } |
