feat(runtime): add terrain-snapped reference movement

This commit is contained in:
2026-07-18 19:42:52 +04:00
parent b1ff50f9db
commit e339256776
5 changed files with 200 additions and 2 deletions
Generated
+1
View File
@@ -607,6 +607,7 @@ dependencies = [
"fparkan-prototype",
"fparkan-render",
"fparkan-resource",
"fparkan-terrain",
"fparkan-vfs",
"fparkan-world",
]
+1
View File
@@ -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" }
+154 -2
View File
@@ -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,
@@ -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<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() {
+20
View File
@@ -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)
+24
View File
@@ -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