From e33925677692b85d096d891b381bac62b2c9942b Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 19:42:52 +0400 Subject: [PATCH] feat(runtime): add terrain-snapped reference movement --- Cargo.lock | 1 + crates/fparkan-runtime/Cargo.toml | 1 + crates/fparkan-runtime/src/lib.rs | 156 +++++++++++++++++++++++++++++- crates/fparkan-world/src/lib.rs | 20 ++++ docs/tomes/06-behavior.md | 24 +++++ 5 files changed, 200 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b8256e..5237da5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -607,6 +607,7 @@ dependencies = [ "fparkan-prototype", "fparkan-render", "fparkan-resource", + "fparkan-terrain", "fparkan-vfs", "fparkan-world", ] diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml index 956c040..6754de1 100644 --- a/crates/fparkan-runtime/Cargo.toml +++ b/crates/fparkan-runtime/Cargo.toml @@ -11,6 +11,7 @@ fparkan-path = { path = "../fparkan-path", version = "0.1.0" } fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" } fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" } fparkan-render = { path = "../fparkan-render", version = "0.1.0" } +fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" } fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" } fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" } fparkan-world = { path = "../fparkan-world", version = "0.1.0" } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index ab17d48..740ad34 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -33,10 +33,12 @@ use fparkan_prototype::{ build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport, }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; +use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; use fparkan_world::{ - construct_object, new as new_world, register_object, set_transform, step, InputSnapshot, - ObjectDraft, OriginalObjectId, TransformState, World, WorldConfig, WorldSnapshot, + construct_object, handle_by_original_id, new as new_world, register_object, set_transform, + step, transform_state, InputSnapshot, ObjectDraft, OriginalObjectId, TransformState, World, + WorldConfig, WorldSnapshot, }; use std::num::NonZeroUsize; use std::sync::Arc; @@ -412,6 +414,8 @@ pub enum EngineError { }, /// World error. World(fparkan_world::WorldError), + /// Reference movement input or terrain query failed. + Movement(String), /// Scheduler phase order was violated. SchedulerPhaseOrder { /// Previous phase. @@ -430,6 +434,65 @@ pub enum EngineError { }, } +/// Advances one object toward an explicit XY target and snaps it to terrain. +/// +/// This is a deterministic reference controller, not recovered original AI. +/// +/// # Errors +/// +/// Returns an error when the input is invalid, the mission/object is absent, +/// the source transform is non-finite, or the requested point has no terrain +/// surface. +pub fn advance_reference_movement( + engine: &mut Engine, + original_id: OriginalObjectId, + target_xy: [f32; 2], + max_step: f32, +) -> Result { + if !target_xy + .iter() + .chain(std::iter::once(&max_step)) + .all(|v| v.is_finite()) + || max_step <= 0.0 + { + return Err(EngineError::Movement( + "target and max_step must be finite; max_step must be positive".to_string(), + )); + } + let terrain = engine + .loaded + .as_ref() + .ok_or_else(|| EngineError::Movement("mission terrain is unavailable".to_string()))? + .terrain + .clone(); + let handle = handle_by_original_id(&engine.world, original_id).ok_or_else(|| { + EngineError::Movement(format!("original object {} is unavailable", original_id.0)) + })?; + let mut transform = transform_state(&engine.world, handle)?; + let position = transform.position.map(f32::from_bits); + if !position.iter().all(|v| v.is_finite()) { + return Err(EngineError::Movement( + "object transform is non-finite".to_string(), + )); + } + let dx = target_xy[0] - position[0]; + let dy = target_xy[1] - position[1]; + let distance = dx.hypot(dy); + let reached = distance <= max_step; + let ratio = if reached { 1.0 } else { max_step / distance }; + let x = position[0] + dx * ratio; + let y = position[1] + dy * ratio; + let z = terrain + .height_at([x, y]) + .map_err(|err| EngineError::Movement(err.to_string()))? + .ok_or_else(|| { + EngineError::Movement("movement target lies outside terrain surface".to_string()) + })?; + transform.position = [x.to_bits(), y.to_bits(), z.to_bits()]; + set_transform(&mut engine.world, handle, transform)?; + Ok(reached) +} + impl From for EngineError { fn from(value: fparkan_world::WorldError) -> Self { Self::World(value) @@ -459,6 +522,7 @@ impl std::fmt::Display for EngineError { write!(f, "{mission}: asset preparation failed: {source}") } Self::World(source) => write!(f, "{source}"), + Self::Movement(message) => write!(f, "reference movement: {message}"), Self::SchedulerPhaseOrder { previous, current } => write!( f, "scheduler phase order regressed from {previous:?} to {current:?}" @@ -487,6 +551,7 @@ impl std::error::Error for EngineError { Self::World(source) => Some(source), Self::AssetPreparation { source, .. } => Some(source), Self::MissingVfs + | Self::Movement(_) | Self::PrototypeGraph { .. } | Self::SchedulerPhaseOrder { .. } | Self::RegistrationTeardown { .. } => None, @@ -1270,6 +1335,30 @@ mod tests { )); } + #[test] + fn reference_movement_rejects_non_finite_or_non_positive_input() { + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + EngineServices::default(), + ) + .expect("engine"); + + let err = + advance_reference_movement(&mut engine, OriginalObjectId(0), [f32::NAN, 0.0], 1.0) + .expect_err("non-finite target must fail"); + assert_eq!( + err.to_string(), + "reference movement: target and max_step must be finite; max_step must be positive" + ); + + assert!(matches!( + advance_reference_movement(&mut engine, OriginalObjectId(0), [0.0, 0.0], 0.0), + Err(EngineError::Movement(_)) + )); + } + #[test] #[ignore = "requires licensed corpus"] fn load_trace_records_preparation_before_registration_and_raw_transforms() { @@ -1313,6 +1402,69 @@ mod tests { .all(|component| component.is_finite()))); } + #[test] + #[ignore = "requires licensed corpus"] + fn reference_movement_snaps_a_live_mission_object_to_terrain() { + let root = licensed_root("IS"); + let vfs: Arc = Arc::new(DirectoryVfs::new(&root)); + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + EngineServices::new(vfs), + ) + .expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + }, + ) + .expect("load mission"); + + let (original_id, start_xy, target_xy, expected_xy, expected_height) = { + let loaded = engine.loaded.as_ref().expect("loaded mission"); + loaded + .object_drafts + .iter() + .find_map(|draft| { + let original_id = draft.original_id?; + let start_xy = [draft.position[0], draft.position[1]]; + [[1.0, 0.0], [-1.0, 0.0], [0.0, 1.0], [0.0, -1.0]] + .into_iter() + .find_map(|offset| { + let target_xy = [start_xy[0] + offset[0], start_xy[1] + offset[1]]; + let expected_xy = + [start_xy[0] + offset[0] * 0.5, start_xy[1] + offset[1] * 0.5]; + let height = loaded.terrain.height_at(expected_xy).ok()??; + loaded + .terrain + .height_at(target_xy) + .ok()?? + .is_finite() + .then_some((original_id, start_xy, target_xy, expected_xy, height)) + }) + }) + .expect("mission object with a movable terrain-neighbour target") + }; + + assert!( + !advance_reference_movement(&mut engine, original_id, target_xy, 0.5) + .expect("bounded reference movement") + ); + let handle = handle_by_original_id(&engine.world, original_id).expect("world object"); + let transform = transform_state(&engine.world, handle).expect("world transform"); + assert_eq!( + transform.position, + [ + expected_xy[0].to_bits(), + expected_xy[1].to_bits(), + expected_height.to_bits() + ] + ); + assert_ne!(start_xy, expected_xy); + } + #[test] #[ignore = "requires licensed corpus"] fn missing_map_and_missing_reachable_resource_fail_before_registration() { diff --git a/crates/fparkan-world/src/lib.rs b/crates/fparkan-world/src/lib.rs index 69cf67d..e14ac97 100644 --- a/crates/fparkan-world/src/lib.rs +++ b/crates/fparkan-world/src/lib.rs @@ -376,6 +376,20 @@ pub fn transform_state(world: &World, handle: ObjectHandle) -> Result Option { + world.slots.iter().enumerate().find_map(|(index, slot)| { + let index = u32::try_from(index).ok()?; + (slot.live && slot.registered && slot.original_id == Some(original_id)).then_some( + ObjectHandle { + generation: slot.generation, + slot: index, + }, + ) + }) +} + /// Requests deletion. /// /// # Errors @@ -814,10 +828,16 @@ mod tests { .expect("second"); register_object(&mut world, first).expect("register first"); register_object(&mut world, second).expect("register second"); + assert_eq!( + handle_by_original_id(&world, OriginalObjectId(7)), + Some(first) + ); + assert_eq!(handle_by_original_id(&world, OriginalObjectId(9)), None); assert_eq!(registration_sequence(&world, first), Ok(Some(0))); assert_eq!(registration_sequence(&world, second), Ok(Some(1))); request_delete(&mut world, first).expect("delete"); + assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None); assert_eq!( register_object(&mut world, first), Err(WorldError::StaleHandle) diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 93ec301..3de3a22 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -290,6 +290,30 @@ Control получает world-interface Terrain и использует пов последовательности, а интеграция использует одну политику `dt` и округления. Иначе одинаковая миссия постепенно расходится даже без сети. +#### Reference controller в текущем runtime + +`fparkan-runtime::advance_reference_movement` — намеренно маленький +детерминированный мост между сохранённым mission transform и `TerrainWorld`. +Он получает `OriginalObjectId`, явную XY-цель и положительный максимум шага, +находит только live/registered object по исходному ID, двигает XY не более чем +на этот шаг и записывает высоту из `TerrainWorld::height_at`. Orientation и +scale остаются исходными IEEE-754 words; функция не изменяет clock, очередь +World3D или animation state. Возвращаемое значение означает достижение именно +заданной XY-цели. + +Это **не** восстановленный Control, Behavior или navigation controller: +функция не строит маршрут, не использует `dt`, скорость, terrain normal, +коллизии, acceleration и оригинальные AI decisions. Она существует как +проверяемый reference path, который фиксирует границу будущих controller-ов и +не позволяет renderer-у или gameplay обходить terrain query. Non-finite input, +неположительный шаг, отсутствующая миссия/объект и XY вне поверхности дают +явную ошибку без частичного изменения transform. + +Licensed test на GOG `Autodemo.00` запускает этот путь для live mission object, +находит существующую поверхность и проверяет точные XY/Z words после snap. Это +доказывает связывание current runtime data, но не доказывает семантику +оригинального движения. + ### Различия Control в Части 2 `Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals