2026-06-22 13:12:27 +04:00
|
|
|
#![forbid(unsafe_code)]
|
2026-09-06 05:22:12 +04:00
|
|
|
//! Runtime orchestration for mission loading and world stepping.
|
2026-06-22 13:12:27 +04:00
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
use fparkan_assets::{
|
2026-06-23 22:32:50 +04:00
|
|
|
decode_mission_land_path, decode_mission_payload, decode_nres_payload,
|
2026-09-06 05:22:12 +04:00
|
|
|
derive_mission_land_paths, extend_graph_report_with_visual_dependencies,
|
2026-07-18 20:56:34 +04:00
|
|
|
mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError,
|
2026-09-06 05:22:12 +04:00
|
|
|
AssetId, AssetManager, BuildCategory, MissionAssetPlan, MissionDocument, MissionError,
|
|
|
|
|
MissionTerrainPaths, NresError, PreparedVisual, TerrainFormatError, TerrainPreparationError,
|
|
|
|
|
TerrainWorld, TmaProfile,
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
|
|
|
|
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
|
|
|
|
|
use fparkan_prototype::{
|
2026-06-23 22:32:50 +04:00
|
|
|
build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
|
2026-10-18 11:38:50 +04:00
|
|
|
UnitComponentRecord, UNIT_DAT_BAS_PLACEMENT_FLAG,
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
2026-07-18 09:36:29 +04:00
|
|
|
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository};
|
2026-07-18 22:15:55 +04:00
|
|
|
use fparkan_script::{
|
2026-10-20 04:11:35 +04:00
|
|
|
Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
|
|
|
|
|
ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand,
|
2026-07-18 22:15:55 +04:00
|
|
|
};
|
2026-07-18 19:42:52 +04:00
|
|
|
use fparkan_terrain::SurfaceQuery;
|
2026-06-22 13:12:27 +04:00
|
|
|
use fparkan_vfs::{Vfs, VfsError};
|
|
|
|
|
use fparkan_world::{
|
2026-07-18 19:42:52 +04:00
|
|
|
construct_object, handle_by_original_id, new as new_world, register_object, set_transform,
|
2026-10-20 08:44:10 +04:00
|
|
|
step, transform_state, InputSnapshot, ObjectDraft, ObjectHandle, OriginalObjectId,
|
|
|
|
|
TransformState, World, WorldConfig, WorldSnapshot,
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
2026-07-18 10:36:29 +04:00
|
|
|
use std::num::NonZeroUsize;
|
2026-06-22 13:12:27 +04:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
pub use fparkan_assets::MissionAssets;
|
|
|
|
|
|
2026-07-18 11:49:55 +04:00
|
|
|
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
|
2026-07-18 21:23:28 +04:00
|
|
|
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
|
2026-10-20 05:15:11 +04:00
|
|
|
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
|
2026-07-18 11:49:55 +04:00
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Injectable engine services used by composition roots.
|
|
|
|
|
#[derive(Clone, Default)]
|
|
|
|
|
pub struct EngineServices {
|
|
|
|
|
/// Resource filesystem.
|
|
|
|
|
pub vfs: Option<Arc<dyn Vfs>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl EngineServices {
|
|
|
|
|
/// Creates services with a VFS.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new(vfs: Arc<dyn Vfs>) -> Self {
|
|
|
|
|
Self { vfs: Some(vfs) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 03:20:20 +04:00
|
|
|
/// Native mission mode used by the recovered host callback.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum MissionMode {
|
|
|
|
|
/// Native application mode 1, used by the selected `missions/single.*` path.
|
|
|
|
|
SingleMission,
|
|
|
|
|
/// Native application mode 3, selected by the main-menu idle timer.
|
|
|
|
|
AutoDemo,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Mission request.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct MissionRequest {
|
|
|
|
|
/// Mission key/path.
|
|
|
|
|
pub key: String,
|
2026-10-20 03:20:20 +04:00
|
|
|
/// Explicit host mode; this is not inferred from the mission path.
|
|
|
|
|
pub mode: MissionMode,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
2026-10-20 03:20:20 +04:00
|
|
|
const INITIAL_SCRIPT_HOST_STATUS: u32 = 5;
|
|
|
|
|
|
2026-07-03 17:11:49 +04:00
|
|
|
/// Ordered mission property preserved for later runtime stages.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct MissionObjectProperty {
|
|
|
|
|
/// Raw property value words.
|
|
|
|
|
pub raw_value: [u32; 4],
|
|
|
|
|
/// Raw property name bytes.
|
|
|
|
|
pub name_raw: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mission-derived object draft preserved for Stage 3 viewer/player work.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct MissionObjectDraft {
|
|
|
|
|
/// Original mission object id.
|
|
|
|
|
pub original_id: Option<OriginalObjectId>,
|
|
|
|
|
/// Raw mission resource reference.
|
|
|
|
|
pub resource_name_raw: Vec<u8>,
|
2026-10-20 08:11:45 +04:00
|
|
|
/// Raw word immediately after the mission resource reference. The native
|
|
|
|
|
/// runtime uses it as an AI-selection index.
|
|
|
|
|
pub raw_after_resource: u32,
|
2026-07-03 17:11:49 +04:00
|
|
|
/// Raw identity/clan word from the mission.
|
|
|
|
|
pub identity_or_clan_raw: u32,
|
2026-10-18 11:38:50 +04:00
|
|
|
/// Raw word after the mission object's instance name. Native BAS
|
|
|
|
|
/// placement uses its zero/nonzero state only when the Unit DAT header
|
|
|
|
|
/// gate is set.
|
|
|
|
|
pub raw_after_name: u32,
|
|
|
|
|
/// Second word of the root Unit DAT header, when it was decoded. A direct
|
|
|
|
|
/// prototype root has no Unit DAT header.
|
|
|
|
|
pub unit_dat_header_flags: Option<u32>,
|
2026-07-03 17:11:49 +04:00
|
|
|
/// Raw position vector.
|
|
|
|
|
pub position: [f32; 3],
|
|
|
|
|
/// Raw orientation vector.
|
|
|
|
|
pub orientation_raw: [f32; 3],
|
|
|
|
|
/// Raw scale vector.
|
|
|
|
|
pub scale: [f32; 3],
|
|
|
|
|
/// Prepared visuals reachable from this mission object.
|
|
|
|
|
pub visual_ids: Vec<AssetId<PreparedVisual>>,
|
|
|
|
|
/// Ordered mission properties.
|
|
|
|
|
pub properties: Vec<MissionObjectProperty>,
|
2026-07-18 20:15:26 +04:00
|
|
|
/// Ordered raw Unit DAT components that selected this mission root.
|
|
|
|
|
///
|
|
|
|
|
/// The records preserve source provenance for future controller loading;
|
|
|
|
|
/// their fields intentionally have no inferred Control/physics semantics.
|
|
|
|
|
pub unit_components: Vec<UnitComponentRecord>,
|
2026-07-03 17:11:49 +04:00
|
|
|
}
|
|
|
|
|
|
2026-10-20 08:44:10 +04:00
|
|
|
/// A mission object registered with one clan's native Behavior distributor.
|
2026-10-20 09:06:17 +04:00
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
2026-10-20 08:44:10 +04:00
|
|
|
pub struct BehaviorRegisteredObject {
|
|
|
|
|
/// Stable identity from the source TMA object order.
|
|
|
|
|
pub original_id: OriginalObjectId,
|
|
|
|
|
/// Corresponding live Rust world object.
|
|
|
|
|
pub handle: ObjectHandle,
|
|
|
|
|
/// Raw DWORD that selects the `GetSuperAI` table slot.
|
|
|
|
|
pub selector: u32,
|
|
|
|
|
/// Class word recovered from the root Unit DAT header.
|
|
|
|
|
pub class_flags: u32,
|
2026-10-20 09:06:17 +04:00
|
|
|
/// Native share field at record offset `+0x04`.
|
|
|
|
|
pub share: f32,
|
|
|
|
|
/// Raw DWORDs at offsets `+0x08..+0x18`; the first controls whether
|
|
|
|
|
/// normalization treats this record as fixed, and the remaining words are
|
|
|
|
|
/// not yet assigned semantics. A normal native Add initializes all five to 0.
|
|
|
|
|
pub native_tail: [u32; 5],
|
2026-10-20 08:44:10 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resource distributor state associated with one TMA clan's constructed AI.
|
2026-10-20 09:06:17 +04:00
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
2026-10-20 08:44:10 +04:00
|
|
|
pub struct BehaviorDistributorState {
|
|
|
|
|
/// TMA clan index; it is not an index into the filtered script bundle list.
|
|
|
|
|
pub clan_index: usize,
|
|
|
|
|
/// Whether a successfully loaded script package published this AI to
|
|
|
|
|
/// `GetSuperAI` in the standard host path.
|
|
|
|
|
pub super_ai_visible: bool,
|
|
|
|
|
/// Objects admitted by the currently recovered standard-load registration path.
|
|
|
|
|
pub registered_objects: Vec<BehaviorRegisteredObject>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LAST_SUPER_AI_TABLE_INDEX: u32 = 63;
|
|
|
|
|
const BEHAVIOR_REGISTRATION_KEY_FLAG: u32 = 0x8000_0000;
|
|
|
|
|
|
2026-10-18 11:38:50 +04:00
|
|
|
/// Native terrain adjustment selected for a mission object's BAS geometry.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum BasementPlacementMode {
|
|
|
|
|
/// Flatten the BAS inner boundary to mean outer terrain height and adjust
|
|
|
|
|
/// the object root transform by the native placement delta.
|
|
|
|
|
FlattenInnerAndAdjustRoot,
|
|
|
|
|
/// Keep authored BAS inner heights and leave the object root transform as
|
|
|
|
|
/// supplied by the mission.
|
|
|
|
|
PreserveAuthoredInnerHeight,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MissionObjectDraft {
|
|
|
|
|
/// Returns the BAS mode selected by the native Unit DAT gate and mission
|
|
|
|
|
/// word, independent of resource path naming.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn basement_placement_mode(&self) -> Option<BasementPlacementMode> {
|
|
|
|
|
let header_flags = self.unit_dat_header_flags?;
|
|
|
|
|
if header_flags & UNIT_DAT_BAS_PLACEMENT_FLAG == 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(if self.raw_after_name == 0 {
|
|
|
|
|
BasementPlacementMode::FlattenInnerAndAdjustRoot
|
|
|
|
|
} else {
|
|
|
|
|
BasementPlacementMode::PreserveAuthoredInnerHeight
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns this mission root's position after applying the native BAS
|
|
|
|
|
/// transform adjustment once. Native code subtracts
|
|
|
|
|
/// `authored_inner_z - mean_outer_z` from matrix element 11. Component
|
|
|
|
|
/// local transforms should continue to be composed beneath this adjusted
|
|
|
|
|
/// root position without applying the delta again.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn root_position_with_basement_delta(&self, placement_delta: f32) -> [f32; 3] {
|
|
|
|
|
let mut position = self.position;
|
|
|
|
|
if self.basement_placement_mode() == Some(BasementPlacementMode::FlattenInnerAndAdjustRoot)
|
|
|
|
|
{
|
|
|
|
|
position[2] -= placement_delta;
|
|
|
|
|
}
|
|
|
|
|
position
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 20:56:34 +04:00
|
|
|
/// A compiled script bundle selected by one mission clan.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct MissionScriptBundle {
|
|
|
|
|
/// TMA clan index selecting this bundle.
|
|
|
|
|
pub clan_index: usize,
|
|
|
|
|
/// Lossy display form of the raw bundle base path from the clan resource.
|
|
|
|
|
pub base_path: String,
|
|
|
|
|
/// Resolved compiled `.scr` path.
|
|
|
|
|
pub script_path: String,
|
2026-07-18 22:15:55 +04:00
|
|
|
/// Losslessly decoded compiled package; only proven `Handler(19)` Init
|
|
|
|
|
/// writes are resolved and retained by runtime.
|
2026-07-18 20:56:34 +04:00
|
|
|
pub package: ScriptPackage,
|
2026-10-20 04:11:35 +04:00
|
|
|
/// Outcome of the restricted first-`Init` resolution for this bundle.
|
|
|
|
|
pub init_execution: MissionScriptInitExecution,
|
2026-10-20 05:15:11 +04:00
|
|
|
/// Recovered per-AI Behavior state computed by a full mission startup.
|
|
|
|
|
/// Static previews leave this empty because they do not run script startup.
|
|
|
|
|
pub behavior_warm_start: Option<BehaviorWarmStartState>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Initial Behavior distributor fields recovered from the original warm-start path.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct BehaviorWarmStartState {
|
|
|
|
|
/// Queue-clock value copied to distributor offset `+0x10` when it is zero.
|
|
|
|
|
pub first_time_ms: u32,
|
|
|
|
|
/// Initial scheduled time written to distributor offset `+0x14`.
|
|
|
|
|
pub next_deadline_ms: u32,
|
|
|
|
|
/// Opaque distributor words at offsets `+0x30..=+0x40`, cleared on first start.
|
|
|
|
|
pub cleared_words: [u32; 5],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl BehaviorWarmStartState {
|
|
|
|
|
fn start(queue_time_ms: u32, rng_state: &mut u32) -> Self {
|
|
|
|
|
const BASE_INTERVAL_MS: u32 = 3 * 64;
|
|
|
|
|
const JITTER_RANGE_MS: u32 = 64;
|
|
|
|
|
|
|
|
|
|
*rng_state = (*rng_state).wrapping_mul(0x343fd).wrapping_add(0x269ec3);
|
|
|
|
|
let random = (*rng_state >> 16) & 0x7fff;
|
|
|
|
|
let jitter_ms = ((random & 0xff) * JITTER_RANGE_MS) >> 8;
|
|
|
|
|
|
|
|
|
|
Self {
|
|
|
|
|
first_time_ms: queue_time_ms,
|
|
|
|
|
next_deadline_ms: queue_time_ms.wrapping_add(BASE_INTERVAL_MS + jitter_ms),
|
|
|
|
|
cleared_words: [0; 5],
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-18 20:56:34 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 21:23:28 +04:00
|
|
|
/// Numeric script defaults selected for the mission's first script bundle.
|
|
|
|
|
///
|
|
|
|
|
/// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared
|
|
|
|
|
/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source
|
2026-07-18 22:15:55 +04:00
|
|
|
/// and supplies the typed targets for the proven `Handler(19)` Init writes.
|
2026-07-18 21:23:28 +04:00
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct MissionScriptVarSet {
|
|
|
|
|
/// Resolved `.var` path selected by the original fallback order.
|
|
|
|
|
pub path: String,
|
|
|
|
|
/// Whether the shared `MISSIONS/SCRIPTS/varset.var` fallback was used.
|
|
|
|
|
pub used_fallback: bool,
|
|
|
|
|
/// Typed numeric declaration defaults in source order.
|
|
|
|
|
pub declarations: VarSet,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:15:55 +04:00
|
|
|
/// One resolved, corpus-proven `Init` result for a mission clan.
|
|
|
|
|
///
|
|
|
|
|
/// This is intentionally limited to `Handler(19)`: an `Init` event's other
|
|
|
|
|
/// selectors are retained in [`MissionScriptBundle::package`] but are not
|
|
|
|
|
/// guessed as executable behavior.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct MissionScriptInitState {
|
|
|
|
|
/// TMA clan index that owns the `SuperAI` instance and varset.
|
|
|
|
|
pub clan_index: usize,
|
2026-10-20 04:11:35 +04:00
|
|
|
/// Zero-based first case-sensitive C-string `Init` event selected by native lookup.
|
|
|
|
|
pub event_index: usize,
|
2026-07-18 22:15:55 +04:00
|
|
|
/// Raw event word preserved from the compiled `Init` event.
|
|
|
|
|
pub event_word: u32,
|
|
|
|
|
/// Exact three DWORD writes made by each proven `Handler(19)` instruction.
|
|
|
|
|
pub writes: Vec<Handler19DwordWrite>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 04:11:35 +04:00
|
|
|
/// Execution status of the first event whose case-sensitive C-string name is `Init`.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum MissionScriptInitExecution {
|
|
|
|
|
/// Native name lookup found no event named `Init` and made no handler call.
|
|
|
|
|
Missing,
|
|
|
|
|
/// The selected event completed within the currently recovered action subset.
|
|
|
|
|
Executed {
|
|
|
|
|
/// Zero-based event table index selected by native lookup.
|
|
|
|
|
event_index: usize,
|
|
|
|
|
},
|
|
|
|
|
/// A preview retained the decoded package but stopped before the first
|
|
|
|
|
/// instruction outside the recovered `Handler(19)` action subset.
|
|
|
|
|
Unsupported {
|
|
|
|
|
/// Zero-based event table index selected by native lookup.
|
|
|
|
|
event_index: usize,
|
|
|
|
|
/// Zero-based instruction index where restricted resolution stopped.
|
|
|
|
|
instruction_index: usize,
|
|
|
|
|
/// Typed selector, control, or result-save shape that is not modeled.
|
|
|
|
|
error: Handler19ResolveError,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:18:49 +04:00
|
|
|
/// Per-clan mutable-value model of one instantiated mission varset.
|
|
|
|
|
///
|
|
|
|
|
/// Declarations remain shared source metadata; these values are the runtime
|
|
|
|
|
/// cells that later recovered handlers will read and write.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct MissionScriptVarSetState {
|
|
|
|
|
/// TMA clan index owning this instantiated varset.
|
|
|
|
|
pub clan_index: usize,
|
|
|
|
|
/// Values in the original varset declaration order.
|
|
|
|
|
pub values: Vec<VarSetDefault>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 03:20:20 +04:00
|
|
|
/// Rust-side outcome selected by the recovered `Iron3D` host callback.
|
2026-07-18 22:36:38 +04:00
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
pub enum MissionScriptOutcome {
|
2026-10-20 03:20:20 +04:00
|
|
|
/// No host callback outcome has been recorded; this does not mean that the
|
|
|
|
|
/// native `IGame` has entered its running state.
|
2026-07-18 22:36:38 +04:00
|
|
|
#[default]
|
|
|
|
|
Active,
|
2026-10-20 02:00:27 +04:00
|
|
|
/// Rust outcome corresponding to completion payload `1`.
|
2026-07-18 22:36:38 +04:00
|
|
|
Completed,
|
2026-10-20 02:00:27 +04:00
|
|
|
/// Rust outcome corresponding to failure payload `0`.
|
2026-07-18 22:36:38 +04:00
|
|
|
Failed,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Result of applying one recovered script host callback command.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum MissionScriptHostEffect {
|
2026-10-20 02:00:27 +04:00
|
|
|
/// The input did not match a currently modeled command.
|
2026-07-18 22:36:38 +04:00
|
|
|
Unhandled,
|
2026-10-20 02:00:27 +04:00
|
|
|
/// The Rust outcome changed to completed.
|
2026-07-18 22:36:38 +04:00
|
|
|
MissionCompleted,
|
2026-10-20 02:00:27 +04:00
|
|
|
/// The Rust outcome changed to failed.
|
2026-07-18 22:36:38 +04:00
|
|
|
MissionFailed,
|
2026-10-20 03:20:20 +04:00
|
|
|
/// The native status guard rejected a repeated callback without changing state.
|
2026-07-18 22:36:38 +04:00
|
|
|
NoChange,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
struct MissionLoadOptions {
|
|
|
|
|
fail_after_registered_objects: Option<usize>,
|
2026-07-18 09:36:29 +04:00
|
|
|
asset_scope: MissionAssetScope,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 21:23:28 +04:00
|
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
struct LoadedMissionScripts {
|
|
|
|
|
bundles: Vec<MissionScriptBundle>,
|
|
|
|
|
varset: Option<MissionScriptVarSet>,
|
2026-07-18 22:15:55 +04:00
|
|
|
init_states: Vec<MissionScriptInitState>,
|
2026-07-18 22:18:49 +04:00
|
|
|
varset_states: Vec<MissionScriptVarSetState>,
|
2026-07-18 21:23:28 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:36:29 +04:00
|
|
|
/// Selects how much of the resolved mission asset graph to prepare.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
enum MissionAssetScope {
|
|
|
|
|
/// Prepare every reachable asset; required by normal runtime loading.
|
|
|
|
|
#[default]
|
|
|
|
|
Full,
|
2026-07-18 09:45:57 +04:00
|
|
|
/// Prepare only the first mission root for a static preview.
|
2026-07-18 09:36:29 +04:00
|
|
|
FirstMeshPreview,
|
2026-07-18 10:36:29 +04:00
|
|
|
/// Prepare a non-zero prefix of mission roots for a static preview.
|
|
|
|
|
PreviewRoots(NonZeroUsize),
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loaded mission.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct LoadedMission {
|
|
|
|
|
/// Mission key.
|
|
|
|
|
pub key: String,
|
|
|
|
|
/// Decoded mission path count.
|
|
|
|
|
pub path_count: usize,
|
|
|
|
|
/// Decoded clan count.
|
|
|
|
|
pub clan_count: usize,
|
|
|
|
|
/// Decoded placed object count.
|
|
|
|
|
pub object_count: usize,
|
|
|
|
|
/// Decoded extra record count.
|
|
|
|
|
pub extra_count: usize,
|
|
|
|
|
/// `Land.msh` path.
|
|
|
|
|
pub land_msh_path: String,
|
|
|
|
|
/// `Land.map` path.
|
|
|
|
|
pub land_map_path: String,
|
|
|
|
|
/// Build category count.
|
|
|
|
|
pub build_category_count: usize,
|
|
|
|
|
/// Runtime navigation area count.
|
|
|
|
|
pub areal_count: usize,
|
|
|
|
|
/// Runtime surface triangle count.
|
|
|
|
|
pub surface_count: usize,
|
|
|
|
|
/// Registered world object count.
|
|
|
|
|
pub registered_objects: usize,
|
|
|
|
|
/// Mission resource roots that point to unit DAT files.
|
|
|
|
|
pub graph_unit_reference_count: usize,
|
|
|
|
|
/// Mission resource roots that point directly to prototype keys.
|
|
|
|
|
pub graph_direct_reference_count: usize,
|
|
|
|
|
/// Component records reached from unit DAT roots.
|
|
|
|
|
pub graph_unit_component_count: usize,
|
|
|
|
|
/// Mission prototype graph root count.
|
|
|
|
|
pub graph_root_count: usize,
|
2026-07-03 17:11:49 +04:00
|
|
|
/// Total materialized graph node count after visual dependency expansion.
|
|
|
|
|
pub graph_node_count: usize,
|
|
|
|
|
/// Total materialized graph edge count after visual dependency expansion.
|
|
|
|
|
pub graph_edge_count: usize,
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Mission asset plan visual count after dependency preparation.
|
|
|
|
|
pub asset_visual_count: usize,
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Expanded prototype requests resolved to effective prototypes.
|
|
|
|
|
pub graph_resolved_count: usize,
|
|
|
|
|
/// Reached mesh dependency count.
|
|
|
|
|
pub graph_mesh_dependency_count: usize,
|
|
|
|
|
/// Graph failure count.
|
|
|
|
|
pub graph_failure_count: usize,
|
|
|
|
|
/// WEAR requests derived from graph meshes.
|
|
|
|
|
pub graph_wear_request_count: usize,
|
|
|
|
|
/// WEAR entries decoded.
|
|
|
|
|
pub graph_wear_resolved_count: usize,
|
|
|
|
|
/// WEAR material slots requested.
|
|
|
|
|
pub graph_material_slot_count: usize,
|
|
|
|
|
/// MAT0 entries decoded.
|
|
|
|
|
pub graph_material_resolved_count: usize,
|
|
|
|
|
/// Texture requests derived from MAT0 phases.
|
|
|
|
|
pub graph_texture_request_count: usize,
|
|
|
|
|
/// Texm texture entries decoded.
|
|
|
|
|
pub graph_texture_resolved_count: usize,
|
|
|
|
|
/// Lightmap requests declared by WEAR tables.
|
|
|
|
|
pub graph_lightmap_request_count: usize,
|
|
|
|
|
/// Lightmap Texm entries decoded.
|
|
|
|
|
pub graph_lightmap_resolved_count: usize,
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Mission asset plan mesh-backed count after dependency preparation.
|
|
|
|
|
pub asset_model_count: usize,
|
|
|
|
|
/// Mission asset plan material count after dependency preparation.
|
|
|
|
|
pub asset_material_count: usize,
|
|
|
|
|
/// Mission asset plan texture count after dependency preparation.
|
|
|
|
|
pub asset_texture_count: usize,
|
|
|
|
|
/// Mission asset plan lightmap count after dependency preparation.
|
|
|
|
|
pub asset_lightmap_count: usize,
|
2026-07-18 20:56:34 +04:00
|
|
|
/// Script bundles selected by mission clans.
|
|
|
|
|
pub script_bundle_count: usize,
|
|
|
|
|
/// Total named events in loaded script packages.
|
|
|
|
|
pub script_event_count: usize,
|
2026-07-18 21:23:28 +04:00
|
|
|
/// Numeric script defaults loaded through the original bundle/fallback rule.
|
|
|
|
|
pub script_varset_declaration_count: usize,
|
2026-07-18 22:15:55 +04:00
|
|
|
/// `Handler(19)` Init results resolved for mission clans.
|
|
|
|
|
pub script_init_state_count: usize,
|
2026-07-18 22:18:49 +04:00
|
|
|
/// Instantiated per-clan varsets whose Init writes have been applied.
|
|
|
|
|
pub script_varset_state_count: usize,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Frame result.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct FrameResult {
|
|
|
|
|
/// Snapshot.
|
|
|
|
|
pub snapshot: WorldSnapshot,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Engine.
|
|
|
|
|
pub struct Engine {
|
|
|
|
|
services: EngineServices,
|
|
|
|
|
world: World,
|
|
|
|
|
loaded: Option<LoadedMissionState>,
|
2026-10-20 05:15:11 +04:00
|
|
|
behavior_rng_state: u32,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct LoadedMissionState {
|
|
|
|
|
summary: LoadedMission,
|
|
|
|
|
mission: MissionDocument,
|
|
|
|
|
terrain: TerrainWorld,
|
|
|
|
|
build_categories: Vec<BuildCategory>,
|
|
|
|
|
prototype_graph: PrototypeGraph,
|
|
|
|
|
prototype_report: PrototypeGraphReport,
|
2026-06-23 22:05:16 +04:00
|
|
|
mission_assets: MissionAssets,
|
|
|
|
|
asset_plan: MissionAssetPlan,
|
2026-07-03 17:11:49 +04:00
|
|
|
object_drafts: Vec<MissionObjectDraft>,
|
2026-10-20 08:44:10 +04:00
|
|
|
behavior_distributors: Vec<BehaviorDistributorState>,
|
2026-07-18 20:56:34 +04:00
|
|
|
script_bundles: Vec<MissionScriptBundle>,
|
2026-07-18 21:23:28 +04:00
|
|
|
script_varset: Option<MissionScriptVarSet>,
|
2026-07-18 22:15:55 +04:00
|
|
|
script_init_states: Vec<MissionScriptInitState>,
|
2026-07-18 22:18:49 +04:00
|
|
|
script_varset_states: Vec<MissionScriptVarSetState>,
|
2026-10-20 03:20:20 +04:00
|
|
|
script_host_mode: MissionMode,
|
|
|
|
|
script_host_status: u32,
|
2026-07-18 22:36:38 +04:00
|
|
|
script_outcome: MissionScriptOutcome,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Engine error.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum EngineError {
|
|
|
|
|
/// Engine was created without a resource VFS.
|
|
|
|
|
MissingVfs,
|
|
|
|
|
/// Invalid resource path.
|
|
|
|
|
Path {
|
|
|
|
|
/// Path role.
|
|
|
|
|
role: &'static str,
|
|
|
|
|
/// Raw value.
|
|
|
|
|
value: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: PathError,
|
|
|
|
|
},
|
|
|
|
|
/// VFS error.
|
|
|
|
|
Vfs {
|
|
|
|
|
/// Resource path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: VfsError,
|
|
|
|
|
},
|
2026-07-18 20:56:34 +04:00
|
|
|
/// Compiled script package could not be decoded.
|
|
|
|
|
Script {
|
|
|
|
|
/// Script path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Decode diagnostic.
|
|
|
|
|
message: String,
|
|
|
|
|
},
|
2026-07-18 21:23:28 +04:00
|
|
|
/// Script defaults could not be decoded.
|
|
|
|
|
VarSet {
|
|
|
|
|
/// Script-default path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Parse diagnostic.
|
|
|
|
|
message: String,
|
|
|
|
|
},
|
2026-07-18 22:15:55 +04:00
|
|
|
/// A recovered `Handler(19)` Init input or target was not representable.
|
|
|
|
|
ScriptInit {
|
|
|
|
|
/// Owning TMA clan index.
|
|
|
|
|
clan_index: usize,
|
|
|
|
|
/// Proven-contract diagnostic.
|
|
|
|
|
message: String,
|
|
|
|
|
},
|
2026-10-20 04:11:35 +04:00
|
|
|
/// A full mission load reached an unsupported instruction in its first Init event.
|
|
|
|
|
ScriptInitUnsupported {
|
|
|
|
|
/// Owning TMA clan index.
|
|
|
|
|
clan_index: usize,
|
|
|
|
|
/// Resolved `.scr` package path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Zero-based first exact Init event index.
|
|
|
|
|
event_index: usize,
|
|
|
|
|
/// Zero-based instruction index within that event.
|
|
|
|
|
instruction_index: usize,
|
|
|
|
|
/// Typed unsupported instruction shape.
|
|
|
|
|
source: Handler19ResolveError,
|
|
|
|
|
},
|
2026-07-18 22:25:28 +04:00
|
|
|
/// A recovered script handler could not resolve against runtime state.
|
|
|
|
|
ScriptRuntime {
|
|
|
|
|
/// Owning TMA clan index.
|
|
|
|
|
clan_index: usize,
|
|
|
|
|
/// Handler-specific diagnostic.
|
|
|
|
|
message: String,
|
|
|
|
|
},
|
2026-06-22 13:12:27 +04:00
|
|
|
/// `NRes` decode error.
|
|
|
|
|
Nres {
|
|
|
|
|
/// Resource path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Source error.
|
2026-06-23 22:05:16 +04:00
|
|
|
source: NresError,
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
|
|
|
|
/// Mission decode error.
|
|
|
|
|
Mission {
|
|
|
|
|
/// Resource path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: MissionError,
|
|
|
|
|
},
|
|
|
|
|
/// Terrain disk format error.
|
|
|
|
|
TerrainFormat {
|
|
|
|
|
/// Resource path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: TerrainFormatError,
|
|
|
|
|
},
|
|
|
|
|
/// Terrain runtime build error.
|
2026-06-23 22:05:16 +04:00
|
|
|
Terrain(fparkan_assets::TerrainError),
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Prototype graph errors.
|
|
|
|
|
PrototypeGraph {
|
|
|
|
|
/// Root failures.
|
|
|
|
|
failures: Vec<PrototypeGraphFailure>,
|
|
|
|
|
},
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Asset preparation errors.
|
|
|
|
|
AssetPreparation {
|
|
|
|
|
/// Mission key.
|
|
|
|
|
mission: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: AssetPreparationError,
|
|
|
|
|
},
|
2026-06-22 13:12:27 +04:00
|
|
|
/// World error.
|
|
|
|
|
World(fparkan_world::WorldError),
|
2026-07-18 19:42:52 +04:00
|
|
|
/// Reference movement input or terrain query failed.
|
|
|
|
|
Movement(String),
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Staged mission world was torn down after a registration-phase failure.
|
|
|
|
|
RegistrationTeardown {
|
|
|
|
|
/// Registered objects before the forced failure.
|
|
|
|
|
registered_objects: usize,
|
|
|
|
|
/// Objects released by normal world shutdown.
|
|
|
|
|
released_objects: usize,
|
|
|
|
|
/// Managers were released after objects.
|
|
|
|
|
managers_released: bool,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 19:42:52 +04:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
impl From<fparkan_world::WorldError> for EngineError {
|
|
|
|
|
fn from(value: fparkan_world::WorldError) -> Self {
|
|
|
|
|
Self::World(value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Display for EngineError {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::MissingVfs => write!(f, "mission loading requires a VFS service"),
|
|
|
|
|
Self::Path {
|
|
|
|
|
role,
|
|
|
|
|
value,
|
|
|
|
|
source,
|
|
|
|
|
} => {
|
|
|
|
|
write!(f, "invalid {role} path '{value}': {source}")
|
|
|
|
|
}
|
|
|
|
|
Self::Vfs { path, source } => write!(f, "{path}: {source}"),
|
2026-07-18 20:56:34 +04:00
|
|
|
Self::Script { path, message } => write!(f, "{path}: script decode failed: {message}"),
|
2026-07-18 21:23:28 +04:00
|
|
|
Self::VarSet { path, message } => write!(f, "{path}: varset decode failed: {message}"),
|
2026-07-18 22:15:55 +04:00
|
|
|
Self::ScriptInit {
|
|
|
|
|
clan_index,
|
|
|
|
|
message,
|
|
|
|
|
} => write!(f, "clan {clan_index}: script Init failed: {message}"),
|
2026-10-20 04:11:35 +04:00
|
|
|
Self::ScriptInitUnsupported {
|
|
|
|
|
clan_index,
|
|
|
|
|
path,
|
|
|
|
|
event_index,
|
|
|
|
|
instruction_index,
|
|
|
|
|
source,
|
|
|
|
|
} => write!(
|
|
|
|
|
f,
|
|
|
|
|
"{path}: clan {clan_index} Init event {event_index} instruction {instruction_index} is unsupported: {source}"
|
|
|
|
|
),
|
2026-07-18 22:25:28 +04:00
|
|
|
Self::ScriptRuntime {
|
|
|
|
|
clan_index,
|
|
|
|
|
message,
|
|
|
|
|
} => write!(f, "clan {clan_index}: script runtime failed: {message}"),
|
2026-06-22 13:12:27 +04:00
|
|
|
Self::Nres { path, source } => write!(f, "{path}: {source}"),
|
|
|
|
|
Self::Mission { path, source } => write!(f, "{path}: {source}"),
|
|
|
|
|
Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"),
|
|
|
|
|
Self::Terrain(source) => write!(f, "{source}"),
|
|
|
|
|
Self::PrototypeGraph { failures } => {
|
|
|
|
|
write!(f, "mission prototype graph has {} failures", failures.len())
|
|
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
Self::AssetPreparation { mission, source } => {
|
|
|
|
|
write!(f, "{mission}: asset preparation failed: {source}")
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
Self::World(source) => write!(f, "{source}"),
|
2026-07-18 19:42:52 +04:00
|
|
|
Self::Movement(message) => write!(f, "reference movement: {message}"),
|
2026-06-22 13:12:27 +04:00
|
|
|
Self::RegistrationTeardown {
|
|
|
|
|
registered_objects,
|
|
|
|
|
released_objects,
|
|
|
|
|
managers_released,
|
|
|
|
|
} => write!(
|
|
|
|
|
f,
|
|
|
|
|
"mission registration failed after {registered_objects} objects; teardown released {released_objects}, managers_released={managers_released}"
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::error::Error for EngineError {
|
|
|
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Path { source, .. } => Some(source),
|
|
|
|
|
Self::Vfs { source, .. } => Some(source),
|
|
|
|
|
Self::Nres { source, .. } => Some(source),
|
|
|
|
|
Self::Mission { source, .. } => Some(source),
|
|
|
|
|
Self::TerrainFormat { source, .. } => Some(source),
|
2026-10-20 04:11:35 +04:00
|
|
|
Self::ScriptInitUnsupported { source, .. } => Some(source),
|
2026-06-22 13:12:27 +04:00
|
|
|
Self::Terrain(source) => Some(source),
|
|
|
|
|
Self::World(source) => Some(source),
|
2026-06-23 22:05:16 +04:00
|
|
|
Self::AssetPreparation { source, .. } => Some(source),
|
2026-06-22 17:32:56 +04:00
|
|
|
Self::MissingVfs
|
2026-07-18 20:56:34 +04:00
|
|
|
| Self::Script { .. }
|
2026-07-18 21:23:28 +04:00
|
|
|
| Self::VarSet { .. }
|
2026-07-18 22:15:55 +04:00
|
|
|
| Self::ScriptInit { .. }
|
2026-07-18 22:25:28 +04:00
|
|
|
| Self::ScriptRuntime { .. }
|
2026-07-18 19:42:52 +04:00
|
|
|
| Self::Movement(_)
|
2026-06-22 17:32:56 +04:00
|
|
|
| Self::PrototypeGraph { .. }
|
|
|
|
|
| Self::RegistrationTeardown { .. } => None,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 05:22:12 +04:00
|
|
|
/// Creates an engine with the supplied services.
|
|
|
|
|
pub fn create(services: EngineServices) -> Result<Engine, EngineError> {
|
2026-06-22 13:12:27 +04:00
|
|
|
Ok(Engine {
|
|
|
|
|
services,
|
|
|
|
|
world: new_world(WorldConfig),
|
|
|
|
|
loaded: None,
|
2026-10-20 05:15:11 +04:00
|
|
|
behavior_rng_state: INITIAL_BEHAVIOR_RNG_STATE,
|
2026-06-22 13:12:27 +04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 05:15:11 +04:00
|
|
|
/// Loads a mission transactionally and runs the recovered initial script subset.
|
|
|
|
|
///
|
|
|
|
|
/// `queue_time_ms` is the caller-supplied 32-bit queue-clock sample used to
|
|
|
|
|
/// initialize the first Behavior deadline for each script bundle. This runtime
|
|
|
|
|
/// entry point does not acquire the native queue clock or reproduce the full
|
|
|
|
|
/// SuperAI host lifecycle.
|
2026-06-22 13:12:27 +04:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
2026-10-20 05:15:11 +04:00
|
|
|
/// Returns [`EngineError`] if VFS services are missing, mission paths are
|
2026-06-22 13:12:27 +04:00
|
|
|
/// invalid, required files cannot be read, disk formats fail validation, terrain
|
2026-10-20 05:15:11 +04:00
|
|
|
/// runtime data cannot be built, prototype graph roots do not resolve, object
|
|
|
|
|
/// registration fails, or a full mission's first `Init` contains an unsupported
|
|
|
|
|
/// action form.
|
2026-06-22 13:12:27 +04:00
|
|
|
pub fn load_mission(
|
|
|
|
|
engine: &mut Engine,
|
|
|
|
|
request: MissionRequest,
|
2026-10-20 05:15:11 +04:00
|
|
|
queue_time_ms: u32,
|
2026-06-22 13:12:27 +04:00
|
|
|
) -> Result<LoadedMission, EngineError> {
|
2026-10-20 05:15:11 +04:00
|
|
|
load_mission_with_options(
|
|
|
|
|
engine,
|
|
|
|
|
request,
|
|
|
|
|
MissionLoadOptions::default(),
|
|
|
|
|
Some(queue_time_ms),
|
|
|
|
|
)
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:36:29 +04:00
|
|
|
/// Loads a mission using the bounded static-preview asset scope.
|
|
|
|
|
///
|
|
|
|
|
/// This mode preserves map, TMA, graph, construction and registration work,
|
2026-07-18 09:45:57 +04:00
|
|
|
/// but visits only the first mission root. It is intended solely for the
|
|
|
|
|
/// opt-in static Vulkan preview and must not be used for normal gameplay,
|
|
|
|
|
/// where all reachable assets remain required.
|
2026-07-18 09:36:29 +04:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`EngineError`] under the same decoding, graph, terrain and world
|
|
|
|
|
/// conditions as [`load_mission`], plus asset errors for the roots examined by
|
|
|
|
|
/// the preview scope.
|
|
|
|
|
pub fn load_mission_static_preview(
|
|
|
|
|
engine: &mut Engine,
|
|
|
|
|
request: MissionRequest,
|
|
|
|
|
) -> Result<LoadedMission, EngineError> {
|
2026-09-06 05:22:12 +04:00
|
|
|
load_mission_with_options(
|
2026-07-18 10:36:29 +04:00
|
|
|
engine,
|
|
|
|
|
request,
|
|
|
|
|
MissionLoadOptions {
|
2026-09-06 05:22:12 +04:00
|
|
|
asset_scope: MissionAssetScope::FirstMeshPreview,
|
2026-07-18 10:36:29 +04:00
|
|
|
..MissionLoadOptions::default()
|
|
|
|
|
},
|
2026-10-20 05:15:11 +04:00
|
|
|
None,
|
2026-07-18 10:36:29 +04:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 05:22:12 +04:00
|
|
|
/// Loads a static preview for the first requested non-zero count of mission roots.
|
2026-07-18 09:42:34 +04:00
|
|
|
///
|
2026-09-06 05:22:12 +04:00
|
|
|
/// Normal gameplay still uses [`load_mission`] and always resolves every root.
|
2026-07-18 09:42:34 +04:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`EngineError`] under the same conditions as
|
|
|
|
|
/// [`load_mission_static_preview`].
|
2026-09-06 05:22:12 +04:00
|
|
|
pub fn load_mission_static_preview_roots(
|
2026-07-18 09:42:34 +04:00
|
|
|
engine: &mut Engine,
|
|
|
|
|
request: MissionRequest,
|
2026-09-06 05:22:12 +04:00
|
|
|
root_count: NonZeroUsize,
|
2026-07-18 09:42:34 +04:00
|
|
|
) -> Result<LoadedMission, EngineError> {
|
2026-09-06 05:22:12 +04:00
|
|
|
load_mission_with_options(
|
2026-07-18 09:36:29 +04:00
|
|
|
engine,
|
|
|
|
|
request,
|
|
|
|
|
MissionLoadOptions {
|
2026-09-06 05:22:12 +04:00
|
|
|
asset_scope: MissionAssetScope::PreviewRoots(root_count),
|
2026-07-18 09:36:29 +04:00
|
|
|
..MissionLoadOptions::default()
|
|
|
|
|
},
|
2026-10-20 05:15:11 +04:00
|
|
|
None,
|
2026-07-18 09:36:29 +04:00
|
|
|
)
|
2026-07-18 09:42:34 +04:00
|
|
|
}
|
|
|
|
|
|
2026-09-06 05:22:12 +04:00
|
|
|
fn load_mission_with_options(
|
2026-06-22 13:12:27 +04:00
|
|
|
engine: &mut Engine,
|
|
|
|
|
request: MissionRequest,
|
|
|
|
|
options: MissionLoadOptions,
|
2026-10-20 05:15:11 +04:00
|
|
|
queue_time_ms: Option<u32>,
|
2026-09-06 05:22:12 +04:00
|
|
|
) -> Result<LoadedMission, EngineError> {
|
2026-06-22 13:12:27 +04:00
|
|
|
let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?;
|
|
|
|
|
let mission_path = normalize_engine_path("mission", &request.key)?;
|
|
|
|
|
let mission_bytes = read_vfs(&vfs, &mission_path)?;
|
|
|
|
|
|
2026-06-23 22:32:50 +04:00
|
|
|
let land_path =
|
|
|
|
|
decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
|
|
|
|
|
EngineError::Mission {
|
|
|
|
|
path: mission_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
})?;
|
2026-06-23 22:32:50 +04:00
|
|
|
let MissionTerrainPaths {
|
|
|
|
|
land_msh: land_msh_path,
|
|
|
|
|
land_map: land_map_path,
|
|
|
|
|
} = derive_mission_land_paths(&land_path).map_err(|source| EngineError::Path {
|
|
|
|
|
role: "mission land",
|
|
|
|
|
value: mission_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
})?;
|
|
|
|
|
let land_msh_nres = decode_nres_payload(read_vfs(&vfs, &land_msh_path)?).map_err(|source| {
|
|
|
|
|
EngineError::Nres {
|
2026-06-22 13:12:27 +04:00
|
|
|
path: land_msh_path.as_str().to_string(),
|
|
|
|
|
source,
|
2026-06-23 22:32:50 +04:00
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?).map_err(|source| {
|
|
|
|
|
EngineError::Nres {
|
2026-06-22 13:12:27 +04:00
|
|
|
path: land_map_path.as_str().to_string(),
|
|
|
|
|
source,
|
2026-06-23 22:32:50 +04:00
|
|
|
}
|
|
|
|
|
})?;
|
2026-06-22 13:12:27 +04:00
|
|
|
let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
|
|
|
|
|
let build_dat = read_vfs(&vfs, &build_dat_path)?;
|
2026-06-23 22:32:50 +04:00
|
|
|
let (terrain, build_categories) =
|
|
|
|
|
prepare_terrain_world(&land_msh_nres, &land_map_nres, &build_dat).map_err(|source| {
|
|
|
|
|
match source {
|
|
|
|
|
TerrainPreparationError::Decode(source) => EngineError::TerrainFormat {
|
|
|
|
|
path: build_dat_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
},
|
|
|
|
|
TerrainPreparationError::Runtime(source) => EngineError::Terrain(source),
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
})?;
|
2026-06-23 22:32:50 +04:00
|
|
|
let mission = decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| {
|
|
|
|
|
EngineError::Mission {
|
2026-06-22 13:12:27 +04:00
|
|
|
path: mission_path.as_str().to_string(),
|
|
|
|
|
source,
|
2026-06-23 22:32:50 +04:00
|
|
|
}
|
|
|
|
|
})?;
|
2026-10-20 05:15:11 +04:00
|
|
|
let mut loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?;
|
2026-07-18 11:49:55 +04:00
|
|
|
let repository = CachedResourceRepository::with_payload_cache_budget(
|
|
|
|
|
vfs.clone(),
|
|
|
|
|
MISSION_DECODED_PAYLOAD_CACHE_ENTRIES,
|
|
|
|
|
);
|
2026-06-22 13:12:27 +04:00
|
|
|
let graph_roots: Vec<_> = mission
|
|
|
|
|
.objects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|object| resource_name(&object.resource_name.raw))
|
|
|
|
|
.collect();
|
2026-07-18 09:45:57 +04:00
|
|
|
let scoped_graph_roots = match options.asset_scope {
|
|
|
|
|
MissionAssetScope::Full => graph_roots.as_slice(),
|
|
|
|
|
MissionAssetScope::FirstMeshPreview => graph_roots.get(..1).unwrap_or_default(),
|
2026-07-18 10:36:29 +04:00
|
|
|
MissionAssetScope::PreviewRoots(root_count) => graph_roots
|
|
|
|
|
.get(..root_count.get().min(graph_roots.len()))
|
|
|
|
|
.unwrap_or_default(),
|
2026-07-18 09:45:57 +04:00
|
|
|
};
|
2026-07-03 17:03:40 +04:00
|
|
|
let (mut prototype_graph, resolved_prototypes, mut prototype_report) =
|
2026-07-18 09:45:57 +04:00
|
|
|
build_prototype_graph_report(&repository, vfs.as_ref(), scoped_graph_roots);
|
2026-09-06 05:22:12 +04:00
|
|
|
extend_graph_report_with_visual_dependencies(
|
2026-06-22 13:12:27 +04:00
|
|
|
&repository,
|
|
|
|
|
&mut prototype_report,
|
2026-07-03 17:03:40 +04:00
|
|
|
&mut prototype_graph,
|
2026-06-22 13:12:27 +04:00
|
|
|
&resolved_prototypes,
|
|
|
|
|
);
|
|
|
|
|
if !prototype_report.is_success() {
|
|
|
|
|
return Err(EngineError::PrototypeGraph {
|
|
|
|
|
failures: prototype_report.failures.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-07-18 09:36:29 +04:00
|
|
|
let asset_manager = AssetManager::new(repository);
|
|
|
|
|
let mission_assets = match options.asset_scope {
|
2026-09-06 05:22:12 +04:00
|
|
|
MissionAssetScope::Full => asset_manager.prepare_mission_assets(
|
2026-06-23 22:05:16 +04:00
|
|
|
&prototype_graph.root_prototype_request_spans,
|
|
|
|
|
&resolved_prototypes,
|
2026-07-18 09:36:29 +04:00
|
|
|
),
|
2026-07-18 09:45:57 +04:00
|
|
|
MissionAssetScope::FirstMeshPreview => prepare_first_preview_assets(
|
2026-07-18 09:36:29 +04:00
|
|
|
&asset_manager,
|
|
|
|
|
&prototype_graph.root_prototype_request_spans,
|
|
|
|
|
&resolved_prototypes,
|
|
|
|
|
),
|
2026-09-06 05:22:12 +04:00
|
|
|
MissionAssetScope::PreviewRoots(root_count) => asset_manager.prepare_mission_assets(
|
|
|
|
|
&prototype_graph.root_prototype_request_spans[..root_count
|
|
|
|
|
.get()
|
|
|
|
|
.min(prototype_graph.root_prototype_request_spans.len())],
|
|
|
|
|
&resolved_prototypes,
|
|
|
|
|
),
|
2026-07-18 09:36:29 +04:00
|
|
|
}
|
|
|
|
|
.map_err(|source| EngineError::AssetPreparation {
|
|
|
|
|
mission: request.key.clone(),
|
|
|
|
|
source,
|
|
|
|
|
})?;
|
2026-06-23 22:05:16 +04:00
|
|
|
let mission_asset_plan = mission_assets.to_plan();
|
2026-07-03 17:11:49 +04:00
|
|
|
let object_drafts: Vec<_> = mission
|
|
|
|
|
.objects
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(index, object)| MissionObjectDraft {
|
|
|
|
|
original_id: u32::try_from(index).ok().map(OriginalObjectId),
|
|
|
|
|
resource_name_raw: object.resource_raw.clone(),
|
2026-10-20 08:11:45 +04:00
|
|
|
raw_after_resource: object.raw_after_resource,
|
2026-07-03 17:11:49 +04:00
|
|
|
identity_or_clan_raw: object.identity_or_clan_raw,
|
2026-10-18 11:38:50 +04:00
|
|
|
raw_after_name: object.raw_after_name,
|
|
|
|
|
unit_dat_header_flags: prototype_graph
|
|
|
|
|
.root_unit_dat_header_flags
|
|
|
|
|
.get(index)
|
|
|
|
|
.copied()
|
|
|
|
|
.flatten(),
|
2026-07-03 17:11:49 +04:00
|
|
|
position: object.position,
|
|
|
|
|
orientation_raw: object.orientation,
|
|
|
|
|
scale: object.scale,
|
|
|
|
|
visual_ids: mission_assets.visuals_for_object(index).to_vec(),
|
|
|
|
|
properties: object
|
|
|
|
|
.properties
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|property| MissionObjectProperty {
|
|
|
|
|
raw_value: property.raw_value,
|
|
|
|
|
name_raw: property.name_raw.clone(),
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
2026-07-18 20:15:26 +04:00
|
|
|
unit_components: prototype_graph
|
|
|
|
|
.root_unit_components
|
|
|
|
|
.get(index)
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default(),
|
2026-07-03 17:11:49 +04:00
|
|
|
})
|
|
|
|
|
.collect();
|
2026-06-22 13:12:27 +04:00
|
|
|
let mut new_runtime_world = new_world(WorldConfig);
|
|
|
|
|
let mut handles = Vec::with_capacity(mission.objects.len());
|
2026-07-18 19:27:28 +04:00
|
|
|
for (index, object) in mission.objects.iter().enumerate() {
|
2026-06-22 13:12:27 +04:00
|
|
|
let original_id = u32::try_from(index).ok().map(OriginalObjectId);
|
|
|
|
|
let handle = construct_object(&mut new_runtime_world, ObjectDraft { original_id })?;
|
2026-07-18 19:27:28 +04:00
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
handles.push(handle);
|
|
|
|
|
}
|
2026-09-06 05:22:12 +04:00
|
|
|
let mut registered_objects = 0;
|
2026-06-22 13:12:27 +04:00
|
|
|
for handle in &handles {
|
2026-09-06 05:22:12 +04:00
|
|
|
if options.fail_after_registered_objects == Some(registered_objects) {
|
2026-06-22 13:12:27 +04:00
|
|
|
let report = fparkan_world::shutdown(new_runtime_world);
|
|
|
|
|
return Err(EngineError::RegistrationTeardown {
|
2026-09-06 05:22:12 +04:00
|
|
|
registered_objects,
|
2026-06-22 13:12:27 +04:00
|
|
|
released_objects: report.released_objects.len(),
|
|
|
|
|
managers_released: report.managers_released,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
register_object(&mut new_runtime_world, *handle)?;
|
2026-09-06 05:22:12 +04:00
|
|
|
registered_objects += 1;
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
2026-10-20 08:44:10 +04:00
|
|
|
let behavior_distributors = if options.asset_scope == MissionAssetScope::Full {
|
|
|
|
|
let visible_clan_indices = loaded_scripts
|
|
|
|
|
.bundles
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|bundle| bundle.clan_index)
|
|
|
|
|
.filter(|clan_index| {
|
|
|
|
|
*clan_index < mission.clans.len()
|
|
|
|
|
&& *clan_index <= LAST_SUPER_AI_TABLE_INDEX as usize
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let mut distributors = mission
|
|
|
|
|
.clans
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(clan_index, _)| BehaviorDistributorState {
|
|
|
|
|
clan_index,
|
|
|
|
|
super_ai_visible: visible_clan_indices.contains(&clan_index),
|
|
|
|
|
registered_objects: Vec::new(),
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
for (draft, handle) in object_drafts.iter().zip(&handles) {
|
|
|
|
|
let Some(class_flags) = draft.unit_dat_header_flags else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if !has_behavior_registration_key(class_flags) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Some(clan_index) = behavior_clan_for_selector(
|
|
|
|
|
draft.raw_after_resource,
|
|
|
|
|
mission.clans.len(),
|
|
|
|
|
&visible_clan_indices,
|
|
|
|
|
) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let Some(original_id) = draft.original_id else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-10-20 09:06:17 +04:00
|
|
|
add_behavior_registered_object(
|
|
|
|
|
&mut distributors[clan_index],
|
|
|
|
|
BehaviorRegisteredObject {
|
2026-10-20 08:44:10 +04:00
|
|
|
original_id,
|
|
|
|
|
handle: *handle,
|
|
|
|
|
selector: draft.raw_after_resource,
|
|
|
|
|
class_flags,
|
2026-10-20 09:06:17 +04:00
|
|
|
share: 0.0,
|
|
|
|
|
native_tail: [0; 5],
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-10-20 08:44:10 +04:00
|
|
|
}
|
|
|
|
|
distributors
|
|
|
|
|
} else {
|
|
|
|
|
Vec::new()
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-20 05:15:11 +04:00
|
|
|
let mut next_behavior_rng_state = engine.behavior_rng_state;
|
|
|
|
|
if options.asset_scope == MissionAssetScope::Full {
|
|
|
|
|
let queue_time_ms = queue_time_ms.expect("full mission loading requires a queue time");
|
|
|
|
|
if let Err(error) = start_mission_scripts(
|
|
|
|
|
&mission,
|
|
|
|
|
&mut loaded_scripts,
|
|
|
|
|
queue_time_ms,
|
|
|
|
|
&mut next_behavior_rng_state,
|
|
|
|
|
) {
|
|
|
|
|
let _ = fparkan_world::shutdown(new_runtime_world);
|
|
|
|
|
return Err(error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
let summary = LoadedMission {
|
|
|
|
|
key: request.key,
|
|
|
|
|
path_count: mission.paths.len(),
|
|
|
|
|
clan_count: mission.clans.len(),
|
|
|
|
|
object_count: mission.objects.len(),
|
|
|
|
|
extra_count: mission.extras.len(),
|
|
|
|
|
land_msh_path: land_msh_path.as_str().to_string(),
|
|
|
|
|
land_map_path: land_map_path.as_str().to_string(),
|
|
|
|
|
build_category_count: build_categories.len(),
|
|
|
|
|
areal_count: terrain.areal_count(),
|
|
|
|
|
surface_count: terrain.surface_count(),
|
|
|
|
|
registered_objects: handles.len(),
|
|
|
|
|
graph_unit_reference_count: prototype_report.unit_reference_count,
|
|
|
|
|
graph_direct_reference_count: prototype_report.direct_reference_count,
|
|
|
|
|
graph_unit_component_count: prototype_report.unit_component_count,
|
|
|
|
|
graph_root_count: prototype_report.root_count,
|
2026-07-03 17:11:49 +04:00
|
|
|
graph_node_count: prototype_graph.nodes.len(),
|
|
|
|
|
graph_edge_count: prototype_graph.edges.len(),
|
2026-06-23 22:05:16 +04:00
|
|
|
asset_visual_count: mission_asset_plan.visual_count,
|
2026-06-22 13:12:27 +04:00
|
|
|
graph_resolved_count: prototype_report.resolved_count,
|
|
|
|
|
graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
|
|
|
|
|
graph_failure_count: prototype_report.failures.len(),
|
|
|
|
|
graph_wear_request_count: prototype_report.wear_request_count,
|
|
|
|
|
graph_wear_resolved_count: prototype_report.wear_resolved_count,
|
|
|
|
|
graph_material_slot_count: prototype_report.material_slot_count,
|
|
|
|
|
graph_material_resolved_count: prototype_report.material_resolved_count,
|
|
|
|
|
graph_texture_request_count: prototype_report.texture_request_count,
|
|
|
|
|
graph_texture_resolved_count: prototype_report.texture_resolved_count,
|
|
|
|
|
graph_lightmap_request_count: prototype_report.lightmap_request_count,
|
|
|
|
|
graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count,
|
2026-06-23 22:05:16 +04:00
|
|
|
asset_model_count: mission_asset_plan.model_count,
|
|
|
|
|
asset_material_count: mission_asset_plan.material_count,
|
|
|
|
|
asset_texture_count: mission_asset_plan.texture_count,
|
|
|
|
|
asset_lightmap_count: mission_asset_plan.lightmap_count,
|
2026-07-18 21:23:28 +04:00
|
|
|
script_bundle_count: loaded_scripts.bundles.len(),
|
|
|
|
|
script_event_count: loaded_scripts
|
|
|
|
|
.bundles
|
2026-07-18 20:56:34 +04:00
|
|
|
.iter()
|
|
|
|
|
.map(|bundle| bundle.package.events.len())
|
|
|
|
|
.sum(),
|
2026-07-18 21:23:28 +04:00
|
|
|
script_varset_declaration_count: loaded_scripts
|
|
|
|
|
.varset
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map_or(0, |varset| varset.declarations.declarations.len()),
|
2026-07-18 22:15:55 +04:00
|
|
|
script_init_state_count: loaded_scripts.init_states.len(),
|
2026-07-18 22:18:49 +04:00
|
|
|
script_varset_state_count: loaded_scripts.varset_states.len(),
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
engine.world = new_runtime_world;
|
2026-10-20 05:15:11 +04:00
|
|
|
engine.behavior_rng_state = next_behavior_rng_state;
|
2026-06-22 13:12:27 +04:00
|
|
|
engine.loaded = Some(LoadedMissionState {
|
|
|
|
|
summary: summary.clone(),
|
|
|
|
|
mission,
|
|
|
|
|
terrain,
|
|
|
|
|
build_categories,
|
|
|
|
|
prototype_graph,
|
|
|
|
|
prototype_report,
|
2026-06-23 22:05:16 +04:00
|
|
|
mission_assets,
|
|
|
|
|
asset_plan: mission_asset_plan,
|
2026-07-03 17:11:49 +04:00
|
|
|
object_drafts,
|
2026-10-20 08:44:10 +04:00
|
|
|
behavior_distributors,
|
2026-07-18 21:23:28 +04:00
|
|
|
script_bundles: loaded_scripts.bundles,
|
|
|
|
|
script_varset: loaded_scripts.varset,
|
2026-07-18 22:15:55 +04:00
|
|
|
script_init_states: loaded_scripts.init_states,
|
2026-07-18 22:18:49 +04:00
|
|
|
script_varset_states: loaded_scripts.varset_states,
|
2026-10-20 03:20:20 +04:00
|
|
|
script_host_mode: request.mode,
|
|
|
|
|
// Fresh IGame construction seeds this field with 5; this is not the
|
|
|
|
|
// later status-4 transition performed after native game initialization.
|
|
|
|
|
script_host_status: INITIAL_SCRIPT_HOST_STATUS,
|
2026-07-18 22:36:38 +04:00
|
|
|
script_outcome: MissionScriptOutcome::Active,
|
2026-06-22 13:12:27 +04:00
|
|
|
});
|
2026-09-06 05:22:12 +04:00
|
|
|
Ok(summary)
|
2026-07-18 09:42:34 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:45:57 +04:00
|
|
|
fn prepare_first_preview_assets<R: ResourceRepository>(
|
2026-07-18 09:36:29 +04:00
|
|
|
asset_manager: &AssetManager<R>,
|
|
|
|
|
root_spans: &[std::ops::Range<usize>],
|
|
|
|
|
prototypes: &[fparkan_prototype::EffectivePrototype],
|
|
|
|
|
) -> Result<MissionAssets, AssetPreparationError> {
|
|
|
|
|
for span in root_spans {
|
2026-09-06 05:22:12 +04:00
|
|
|
let assets =
|
|
|
|
|
asset_manager.prepare_mission_assets(std::slice::from_ref(span), prototypes)?;
|
2026-07-18 09:36:29 +04:00
|
|
|
if !assets.models.is_empty() {
|
|
|
|
|
return Ok(assets);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(MissionAssets::default())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Steps headless mode.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`EngineError`] when the world step fails.
|
|
|
|
|
pub fn step_headless(
|
|
|
|
|
engine: &mut Engine,
|
|
|
|
|
input: InputSnapshot,
|
|
|
|
|
) -> Result<FrameResult, EngineError> {
|
2026-09-06 05:22:12 +04:00
|
|
|
Ok(FrameResult {
|
|
|
|
|
snapshot: step(&mut engine.world, &input)?,
|
|
|
|
|
})
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Steps rendered mode.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`EngineError`] when the world step fails.
|
|
|
|
|
pub fn frame(engine: &mut Engine) -> Result<FrameResult, EngineError> {
|
2026-09-06 05:22:12 +04:00
|
|
|
step_headless(engine, InputSnapshot)
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the loaded mission summary.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission(engine: &Engine) -> Option<&LoadedMission> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.summary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the decoded mission document for the loaded mission.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_document(engine: &Engine) -> Option<&MissionDocument> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.mission)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 20:56:34 +04:00
|
|
|
/// Returns compiled script bundles selected by TMA clan resources.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptBundle]> {
|
|
|
|
|
engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|state| state.script_bundles.as_slice())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 21:23:28 +04:00
|
|
|
/// Returns the numeric script defaults selected by the original `.var` fallback rule.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVarSet> {
|
|
|
|
|
engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|state| state.script_varset.as_ref())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:15:55 +04:00
|
|
|
/// Returns the proven script `Init` writes resolved for the loaded mission.
|
|
|
|
|
///
|
|
|
|
|
/// The list has one entry for each clan whose `Init` event contains recovered
|
|
|
|
|
/// `Handler(19)` instructions. It is not a general script event executor.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> {
|
|
|
|
|
engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|state| state.script_init_states.as_slice())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:18:49 +04:00
|
|
|
/// Returns instantiated per-clan varsets after proven Init writes are applied.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_script_varset_states(engine: &Engine) -> Option<&[MissionScriptVarSetState]> {
|
|
|
|
|
engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|state| state.script_varset_states.as_slice())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:25:28 +04:00
|
|
|
/// Resolves the proven opaque callback command for one `Handler(30)` record.
|
|
|
|
|
///
|
|
|
|
|
/// Unlike the loader-only resolver, operands are read from the selected
|
|
|
|
|
/// clan's instantiated varset cells. Dispatching the returned host callback
|
2026-10-20 03:20:20 +04:00
|
|
|
/// remains separate from this function. The runtime currently models the
|
|
|
|
|
/// callback's recovered mode and status guards, but not the full game startup
|
|
|
|
|
/// transition or frame/event execution that reaches it.
|
2026-07-18 22:25:28 +04:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns an explicit error when no mission, declaration table, or clan state
|
|
|
|
|
/// is available, or if the instruction violates the proven handler contract.
|
|
|
|
|
pub fn resolve_loaded_handler30(
|
|
|
|
|
engine: &Engine,
|
|
|
|
|
clan_index: usize,
|
|
|
|
|
instruction: &ScriptInstruction,
|
|
|
|
|
) -> Result<VmHostCallbackCommand, EngineError> {
|
|
|
|
|
let state = engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or_else(|| EngineError::ScriptRuntime {
|
|
|
|
|
clan_index,
|
|
|
|
|
message: "mission script state is unavailable".to_string(),
|
|
|
|
|
})?;
|
|
|
|
|
let declarations = state
|
|
|
|
|
.script_varset
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or_else(|| EngineError::ScriptRuntime {
|
|
|
|
|
clan_index,
|
|
|
|
|
message: "mission varset declarations are unavailable".to_string(),
|
|
|
|
|
})?;
|
|
|
|
|
let values = state
|
|
|
|
|
.script_varset_states
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|varset| varset.clan_index == clan_index)
|
|
|
|
|
.ok_or_else(|| EngineError::ScriptRuntime {
|
|
|
|
|
clan_index,
|
|
|
|
|
message: "instantiated mission varset is unavailable".to_string(),
|
|
|
|
|
})?;
|
|
|
|
|
resolve_handler30_from_varset_state(declarations, values, instruction)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:36:38 +04:00
|
|
|
/// Returns the currently recovered mission outcome, if a mission is loaded.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_script_outcome(engine: &Engine) -> Option<MissionScriptOutcome> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| state.script_outcome)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 03:20:20 +04:00
|
|
|
/// Applies the recovered `Iron3D` host-callback guards and records its outcome.
|
2026-07-18 22:36:38 +04:00
|
|
|
///
|
2026-10-20 03:20:20 +04:00
|
|
|
/// Native mission mode `3` closes the callback gate. Otherwise the callback
|
|
|
|
|
/// must have `(mode=0, command=0)` and payload `0` or `1`. Payload `0` writes
|
|
|
|
|
/// status `1` and records failure only when the current status is not `1`;
|
|
|
|
|
/// payload `1` writes status `0` and records completion only when the current
|
|
|
|
|
/// status is not `0`. A duplicate returns [`MissionScriptHostEffect::NoChange`]
|
|
|
|
|
/// without repairing any other state. Loaded mission state seeds the raw
|
|
|
|
|
/// status from the native constructor value `5`; it does not model the later
|
|
|
|
|
/// game-initialization transition to status `4`.
|
2026-07-18 22:36:38 +04:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns an error if no mission is loaded.
|
|
|
|
|
pub fn apply_loaded_script_host_callback(
|
|
|
|
|
engine: &mut Engine,
|
|
|
|
|
command: VmHostCallbackCommand,
|
|
|
|
|
) -> Result<MissionScriptHostEffect, EngineError> {
|
|
|
|
|
let state = engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_mut()
|
|
|
|
|
.ok_or_else(|| EngineError::ScriptRuntime {
|
|
|
|
|
clan_index: 0,
|
|
|
|
|
message: "mission script host state is unavailable".to_string(),
|
|
|
|
|
})?;
|
|
|
|
|
Ok(apply_recovered_script_host_callback(
|
2026-10-20 03:20:20 +04:00
|
|
|
state.script_host_mode,
|
|
|
|
|
&mut state.script_host_status,
|
2026-07-18 22:36:38 +04:00
|
|
|
&mut state.script_outcome,
|
|
|
|
|
command,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_recovered_script_host_callback(
|
2026-10-20 03:20:20 +04:00
|
|
|
native_mode: MissionMode,
|
|
|
|
|
status: &mut u32,
|
2026-07-18 22:36:38 +04:00
|
|
|
outcome: &mut MissionScriptOutcome,
|
|
|
|
|
command: VmHostCallbackCommand,
|
|
|
|
|
) -> MissionScriptHostEffect {
|
2026-10-20 03:20:20 +04:00
|
|
|
if native_mode == MissionMode::AutoDemo || command.mode != 0 || command.first != 0 {
|
2026-07-18 22:36:38 +04:00
|
|
|
return MissionScriptHostEffect::Unhandled;
|
|
|
|
|
}
|
2026-10-20 03:20:20 +04:00
|
|
|
match command.second {
|
|
|
|
|
0 if *status != 1 => {
|
|
|
|
|
*status = 1;
|
|
|
|
|
*outcome = MissionScriptOutcome::Failed;
|
|
|
|
|
MissionScriptHostEffect::MissionFailed
|
|
|
|
|
}
|
|
|
|
|
0 => MissionScriptHostEffect::NoChange,
|
|
|
|
|
1 if *status != 0 => {
|
|
|
|
|
*status = 0;
|
|
|
|
|
*outcome = MissionScriptOutcome::Completed;
|
|
|
|
|
MissionScriptHostEffect::MissionCompleted
|
|
|
|
|
}
|
|
|
|
|
1 => MissionScriptHostEffect::NoChange,
|
|
|
|
|
_ => MissionScriptHostEffect::Unhandled,
|
2026-07-18 22:36:38 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:25:28 +04:00
|
|
|
fn resolve_handler30_from_varset_state(
|
|
|
|
|
declarations: &MissionScriptVarSet,
|
|
|
|
|
values: &MissionScriptVarSetState,
|
|
|
|
|
instruction: &ScriptInstruction,
|
|
|
|
|
) -> Result<VmHostCallbackCommand, EngineError> {
|
|
|
|
|
declarations
|
|
|
|
|
.declarations
|
|
|
|
|
.resolve_handler30_with_values(instruction, &values.values)
|
|
|
|
|
.map_err(|source| EngineError::ScriptRuntime {
|
|
|
|
|
clan_index: values.clan_index,
|
|
|
|
|
message: source.to_string(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Returns terrain runtime data for the loaded mission.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.terrain)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns decoded build categories for the loaded game root.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_build_categories(engine: &Engine) -> Option<&[BuildCategory]> {
|
|
|
|
|
engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|state| state.build_categories.as_slice())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the loaded prototype graph.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_prototype_graph(engine: &Engine) -> Option<&PrototypeGraph> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.prototype_graph)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the loaded prototype graph report.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphReport> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.prototype_report)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Returns the prepared mission asset plan for the loaded mission.
|
2026-06-22 13:12:27 +04:00
|
|
|
#[must_use]
|
2026-06-23 22:05:16 +04:00
|
|
|
pub fn loaded_mission_asset_plan(engine: &Engine) -> Option<&MissionAssetPlan> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.asset_plan)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns prepared mission assets for the loaded mission.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_assets(engine: &Engine) -> Option<&MissionAssets> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.mission_assets)
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:11:49 +04:00
|
|
|
/// Returns mission-derived object drafts preserved for later runtime stages.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_object_drafts(engine: &Engine) -> Option<&[MissionObjectDraft]> {
|
|
|
|
|
engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|state| state.object_drafts.as_slice())
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 08:44:10 +04:00
|
|
|
/// Returns the clan-indexed Behavior distributors and their registered objects.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_behavior_distributors(
|
|
|
|
|
engine: &Engine,
|
|
|
|
|
) -> Option<&[BehaviorDistributorState]> {
|
|
|
|
|
engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|state| state.behavior_distributors.as_slice())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn behavior_clan_for_selector(
|
|
|
|
|
selector: u32,
|
|
|
|
|
clan_count: usize,
|
|
|
|
|
super_ai_clan_indices: &[usize],
|
|
|
|
|
) -> Option<usize> {
|
|
|
|
|
if selector > LAST_SUPER_AI_TABLE_INDEX {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let clan_index = usize::try_from(selector).ok()?;
|
|
|
|
|
(clan_index < clan_count && super_ai_clan_indices.contains(&clan_index)).then_some(clan_index)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn has_behavior_registration_key(class_flags: u32) -> bool {
|
|
|
|
|
class_flags & BEHAVIOR_REGISTRATION_KEY_FLAG != 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 09:06:17 +04:00
|
|
|
fn add_behavior_registered_object(
|
|
|
|
|
distributor: &mut BehaviorDistributorState,
|
|
|
|
|
mut object: BehaviorRegisteredObject,
|
|
|
|
|
) {
|
|
|
|
|
let existing_index = distributor
|
|
|
|
|
.registered_objects
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|entry| entry.handle == object.handle);
|
|
|
|
|
let count = distributor.registered_objects.len() + usize::from(existing_index.is_none());
|
|
|
|
|
|
|
|
|
|
// Native Add/normalize ran with x87 control word 0x007f. Keep the list-order
|
|
|
|
|
// arithmetic in f32 so each operation has the observed 24-bit precision.
|
|
|
|
|
let share = 1.0_f32 / count as f32;
|
|
|
|
|
let handle = object.handle;
|
|
|
|
|
if let Some(index) = existing_index {
|
|
|
|
|
distributor.registered_objects[index].share = share;
|
|
|
|
|
} else {
|
|
|
|
|
object.share = share;
|
|
|
|
|
object.native_tail = [0; 5];
|
|
|
|
|
distributor.registered_objects.push(object);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
normalize_behavior_distributor(distributor, handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_behavior_distributor(
|
|
|
|
|
distributor: &mut BehaviorDistributorState,
|
|
|
|
|
fixed_handle: ObjectHandle,
|
|
|
|
|
) {
|
|
|
|
|
let mut fixed_sum = 0.0_f32;
|
|
|
|
|
let mut free_sum = 0.0_f32;
|
|
|
|
|
let mut adjustable_count = 0_usize;
|
|
|
|
|
|
|
|
|
|
for object in &distributor.registered_objects {
|
|
|
|
|
if object.handle == fixed_handle || object.native_tail[0] != 0 {
|
|
|
|
|
fixed_sum = fixed_sum + object.share;
|
|
|
|
|
} else {
|
|
|
|
|
free_sum = free_sum + object.share;
|
|
|
|
|
adjustable_count += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if adjustable_count == 0 {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if free_sum <= 0.0 {
|
|
|
|
|
let share = (1.0_f32 - fixed_sum) / adjustable_count as f32;
|
|
|
|
|
for object in &mut distributor.registered_objects {
|
|
|
|
|
if object.handle != fixed_handle && object.native_tail[0] == 0 {
|
|
|
|
|
object.share = share;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let factor = (1.0_f32 - fixed_sum) / free_sum;
|
|
|
|
|
for object in &mut distributor.registered_objects {
|
|
|
|
|
if object.handle != fixed_handle && object.native_tail[0] == 0 {
|
|
|
|
|
object.share = object.share * factor;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPath, EngineError> {
|
|
|
|
|
normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
|
|
|
|
|
EngineError::Path {
|
|
|
|
|
role,
|
|
|
|
|
value: value.to_string(),
|
|
|
|
|
source,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 21:23:28 +04:00
|
|
|
fn load_mission_scripts(
|
2026-07-18 20:56:34 +04:00
|
|
|
vfs: &Arc<dyn Vfs>,
|
|
|
|
|
mission: &MissionDocument,
|
2026-10-20 04:11:35 +04:00
|
|
|
asset_scope: MissionAssetScope,
|
2026-07-18 21:23:28 +04:00
|
|
|
) -> Result<LoadedMissionScripts, EngineError> {
|
2026-07-18 20:56:34 +04:00
|
|
|
let script_bases = mission_script_bundle_bases(mission);
|
|
|
|
|
let mut bundles = Vec::with_capacity(script_bases.len());
|
2026-07-18 21:23:28 +04:00
|
|
|
let mut first_base_path = None;
|
2026-07-18 20:56:34 +04:00
|
|
|
for script_base in script_bases {
|
|
|
|
|
let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned();
|
|
|
|
|
let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") {
|
|
|
|
|
base_path.clone()
|
|
|
|
|
} else {
|
|
|
|
|
format!("{base_path}.scr")
|
|
|
|
|
};
|
|
|
|
|
let normalized = normalize_engine_path("mission script", &script_path)?;
|
|
|
|
|
let bytes = read_vfs(vfs, &normalized)?;
|
|
|
|
|
let package = fparkan_script::decode(&bytes).map_err(|source| EngineError::Script {
|
|
|
|
|
path: normalized.as_str().to_string(),
|
|
|
|
|
message: source.to_string(),
|
|
|
|
|
})?;
|
2026-07-18 21:23:28 +04:00
|
|
|
if first_base_path.is_none() {
|
|
|
|
|
first_base_path = Some(base_path.clone());
|
|
|
|
|
}
|
2026-07-18 20:56:34 +04:00
|
|
|
bundles.push(MissionScriptBundle {
|
|
|
|
|
clan_index: script_base.clan_index,
|
|
|
|
|
base_path,
|
|
|
|
|
script_path: normalized.as_str().to_string(),
|
|
|
|
|
package,
|
2026-10-20 04:11:35 +04:00
|
|
|
init_execution: MissionScriptInitExecution::Missing,
|
2026-10-20 05:15:11 +04:00
|
|
|
behavior_warm_start: None,
|
2026-07-18 20:56:34 +04:00
|
|
|
});
|
|
|
|
|
}
|
2026-07-18 21:23:28 +04:00
|
|
|
let varset = first_base_path
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(|base_path| load_script_varset(vfs, base_path))
|
|
|
|
|
.transpose()?;
|
2026-10-20 05:15:11 +04:00
|
|
|
let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full {
|
|
|
|
|
(Vec::new(), Vec::new())
|
|
|
|
|
} else if let Some(varset) = &varset {
|
|
|
|
|
let init_states = resolve_handler19_init_states(
|
2026-07-18 22:15:55 +04:00
|
|
|
&mission
|
|
|
|
|
.clans
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|clan| clan.anchor)
|
|
|
|
|
.collect::<Vec<_>>(),
|
2026-10-20 04:11:35 +04:00
|
|
|
&mut bundles,
|
2026-07-18 22:15:55 +04:00
|
|
|
varset,
|
2026-10-20 04:11:35 +04:00
|
|
|
asset_scope,
|
2026-10-20 05:15:11 +04:00
|
|
|
)?;
|
|
|
|
|
let varset_states = materialize_script_varset_states(&bundles, varset, &init_states)?;
|
|
|
|
|
(init_states, varset_states)
|
|
|
|
|
} else {
|
|
|
|
|
(Vec::new(), Vec::new())
|
2026-07-18 22:18:49 +04:00
|
|
|
};
|
2026-07-18 22:15:55 +04:00
|
|
|
Ok(LoadedMissionScripts {
|
|
|
|
|
bundles,
|
|
|
|
|
varset,
|
|
|
|
|
init_states,
|
2026-07-18 22:18:49 +04:00
|
|
|
varset_states,
|
2026-07-18 22:15:55 +04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 05:15:11 +04:00
|
|
|
fn start_mission_scripts(
|
|
|
|
|
mission: &MissionDocument,
|
|
|
|
|
scripts: &mut LoadedMissionScripts,
|
|
|
|
|
queue_time_ms: u32,
|
|
|
|
|
behavior_rng_state: &mut u32,
|
|
|
|
|
) -> Result<(), EngineError> {
|
|
|
|
|
// The recovered slot refreshes one Behavior instance before dispatching
|
|
|
|
|
// that same instance's Init event. The Rust loader waits until its new
|
|
|
|
|
// world is registered before it starts this per-bundle sequence.
|
|
|
|
|
let clan_anchors = mission
|
|
|
|
|
.clans
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|clan| clan.anchor)
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let mut init_states = Vec::new();
|
|
|
|
|
for bundle in &mut scripts.bundles {
|
|
|
|
|
bundle.behavior_warm_start = Some(BehaviorWarmStartState::start(
|
|
|
|
|
queue_time_ms,
|
|
|
|
|
behavior_rng_state,
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
if let Some(varset) = scripts.varset.as_ref() {
|
|
|
|
|
init_states.extend(resolve_handler19_init_states(
|
|
|
|
|
&clan_anchors,
|
|
|
|
|
std::slice::from_mut(bundle),
|
|
|
|
|
varset,
|
|
|
|
|
MissionAssetScope::Full,
|
|
|
|
|
)?);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
scripts.init_states = init_states;
|
|
|
|
|
scripts.varset_states = match scripts.varset.as_ref() {
|
|
|
|
|
Some(varset) => {
|
|
|
|
|
materialize_script_varset_states(&scripts.bundles, varset, &scripts.init_states)?
|
|
|
|
|
}
|
|
|
|
|
None => Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:18:49 +04:00
|
|
|
fn materialize_script_varset_states(
|
|
|
|
|
bundles: &[MissionScriptBundle],
|
|
|
|
|
varset: &MissionScriptVarSet,
|
|
|
|
|
init_states: &[MissionScriptInitState],
|
|
|
|
|
) -> Result<Vec<MissionScriptVarSetState>, EngineError> {
|
|
|
|
|
let defaults: Vec<_> = varset
|
|
|
|
|
.declarations
|
|
|
|
|
.declarations
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|declaration| declaration.default_value)
|
|
|
|
|
.collect();
|
|
|
|
|
bundles
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|bundle| {
|
|
|
|
|
let mut values = defaults.clone();
|
|
|
|
|
for init in init_states
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|init| init.clan_index == bundle.clan_index)
|
|
|
|
|
{
|
|
|
|
|
for write in &init.writes {
|
|
|
|
|
let Some(value) = values.get_mut(write.index as usize) else {
|
|
|
|
|
return Err(EngineError::ScriptInit {
|
|
|
|
|
clan_index: bundle.clan_index,
|
|
|
|
|
message: format!(
|
|
|
|
|
"Handler(19) write index {} is outside the instantiated varset",
|
|
|
|
|
write.index
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
*value = VarSetDefault::Dword(write.value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(MissionScriptVarSetState {
|
|
|
|
|
clan_index: bundle.clan_index,
|
|
|
|
|
values,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:15:55 +04:00
|
|
|
fn resolve_handler19_init_states(
|
|
|
|
|
clan_anchors: &[[f32; 2]],
|
2026-10-20 04:11:35 +04:00
|
|
|
bundles: &mut [MissionScriptBundle],
|
2026-07-18 22:15:55 +04:00
|
|
|
varset: &MissionScriptVarSet,
|
2026-10-20 04:11:35 +04:00
|
|
|
asset_scope: MissionAssetScope,
|
2026-07-18 22:15:55 +04:00
|
|
|
) -> Result<Vec<MissionScriptInitState>, EngineError> {
|
|
|
|
|
let mut states = Vec::new();
|
|
|
|
|
for bundle in bundles {
|
2026-10-20 04:11:35 +04:00
|
|
|
if !bundle
|
|
|
|
|
.package
|
|
|
|
|
.events
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]))
|
|
|
|
|
{
|
|
|
|
|
bundle.init_execution = MissionScriptInitExecution::Missing;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-07-18 22:15:55 +04:00
|
|
|
let Some(anchor) = clan_anchors.get(bundle.clan_index) else {
|
|
|
|
|
return Err(EngineError::ScriptInit {
|
|
|
|
|
clan_index: bundle.clan_index,
|
|
|
|
|
message: "script bundle clan index is outside the decoded mission".to_string(),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
let input = Handler19InitInput {
|
2026-10-20 04:11:35 +04:00
|
|
|
first_x87_word: handler19_anchor_word(anchor[0], bundle.clan_index, "x")?,
|
|
|
|
|
second_x87_word: handler19_anchor_word(anchor[1], bundle.clan_index, "y")?,
|
2026-07-18 22:15:55 +04:00
|
|
|
third_word: u32::try_from(bundle.clan_index).map_err(|_| EngineError::ScriptInit {
|
|
|
|
|
clan_index: bundle.clan_index,
|
|
|
|
|
message: "clan index does not fit the recovered DWORD ClanID field".to_string(),
|
|
|
|
|
})?,
|
|
|
|
|
};
|
2026-10-20 04:11:35 +04:00
|
|
|
match varset
|
|
|
|
|
.declarations
|
|
|
|
|
.resolve_handler19_init(&bundle.package, input)
|
|
|
|
|
.map_err(|source| EngineError::ScriptInit {
|
|
|
|
|
clan_index: bundle.clan_index,
|
|
|
|
|
message: source.to_string(),
|
|
|
|
|
})? {
|
|
|
|
|
Handler19InitResolution::Missing => {
|
|
|
|
|
bundle.init_execution = MissionScriptInitExecution::Missing;
|
2026-07-18 22:15:55 +04:00
|
|
|
}
|
2026-10-20 04:11:35 +04:00
|
|
|
Handler19InitResolution::Executed {
|
|
|
|
|
event_index,
|
|
|
|
|
event_word,
|
|
|
|
|
writes,
|
|
|
|
|
} => {
|
|
|
|
|
bundle.init_execution = MissionScriptInitExecution::Executed { event_index };
|
|
|
|
|
if !writes.is_empty() {
|
|
|
|
|
states.push(MissionScriptInitState {
|
|
|
|
|
clan_index: bundle.clan_index,
|
|
|
|
|
event_index,
|
|
|
|
|
event_word,
|
|
|
|
|
writes,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Handler19InitResolution::Unsupported {
|
|
|
|
|
event_index,
|
|
|
|
|
instruction_index,
|
|
|
|
|
error,
|
|
|
|
|
} if asset_scope != MissionAssetScope::Full => {
|
|
|
|
|
bundle.init_execution = MissionScriptInitExecution::Unsupported {
|
|
|
|
|
event_index,
|
|
|
|
|
instruction_index,
|
|
|
|
|
error,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
Handler19InitResolution::Unsupported {
|
|
|
|
|
event_index,
|
|
|
|
|
instruction_index,
|
|
|
|
|
error,
|
|
|
|
|
} => {
|
|
|
|
|
return Err(EngineError::ScriptInitUnsupported {
|
2026-07-18 22:15:55 +04:00
|
|
|
clan_index: bundle.clan_index,
|
2026-10-20 04:11:35 +04:00
|
|
|
path: bundle.script_path.clone(),
|
|
|
|
|
event_index,
|
|
|
|
|
instruction_index,
|
|
|
|
|
source: error,
|
2026-07-18 22:15:55 +04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(states)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 04:11:35 +04:00
|
|
|
fn handler19_anchor_word(
|
2026-07-18 22:15:55 +04:00
|
|
|
value: f32,
|
|
|
|
|
clan_index: usize,
|
|
|
|
|
axis: &'static str,
|
|
|
|
|
) -> Result<u32, EngineError> {
|
2026-10-20 04:11:35 +04:00
|
|
|
const SIGNED_QWORD_LIMIT: f32 = 9_223_372_036_854_775_808.0;
|
|
|
|
|
|
|
|
|
|
let rounded = value.round_ties_even();
|
|
|
|
|
if !value.is_finite() || rounded < -SIGNED_QWORD_LIMIT || rounded >= SIGNED_QWORD_LIMIT {
|
2026-07-18 22:15:55 +04:00
|
|
|
return Err(EngineError::ScriptInit {
|
|
|
|
|
clan_index,
|
|
|
|
|
message: format!(
|
2026-10-20 04:11:35 +04:00
|
|
|
"ClanBase{axis}={value:?} is outside the modeled x87 FISTP qword range"
|
2026-07-18 22:15:55 +04:00
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-09-06 05:22:12 +04:00
|
|
|
|
2026-10-20 04:11:35 +04:00
|
|
|
// Iron3D FISTP qword keeps its low DWORD; AI FUN_10005c70 then round-trips
|
|
|
|
|
// that unsigned word through f32 before Handler(19)'s truncating __ftol.
|
|
|
|
|
let creation_word = (rounded as i64) as u32;
|
|
|
|
|
let ai_field = creation_word as f32;
|
|
|
|
|
let handler_qword = ai_field.trunc() as i64;
|
|
|
|
|
Ok(handler_qword as u32)
|
2026-07-18 21:23:28 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_script_varset(
|
|
|
|
|
vfs: &Arc<dyn Vfs>,
|
|
|
|
|
base_path: &str,
|
|
|
|
|
) -> Result<MissionScriptVarSet, EngineError> {
|
|
|
|
|
let base_path = base_path
|
|
|
|
|
.strip_suffix(".scr")
|
|
|
|
|
.or_else(|| base_path.strip_suffix(".SCR"))
|
|
|
|
|
.unwrap_or(base_path);
|
|
|
|
|
let primary_path = normalize_engine_path("mission script varset", &format!("{base_path}.var"))?;
|
|
|
|
|
let (path, bytes, used_fallback) = match vfs.read(&primary_path) {
|
|
|
|
|
Ok(bytes) => (primary_path, bytes, false),
|
|
|
|
|
Err(VfsError::NotFound(_)) => {
|
|
|
|
|
let fallback_path =
|
|
|
|
|
normalize_engine_path("fallback script varset", FALLBACK_SCRIPT_VARSET_PATH)?;
|
|
|
|
|
let bytes = read_vfs(vfs, &fallback_path)?;
|
|
|
|
|
(fallback_path, bytes, true)
|
|
|
|
|
}
|
|
|
|
|
Err(source) => {
|
|
|
|
|
return Err(EngineError::Vfs {
|
|
|
|
|
path: primary_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let declarations =
|
|
|
|
|
fparkan_script::parse_varset(&bytes).map_err(|source| EngineError::VarSet {
|
|
|
|
|
path: path.as_str().to_string(),
|
|
|
|
|
message: source.to_string(),
|
|
|
|
|
})?;
|
|
|
|
|
Ok(MissionScriptVarSet {
|
|
|
|
|
path: path.as_str().to_string(),
|
|
|
|
|
used_fallback,
|
|
|
|
|
declarations,
|
|
|
|
|
})
|
2026-07-18 20:56:34 +04:00
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, EngineError> {
|
|
|
|
|
vfs.read(path).map_err(|source| EngineError::Vfs {
|
|
|
|
|
path: path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2026-10-20 04:11:35 +04:00
|
|
|
use fparkan_script::ScriptDispatchSelector;
|
2026-07-18 21:23:28 +04:00
|
|
|
use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata};
|
2026-06-22 13:12:27 +04:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
2026-10-18 11:38:50 +04:00
|
|
|
fn basement_draft(header_flags: Option<u32>, raw_after_name: u32) -> MissionObjectDraft {
|
|
|
|
|
MissionObjectDraft {
|
|
|
|
|
original_id: Some(OriginalObjectId(7)),
|
|
|
|
|
resource_name_raw: b"BUILDS/example.dat".to_vec(),
|
2026-10-20 08:11:45 +04:00
|
|
|
raw_after_resource: 0,
|
2026-10-18 11:38:50 +04:00
|
|
|
identity_or_clan_raw: 0,
|
|
|
|
|
raw_after_name,
|
|
|
|
|
unit_dat_header_flags: header_flags,
|
|
|
|
|
position: [12.0, 34.0, 100.0],
|
|
|
|
|
orientation_raw: [0.0; 3],
|
|
|
|
|
scale: [1.0; 3],
|
|
|
|
|
visual_ids: Vec::new(),
|
|
|
|
|
properties: Vec::new(),
|
|
|
|
|
unit_components: Vec::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn native_basement_mode_uses_unit_header_gate_not_builds_path() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0).basement_placement_mode(),
|
|
|
|
|
Some(BasementPlacementMode::FlattenInnerAndAdjustRoot)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 1).basement_placement_mode(),
|
|
|
|
|
Some(BasementPlacementMode::PreserveAuthoredInnerHeight)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(basement_draft(Some(0), 0).basement_placement_mode(), None);
|
|
|
|
|
assert_eq!(basement_draft(None, 1).basement_placement_mode(), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn native_basement_delta_adjusts_only_flattened_root_once() {
|
|
|
|
|
let flatten = basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0);
|
|
|
|
|
let root = flatten.root_position_with_basement_delta(3.25);
|
|
|
|
|
assert_eq!(root, [12.0, 34.0, 96.75]);
|
|
|
|
|
// A component remains local to the root; it receives the root shift
|
|
|
|
|
// through composition instead of applying the delta itself.
|
|
|
|
|
let child_local_z = 8.0;
|
|
|
|
|
assert_eq!(root[2] + child_local_z, 104.75);
|
|
|
|
|
|
|
|
|
|
let preserve = basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0x40);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
preserve.root_position_with_basement_delta(3.25),
|
|
|
|
|
preserve.position
|
|
|
|
|
);
|
|
|
|
|
let no_gate = basement_draft(Some(0), 0);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
no_gate.root_position_with_basement_delta(3.25),
|
|
|
|
|
no_gate.position
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 08:44:10 +04:00
|
|
|
#[test]
|
|
|
|
|
fn behavior_selector_requires_an_exact_visible_clan_slot() {
|
|
|
|
|
let sparse_slots = [1, 63];
|
|
|
|
|
assert_eq!(behavior_clan_for_selector(1, 64, &sparse_slots), Some(1));
|
|
|
|
|
assert_eq!(behavior_clan_for_selector(63, 64, &[63]), Some(63));
|
|
|
|
|
assert_eq!(behavior_clan_for_selector(0, 64, &[1, 63]), None);
|
|
|
|
|
assert_eq!(behavior_clan_for_selector(64, 65, &[64]), None);
|
|
|
|
|
assert_eq!(behavior_clan_for_selector(0xfffe, 65_535, &[0xfffe]), None);
|
|
|
|
|
assert_eq!(behavior_clan_for_selector(63, 63, &[63]), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn behavior_registration_uses_the_class_high_bit() {
|
|
|
|
|
assert!(has_behavior_registration_key(0x8000_0010));
|
|
|
|
|
assert!(has_behavior_registration_key(0x8000_0002));
|
|
|
|
|
assert!(has_behavior_registration_key(0x8000_0123));
|
|
|
|
|
assert!(!has_behavior_registration_key(0x0000_0123));
|
|
|
|
|
assert!(!has_behavior_registration_key(0x7fff_ffff));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 05:15:11 +04:00
|
|
|
#[test]
|
|
|
|
|
fn behavior_warm_start_uses_native_clock_and_shared_rng_vectors() {
|
|
|
|
|
let mut rng_state = 1;
|
|
|
|
|
let first = BehaviorWarmStartState::start(100_000, &mut rng_state);
|
|
|
|
|
assert_eq!(first.first_time_ms, 100_000);
|
|
|
|
|
assert_eq!(first.next_deadline_ms, 100_202);
|
|
|
|
|
assert_eq!(first.cleared_words, [0; 5]);
|
|
|
|
|
assert_eq!(rng_state, 0x0029_e2c0);
|
|
|
|
|
|
|
|
|
|
let second = BehaviorWarmStartState::start(100_500, &mut rng_state);
|
|
|
|
|
assert_eq!(second.first_time_ms, 100_500);
|
|
|
|
|
assert_eq!(second.next_deadline_ms, 100_700);
|
|
|
|
|
assert_eq!(second.cleared_words, [0; 5]);
|
|
|
|
|
assert_eq!(rng_state, 0xc823_f683);
|
|
|
|
|
|
|
|
|
|
let zero = BehaviorWarmStartState::start(0, &mut rng_state);
|
|
|
|
|
assert_eq!(zero.first_time_ms, 0);
|
|
|
|
|
assert_eq!(zero.next_deadline_ms, 239);
|
|
|
|
|
assert_eq!(rng_state, 0x18be_873a);
|
|
|
|
|
|
|
|
|
|
// A second fresh AI with a zero queue clock receives its own LCG draw.
|
|
|
|
|
let second_zero_clock_start = BehaviorWarmStartState::start(0, &mut rng_state);
|
|
|
|
|
assert_eq!(second_zero_clock_start.first_time_ms, 0);
|
|
|
|
|
assert_eq!(second_zero_clock_start.next_deadline_ms, 225);
|
|
|
|
|
assert_eq!(rng_state, 0xe784_7115);
|
|
|
|
|
|
|
|
|
|
let wrapping = BehaviorWarmStartState::start(0xffff_ff80, &mut rng_state);
|
|
|
|
|
assert_eq!(wrapping.next_deadline_ms, 120);
|
|
|
|
|
assert_eq!(rng_state, 0xcae1_df84);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn mission_script_start_stops_before_warming_later_bundles() {
|
|
|
|
|
fn push_u32(bytes: &mut Vec<u8>, value: u32) {
|
|
|
|
|
bytes.extend_from_slice(&value.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) {
|
|
|
|
|
push_u32(bytes, u32::try_from(value.len()).expect("length fits u32"));
|
|
|
|
|
bytes.extend_from_slice(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut tma = Vec::new();
|
|
|
|
|
push_u32(&mut tma, 1); // TMA format version.
|
|
|
|
|
push_u32(&mut tma, 0); // Paths.
|
|
|
|
|
push_u32(&mut tma, 6); // Clan section version.
|
|
|
|
|
push_u32(&mut tma, 2); // Two clans provide Handler(19) anchors.
|
|
|
|
|
for (x, y) in [(500.0_f32, 752.0_f32), (728.0_f32, 449.0_f32)] {
|
|
|
|
|
push_lp(&mut tma, b"");
|
|
|
|
|
push_u32(&mut tma, u32::MAX); // Raw clan id -1.
|
|
|
|
|
tma.extend_from_slice(&x.to_le_bytes());
|
|
|
|
|
tma.extend_from_slice(&y.to_le_bytes());
|
|
|
|
|
push_u32(&mut tma, 1); // Standard clan body.
|
|
|
|
|
push_lp(&mut tma, b"");
|
|
|
|
|
push_u32(&mut tma, 0);
|
|
|
|
|
push_lp(&mut tma, b"");
|
|
|
|
|
push_u32(&mut tma, 0);
|
|
|
|
|
push_u32(&mut tma, 0); // Relations.
|
|
|
|
|
}
|
|
|
|
|
push_u32(&mut tma, 10); // Object section version.
|
|
|
|
|
push_u32(&mut tma, 0); // Objects.
|
|
|
|
|
push_lp(&mut tma, b""); // Landscape path.
|
|
|
|
|
push_u32(&mut tma, 0); // Mission flag.
|
|
|
|
|
push_lp(&mut tma, b""); // Description.
|
|
|
|
|
push_u32(&mut tma, 1); // Extra section version.
|
|
|
|
|
push_u32(&mut tma, 0); // Extras.
|
|
|
|
|
let mission = decode_mission_payload(Arc::from(tma), TmaProfile::Strict)
|
|
|
|
|
.expect("two-clan startup fixture");
|
|
|
|
|
|
|
|
|
|
let varset = MissionScriptVarSet {
|
|
|
|
|
path: FALLBACK_SCRIPT_VARSET_PATH.to_string(),
|
|
|
|
|
used_fallback: true,
|
|
|
|
|
declarations: fparkan_script::parse_varset(
|
|
|
|
|
b"VAR( DWORD, ClanBaseX, 0)\nVAR( DWORD, ClanBaseY, 0)\nVAR( DWORD, ClanID, 0)\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("varset"),
|
|
|
|
|
};
|
|
|
|
|
let bundle = |clan_index, selector, references: Vec<u32>| MissionScriptBundle {
|
|
|
|
|
clan_index,
|
|
|
|
|
base_path: "MISSIONS/SCRIPTS/default".to_string(),
|
|
|
|
|
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
|
|
|
|
|
package: ScriptPackage {
|
|
|
|
|
opcode_handler_count: 73,
|
|
|
|
|
events: vec![fparkan_script::ScriptEvent {
|
|
|
|
|
name_len: 4,
|
|
|
|
|
name_raw: b"Init\0".to_vec(),
|
|
|
|
|
event_word: 0,
|
|
|
|
|
instructions: vec![ScriptInstruction {
|
|
|
|
|
header_words: [selector, u32::MAX, u32::MAX, 0, 0, 3, 0],
|
|
|
|
|
references,
|
|
|
|
|
}],
|
|
|
|
|
}],
|
|
|
|
|
trailing_bytes: Vec::new(),
|
|
|
|
|
raw: Arc::from(&b""[..]),
|
|
|
|
|
},
|
|
|
|
|
init_execution: MissionScriptInitExecution::Missing,
|
|
|
|
|
behavior_warm_start: None,
|
|
|
|
|
};
|
|
|
|
|
let mut scripts = LoadedMissionScripts {
|
|
|
|
|
bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])],
|
|
|
|
|
varset: Some(varset),
|
|
|
|
|
..LoadedMissionScripts::default()
|
|
|
|
|
};
|
|
|
|
|
let mut rng_state = INITIAL_BEHAVIOR_RNG_STATE;
|
|
|
|
|
|
|
|
|
|
let error = start_mission_scripts(&mission, &mut scripts, 100_000, &mut rng_state)
|
|
|
|
|
.expect_err("unsupported first Init stops startup before the second bundle");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
error,
|
|
|
|
|
EngineError::ScriptInitUnsupported {
|
|
|
|
|
source: Handler19ResolveError::UnsupportedSelector {
|
|
|
|
|
found: fparkan_script::ScriptDispatchSelector::Handler(18),
|
|
|
|
|
},
|
|
|
|
|
..
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
scripts.bundles[0].behavior_warm_start,
|
|
|
|
|
Some(BehaviorWarmStartState {
|
|
|
|
|
first_time_ms: 100_000,
|
|
|
|
|
next_deadline_ms: 100_202,
|
|
|
|
|
cleared_words: [0; 5],
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
assert!(scripts.bundles[1].behavior_warm_start.is_none());
|
|
|
|
|
assert_eq!(rng_state, 0x0029_e2c0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
|
|
|
|
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
|
2026-09-06 05:22:12 +04:00
|
|
|
let mut engine = create(EngineServices::default()).expect("engine");
|
2026-06-22 13:12:27 +04:00
|
|
|
let before = step_headless(&mut engine, InputSnapshot).expect("step");
|
|
|
|
|
let err = load_mission(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
2026-10-20 03:20:20 +04:00
|
|
|
mode: MissionMode::SingleMission,
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
2026-10-20 05:15:11 +04:00
|
|
|
0,
|
2026-06-22 13:12:27 +04:00
|
|
|
)
|
|
|
|
|
.expect_err("missing VFS");
|
|
|
|
|
assert!(matches!(err, EngineError::MissingVfs));
|
|
|
|
|
let after = step_headless(&mut engine, InputSnapshot).expect("step");
|
|
|
|
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 21:23:28 +04:00
|
|
|
#[test]
|
|
|
|
|
fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
|
|
|
|
|
let mut personal_files = MemoryVfs::default();
|
|
|
|
|
personal_files.insert(
|
|
|
|
|
normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.var").expect("path"),
|
|
|
|
|
Arc::from(&b"VAR( DWORD, personal, 7)\n"[..]),
|
|
|
|
|
);
|
|
|
|
|
personal_files.insert(
|
|
|
|
|
normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"),
|
|
|
|
|
Arc::from(&b"VAR( DWORD, fallback, 8)\n"[..]),
|
|
|
|
|
);
|
|
|
|
|
let personal_vfs: Arc<dyn Vfs> = Arc::new(personal_files);
|
|
|
|
|
let personal =
|
|
|
|
|
load_script_varset(&personal_vfs, "MISSIONS/SCRIPTS/custom").expect("personal varset");
|
|
|
|
|
assert_eq!(personal.path, "MISSIONS/SCRIPTS/custom.var");
|
|
|
|
|
assert!(!personal.used_fallback);
|
|
|
|
|
assert_eq!(personal.declarations.declarations[0].name, "personal");
|
|
|
|
|
|
|
|
|
|
let mut fallback_files = MemoryVfs::default();
|
|
|
|
|
fallback_files.insert(
|
|
|
|
|
normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"),
|
|
|
|
|
Arc::from(&b"VAR( float, fallback, 0.5)\n"[..]),
|
|
|
|
|
);
|
|
|
|
|
let fallback_vfs: Arc<dyn Vfs> = Arc::new(fallback_files);
|
|
|
|
|
let fallback =
|
|
|
|
|
load_script_varset(&fallback_vfs, "MISSIONS/SCRIPTS/custom").expect("fallback varset");
|
|
|
|
|
assert_eq!(fallback.path, FALLBACK_SCRIPT_VARSET_PATH);
|
|
|
|
|
assert!(fallback.used_fallback);
|
|
|
|
|
assert_eq!(fallback.declarations.declarations[0].name, "fallback");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn malformed_personal_script_varset_does_not_silently_use_fallback() {
|
|
|
|
|
let mut files = MemoryVfs::default();
|
|
|
|
|
files.insert(
|
|
|
|
|
normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.var").expect("path"),
|
|
|
|
|
Arc::from(&b"VAR( DWORD, broken, nope)\n"[..]),
|
|
|
|
|
);
|
|
|
|
|
files.insert(
|
|
|
|
|
normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"),
|
|
|
|
|
Arc::from(&b"VAR( DWORD, fallback, 8)\n"[..]),
|
|
|
|
|
);
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(files);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
load_script_varset(&vfs, "MISSIONS/SCRIPTS/custom"),
|
|
|
|
|
Err(EngineError::VarSet { path, .. }) if path == "MISSIONS/SCRIPTS/custom.var"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:15:55 +04:00
|
|
|
#[test]
|
|
|
|
|
fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() {
|
|
|
|
|
let declarations = fparkan_script::parse_varset(
|
|
|
|
|
b"VAR( DWORD, ClanBaseX, 950)\nVAR( DWORD, ClanBaseY, 1000)\nVAR( DWORD, ClanID, 0)\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("varset");
|
|
|
|
|
let varset = MissionScriptVarSet {
|
|
|
|
|
path: FALLBACK_SCRIPT_VARSET_PATH.to_string(),
|
|
|
|
|
used_fallback: true,
|
|
|
|
|
declarations,
|
|
|
|
|
};
|
2026-10-20 04:11:35 +04:00
|
|
|
let mut bundles = [MissionScriptBundle {
|
2026-07-18 22:15:55 +04:00
|
|
|
clan_index: 1,
|
|
|
|
|
base_path: "MISSIONS/SCRIPTS/default".to_string(),
|
|
|
|
|
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
|
|
|
|
|
package: ScriptPackage {
|
|
|
|
|
opcode_handler_count: 73,
|
|
|
|
|
events: vec![fparkan_script::ScriptEvent {
|
|
|
|
|
name_len: 4,
|
|
|
|
|
name_raw: b"Init\0".to_vec(),
|
|
|
|
|
event_word: 0x1234_5678,
|
|
|
|
|
instructions: vec![fparkan_script::ScriptInstruction {
|
2026-10-20 04:11:35 +04:00
|
|
|
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
|
2026-07-18 22:15:55 +04:00
|
|
|
references: vec![0, 1, 2],
|
|
|
|
|
}],
|
|
|
|
|
}],
|
|
|
|
|
trailing_bytes: Vec::new(),
|
|
|
|
|
raw: Arc::from(&b""[..]),
|
|
|
|
|
},
|
2026-10-20 04:11:35 +04:00
|
|
|
init_execution: MissionScriptInitExecution::Missing,
|
2026-10-20 05:15:11 +04:00
|
|
|
behavior_warm_start: None,
|
2026-07-18 22:15:55 +04:00
|
|
|
}];
|
|
|
|
|
|
2026-10-20 04:11:35 +04:00
|
|
|
let states = resolve_handler19_init_states(
|
|
|
|
|
&[[500.0, 752.0], [728.0, 449.0]],
|
|
|
|
|
&mut bundles,
|
|
|
|
|
&varset,
|
|
|
|
|
MissionAssetScope::Full,
|
|
|
|
|
)
|
|
|
|
|
.expect("exact Init execution");
|
2026-07-18 22:15:55 +04:00
|
|
|
assert_eq!(
|
|
|
|
|
states,
|
|
|
|
|
vec![MissionScriptInitState {
|
|
|
|
|
clan_index: 1,
|
2026-10-20 04:11:35 +04:00
|
|
|
event_index: 0,
|
2026-07-18 22:15:55 +04:00
|
|
|
event_word: 0x1234_5678,
|
|
|
|
|
writes: vec![
|
|
|
|
|
Handler19DwordWrite {
|
|
|
|
|
index: 0,
|
|
|
|
|
value: 728
|
|
|
|
|
},
|
|
|
|
|
Handler19DwordWrite {
|
|
|
|
|
index: 1,
|
|
|
|
|
value: 449
|
|
|
|
|
},
|
|
|
|
|
Handler19DwordWrite { index: 2, value: 1 },
|
|
|
|
|
],
|
|
|
|
|
}]
|
|
|
|
|
);
|
2026-07-18 22:18:49 +04:00
|
|
|
let runtime_varsets = materialize_script_varset_states(&bundles, &varset, &states)
|
|
|
|
|
.expect("materialized per-clan varset");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
runtime_varsets,
|
|
|
|
|
vec![MissionScriptVarSetState {
|
|
|
|
|
clan_index: 1,
|
|
|
|
|
values: vec![
|
|
|
|
|
VarSetDefault::Dword(728),
|
|
|
|
|
VarSetDefault::Dword(449),
|
|
|
|
|
VarSetDefault::Dword(1),
|
|
|
|
|
],
|
|
|
|
|
}]
|
|
|
|
|
);
|
2026-07-18 22:25:28 +04:00
|
|
|
let callback = resolve_handler30_from_varset_state(
|
|
|
|
|
&varset,
|
|
|
|
|
&runtime_varsets[0],
|
|
|
|
|
&ScriptInstruction {
|
|
|
|
|
header_words: [30, 0, 0, 0, 0, 2, 0],
|
|
|
|
|
references: vec![0, 2],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("runtime Handler(30) callback");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
callback,
|
|
|
|
|
VmHostCallbackCommand {
|
|
|
|
|
mode: 0,
|
|
|
|
|
first: 728,
|
|
|
|
|
second: 1,
|
|
|
|
|
}
|
|
|
|
|
);
|
2026-07-18 22:15:55 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-10-20 04:11:35 +04:00
|
|
|
fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() {
|
|
|
|
|
let varset = MissionScriptVarSet {
|
|
|
|
|
path: FALLBACK_SCRIPT_VARSET_PATH.to_string(),
|
|
|
|
|
used_fallback: true,
|
|
|
|
|
declarations: fparkan_script::parse_varset(
|
|
|
|
|
b"VAR( DWORD, x, 950)\nVAR( DWORD, y, 1000)\nVAR( DWORD, id, 0)\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("varset"),
|
|
|
|
|
};
|
|
|
|
|
let package = ScriptPackage {
|
|
|
|
|
opcode_handler_count: 73,
|
|
|
|
|
events: vec![fparkan_script::ScriptEvent {
|
|
|
|
|
name_len: 4,
|
|
|
|
|
name_raw: b"Init\0".to_vec(),
|
|
|
|
|
event_word: 0x1234,
|
|
|
|
|
instructions: vec![
|
|
|
|
|
fparkan_script::ScriptInstruction {
|
|
|
|
|
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
|
|
|
|
|
references: vec![0, 1, 2],
|
|
|
|
|
},
|
|
|
|
|
fparkan_script::ScriptInstruction {
|
|
|
|
|
header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
|
|
|
|
|
references: Vec::new(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}],
|
|
|
|
|
trailing_bytes: Vec::new(),
|
|
|
|
|
raw: Arc::from(&b""[..]),
|
|
|
|
|
};
|
|
|
|
|
let bundle = || MissionScriptBundle {
|
|
|
|
|
clan_index: 0,
|
|
|
|
|
base_path: "MISSIONS/SCRIPTS/default".to_string(),
|
|
|
|
|
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
|
|
|
|
|
package: package.clone(),
|
|
|
|
|
init_execution: MissionScriptInitExecution::Missing,
|
2026-10-20 05:15:11 +04:00
|
|
|
behavior_warm_start: None,
|
2026-10-20 04:11:35 +04:00
|
|
|
};
|
|
|
|
|
let anchors = [[500.0, 752.0]];
|
|
|
|
|
let mut preview_bundles = vec![bundle()];
|
|
|
|
|
let preview_states = resolve_handler19_init_states(
|
|
|
|
|
&anchors,
|
|
|
|
|
&mut preview_bundles,
|
|
|
|
|
&varset,
|
|
|
|
|
MissionAssetScope::FirstMeshPreview,
|
|
|
|
|
)
|
|
|
|
|
.expect("static preview retains unsupported script");
|
|
|
|
|
assert!(preview_states.is_empty(), "no partial writes are retained");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
preview_bundles[0].init_execution,
|
|
|
|
|
MissionScriptInitExecution::Unsupported {
|
|
|
|
|
event_index: 0,
|
|
|
|
|
instruction_index: 1,
|
|
|
|
|
error: Handler19ResolveError::UnsupportedSelector {
|
|
|
|
|
found: ScriptDispatchSelector::Handler(18),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
materialize_script_varset_states(&preview_bundles, &varset, &preview_states)
|
|
|
|
|
.expect("preview keeps default varset values"),
|
|
|
|
|
vec![MissionScriptVarSetState {
|
|
|
|
|
clan_index: 0,
|
|
|
|
|
values: vec![
|
|
|
|
|
VarSetDefault::Dword(950),
|
|
|
|
|
VarSetDefault::Dword(1000),
|
|
|
|
|
VarSetDefault::Dword(0),
|
|
|
|
|
],
|
|
|
|
|
}]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(preview_bundles[0].package, package);
|
|
|
|
|
|
|
|
|
|
let mut full_bundles = vec![bundle()];
|
2026-07-18 22:15:55 +04:00
|
|
|
assert!(matches!(
|
2026-10-20 04:11:35 +04:00
|
|
|
resolve_handler19_init_states(
|
|
|
|
|
&anchors,
|
|
|
|
|
&mut full_bundles,
|
|
|
|
|
&varset,
|
|
|
|
|
MissionAssetScope::Full,
|
|
|
|
|
),
|
|
|
|
|
Err(EngineError::ScriptInitUnsupported {
|
|
|
|
|
clan_index: 0,
|
|
|
|
|
event_index: 0,
|
|
|
|
|
instruction_index: 1,
|
|
|
|
|
source: Handler19ResolveError::UnsupportedSelector {
|
|
|
|
|
found: ScriptDispatchSelector::Handler(18),
|
|
|
|
|
},
|
|
|
|
|
..
|
|
|
|
|
})
|
2026-07-18 22:15:55 +04:00
|
|
|
));
|
2026-10-20 04:11:35 +04:00
|
|
|
|
|
|
|
|
let mut malformed_package = package.clone();
|
|
|
|
|
malformed_package.events[0].instructions[0]
|
|
|
|
|
.references
|
|
|
|
|
.clear();
|
|
|
|
|
let mut malformed_bundle = vec![MissionScriptBundle {
|
|
|
|
|
package: malformed_package,
|
|
|
|
|
..bundle()
|
|
|
|
|
}];
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
resolve_handler19_init_states(
|
|
|
|
|
&anchors,
|
|
|
|
|
&mut malformed_bundle,
|
|
|
|
|
&varset,
|
|
|
|
|
MissionAssetScope::FirstMeshPreview,
|
|
|
|
|
),
|
|
|
|
|
Err(EngineError::ScriptInit { message, .. })
|
|
|
|
|
if message.contains("Handler(19) is missing reference 0")
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn handler_nineteen_init_matches_original_x87_rounding_and_dword_path() {
|
|
|
|
|
for (value, expected) in [
|
|
|
|
|
(500.2857, 500),
|
|
|
|
|
(751.8217, 752),
|
|
|
|
|
(500.5, 500),
|
|
|
|
|
(501.5, 502),
|
|
|
|
|
(-0.5, 0),
|
|
|
|
|
(-0.4, 0),
|
|
|
|
|
(-0.6, 0),
|
|
|
|
|
(10_000.4, 10_000),
|
|
|
|
|
(10_000.5, 10_000),
|
|
|
|
|
(10_000.6, 10_001),
|
|
|
|
|
(10_001.0, 10_001),
|
|
|
|
|
(4_294_967_296.0, 0),
|
|
|
|
|
(u32::MAX as f32, 0),
|
|
|
|
|
(4_294_967_808.0, 512),
|
|
|
|
|
] {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
handler19_anchor_word(value, 0, "x").expect("supported anchor"),
|
|
|
|
|
expected,
|
|
|
|
|
"anchor {value:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
assert_eq!(u32::MAX as f32, 4_294_967_296.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn handler_nineteen_init_rejects_unmodeled_x87_qword_conversions() {
|
|
|
|
|
let qword_limit = 9_223_372_036_854_775_808.0_f32;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
handler19_anchor_word(-qword_limit, 0, "x").expect("valid signed qword minimum"),
|
|
|
|
|
0,
|
|
|
|
|
"the signed qword minimum is representable before low-DWORD conversion"
|
|
|
|
|
);
|
|
|
|
|
for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, qword_limit] {
|
|
|
|
|
let error =
|
|
|
|
|
handler19_anchor_word(value, 0, "x").expect_err("unmodeled FISTP qword input");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
error,
|
|
|
|
|
EngineError::ScriptInit { clan_index: 0, message }
|
|
|
|
|
if message.contains("outside the modeled x87 FISTP qword range")
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-07-18 22:15:55 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 22:36:38 +04:00
|
|
|
#[test]
|
2026-10-20 03:20:20 +04:00
|
|
|
fn handler_thirty_host_callback_obeys_native_mode_and_status_guards() {
|
|
|
|
|
fn command(second: u32) -> VmHostCallbackCommand {
|
|
|
|
|
VmHostCallbackCommand {
|
|
|
|
|
mode: 0,
|
|
|
|
|
first: 0,
|
|
|
|
|
second,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut status = 5;
|
2026-07-18 22:36:38 +04:00
|
|
|
let mut outcome = MissionScriptOutcome::Active;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
apply_recovered_script_host_callback(
|
2026-10-20 03:20:20 +04:00
|
|
|
MissionMode::SingleMission,
|
|
|
|
|
&mut status,
|
2026-07-18 22:36:38 +04:00
|
|
|
&mut outcome,
|
2026-10-20 03:20:20 +04:00
|
|
|
command(0),
|
2026-07-18 22:36:38 +04:00
|
|
|
),
|
|
|
|
|
MissionScriptHostEffect::MissionFailed
|
|
|
|
|
);
|
2026-10-20 03:20:20 +04:00
|
|
|
assert_eq!((status, outcome), (1, MissionScriptOutcome::Failed));
|
|
|
|
|
outcome = MissionScriptOutcome::Completed;
|
2026-07-18 22:36:38 +04:00
|
|
|
assert_eq!(
|
|
|
|
|
apply_recovered_script_host_callback(
|
2026-10-20 03:20:20 +04:00
|
|
|
MissionMode::SingleMission,
|
|
|
|
|
&mut status,
|
2026-07-18 22:36:38 +04:00
|
|
|
&mut outcome,
|
2026-10-20 03:20:20 +04:00
|
|
|
command(0),
|
|
|
|
|
),
|
|
|
|
|
MissionScriptHostEffect::NoChange
|
|
|
|
|
);
|
|
|
|
|
assert_eq!((status, outcome), (1, MissionScriptOutcome::Completed));
|
|
|
|
|
outcome = MissionScriptOutcome::Failed;
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
apply_recovered_script_host_callback(
|
|
|
|
|
MissionMode::SingleMission,
|
|
|
|
|
&mut status,
|
|
|
|
|
&mut outcome,
|
|
|
|
|
command(1),
|
|
|
|
|
),
|
|
|
|
|
MissionScriptHostEffect::MissionCompleted
|
|
|
|
|
);
|
|
|
|
|
assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
apply_recovered_script_host_callback(
|
|
|
|
|
MissionMode::SingleMission,
|
|
|
|
|
&mut status,
|
|
|
|
|
&mut outcome,
|
|
|
|
|
command(1),
|
|
|
|
|
),
|
|
|
|
|
MissionScriptHostEffect::NoChange
|
|
|
|
|
);
|
|
|
|
|
assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed));
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
apply_recovered_script_host_callback(
|
|
|
|
|
MissionMode::SingleMission,
|
|
|
|
|
&mut status,
|
|
|
|
|
&mut outcome,
|
|
|
|
|
command(0),
|
|
|
|
|
),
|
|
|
|
|
MissionScriptHostEffect::MissionFailed
|
|
|
|
|
);
|
|
|
|
|
assert_eq!((status, outcome), (1, MissionScriptOutcome::Failed));
|
|
|
|
|
|
|
|
|
|
status = 4;
|
|
|
|
|
outcome = MissionScriptOutcome::Active;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
apply_recovered_script_host_callback(
|
|
|
|
|
MissionMode::SingleMission,
|
|
|
|
|
&mut status,
|
|
|
|
|
&mut outcome,
|
|
|
|
|
command(1),
|
2026-07-18 22:36:38 +04:00
|
|
|
),
|
|
|
|
|
MissionScriptHostEffect::MissionCompleted
|
|
|
|
|
);
|
2026-10-20 03:20:20 +04:00
|
|
|
assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed));
|
|
|
|
|
|
|
|
|
|
status = 5;
|
|
|
|
|
outcome = MissionScriptOutcome::Active;
|
2026-07-18 22:36:38 +04:00
|
|
|
assert_eq!(
|
|
|
|
|
apply_recovered_script_host_callback(
|
2026-10-20 03:20:20 +04:00
|
|
|
MissionMode::AutoDemo,
|
|
|
|
|
&mut status,
|
2026-07-18 22:36:38 +04:00
|
|
|
&mut outcome,
|
2026-10-20 03:20:20 +04:00
|
|
|
command(0),
|
|
|
|
|
),
|
|
|
|
|
MissionScriptHostEffect::Unhandled
|
|
|
|
|
);
|
|
|
|
|
assert_eq!((status, outcome), (5, MissionScriptOutcome::Active));
|
|
|
|
|
|
|
|
|
|
for command in [
|
|
|
|
|
VmHostCallbackCommand {
|
|
|
|
|
mode: 1,
|
|
|
|
|
first: 0,
|
|
|
|
|
second: 0,
|
|
|
|
|
},
|
|
|
|
|
VmHostCallbackCommand {
|
|
|
|
|
mode: 0,
|
|
|
|
|
first: 3,
|
|
|
|
|
second: 0,
|
|
|
|
|
},
|
|
|
|
|
VmHostCallbackCommand {
|
|
|
|
|
mode: 0,
|
|
|
|
|
first: 0,
|
|
|
|
|
second: 42,
|
|
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
apply_recovered_script_host_callback(
|
|
|
|
|
MissionMode::SingleMission,
|
|
|
|
|
&mut status,
|
|
|
|
|
&mut outcome,
|
|
|
|
|
command,
|
|
|
|
|
),
|
|
|
|
|
MissionScriptHostEffect::Unhandled
|
|
|
|
|
);
|
|
|
|
|
assert_eq!((status, outcome), (5, MissionScriptOutcome::Active));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 04:11:35 +04:00
|
|
|
#[test]
|
|
|
|
|
#[ignore = "requires licensed corpus"]
|
|
|
|
|
fn licensed_autodemo_load_resolves_native_handler19_init_for_both_clans() {
|
|
|
|
|
let root = licensed_root("IS");
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
|
|
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
|
|
|
|
let loaded = load_mission(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
|
|
|
mode: MissionMode::AutoDemo,
|
|
|
|
|
},
|
2026-10-20 05:15:11 +04:00
|
|
|
100_000,
|
2026-10-20 04:11:35 +04:00
|
|
|
)
|
|
|
|
|
.expect("load licensed AutoDemo");
|
|
|
|
|
|
2026-10-20 08:11:45 +04:00
|
|
|
let mission = loaded_mission_document(&engine).expect("mission document");
|
|
|
|
|
let drafts = loaded_mission_object_drafts(&engine).expect("object drafts");
|
|
|
|
|
assert_eq!(drafts.len(), mission.objects.len());
|
|
|
|
|
assert!(mission
|
|
|
|
|
.objects
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|object| object.raw_after_resource != object.identity_or_clan_raw));
|
|
|
|
|
for (object, draft) in mission.objects.iter().zip(drafts) {
|
|
|
|
|
assert_eq!(draft.raw_after_resource, object.raw_after_resource);
|
|
|
|
|
assert_eq!(draft.identity_or_clan_raw, object.identity_or_clan_raw);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 08:44:10 +04:00
|
|
|
let distributors =
|
|
|
|
|
loaded_mission_behavior_distributors(&engine).expect("Behavior distributors");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
distributors
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|distributor| (distributor.clan_index, distributor.super_ai_visible))
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![(0, true), (1, true)]
|
|
|
|
|
);
|
|
|
|
|
let registered = distributors
|
|
|
|
|
.iter()
|
|
|
|
|
.flat_map(|distributor| {
|
|
|
|
|
distributor
|
|
|
|
|
.registered_objects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(move |object| (distributor.clan_index, object))
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
registered
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(clan_index, object)| {
|
|
|
|
|
let draft = drafts
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|draft| draft.original_id == Some(object.original_id))
|
|
|
|
|
.expect("registered object draft");
|
|
|
|
|
(
|
|
|
|
|
*clan_index,
|
|
|
|
|
object.original_id,
|
|
|
|
|
draft.resource_name_raw.as_slice(),
|
|
|
|
|
object.selector,
|
|
|
|
|
object.class_flags,
|
2026-10-20 09:06:17 +04:00
|
|
|
object.share.to_bits(),
|
|
|
|
|
object.native_tail,
|
2026-10-20 08:44:10 +04:00
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![
|
|
|
|
|
(
|
|
|
|
|
0,
|
|
|
|
|
OriginalObjectId(4),
|
|
|
|
|
&b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"[..],
|
|
|
|
|
0,
|
|
|
|
|
0x8000_0010,
|
2026-10-20 09:06:17 +04:00
|
|
|
0x3f00_0000,
|
|
|
|
|
[0; 5],
|
2026-10-20 08:44:10 +04:00
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
0,
|
|
|
|
|
OriginalObjectId(6),
|
|
|
|
|
&b"UNITS\\BUILDS\\GENER\\gener01.dat"[..],
|
|
|
|
|
0,
|
|
|
|
|
0x8000_0002,
|
2026-10-20 09:06:17 +04:00
|
|
|
0x3f00_0000,
|
|
|
|
|
[0; 5],
|
2026-10-20 08:44:10 +04:00
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
1,
|
|
|
|
|
OriginalObjectId(5),
|
|
|
|
|
&b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"[..],
|
|
|
|
|
1,
|
|
|
|
|
0x8000_0010,
|
2026-10-20 09:06:17 +04:00
|
|
|
0x3f00_0000,
|
|
|
|
|
[0; 5],
|
2026-10-20 08:44:10 +04:00
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
1,
|
|
|
|
|
OriginalObjectId(7),
|
|
|
|
|
&b"UNITS\\BUILDS\\GENER\\gener01.dat"[..],
|
|
|
|
|
1,
|
|
|
|
|
0x8000_0002,
|
2026-10-20 09:06:17 +04:00
|
|
|
0x3f00_0000,
|
|
|
|
|
[0; 5],
|
2026-10-20 08:44:10 +04:00
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(registered.len(), 4);
|
|
|
|
|
for (_, object) in registered {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
handle_by_original_id(&engine.world, object.original_id),
|
|
|
|
|
Some(object.handle)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 04:11:35 +04:00
|
|
|
assert_eq!(
|
2026-10-20 08:11:45 +04:00
|
|
|
mission
|
2026-10-20 04:11:35 +04:00
|
|
|
.clans
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|clan| clan.anchor)
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![[500.2857, 751.8217], [728.0, 449.0]]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.script_bundle_count, 2);
|
|
|
|
|
assert_eq!(loaded.script_init_state_count, 2);
|
|
|
|
|
assert_eq!(loaded.script_varset_state_count, 2);
|
|
|
|
|
assert!(loaded_mission_script_bundles(&engine)
|
|
|
|
|
.expect("script bundles")
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|bundle| bundle.init_execution
|
|
|
|
|
== MissionScriptInitExecution::Executed { event_index: 0 }));
|
2026-10-20 05:15:11 +04:00
|
|
|
assert_eq!(engine.behavior_rng_state, 0xc823_f683);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded_mission_script_bundles(&engine)
|
|
|
|
|
.expect("warmed script bundles")
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|bundle| bundle
|
|
|
|
|
.behavior_warm_start
|
|
|
|
|
.expect("full startup warms the AI"))
|
|
|
|
|
.map(|warm_start| warm_start.next_deadline_ms)
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![100_202, 100_200]
|
|
|
|
|
);
|
2026-10-20 04:11:35 +04:00
|
|
|
|
|
|
|
|
let varsets = loaded_mission_script_varset_states(&engine).expect("per-clan varsets");
|
|
|
|
|
let clan_zero = varsets
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|state| state.clan_index == 0)
|
|
|
|
|
.expect("first clan varset");
|
|
|
|
|
let clan_one = varsets
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|state| state.clan_index == 1)
|
|
|
|
|
.expect("second clan varset");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
&clan_zero.values[224..227],
|
|
|
|
|
&[
|
|
|
|
|
VarSetDefault::Dword(500),
|
|
|
|
|
VarSetDefault::Dword(752),
|
|
|
|
|
VarSetDefault::Dword(0),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
&clan_one.values[224..227],
|
|
|
|
|
&[
|
|
|
|
|
VarSetDefault::Dword(728),
|
|
|
|
|
VarSetDefault::Dword(449),
|
|
|
|
|
VarSetDefault::Dword(1),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 09:06:17 +04:00
|
|
|
fn behavior_test_object(
|
|
|
|
|
slot: u32,
|
|
|
|
|
share: f32,
|
|
|
|
|
native_tail: [u32; 5],
|
|
|
|
|
) -> BehaviorRegisteredObject {
|
|
|
|
|
BehaviorRegisteredObject {
|
|
|
|
|
original_id: OriginalObjectId(slot),
|
|
|
|
|
handle: ObjectHandle {
|
|
|
|
|
generation: 0,
|
|
|
|
|
slot,
|
|
|
|
|
},
|
|
|
|
|
selector: 0,
|
|
|
|
|
class_flags: 0,
|
|
|
|
|
share,
|
|
|
|
|
native_tail,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn behavior_distributor_add_uses_handle_and_preserves_duplicate_tail() {
|
|
|
|
|
let mut distributor = BehaviorDistributorState {
|
|
|
|
|
clan_index: 0,
|
|
|
|
|
super_ai_visible: true,
|
|
|
|
|
registered_objects: Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
add_behavior_registered_object(&mut distributor, behavior_test_object(4, 0.0, [0; 5]));
|
|
|
|
|
add_behavior_registered_object(&mut distributor, behavior_test_object(6, 0.0, [0; 5]));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
distributor
|
|
|
|
|
.registered_objects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|object| object.share.to_bits())
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![0x3f00_0000, 0x3f00_0000]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
distributor.registered_objects[0].share = 0.2;
|
|
|
|
|
distributor.registered_objects[0].native_tail = [0xa1, 0xa2, 0xa3, 0xa4, 0xa5];
|
|
|
|
|
add_behavior_registered_object(&mut distributor, behavior_test_object(4, 0.0, [0; 5]));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
distributor.registered_objects[0].share.to_bits(),
|
|
|
|
|
0x3f00_0000
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
distributor.registered_objects[0].native_tail,
|
|
|
|
|
[0xa1, 0xa2, 0xa3, 0xa4, 0xa5]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
add_behavior_registered_object(&mut distributor, behavior_test_object(8, 0.0, [0; 5]));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
distributor
|
|
|
|
|
.registered_objects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|object| object.share.to_bits())
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![0x3f00_0000, 0x3e2a_aaa8, 0x3eaa_aaab]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
distributor
|
|
|
|
|
.registered_objects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|object| object.native_tail)
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![[0xa1, 0xa2, 0xa3, 0xa4, 0xa5], [0; 5], [0; 5]]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn behavior_distributor_normalize_matches_native_24_bit_vectors() {
|
|
|
|
|
let mut pinned = BehaviorDistributorState {
|
|
|
|
|
clan_index: 0,
|
|
|
|
|
super_ai_visible: true,
|
|
|
|
|
registered_objects: vec![
|
|
|
|
|
behavior_test_object(0x11, 0.2, [0; 5]),
|
|
|
|
|
behavior_test_object(0x22, 0.3, [1, 0x11, 0x22, 0x33, 0x44]),
|
|
|
|
|
behavior_test_object(0x33, 0.3, [0; 5]),
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
normalize_behavior_distributor(
|
|
|
|
|
&mut pinned,
|
|
|
|
|
ObjectHandle {
|
|
|
|
|
generation: 0,
|
|
|
|
|
slot: 0x11,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
pinned
|
|
|
|
|
.registered_objects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|object| object.share.to_bits())
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![0x3e4c_cccd, 0x3e99_999a, 0x3f00_0000]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut zero_sum = BehaviorDistributorState {
|
|
|
|
|
clan_index: 0,
|
|
|
|
|
super_ai_visible: true,
|
|
|
|
|
registered_objects: vec![
|
|
|
|
|
behavior_test_object(0x41, 0.25, [0; 5]),
|
|
|
|
|
behavior_test_object(0x42, 0.0, [0; 5]),
|
|
|
|
|
behavior_test_object(0x43, 0.0, [0; 5]),
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
normalize_behavior_distributor(
|
|
|
|
|
&mut zero_sum,
|
|
|
|
|
ObjectHandle {
|
|
|
|
|
generation: 0,
|
|
|
|
|
slot: 0x41,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
zero_sum
|
|
|
|
|
.registered_objects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|object| object.share.to_bits())
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![0x3e80_0000, 0x3ec0_0000, 0x3ec0_0000]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut near_rounding = BehaviorDistributorState {
|
|
|
|
|
clan_index: 0,
|
|
|
|
|
super_ai_visible: true,
|
|
|
|
|
registered_objects: vec![
|
|
|
|
|
behavior_test_object(0x51, 0.5, [0; 5]),
|
|
|
|
|
behavior_test_object(0x52, 1.0, [0; 5]),
|
|
|
|
|
behavior_test_object(0x53, f32::from_bits(0x3380_0000), [0; 5]),
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
normalize_behavior_distributor(
|
|
|
|
|
&mut near_rounding,
|
|
|
|
|
ObjectHandle {
|
|
|
|
|
generation: 0,
|
|
|
|
|
slot: 0x51,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
near_rounding
|
|
|
|
|
.registered_objects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|object| object.share.to_bits())
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
vec![0x3f00_0000, 0x3f00_0000, 0x3300_0000]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 04:11:35 +04:00
|
|
|
#[test]
|
|
|
|
|
#[ignore = "requires licensed corpus"]
|
|
|
|
|
fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() {
|
|
|
|
|
let root = licensed_root("IS");
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
|
|
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
|
|
|
|
let loaded = load_mission_static_preview_roots(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
|
|
|
|
|
mode: MissionMode::SingleMission,
|
|
|
|
|
},
|
|
|
|
|
NonZeroUsize::new(1).expect("non-zero root count"),
|
|
|
|
|
)
|
|
|
|
|
.expect("load licensed campaign preview");
|
|
|
|
|
|
|
|
|
|
let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
|
2026-10-20 08:44:10 +04:00
|
|
|
assert!(loaded_mission_behavior_distributors(&engine)
|
|
|
|
|
.expect("preview Behavior distributors")
|
|
|
|
|
.is_empty());
|
2026-10-20 05:15:11 +04:00
|
|
|
assert!(bundles
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|bundle| bundle.behavior_warm_start.is_none()));
|
|
|
|
|
assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE);
|
2026-10-20 04:11:35 +04:00
|
|
|
let unsupported = bundles
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|bundle| {
|
|
|
|
|
matches!(
|
|
|
|
|
&bundle.init_execution,
|
|
|
|
|
MissionScriptInitExecution::Unsupported { .. }
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.expect("campaign package with an unsupported Init instruction");
|
|
|
|
|
let clan_index = unsupported.clan_index;
|
|
|
|
|
let states = loaded_mission_script_init_states(&engine).expect("Init writes");
|
|
|
|
|
assert!(states.iter().all(|state| state.clan_index != clan_index));
|
|
|
|
|
let varset = loaded_mission_script_varset_states(&engine)
|
|
|
|
|
.expect("per-clan varsets")
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|state| state.clan_index == clan_index)
|
|
|
|
|
.expect("unsupported clan varset");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
&varset.values[224..227],
|
|
|
|
|
&[
|
|
|
|
|
VarSetDefault::Dword(950),
|
|
|
|
|
VarSetDefault::Dword(1000),
|
|
|
|
|
VarSetDefault::Dword(0),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
assert!(loaded.script_bundle_count >= 1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 03:20:20 +04:00
|
|
|
#[test]
|
|
|
|
|
#[ignore = "requires licensed corpus"]
|
|
|
|
|
fn loaded_host_callback_uses_requested_mode_and_constructor_status() {
|
|
|
|
|
let root = licensed_root("IS");
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
|
|
|
|
let key = "MISSIONS/Autodemo.00/data.tma".to_string();
|
|
|
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
|
|
|
|
|
|
|
|
|
load_mission_static_preview_roots(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: key.clone(),
|
|
|
|
|
mode: MissionMode::SingleMission,
|
|
|
|
|
},
|
|
|
|
|
NonZeroUsize::new(1).expect("non-zero root count"),
|
|
|
|
|
)
|
|
|
|
|
.expect("load single-mission preview");
|
|
|
|
|
let state = engine.loaded.as_mut().expect("loaded state");
|
|
|
|
|
assert_eq!(state.script_host_status, INITIAL_SCRIPT_HOST_STATUS);
|
|
|
|
|
assert_eq!(state.script_outcome, MissionScriptOutcome::Active);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
apply_loaded_script_host_callback(
|
|
|
|
|
&mut engine,
|
2026-07-18 22:36:38 +04:00
|
|
|
VmHostCallbackCommand {
|
|
|
|
|
mode: 0,
|
|
|
|
|
first: 0,
|
2026-10-20 03:20:20 +04:00
|
|
|
second: 0,
|
2026-07-18 22:36:38 +04:00
|
|
|
},
|
2026-10-20 03:20:20 +04:00
|
|
|
)
|
|
|
|
|
.expect("apply failure callback"),
|
|
|
|
|
MissionScriptHostEffect::MissionFailed
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded_mission_script_outcome(&engine),
|
|
|
|
|
Some(MissionScriptOutcome::Failed)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
engine.loaded.as_mut().unwrap().script_outcome = MissionScriptOutcome::Completed;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
apply_loaded_script_host_callback(
|
|
|
|
|
&mut engine,
|
|
|
|
|
VmHostCallbackCommand {
|
|
|
|
|
mode: 0,
|
|
|
|
|
first: 0,
|
|
|
|
|
second: 0,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("apply repeated failure callback"),
|
2026-07-18 22:36:38 +04:00
|
|
|
MissionScriptHostEffect::NoChange
|
|
|
|
|
);
|
2026-10-20 03:20:20 +04:00
|
|
|
assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1);
|
2026-07-18 22:36:38 +04:00
|
|
|
assert_eq!(
|
2026-10-20 03:20:20 +04:00
|
|
|
loaded_mission_script_outcome(&engine),
|
|
|
|
|
Some(MissionScriptOutcome::Completed)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
load_mission_static_preview_roots(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key,
|
|
|
|
|
mode: MissionMode::AutoDemo,
|
|
|
|
|
},
|
|
|
|
|
NonZeroUsize::new(1).expect("non-zero root count"),
|
|
|
|
|
)
|
|
|
|
|
.expect("reload AutoDemo preview");
|
|
|
|
|
assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 5);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
apply_loaded_script_host_callback(
|
|
|
|
|
&mut engine,
|
2026-07-18 22:36:38 +04:00
|
|
|
VmHostCallbackCommand {
|
|
|
|
|
mode: 0,
|
2026-10-20 03:20:20 +04:00
|
|
|
first: 0,
|
|
|
|
|
second: 1,
|
2026-07-18 22:36:38 +04:00
|
|
|
},
|
2026-10-20 03:20:20 +04:00
|
|
|
)
|
|
|
|
|
.expect("apply AutoDemo callback"),
|
2026-07-18 22:36:38 +04:00
|
|
|
MissionScriptHostEffect::Unhandled
|
|
|
|
|
);
|
2026-10-20 03:20:20 +04:00
|
|
|
assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 5);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded_mission_script_outcome(&engine),
|
|
|
|
|
Some(MissionScriptOutcome::Active)
|
|
|
|
|
);
|
2026-07-18 22:36:38 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 19:42:52 +04:00
|
|
|
#[test]
|
|
|
|
|
fn reference_movement_rejects_non_finite_or_non_positive_input() {
|
2026-09-06 05:22:12 +04:00
|
|
|
let mut engine = create(EngineServices::default()).expect("engine");
|
2026-07-18 19:42:52 +04:00
|
|
|
|
|
|
|
|
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 reference_movement_snaps_a_live_mission_object_to_terrain() {
|
|
|
|
|
let root = licensed_root("IS");
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
2026-09-06 05:22:12 +04:00
|
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
2026-07-18 19:42:52 +04:00
|
|
|
load_mission(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
2026-10-20 03:20:20 +04:00
|
|
|
mode: MissionMode::SingleMission,
|
2026-07-18 19:42:52 +04:00
|
|
|
},
|
2026-10-20 05:15:11 +04:00
|
|
|
0,
|
2026-07-18 19:42:52 +04:00
|
|
|
)
|
|
|
|
|
.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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn missing_map_and_missing_reachable_resource_fail_before_registration() {
|
2026-06-22 17:29:33 +04:00
|
|
|
let root = licensed_root("IS");
|
2026-06-22 13:12:27 +04:00
|
|
|
for (denied, mission) in [
|
|
|
|
|
(
|
|
|
|
|
DenyRule::Suffix("Land.map"),
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
2026-10-20 03:20:20 +04:00
|
|
|
mode: MissionMode::SingleMission,
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
DenyRule::Suffix("objects.rlb"),
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
|
2026-10-20 03:20:20 +04:00
|
|
|
mode: MissionMode::SingleMission,
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
] {
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DenyVfs {
|
|
|
|
|
inner: DirectoryVfs::new(&root),
|
|
|
|
|
denied,
|
|
|
|
|
});
|
2026-09-06 05:22:12 +04:00
|
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
2026-06-22 13:12:27 +04:00
|
|
|
let before = step_headless(&mut engine, InputSnapshot).expect("before");
|
2026-10-20 05:15:11 +04:00
|
|
|
let err = load_mission(&mut engine, mission, 0).expect_err("load error");
|
2026-06-22 13:12:27 +04:00
|
|
|
match denied {
|
|
|
|
|
DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })),
|
|
|
|
|
DenyRule::Suffix("objects.rlb") => {
|
|
|
|
|
assert!(matches!(err, EngineError::PrototypeGraph { .. }))
|
|
|
|
|
}
|
|
|
|
|
DenyRule::Suffix(unexpected) => panic!("unexpected deny rule {unexpected}"),
|
|
|
|
|
}
|
|
|
|
|
assert!(loaded_mission(&engine).is_none());
|
|
|
|
|
let after = step_headless(&mut engine, InputSnapshot).expect("after");
|
|
|
|
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() {
|
2026-06-22 17:29:33 +04:00
|
|
|
let root = licensed_root("IS");
|
2026-06-22 13:12:27 +04:00
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
2026-09-06 05:22:12 +04:00
|
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
2026-06-22 13:12:27 +04:00
|
|
|
let before = step_headless(&mut engine, InputSnapshot).expect("before");
|
|
|
|
|
|
2026-09-06 05:22:12 +04:00
|
|
|
let err = load_mission_with_options(
|
2026-06-22 13:12:27 +04:00
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
|
2026-10-20 03:20:20 +04:00
|
|
|
mode: MissionMode::SingleMission,
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
|
|
|
|
MissionLoadOptions {
|
|
|
|
|
fail_after_registered_objects: Some(1),
|
2026-07-18 09:36:29 +04:00
|
|
|
..MissionLoadOptions::default()
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
2026-10-20 05:15:11 +04:00
|
|
|
Some(0),
|
2026-06-22 13:12:27 +04:00
|
|
|
)
|
|
|
|
|
.expect_err("forced registration failure");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
err,
|
|
|
|
|
EngineError::RegistrationTeardown {
|
|
|
|
|
registered_objects: 1,
|
|
|
|
|
released_objects: 1,
|
|
|
|
|
managers_released: true,
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
assert!(loaded_mission(&engine).is_none());
|
|
|
|
|
let after = step_headless(&mut engine, InputSnapshot).expect("after");
|
|
|
|
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 05:15:11 +04:00
|
|
|
#[test]
|
|
|
|
|
#[ignore = "requires licensed corpus"]
|
|
|
|
|
fn full_startup_init_failure_keeps_the_previous_world_and_rng() {
|
|
|
|
|
let root = licensed_root("IS");
|
|
|
|
|
let original_script = std::fs::read(root.join("MISSIONS/SCRIPTS/default.scr"))
|
|
|
|
|
.expect("read licensed default.scr");
|
|
|
|
|
let mut package = fparkan_script::decode(&original_script).expect("decode default.scr");
|
|
|
|
|
let init_index = package
|
|
|
|
|
.events
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]))
|
|
|
|
|
.expect("Init event");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
package.events[init_index].instructions[0].header_words[0],
|
|
|
|
|
19
|
|
|
|
|
);
|
|
|
|
|
package.events[init_index].instructions[0].header_words[0] = 18;
|
|
|
|
|
let replacement = Arc::<[u8]>::from(encode_script_package(&package));
|
|
|
|
|
|
|
|
|
|
let vfs = Arc::new(SwappingScriptVfs {
|
|
|
|
|
inner: DirectoryVfs::new(&root),
|
|
|
|
|
replacement,
|
|
|
|
|
enabled: std::sync::atomic::AtomicBool::new(false),
|
|
|
|
|
});
|
|
|
|
|
let services_vfs: Arc<dyn Vfs> = vfs.clone();
|
|
|
|
|
let mut engine = create(EngineServices::new(services_vfs)).expect("engine");
|
|
|
|
|
let mission = || MissionRequest {
|
|
|
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
|
|
|
mode: MissionMode::AutoDemo,
|
|
|
|
|
};
|
|
|
|
|
load_mission(&mut engine, mission(), 100_000).expect("initial full startup");
|
|
|
|
|
|
|
|
|
|
let previous_loaded = loaded_mission(&engine).expect("previous mission").clone();
|
|
|
|
|
let previous_bundles = loaded_mission_script_bundles(&engine)
|
|
|
|
|
.expect("previous bundles")
|
|
|
|
|
.to_vec();
|
2026-10-20 08:44:10 +04:00
|
|
|
let previous_distributors = loaded_mission_behavior_distributors(&engine)
|
|
|
|
|
.expect("previous Behavior distributors")
|
|
|
|
|
.to_vec();
|
2026-10-20 05:15:11 +04:00
|
|
|
let previous_rng = engine.behavior_rng_state;
|
|
|
|
|
let before = step_headless(&mut engine, InputSnapshot)
|
|
|
|
|
.expect("step previous world")
|
|
|
|
|
.snapshot
|
|
|
|
|
.objects;
|
|
|
|
|
|
|
|
|
|
vfs.enabled.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
|
|
|
let error = load_mission(&mut engine, mission(), 100_500)
|
|
|
|
|
.expect_err("unsupported Init must fail after new-world registration");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
error,
|
|
|
|
|
EngineError::ScriptInitUnsupported {
|
|
|
|
|
source: Handler19ResolveError::UnsupportedSelector {
|
|
|
|
|
found: ScriptDispatchSelector::Handler(18),
|
|
|
|
|
},
|
|
|
|
|
..
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
assert_eq!(loaded_mission(&engine), Some(&previous_loaded));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded_mission_script_bundles(&engine),
|
|
|
|
|
Some(previous_bundles.as_slice())
|
|
|
|
|
);
|
2026-10-20 08:44:10 +04:00
|
|
|
assert_eq!(
|
|
|
|
|
loaded_mission_behavior_distributors(&engine),
|
|
|
|
|
Some(previous_distributors.as_slice())
|
|
|
|
|
);
|
2026-10-20 05:15:11 +04:00
|
|
|
assert_eq!(engine.behavior_rng_state, previous_rng);
|
|
|
|
|
let after = step_headless(&mut engine, InputSnapshot)
|
|
|
|
|
.expect("step retained world")
|
|
|
|
|
.snapshot
|
|
|
|
|
.objects;
|
|
|
|
|
assert_eq!(before, after);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
|
|
|
|
|
for case in [
|
|
|
|
|
HeadlessCase {
|
|
|
|
|
root: "IS",
|
|
|
|
|
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
|
|
|
|
|
object_count: 33,
|
|
|
|
|
expected_hash: [
|
2026-06-22 17:29:33 +04:00
|
|
|
0xc7, 0xb0, 0x6e, 0x0a, 0x31, 0x1f, 0x5d, 0x8c, 0xde, 0x64, 0xa5, 0x33, 0x1f,
|
|
|
|
|
0x2c, 0xd0, 0x2c, 0x21, 0x44, 0x2f, 0x34, 0x5d, 0x16, 0xe8, 0x94, 0xaf, 0xa2,
|
|
|
|
|
0x2b, 0xa9, 0xd4, 0x24, 0xd2, 0xf9,
|
2026-06-22 13:12:27 +04:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
HeadlessCase {
|
|
|
|
|
root: "IS2",
|
|
|
|
|
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
|
|
|
|
object_count: 10,
|
|
|
|
|
expected_hash: [
|
2026-06-22 17:29:33 +04:00
|
|
|
0x3c, 0xe5, 0xa6, 0x39, 0x47, 0x86, 0x76, 0xe1, 0xb2, 0x1a, 0x8e, 0x96, 0x3d,
|
|
|
|
|
0x60, 0x6e, 0xc6, 0x8c, 0xe2, 0x28, 0x4f, 0x57, 0xd9, 0xe1, 0xe4, 0xb5, 0x95,
|
|
|
|
|
0xdf, 0x88, 0xd3, 0x2f, 0x4a, 0x4d,
|
2026-06-22 13:12:27 +04:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
let first = run_headless_case(case);
|
|
|
|
|
let second = run_headless_case(case);
|
|
|
|
|
assert_eq!(first, second);
|
|
|
|
|
assert_eq!(first.tick.0, 10_000);
|
|
|
|
|
assert_eq!(first.objects.len(), case.object_count);
|
|
|
|
|
assert_eq!(first.hash.0, case.expected_hash);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:44:26 +04:00
|
|
|
#[test]
|
|
|
|
|
#[ignore = "requires local testdata corpus"]
|
|
|
|
|
fn selected_is_and_is2_missions_preserve_runtime_object_drafts() {
|
|
|
|
|
for case in [
|
|
|
|
|
("IS", "MISSIONS/Autodemo.00/data.tma"),
|
|
|
|
|
("IS2", "MISSIONS/Autodemo.00/data.tma"),
|
|
|
|
|
] {
|
|
|
|
|
let root = local_testdata_root(case.0);
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
2026-09-06 05:22:12 +04:00
|
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
2026-07-03 21:44:26 +04:00
|
|
|
let loaded = load_mission(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: case.1.to_string(),
|
2026-10-20 03:20:20 +04:00
|
|
|
mode: MissionMode::SingleMission,
|
2026-07-03 21:44:26 +04:00
|
|
|
},
|
2026-10-20 05:15:11 +04:00
|
|
|
0,
|
2026-07-03 21:44:26 +04:00
|
|
|
)
|
|
|
|
|
.expect("load mission");
|
|
|
|
|
let drafts = loaded_mission_object_drafts(&engine).expect("object drafts");
|
|
|
|
|
let assets = loaded_mission_assets(&engine).expect("mission assets");
|
2026-07-18 20:15:26 +04:00
|
|
|
let graph = loaded_prototype_graph(&engine).expect("prototype graph");
|
2026-07-03 21:44:26 +04:00
|
|
|
|
|
|
|
|
assert_eq!(drafts.len(), loaded.object_count);
|
|
|
|
|
assert!(drafts.iter().any(|draft| !draft.visual_ids.is_empty()));
|
|
|
|
|
for (index, draft) in drafts.iter().enumerate() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
draft.original_id,
|
|
|
|
|
u32::try_from(index).ok().map(OriginalObjectId)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(draft.visual_ids, assets.visuals_for_object(index));
|
2026-07-18 20:15:26 +04:00
|
|
|
assert_eq!(draft.unit_components, graph.root_unit_components[index]);
|
2026-07-03 21:44:26 +04:00
|
|
|
assert!(draft.position.iter().all(|component| component.is_finite()));
|
|
|
|
|
assert!(draft
|
|
|
|
|
.orientation_raw
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|component| component.is_finite()));
|
|
|
|
|
assert!(draft.scale.iter().all(|component| component.is_finite()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-10-11 13:12:50 +04:00
|
|
|
fn licensed_corpus_part1_loads_all_mission_foundations() {
|
2026-06-22 17:29:33 +04:00
|
|
|
let part1 = load_all(&licensed_root("IS"));
|
2026-06-22 13:12:27 +04:00
|
|
|
assert_eq!(part1.missions, 29);
|
|
|
|
|
assert_eq!(part1.paths, 34);
|
|
|
|
|
assert_eq!(part1.clans, 101);
|
|
|
|
|
assert_eq!(part1.objects, 864);
|
|
|
|
|
assert_eq!(part1.extras, 28);
|
|
|
|
|
assert_eq!(part1.unit_references, 463);
|
|
|
|
|
assert_eq!(part1.direct_references, 401);
|
|
|
|
|
assert_eq!(part1.unit_components, 4_300);
|
|
|
|
|
assert_eq!(part1.prototype_requests, 4_701);
|
|
|
|
|
assert_eq!(part1.material_slots, 36_954);
|
|
|
|
|
assert_eq!(part1.texture_requests, 48_806);
|
|
|
|
|
assert_eq!(part1.lightmap_requests, 139);
|
|
|
|
|
assert_eq!(part1.graph_failures, 0);
|
|
|
|
|
assert_eq!(part1.wear_requests, part1.prototype_requests);
|
|
|
|
|
assert_eq!(part1.wear_requests, part1.wear_resolved);
|
|
|
|
|
assert_eq!(part1.material_slots, part1.material_resolved);
|
|
|
|
|
assert_eq!(part1.texture_requests, part1.texture_resolved);
|
|
|
|
|
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
|
2026-10-11 13:12:50 +04:00
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
#[test]
|
|
|
|
|
#[ignore = "requires licensed corpus"]
|
|
|
|
|
fn licensed_corpus_part2_loads_all_mission_foundations() {
|
2026-06-22 17:29:33 +04:00
|
|
|
let part2 = load_all(&licensed_root("IS2"));
|
2026-06-22 13:12:27 +04:00
|
|
|
assert_eq!(part2.missions, 31);
|
|
|
|
|
assert_eq!(part2.paths, 61);
|
|
|
|
|
assert_eq!(part2.clans, 91);
|
|
|
|
|
assert_eq!(part2.objects, 885);
|
|
|
|
|
assert_eq!(part2.extras, 41);
|
|
|
|
|
assert_eq!(part2.unit_references, 561);
|
|
|
|
|
assert_eq!(part2.direct_references, 324);
|
|
|
|
|
assert_eq!(part2.unit_components, 5_521);
|
|
|
|
|
assert_eq!(part2.prototype_requests, 5_845);
|
|
|
|
|
assert_eq!(part2.material_slots, 50_888);
|
|
|
|
|
assert_eq!(part2.texture_requests, 68_603);
|
|
|
|
|
assert_eq!(part2.lightmap_requests, 214);
|
|
|
|
|
assert_eq!(part2.graph_failures, 0);
|
|
|
|
|
assert_eq!(part2.wear_requests, part2.prototype_requests);
|
|
|
|
|
assert_eq!(part2.wear_requests, part2.wear_resolved);
|
|
|
|
|
assert_eq!(part2.material_slots, part2.material_resolved);
|
|
|
|
|
assert_eq!(part2.texture_requests, part2.texture_resolved);
|
|
|
|
|
assert_eq!(part2.lightmap_requests, part2.lightmap_resolved);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
struct LoadTotals {
|
|
|
|
|
missions: usize,
|
|
|
|
|
paths: usize,
|
|
|
|
|
clans: usize,
|
|
|
|
|
objects: usize,
|
|
|
|
|
extras: usize,
|
|
|
|
|
unit_references: usize,
|
|
|
|
|
direct_references: usize,
|
|
|
|
|
unit_components: usize,
|
|
|
|
|
prototype_requests: usize,
|
|
|
|
|
wear_requests: usize,
|
|
|
|
|
wear_resolved: usize,
|
|
|
|
|
material_slots: usize,
|
|
|
|
|
material_resolved: usize,
|
|
|
|
|
texture_requests: usize,
|
|
|
|
|
texture_resolved: usize,
|
|
|
|
|
lightmap_requests: usize,
|
|
|
|
|
lightmap_resolved: usize,
|
|
|
|
|
graph_failures: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
struct HeadlessCase {
|
|
|
|
|
root: &'static str,
|
|
|
|
|
mission: &'static str,
|
|
|
|
|
object_count: usize,
|
|
|
|
|
expected_hash: [u8; 32],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_headless_case(case: HeadlessCase) -> WorldSnapshot {
|
2026-06-22 17:29:33 +04:00
|
|
|
let root = licensed_root(case.root);
|
2026-06-22 13:12:27 +04:00
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
2026-09-06 05:22:12 +04:00
|
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
2026-06-22 13:12:27 +04:00
|
|
|
let loaded = load_mission(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: case.mission.to_string(),
|
2026-10-20 03:20:20 +04:00
|
|
|
mode: MissionMode::SingleMission,
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
2026-10-20 05:15:11 +04:00
|
|
|
0,
|
2026-06-22 13:12:27 +04:00
|
|
|
)
|
|
|
|
|
.expect("load selected mission");
|
|
|
|
|
assert_eq!(loaded.object_count, case.object_count);
|
|
|
|
|
|
|
|
|
|
let mut snapshot = None;
|
|
|
|
|
for _ in 0..10_000 {
|
|
|
|
|
snapshot = Some(
|
|
|
|
|
step_headless(&mut engine, InputSnapshot)
|
|
|
|
|
.expect("selected mission deterministic tick")
|
|
|
|
|
.snapshot,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
snapshot.expect("at least one tick")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_all(root: &Path) -> LoadTotals {
|
|
|
|
|
assert!(root.is_dir(), "missing licensed corpus {}", root.display());
|
|
|
|
|
let mut missions = mission_paths(root);
|
|
|
|
|
missions.sort();
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
|
|
|
|
let mut totals = LoadTotals::default();
|
|
|
|
|
for mission in missions {
|
2026-09-06 05:22:12 +04:00
|
|
|
let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
|
2026-10-20 03:20:20 +04:00
|
|
|
let loaded = load_mission(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: mission,
|
|
|
|
|
mode: MissionMode::SingleMission,
|
|
|
|
|
},
|
2026-10-20 05:15:11 +04:00
|
|
|
0,
|
2026-10-20 03:20:20 +04:00
|
|
|
)
|
|
|
|
|
.expect("load mission foundation");
|
2026-06-22 13:12:27 +04:00
|
|
|
assert_eq!(loaded.object_count, loaded.registered_objects);
|
|
|
|
|
assert_eq!(loaded.object_count, loaded.graph_root_count);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_direct_reference_count + loaded.graph_unit_component_count,
|
|
|
|
|
loaded.graph_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.graph_failure_count, 0);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_wear_request_count,
|
|
|
|
|
loaded.graph_wear_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_material_slot_count,
|
|
|
|
|
loaded.graph_material_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_texture_request_count,
|
|
|
|
|
loaded.graph_texture_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_lightmap_request_count,
|
|
|
|
|
loaded.graph_lightmap_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.build_category_count, 12);
|
|
|
|
|
assert!(loaded.areal_count > 0);
|
|
|
|
|
assert!(loaded.surface_count > 0);
|
|
|
|
|
totals.missions += 1;
|
|
|
|
|
totals.paths += loaded.path_count;
|
|
|
|
|
totals.clans += loaded.clan_count;
|
|
|
|
|
totals.objects += loaded.object_count;
|
|
|
|
|
totals.extras += loaded.extra_count;
|
|
|
|
|
totals.unit_references += loaded.graph_unit_reference_count;
|
|
|
|
|
totals.direct_references += loaded.graph_direct_reference_count;
|
|
|
|
|
totals.unit_components += loaded.graph_unit_component_count;
|
|
|
|
|
totals.prototype_requests += loaded.graph_resolved_count;
|
|
|
|
|
totals.wear_requests += loaded.graph_wear_request_count;
|
|
|
|
|
totals.wear_resolved += loaded.graph_wear_resolved_count;
|
|
|
|
|
totals.material_slots += loaded.graph_material_slot_count;
|
|
|
|
|
totals.material_resolved += loaded.graph_material_resolved_count;
|
|
|
|
|
totals.texture_requests += loaded.graph_texture_request_count;
|
|
|
|
|
totals.texture_resolved += loaded.graph_texture_resolved_count;
|
|
|
|
|
totals.lightmap_requests += loaded.graph_lightmap_request_count;
|
|
|
|
|
totals.lightmap_resolved += loaded.graph_lightmap_resolved_count;
|
|
|
|
|
totals.graph_failures += loaded.graph_failure_count;
|
|
|
|
|
}
|
|
|
|
|
totals
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn mission_paths(root: &Path) -> Vec<String> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
collect_missions(root, root, &mut out);
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_missions(root: &Path, dir: &Path, out: &mut Vec<String>) {
|
|
|
|
|
let mut children: Vec<PathBuf> = std::fs::read_dir(dir)
|
|
|
|
|
.expect("read dir")
|
|
|
|
|
.map(|entry| entry.expect("entry").path())
|
|
|
|
|
.collect();
|
|
|
|
|
children.sort();
|
|
|
|
|
for child in children {
|
|
|
|
|
if child.is_dir() {
|
|
|
|
|
collect_missions(root, &child, out);
|
|
|
|
|
} else if child
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("data.tma"))
|
|
|
|
|
{
|
|
|
|
|
let rel = child.strip_prefix(root).expect("relative");
|
|
|
|
|
let rel = rel.to_str().expect("utf8 path").replace('\\', "/");
|
|
|
|
|
out.push(rel);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 17:29:33 +04:00
|
|
|
fn licensed_root(name: &str) -> PathBuf {
|
|
|
|
|
let variable = match name {
|
|
|
|
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
|
|
|
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
|
|
|
|
_ => panic!("unknown licensed corpus part: {name}"),
|
|
|
|
|
};
|
|
|
|
|
let root = std::env::var_os(variable)
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
|
|
|
|
assert!(
|
|
|
|
|
root.is_dir(),
|
|
|
|
|
"licensed corpus root is missing: {}",
|
|
|
|
|
root.display()
|
|
|
|
|
);
|
|
|
|
|
root
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:44:26 +04:00
|
|
|
fn local_testdata_root(name: &str) -> PathBuf {
|
|
|
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
|
|
|
.join("../../testdata")
|
|
|
|
|
.join(name);
|
|
|
|
|
assert!(
|
|
|
|
|
root.is_dir(),
|
|
|
|
|
"local testdata root is missing: {}",
|
|
|
|
|
root.display()
|
|
|
|
|
);
|
|
|
|
|
root
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 05:15:11 +04:00
|
|
|
fn encode_script_package(package: &ScriptPackage) -> Vec<u8> {
|
|
|
|
|
fn push_u32(bytes: &mut Vec<u8>, value: u32) {
|
|
|
|
|
bytes.extend_from_slice(&value.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut bytes = Vec::new();
|
|
|
|
|
push_u32(&mut bytes, package.opcode_handler_count);
|
|
|
|
|
push_u32(
|
|
|
|
|
&mut bytes,
|
|
|
|
|
u32::try_from(package.events.len()).expect("event count fits u32"),
|
|
|
|
|
);
|
|
|
|
|
for event in &package.events {
|
|
|
|
|
push_u32(&mut bytes, event.name_len);
|
|
|
|
|
bytes.extend_from_slice(&event.name_raw);
|
|
|
|
|
push_u32(&mut bytes, event.event_word);
|
|
|
|
|
push_u32(
|
|
|
|
|
&mut bytes,
|
|
|
|
|
u32::try_from(event.instructions.len()).expect("instruction count fits u32"),
|
|
|
|
|
);
|
|
|
|
|
for instruction in &event.instructions {
|
|
|
|
|
for word in &instruction.header_words[..6] {
|
|
|
|
|
push_u32(&mut bytes, *word);
|
|
|
|
|
}
|
|
|
|
|
for reference in &instruction.references {
|
|
|
|
|
push_u32(&mut bytes, *reference);
|
|
|
|
|
}
|
|
|
|
|
push_u32(&mut bytes, instruction.header_words[6]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
bytes.extend_from_slice(&package.trailing_bytes);
|
|
|
|
|
bytes
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
enum DenyRule {
|
|
|
|
|
Suffix(&'static str),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct DenyVfs {
|
|
|
|
|
inner: DirectoryVfs,
|
|
|
|
|
denied: DenyRule,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 05:15:11 +04:00
|
|
|
struct SwappingScriptVfs {
|
|
|
|
|
inner: DirectoryVfs,
|
|
|
|
|
replacement: Arc<[u8]>,
|
|
|
|
|
enabled: std::sync::atomic::AtomicBool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SwappingScriptVfs {
|
|
|
|
|
fn use_replacement(&self, path: &NormalizedPath) -> bool {
|
|
|
|
|
self.enabled.load(std::sync::atomic::Ordering::SeqCst)
|
|
|
|
|
&& path
|
|
|
|
|
.as_str()
|
|
|
|
|
.eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Vfs for SwappingScriptVfs {
|
|
|
|
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
|
|
|
|
if self.use_replacement(path) {
|
|
|
|
|
return Ok(VfsMetadata {
|
|
|
|
|
len: self.replacement.len() as u64,
|
|
|
|
|
fingerprint: [0x18; 32],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
self.inner.metadata(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
|
|
|
|
if self.use_replacement(path) {
|
|
|
|
|
return Ok(self.replacement.clone());
|
|
|
|
|
}
|
|
|
|
|
self.inner.read(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
|
|
|
|
self.inner.list(prefix)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
impl DenyVfs {
|
|
|
|
|
fn denied(&self, path: &NormalizedPath) -> bool {
|
|
|
|
|
match self.denied {
|
|
|
|
|
DenyRule::Suffix(suffix) => path
|
|
|
|
|
.as_str()
|
|
|
|
|
.to_ascii_uppercase()
|
|
|
|
|
.ends_with(&suffix.to_ascii_uppercase()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Vfs for DenyVfs {
|
|
|
|
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
|
|
|
|
if self.denied(path) {
|
|
|
|
|
return Err(VfsError::NotFound(path.as_str().to_string()));
|
|
|
|
|
}
|
|
|
|
|
self.inner.metadata(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
|
|
|
|
if self.denied(path) {
|
|
|
|
|
return Err(VfsError::NotFound(path.as_str().to_string()));
|
|
|
|
|
}
|
|
|
|
|
self.inner.read(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
|
|
|
|
self.inner.list(prefix).map(|entries| {
|
|
|
|
|
entries
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|entry| !self.denied(&entry.path))
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|