aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 16:11:21 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 16:11:21 +0300
commit0b23cf48e7aba160b2786d8359e8cfb4ca13da07 (patch)
treefe76daf7987d78fae1537c07cb1bddc96e79876f
parent7356238ffbdb8d0e1229124eff23295cf3f410e2 (diff)
downloadfparkan-0b23cf48e7aba160b2786d8359e8cfb4ca13da07.tar.xz
fparkan-0b23cf48e7aba160b2786d8359e8cfb4ca13da07.zip
fix: use canonical sha256 world hashes
-rw-r--r--Cargo.lock3
-rw-r--r--crates/fparkan-world/Cargo.toml1
-rw-r--r--crates/fparkan-world/src/lib.rs162
3 files changed, 145 insertions, 21 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 1068926..8297d80 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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);
+ }
+ }
}