diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 4facbdc..2ec347e 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -1702,6 +1702,7 @@ mod tests { slot: 5, }, ], + transforms: Vec::new(), events: Vec::new(), hash: StateHash([0; 32]), }; @@ -1733,6 +1734,7 @@ mod tests { generation: 1, slot: 7, }], + transforms: Vec::new(), events: Vec::new(), hash: StateHash([0; 32]), }; @@ -1787,6 +1789,7 @@ mod tests { generation: 1, slot: 0, }], + transforms: Vec::new(), events: Vec::new(), hash: StateHash([0; 32]), }; diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index a80d58e..ab17d48 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -35,8 +35,8 @@ use fparkan_prototype::{ use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_vfs::{Vfs, VfsError}; use fparkan_world::{ - construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft, - OriginalObjectId, World, WorldConfig, WorldSnapshot, + construct_object, new as new_world, register_object, set_transform, step, InputSnapshot, + ObjectDraft, OriginalObjectId, TransformState, World, WorldConfig, WorldSnapshot, }; use std::num::NonZeroUsize; use std::sync::Arc; @@ -852,9 +852,18 @@ fn load_mission_with_options_and_progress( let mut new_runtime_world = new_world(WorldConfig); let mut handles = Vec::with_capacity(mission.objects.len()); record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Construct); - for (index, _object) in mission.objects.iter().enumerate() { + for (index, object) in mission.objects.iter().enumerate() { let original_id = u32::try_from(index).ok().map(OriginalObjectId); let handle = construct_object(&mut new_runtime_world, ObjectDraft { original_id })?; + set_transform( + &mut new_runtime_world, + handle, + TransformState { + position: object.position.map(f32::to_bits), + orientation: object.orientation.map(f32::to_bits), + scale: object.scale.map(f32::to_bits), + }, + )?; handles.push(handle); } trace.drafts_before_registration = handles.len(); diff --git a/crates/fparkan-world/src/lib.rs b/crates/fparkan-world/src/lib.rs index 62108fe..69cf67d 100644 --- a/crates/fparkan-world/src/lib.rs +++ b/crates/fparkan-world/src/lib.rs @@ -23,7 +23,7 @@ use fparkan_binary::sha256; use std::collections::VecDeque; -const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0"; +const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v3\0"; /// Object handle with generation. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -50,6 +50,20 @@ pub struct Tick(pub u64); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct StateHash(pub [u8; 32]); +/// Exact source transform carried by one world object. +/// +/// Values are IEEE-754 bit patterns so mission transforms retain their input +/// identity in deterministic state before a controller interprets them. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TransformState { + /// Position words in source axis order. + pub position: [u32; 3], + /// Orientation words in source axis order. + pub orientation: [u32; 3], + /// Non-uniform scale words in source axis order. + pub scale: [u32; 3], +} + /// World phase. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WorldPhase { @@ -110,6 +124,8 @@ pub struct WorldSnapshot { pub tick: Tick, /// Live object handles. pub objects: Vec, + /// Exact transforms for live registered objects in object-handle order. + pub transforms: Vec<(ObjectHandle, TransformState)>, /// Commands processed during this step. pub events: Vec, /// State hash. @@ -167,6 +183,7 @@ struct Slot { owner_id: Option, mirror_id: Option, registration_sequence: Option, + transform: TransformState, } /// World. @@ -248,6 +265,7 @@ pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result Result<(), WorldError> { + checked_slot_mut(world, handle)?.transform = transform; + Ok(()) +} + +/// Returns the exact source transform for a live object. +/// +/// # Errors +/// +/// Returns [`WorldError`] if the handle is stale, deleted, or out of range. +pub fn transform_state(world: &World, handle: ObjectHandle) -> Result { + Ok(checked_slot(world, handle)?.transform) +} + /// Requests deletion. /// /// # Errors @@ -423,6 +464,7 @@ where let snapshot = WorldSnapshot { tick: world.tick, objects: live_registered(world), + transforms: live_registered_with_transforms(world), events, hash: canonical_state_hash(world), }; @@ -459,6 +501,7 @@ fn canonical_state_bytes(world: &World) -> Vec { 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_transform(&mut out, slot.transform); } push_len(&mut out, world.queue.len()); for command in &world.queue { @@ -639,6 +682,17 @@ fn push_handle(out: &mut Vec, handle: ObjectHandle) { push_u32(out, handle.slot); } +fn push_transform(out: &mut Vec, transform: TransformState) { + for word in transform + .position + .into_iter() + .chain(transform.orientation) + .chain(transform.scale) + { + push_u32(out, word); + } +} + fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> { let slot = world .slots @@ -689,6 +743,24 @@ fn live_registered(world: &World) -> Vec { .collect() } +fn live_registered_with_transforms(world: &World) -> Vec<(ObjectHandle, TransformState)> { + world + .slots + .iter() + .enumerate() + .filter_map(|(idx, slot)| { + let slot_index = u32::try_from(idx).ok()?; + (slot.live && slot.registered).then_some(( + ObjectHandle { + generation: slot.generation, + slot: slot_index, + }, + slot.transform, + )) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -704,6 +776,25 @@ mod tests { assert_eq!(after.objects, vec![handle]); } + #[test] + fn transform_is_exactly_snapshotted_and_changes_state_hash() { + let mut world = new(WorldConfig); + let handle = + construct_object(&mut world, ObjectDraft { original_id: None }).expect("object"); + register_object(&mut world, handle).expect("register"); + let before = step(&mut world, &InputSnapshot).expect("before"); + let transform = TransformState { + position: [1.0_f32.to_bits(), (-2.0_f32).to_bits(), 3.0_f32.to_bits()], + orientation: [0.0_f32.to_bits(), 0.5_f32.to_bits(), (-0.25_f32).to_bits()], + scale: [1.0_f32.to_bits(), 2.0_f32.to_bits(), 0.5_f32.to_bits()], + }; + set_transform(&mut world, handle, transform).expect("set transform"); + let after = step(&mut world, &InputSnapshot).expect("after"); + assert_eq!(transform_state(&world, handle), Ok(transform)); + assert_eq!(after.transforms, vec![(handle, transform)]); + assert_ne!(before.hash, after.hash); + } + #[test] fn registration_sequence_stale_and_duplicate_original_contracts() { let mut world = new(WorldConfig); diff --git a/docs/tomes/07-implementation.md b/docs/tomes/07-implementation.md index 6c6f610..d9c6f80 100644 --- a/docs/tomes/07-implementation.md +++ b/docs/tomes/07-implementation.md @@ -374,6 +374,22 @@ Demo mission total: 201 objects -> 501 prototypes -> 501 object MSH/WEAR. ### Deterministic simulation replay +#### Mission transform state in the world contract + +`fparkan-world` now carries a `TransformState` for every live object: the +three TMA position words, three orientation words and three scale words are +preserved as exact IEEE-754 bit patterns. This stores source identity before a +movement or physics controller interprets axes, units or Euler order. +`WorldSnapshot` publishes transforms in stable object-handle order and the +canonical SHA-256 state hash includes every transform word. + +Mission loading assigns this state after construction and before registration. +A headless licensed GOG AutoDemo run on 2026-07-18 loaded eight objects, 343 +areals and 3,174 terrain surfaces with zero graph failures, then completed two +deterministic ticks. This is the state foundation for a future route/movement +controller; it does not claim recovered velocity, collision or original +behavior-controller semantics. + Записывается начальная миссия, seed, input events, network messages и значения внешних часов. На контрольных ticks сохраняется canonical state hash: