aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 18:42:52 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 18:42:52 +0300
commite33925677692b85d096d891b381bac62b2c9942b (patch)
tree8abe294a8098beb00cf79b57e325beaafb44a37b
parentb1ff50f9db85dc1f04a0e975adbb98ee0cf3a436 (diff)
downloadfparkan-e33925677692b85d096d891b381bac62b2c9942b.tar.xz
fparkan-e33925677692b85d096d891b381bac62b2c9942b.zip
feat(runtime): add terrain-snapped reference movement
-rw-r--r--Cargo.lock1
-rw-r--r--crates/fparkan-runtime/Cargo.toml1
-rw-r--r--crates/fparkan-runtime/src/lib.rs156
-rw-r--r--crates/fparkan-world/src/lib.rs20
-rw-r--r--docs/tomes/06-behavior.md24
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<bool, EngineError> {
+ 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<fparkan_world::WorldError> 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,
@@ -1271,6 +1336,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() {
let root = licensed_root("IS");
@@ -1315,6 +1404,69 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn reference_movement_snaps_a_live_mission_object_to_terrain() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = 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() {
let root = licensed_root("IS");
for (denied, mission) in [
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<TransformS
Ok(checked_slot(world, handle)?.transform)
}
+/// Returns the live registered handle carrying an original mission id.
+#[must_use]
+pub fn handle_by_original_id(world: &World, original_id: OriginalObjectId) -> Option<ObjectHandle> {
+ 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