8724 lines
322 KiB
Rust
8724 lines
322 KiB
Rust
#![forbid(unsafe_code)]
|
|
//! Runtime orchestration for mission loading and world stepping.
|
|
|
|
#[cfg(test)]
|
|
use fparkan_assets::PreparedControlResourcePair;
|
|
use fparkan_assets::{
|
|
decode_mission_land_path, decode_mission_payload, decode_nres_payload,
|
|
derive_mission_land_paths, extend_graph_report_with_visual_dependencies,
|
|
mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError,
|
|
AssetId, AssetManager, BehaviorProfileRates, BehaviorProfileVarError, BuildCategory,
|
|
MissionAssetPlan, MissionDocument, MissionError, MissionTerrainPaths, NresError,
|
|
PreparedComponentControl, PreparedControlEffectSection, PreparedControlNodeDataRow,
|
|
PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedVisual, TerrainFormatError,
|
|
TerrainPreparationError, TerrainWorld, TmaProfile,
|
|
};
|
|
use fparkan_msh::{node38_metadata, selected_slot, Group, Lod, NodeId};
|
|
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
|
|
use fparkan_prototype::{
|
|
build_prototype_graph_report, unit_component_tree, PrototypeGraph, PrototypeGraphFailure,
|
|
PrototypeGraphReport, UnitComponentRecord, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO,
|
|
UNIT_DAT_BAS_PLACEMENT_FLAG,
|
|
};
|
|
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceKey, ResourceRepository};
|
|
use fparkan_script::{
|
|
Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
|
|
ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand,
|
|
};
|
|
use fparkan_terrain::SurfaceQuery;
|
|
use fparkan_vfs::{Vfs, VfsError};
|
|
use fparkan_world::{
|
|
construct_object, handle_by_original_id, new as new_world, register_object, set_transform,
|
|
step, transform_state, InputSnapshot, ObjectDraft, ObjectHandle, OriginalObjectId,
|
|
TransformState, World, WorldConfig, WorldSnapshot,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::num::NonZeroUsize;
|
|
use std::sync::Arc;
|
|
|
|
pub use fparkan_assets::MissionAssets;
|
|
|
|
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
|
|
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
|
|
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
|
|
|
|
/// 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) }
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// Mission request.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct MissionRequest {
|
|
/// Mission key/path.
|
|
pub key: String,
|
|
/// Explicit host mode; this is not inferred from the mission path.
|
|
pub mode: MissionMode,
|
|
}
|
|
|
|
const INITIAL_SCRIPT_HOST_STATUS: u32 = 5;
|
|
|
|
/// 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>,
|
|
/// Raw word immediately after the mission resource reference. The native
|
|
/// runtime uses it as an AI-selection index.
|
|
pub raw_after_resource: u32,
|
|
/// Raw identity/clan word from the mission.
|
|
pub identity_or_clan_raw: u32,
|
|
/// 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>,
|
|
/// 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>,
|
|
/// 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>,
|
|
}
|
|
|
|
/// A mission object registered with one clan's native Behavior distributor.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
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,
|
|
/// Native share field at record offset `+0x04`.
|
|
pub share: f32,
|
|
/// Raw DWORDs at offsets `+0x08..+0x18`: fixed-share flag, power demand,
|
|
/// power allocation, ore demand, and ore allocation. A native Add zeros them.
|
|
pub native_tail: [u32; 5],
|
|
}
|
|
|
|
/// Native `CurrentOre`/`MaximumOre` state for one full Unit DAT mission object.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct BehaviorOreState {
|
|
/// Stable identity from the source TMA object order.
|
|
pub original_id: OriginalObjectId,
|
|
/// Corresponding live Rust world object.
|
|
pub handle: ObjectHandle,
|
|
/// Class word recovered from the root Unit DAT header.
|
|
pub class_flags: u32,
|
|
/// Raw bits of the `CurrentOre` float property.
|
|
pub current_ore_bits: u32,
|
|
/// Raw bits of the read-only `MaximumOre` float property.
|
|
pub maximum_ore_bits: u32,
|
|
}
|
|
|
|
/// Initial Behavior rates loaded for one full Unit DAT mission object.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct BehaviorProfileState {
|
|
/// Stable identity from the source TMA object order.
|
|
pub original_id: OriginalObjectId,
|
|
/// Corresponding live Rust world object.
|
|
pub handle: ObjectHandle,
|
|
/// Class word recovered from the root Unit DAT header.
|
|
pub class_flags: u32,
|
|
/// Profile selected by the native class mapper; unknown classes use defaults.
|
|
pub profile_entry: Option<&'static str>,
|
|
/// Raw bits of `Transfer_Ore_OffBoard` after applying the profile.
|
|
pub transfer_ore_off_board_bits: u32,
|
|
/// Raw bits of `Transfer_Power_Out` after applying the profile.
|
|
pub transfer_power_out_bits: u32,
|
|
}
|
|
|
|
/// Origin of a Control descriptor retained in the effective table projection.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ControlDeviceRecordProvenance {
|
|
/// Unit DAT component index that supplied the descriptor.
|
|
pub unit_component_index: usize,
|
|
/// Zero-based record index in that component's CTLD typed-record list.
|
|
pub typed_record_index: usize,
|
|
/// CTLD resource that supplied the descriptor.
|
|
pub source: ResourceKey,
|
|
}
|
|
|
|
/// Common fields known for one row in a host object's Control device table.
|
|
///
|
|
/// The row index is its position in the containing `Vec`. This stores the
|
|
/// confirmed base fields and latest raw descriptor, not tag-specific callbacks.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct EffectiveControlDevice {
|
|
/// Factory tag of the original device instance; INTO does not replace it.
|
|
pub factory_tag: u32,
|
|
/// Native component ID that first appended this row.
|
|
pub component_id: u32,
|
|
/// Current common key field. INTO preserves the receiver's key.
|
|
pub key: u32,
|
|
/// Sixteen raw DWORD coefficients copied by the common setter.
|
|
pub coefficient_bits: Option<[u32; 16]>,
|
|
/// Multiplier bits when the factory default is confirmed.
|
|
pub multiplier_bits: Option<u32>,
|
|
/// Class-9-only scale used by its selector-0 getter.
|
|
pub class9_selector0_scale_bits: Option<u32>,
|
|
/// Current native word at `+0x50` when it is known.
|
|
pub native_word_50_bits: Option<u32>,
|
|
/// Current raw descriptor, including typed values and string bytes.
|
|
pub descriptor: PreparedControlTypedRecord,
|
|
/// Source location that created the original factory instance.
|
|
pub factory_provenance: ControlDeviceRecordProvenance,
|
|
/// Source location of the descriptor currently applied to the row.
|
|
pub descriptor_provenance: ControlDeviceRecordProvenance,
|
|
}
|
|
|
|
/// Initial Control metadata projected from one node in AniMesh's global table.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct MissionControlNodeInitialMeshState {
|
|
/// Parent index in AniMesh's concatenated runtime-node table.
|
|
pub parent_index: i32,
|
|
/// Node38 header bits translated to the Control row's `+0x28` flags.
|
|
pub flags: u32,
|
|
/// Effective state count; an empty native state prefix is exposed as one.
|
|
pub stage_count: u32,
|
|
}
|
|
|
|
/// Initial row in the loaded global Control node-data provider.
|
|
///
|
|
/// The native table is indexed independently of MSH model nodes. Its rows are
|
|
/// appended in root/EXTO resource order and begin with a type-1 value of 1.0.
|
|
/// Mesh metadata uses the global row ordinal; `None` records that the matching
|
|
/// MSH projection was unavailable and does not affect provider queries.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct MissionControlNodeProviderRow {
|
|
/// NDP resource that contributed the row.
|
|
pub source: ResourceKey,
|
|
/// Unit DAT component that contributed the row.
|
|
pub unit_component_index: usize,
|
|
/// Index in that component's original NDP table.
|
|
pub source_row_index: usize,
|
|
/// Original NDP descriptor fields and provenance for this provider row.
|
|
pub source_row: PreparedControlNodeDataRow,
|
|
/// Shared initial provider amount (`+0x08` and `+0x0c`) as raw bits.
|
|
///
|
|
/// Control initializes both fields from the selector-3 result. On the
|
|
/// confirmed UnitDAT load path, the two scale factors are 1.0, so this is
|
|
/// the NDP row's `max_volume`; later mutations can change both fields.
|
|
pub initial_amount_bits: u32,
|
|
/// Initial native provider selector-1 value (`+0x14`) as raw bits.
|
|
pub initial_type1_value_bits: u32,
|
|
/// MSH state exposed at the same global ordinal, if the mesh projection exists.
|
|
pub initial_mesh_state: Option<MissionControlNodeInitialMeshState>,
|
|
}
|
|
|
|
/// Availability of the initial global Control node-data provider projection.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum MissionControlNodeProviderTableStatus {
|
|
/// Rows in native append order, without padding to the MSH node count.
|
|
Available(Vec<MissionControlNodeProviderRow>),
|
|
/// Provider lookup is not modeled for this object's available inputs.
|
|
Unavailable(MissionControlNodeProviderTableUnavailableReason),
|
|
}
|
|
|
|
/// Why the global Control node-data provider could not be projected.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum MissionControlNodeProviderTableUnavailableReason {
|
|
/// A root or mounted EXTO Control resource has no resolved NDP sidecar.
|
|
MissingNodeData {
|
|
/// Unit DAT component needing the provider rows.
|
|
unit_component_index: usize,
|
|
/// Explicitly requested sidecar when the reference was known.
|
|
source: Option<ResourceKey>,
|
|
},
|
|
/// A mounted EXTO needs a first NDP row for the native skip-first path.
|
|
MissingExToRootRow { unit_component_index: usize },
|
|
/// The Control component traversal itself was incomplete.
|
|
DeviceTableUnavailable(MissionControlDeviceTableUnavailableReason),
|
|
}
|
|
|
|
/// Why a loaded Control resource query or write could not be represented.
|
|
#[derive(Debug, Eq, PartialEq)]
|
|
pub enum ControlResourceAccessError {
|
|
/// The requested world object handle is invalid, stale, or deleted.
|
|
World(fparkan_world::WorldError),
|
|
/// No mission is currently loaded.
|
|
MissionUnavailable,
|
|
/// The live object is not part of the loaded mission projection.
|
|
ObjectUnavailable { handle: ObjectHandle },
|
|
/// The object's Control table could not be completely projected.
|
|
TableUnavailable(MissionControlDeviceTableUnavailableReason),
|
|
/// The requested resource selector is not implemented for this operation.
|
|
UnsupportedSelector(u32),
|
|
/// A selected row has a factory class whose getter has not been checked.
|
|
UnsupportedFactoryTag { factory_tag: u32 },
|
|
/// A device index does not exist in the object's native Control table.
|
|
DeviceIndexOutOfRange {
|
|
/// Requested global Control device index.
|
|
device_index: usize,
|
|
/// Projected device count.
|
|
device_count: usize,
|
|
},
|
|
/// The node provider table could not be projected.
|
|
NodeProviderTableUnavailable(MissionControlNodeProviderTableUnavailableReason),
|
|
/// A device's native provider key exceeds the actual appended NDP rows.
|
|
NodeProviderKeyOutOfRange {
|
|
/// Current native device key, used directly as a provider row index.
|
|
key: u32,
|
|
/// Actual global provider row count.
|
|
row_count: usize,
|
|
},
|
|
/// A native tag has an e00 callback that has not been implemented here.
|
|
UnsupportedCurrentTag { tag: u32 },
|
|
/// A selected row lacks a value needed by the native getter or setter.
|
|
UnsupportedField {
|
|
factory_tag: u32,
|
|
field: &'static str,
|
|
},
|
|
/// A native float operand or intermediate is not finite.
|
|
NonFiniteValue {
|
|
factory_tag: u32,
|
|
field: &'static str,
|
|
},
|
|
/// A native float operand is subnormal and is outside the checked domain.
|
|
SubnormalValue {
|
|
factory_tag: u32,
|
|
field: &'static str,
|
|
},
|
|
/// A range probe places a nonzero arithmetic result outside normal f32 range.
|
|
UnsupportedFloatRange {
|
|
factory_tag: Option<u32>,
|
|
field: &'static str,
|
|
},
|
|
}
|
|
|
|
/// Why the base Control device table could not be projected for an object.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum MissionControlDeviceTableUnavailableReason {
|
|
/// The mission root has no decoded Unit DAT component tree.
|
|
NoUnitDatTree,
|
|
/// Unit DAT child counts do not form a valid preorder tree.
|
|
InvalidUnitComponentTree,
|
|
/// Prototype, visual, and component data are not aligned.
|
|
ComponentAlignmentMismatch,
|
|
/// No component explicitly references a Control resource.
|
|
NoControlResource,
|
|
/// An explicit Control resource could not be prepared.
|
|
ControlResourceUnavailable { unit_component_index: usize },
|
|
/// A Control resource belongs to a non-geometric prototype.
|
|
NonGeometricControlResource { unit_component_index: usize },
|
|
/// The parser stopped before the component's typed record list completed.
|
|
IncompleteTypedRecordList {
|
|
unit_component_index: usize,
|
|
first_unparsed_record: usize,
|
|
},
|
|
/// An INTO descriptor has no first typed record for its one native override call.
|
|
MissingOverrideRecord { unit_component_index: usize },
|
|
/// Native wrapping produced a non-sentinel index outside the current table.
|
|
OverrideIndexOutOfRange {
|
|
unit_component_index: usize,
|
|
target_index: u32,
|
|
},
|
|
/// The receiver tag does not have a verified common override method.
|
|
UnsupportedOverrideReceiver { factory_tag: u32 },
|
|
/// The EXTO key needs a global model-node mapping that is not available.
|
|
UnsupportedExToNodeMapping { unit_component_index: usize },
|
|
/// An unknown component kind prevents calculating EXTO global node offsets.
|
|
UnknownComponentKindForExTo {
|
|
unit_component_index: usize,
|
|
source_type: Option<u32>,
|
|
},
|
|
}
|
|
|
|
/// Availability of the confirmed common-field Control table projection.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum MissionControlDeviceTableStatus {
|
|
/// Rows in native global-table order.
|
|
Available(Vec<EffectiveControlDevice>),
|
|
/// No complete table is exposed when required source state is unsupported.
|
|
Unavailable(MissionControlDeviceTableUnavailableReason),
|
|
}
|
|
|
|
/// Control device table projection attached to one loaded mission object.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct MissionControlDeviceTableState {
|
|
/// Stable identity from the source TMA object order.
|
|
pub original_id: Option<OriginalObjectId>,
|
|
/// Corresponding live Rust world object.
|
|
pub handle: ObjectHandle,
|
|
/// Complete base projection, or an explicit unsupported boundary.
|
|
pub status: MissionControlDeviceTableStatus,
|
|
/// Initial global NDP provider rows used by indexed Control selectors.
|
|
pub node_provider_status: MissionControlNodeProviderTableStatus,
|
|
}
|
|
|
|
struct ControlDeviceProjection {
|
|
devices: Vec<EffectiveControlDevice>,
|
|
node_provider_status: MissionControlNodeProviderTableStatus,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct PreparedBehaviorProfileState {
|
|
original_id: OriginalObjectId,
|
|
class_flags: u32,
|
|
profile_entry: Option<&'static str>,
|
|
transfer_ore_off_board_bits: u32,
|
|
transfer_power_out_bits: u32,
|
|
}
|
|
|
|
/// Resource distributor state associated with one TMA clan's constructed AI.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
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;
|
|
#[cfg(test)]
|
|
const PROTOTYPE_TYPE_INTO: u32 = u32::from_le_bytes(*b"INTO");
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
/// Losslessly decoded compiled package; only proven `Handler(19)` Init
|
|
/// writes are resolved and retained by runtime.
|
|
pub package: ScriptPackage,
|
|
/// Outcome of the restricted first-`Init` resolution for this bundle.
|
|
pub init_execution: MissionScriptInitExecution,
|
|
/// Recovered per-AI Behavior scheduler and total state.
|
|
/// Static previews leave this empty because they do not run script startup.
|
|
pub behavior_warm_start: Option<BehaviorWarmStartState>,
|
|
}
|
|
|
|
/// Behavior distributor clock and totals tracked from warm start through resource ticks.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct BehaviorWarmStartState {
|
|
/// Native offset `+0x10`: startup queue time, later replaced by the last due tick time.
|
|
pub first_time_ms: u32,
|
|
/// Native offset `+0x14`: current deadline, rescheduled after each due tick.
|
|
pub next_deadline_ms: u32,
|
|
/// Native totals at offsets `+0x30..=+0x40`: zeroed at startup, then power
|
|
/// available, ore available, ore in buffer, power usage, and ore usage.
|
|
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],
|
|
}
|
|
}
|
|
}
|
|
|
|
fn behavior_elapsed_seconds(elapsed_ms: u32) -> f32 {
|
|
// The exact integer product is rounded once to f32; scaling by 2^-33 is
|
|
// exact and matches native FILD / FMUL / FSTP under the measured control word.
|
|
((u64::from(elapsed_ms) * 0x0083_126f) as f32) * f32::from_bits(0x2f00_0000)
|
|
}
|
|
|
|
fn schedule_behavior_distributor_tick(
|
|
state: &mut BehaviorWarmStartState,
|
|
queue_time_ms: u32,
|
|
rng_state: &mut u32,
|
|
) -> Option<f32> {
|
|
if state.first_time_ms == 0 {
|
|
let initialized = BehaviorWarmStartState::start(queue_time_ms, rng_state);
|
|
state.first_time_ms = initialized.first_time_ms;
|
|
state.next_deadline_ms = initialized.next_deadline_ms;
|
|
state.cleared_words = [0; 5];
|
|
return None;
|
|
}
|
|
if state.next_deadline_ms != 0 && queue_time_ms <= state.next_deadline_ms {
|
|
return None;
|
|
}
|
|
|
|
let elapsed_ms = queue_time_ms.wrapping_sub(state.first_time_ms);
|
|
let next = BehaviorWarmStartState::start(queue_time_ms, rng_state);
|
|
state.first_time_ms = next.first_time_ms;
|
|
state.next_deadline_ms = next.next_deadline_ms;
|
|
Some(behavior_elapsed_seconds(elapsed_ms))
|
|
}
|
|
|
|
/// 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
|
|
/// and supplies the typed targets for the proven `Handler(19)` Init writes.
|
|
#[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,
|
|
}
|
|
|
|
/// 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,
|
|
/// Zero-based first case-sensitive C-string `Init` event selected by native lookup.
|
|
pub event_index: usize,
|
|
/// 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>,
|
|
}
|
|
|
|
/// 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,
|
|
},
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
/// Rust-side outcome selected by the recovered `Iron3D` host callback.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub enum MissionScriptOutcome {
|
|
/// No host callback outcome has been recorded; this does not mean that the
|
|
/// native `IGame` has entered its running state.
|
|
#[default]
|
|
Active,
|
|
/// Rust outcome corresponding to completion payload `1`.
|
|
Completed,
|
|
/// Rust outcome corresponding to failure payload `0`.
|
|
Failed,
|
|
}
|
|
|
|
/// Result of applying one recovered script host callback command.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum MissionScriptHostEffect {
|
|
/// The input did not match a currently modeled command.
|
|
Unhandled,
|
|
/// The Rust outcome changed to completed.
|
|
MissionCompleted,
|
|
/// The Rust outcome changed to failed.
|
|
MissionFailed,
|
|
/// The native status guard rejected a repeated callback without changing state.
|
|
NoChange,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
struct MissionLoadOptions {
|
|
fail_after_registered_objects: Option<usize>,
|
|
asset_scope: MissionAssetScope,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
struct LoadedMissionScripts {
|
|
bundles: Vec<MissionScriptBundle>,
|
|
varset: Option<MissionScriptVarSet>,
|
|
init_states: Vec<MissionScriptInitState>,
|
|
varset_states: Vec<MissionScriptVarSetState>,
|
|
}
|
|
|
|
/// 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,
|
|
/// Prepare only the first mission root for a static preview.
|
|
FirstMeshPreview,
|
|
/// Prepare a non-zero prefix of mission roots for a static preview.
|
|
PreviewRoots(NonZeroUsize),
|
|
}
|
|
|
|
/// 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,
|
|
/// 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,
|
|
/// Mission asset plan visual count after dependency preparation.
|
|
pub asset_visual_count: usize,
|
|
/// 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,
|
|
/// 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,
|
|
/// Script bundles selected by mission clans.
|
|
pub script_bundle_count: usize,
|
|
/// Total named events in loaded script packages.
|
|
pub script_event_count: usize,
|
|
/// Numeric script defaults loaded through the original bundle/fallback rule.
|
|
pub script_varset_declaration_count: usize,
|
|
/// `Handler(19)` Init results resolved for mission clans.
|
|
pub script_init_state_count: usize,
|
|
/// Instantiated per-clan varsets whose Init writes have been applied.
|
|
pub script_varset_state_count: usize,
|
|
}
|
|
|
|
/// 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>,
|
|
behavior_rng_state: u32,
|
|
}
|
|
|
|
struct LoadedMissionState {
|
|
summary: LoadedMission,
|
|
mission: MissionDocument,
|
|
terrain: TerrainWorld,
|
|
build_categories: Vec<BuildCategory>,
|
|
prototype_graph: PrototypeGraph,
|
|
prototype_report: PrototypeGraphReport,
|
|
mission_assets: MissionAssets,
|
|
asset_plan: MissionAssetPlan,
|
|
object_drafts: Vec<MissionObjectDraft>,
|
|
behavior_distributors: Vec<BehaviorDistributorState>,
|
|
behavior_ore_states: Vec<BehaviorOreState>,
|
|
behavior_profiles: Vec<BehaviorProfileState>,
|
|
control_device_tables: Vec<MissionControlDeviceTableState>,
|
|
script_bundles: Vec<MissionScriptBundle>,
|
|
script_varset: Option<MissionScriptVarSet>,
|
|
script_init_states: Vec<MissionScriptInitState>,
|
|
script_varset_states: Vec<MissionScriptVarSetState>,
|
|
script_host_mode: MissionMode,
|
|
script_host_status: u32,
|
|
script_outcome: MissionScriptOutcome,
|
|
}
|
|
|
|
/// 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,
|
|
},
|
|
/// Compiled script package could not be decoded.
|
|
Script {
|
|
/// Script path.
|
|
path: String,
|
|
/// Decode diagnostic.
|
|
message: String,
|
|
},
|
|
/// Script defaults could not be decoded.
|
|
VarSet {
|
|
/// Script-default path.
|
|
path: String,
|
|
/// Parse diagnostic.
|
|
message: String,
|
|
},
|
|
/// A recovered `Handler(19)` Init input or target was not representable.
|
|
ScriptInit {
|
|
/// Owning TMA clan index.
|
|
clan_index: usize,
|
|
/// Proven-contract diagnostic.
|
|
message: String,
|
|
},
|
|
/// 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,
|
|
},
|
|
/// A recovered script handler could not resolve against runtime state.
|
|
ScriptRuntime {
|
|
/// Owning TMA clan index.
|
|
clan_index: usize,
|
|
/// Handler-specific diagnostic.
|
|
message: String,
|
|
},
|
|
/// `NRes` decode error.
|
|
Nres {
|
|
/// Resource path.
|
|
path: String,
|
|
/// Source error.
|
|
source: NresError,
|
|
},
|
|
/// 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.
|
|
Terrain(fparkan_assets::TerrainError),
|
|
/// Prototype graph errors.
|
|
PrototypeGraph {
|
|
/// Root failures.
|
|
failures: Vec<PrototypeGraphFailure>,
|
|
},
|
|
/// Asset preparation errors.
|
|
AssetPreparation {
|
|
/// Mission key.
|
|
mission: String,
|
|
/// Source error.
|
|
source: AssetPreparationError,
|
|
},
|
|
/// A native Behavior profile could not be found or decoded.
|
|
BehaviorProfile {
|
|
/// Behavior profile archive path.
|
|
path: String,
|
|
/// Selected `.var` entry name.
|
|
entry: String,
|
|
/// Entry, field, or format diagnostic.
|
|
message: String,
|
|
},
|
|
/// A Behavior resource tick could not resolve a required runtime value.
|
|
BehaviorDistributorTick {
|
|
/// TMA clan whose distributor could not complete its tick.
|
|
clan_index: usize,
|
|
/// Unsupported query, property, or arithmetic detail.
|
|
message: String,
|
|
},
|
|
/// World error.
|
|
World(fparkan_world::WorldError),
|
|
/// Reference movement input or terrain query failed.
|
|
Movement(String),
|
|
/// 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,
|
|
},
|
|
}
|
|
|
|
/// Advances one object toward an explicit XY target and snaps it to terrain.
|
|
///
|
|
/// This is a deterministic reference controller, not recovered original AI.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error when the input is invalid, the mission/object is absent,
|
|
/// the source transform is non-finite, or the requested point has no terrain
|
|
/// surface.
|
|
pub fn advance_reference_movement(
|
|
engine: &mut Engine,
|
|
original_id: OriginalObjectId,
|
|
target_xy: [f32; 2],
|
|
max_step: f32,
|
|
) -> Result<bool, EngineError> {
|
|
if !target_xy
|
|
.iter()
|
|
.chain(std::iter::once(&max_step))
|
|
.all(|v| v.is_finite())
|
|
|| max_step <= 0.0
|
|
{
|
|
return Err(EngineError::Movement(
|
|
"target and max_step must be finite; max_step must be positive".to_string(),
|
|
));
|
|
}
|
|
let terrain = engine
|
|
.loaded
|
|
.as_ref()
|
|
.ok_or_else(|| EngineError::Movement("mission terrain is unavailable".to_string()))?
|
|
.terrain
|
|
.clone();
|
|
let handle = handle_by_original_id(&engine.world, original_id).ok_or_else(|| {
|
|
EngineError::Movement(format!("original object {} is unavailable", original_id.0))
|
|
})?;
|
|
let mut transform = transform_state(&engine.world, handle)?;
|
|
let position = transform.position.map(f32::from_bits);
|
|
if !position.iter().all(|v| v.is_finite()) {
|
|
return Err(EngineError::Movement(
|
|
"object transform is non-finite".to_string(),
|
|
));
|
|
}
|
|
let dx = target_xy[0] - position[0];
|
|
let dy = target_xy[1] - position[1];
|
|
let distance = dx.hypot(dy);
|
|
let reached = distance <= max_step;
|
|
let ratio = if reached { 1.0 } else { max_step / distance };
|
|
let x = position[0] + dx * ratio;
|
|
let y = position[1] + dy * ratio;
|
|
let z = terrain
|
|
.height_at([x, y])
|
|
.map_err(|err| EngineError::Movement(err.to_string()))?
|
|
.ok_or_else(|| {
|
|
EngineError::Movement("movement target lies outside terrain surface".to_string())
|
|
})?;
|
|
transform.position = [x.to_bits(), y.to_bits(), z.to_bits()];
|
|
set_transform(&mut engine.world, handle, transform)?;
|
|
Ok(reached)
|
|
}
|
|
|
|
impl From<fparkan_world::WorldError> for EngineError {
|
|
fn from(value: fparkan_world::WorldError) -> Self {
|
|
Self::World(value)
|
|
}
|
|
}
|
|
|
|
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}"),
|
|
Self::Script { path, message } => write!(f, "{path}: script decode failed: {message}"),
|
|
Self::VarSet { path, message } => write!(f, "{path}: varset decode failed: {message}"),
|
|
Self::ScriptInit {
|
|
clan_index,
|
|
message,
|
|
} => write!(f, "clan {clan_index}: script Init failed: {message}"),
|
|
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}"
|
|
),
|
|
Self::ScriptRuntime {
|
|
clan_index,
|
|
message,
|
|
} => write!(f, "clan {clan_index}: script runtime failed: {message}"),
|
|
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())
|
|
}
|
|
Self::AssetPreparation { mission, source } => {
|
|
write!(f, "{mission}: asset preparation failed: {source}")
|
|
}
|
|
Self::BehaviorProfile {
|
|
path,
|
|
entry,
|
|
message,
|
|
} => write!(f, "{path}:{entry}: Behavior profile failed: {message}"),
|
|
Self::BehaviorDistributorTick {
|
|
clan_index,
|
|
message,
|
|
} => write!(f, "Behavior resource tick for clan {clan_index}: {message}"),
|
|
Self::World(source) => write!(f, "{source}"),
|
|
Self::Movement(message) => write!(f, "reference movement: {message}"),
|
|
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),
|
|
Self::ScriptInitUnsupported { source, .. } => Some(source),
|
|
Self::Terrain(source) => Some(source),
|
|
Self::World(source) => Some(source),
|
|
Self::AssetPreparation { source, .. } => Some(source),
|
|
Self::MissingVfs
|
|
| Self::Script { .. }
|
|
| Self::VarSet { .. }
|
|
| Self::ScriptInit { .. }
|
|
| Self::ScriptRuntime { .. }
|
|
| Self::Movement(_)
|
|
| Self::PrototypeGraph { .. }
|
|
| Self::BehaviorProfile { .. }
|
|
| Self::BehaviorDistributorTick { .. }
|
|
| Self::RegistrationTeardown { .. } => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Creates an engine with the supplied services.
|
|
pub fn create(services: EngineServices) -> Result<Engine, EngineError> {
|
|
Ok(Engine {
|
|
services,
|
|
world: new_world(WorldConfig),
|
|
loaded: None,
|
|
behavior_rng_state: INITIAL_BEHAVIOR_RNG_STATE,
|
|
})
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`EngineError`] if VFS services are missing, mission paths are
|
|
/// invalid, required files cannot be read, disk formats fail validation, terrain
|
|
/// 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.
|
|
pub fn load_mission(
|
|
engine: &mut Engine,
|
|
request: MissionRequest,
|
|
queue_time_ms: u32,
|
|
) -> Result<LoadedMission, EngineError> {
|
|
load_mission_with_options(
|
|
engine,
|
|
request,
|
|
MissionLoadOptions::default(),
|
|
Some(queue_time_ms),
|
|
)
|
|
}
|
|
|
|
/// Loads a mission using the bounded static-preview asset scope.
|
|
///
|
|
/// This mode preserves map, TMA, graph, construction and registration work,
|
|
/// 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.
|
|
///
|
|
/// # 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> {
|
|
load_mission_with_options(
|
|
engine,
|
|
request,
|
|
MissionLoadOptions {
|
|
asset_scope: MissionAssetScope::FirstMeshPreview,
|
|
..MissionLoadOptions::default()
|
|
},
|
|
None,
|
|
)
|
|
}
|
|
|
|
/// Loads a static preview for the first requested non-zero count of mission roots.
|
|
///
|
|
/// Normal gameplay still uses [`load_mission`] and always resolves every root.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`EngineError`] under the same conditions as
|
|
/// [`load_mission_static_preview`].
|
|
pub fn load_mission_static_preview_roots(
|
|
engine: &mut Engine,
|
|
request: MissionRequest,
|
|
root_count: NonZeroUsize,
|
|
) -> Result<LoadedMission, EngineError> {
|
|
load_mission_with_options(
|
|
engine,
|
|
request,
|
|
MissionLoadOptions {
|
|
asset_scope: MissionAssetScope::PreviewRoots(root_count),
|
|
..MissionLoadOptions::default()
|
|
},
|
|
None,
|
|
)
|
|
}
|
|
|
|
fn load_mission_with_options(
|
|
engine: &mut Engine,
|
|
request: MissionRequest,
|
|
options: MissionLoadOptions,
|
|
queue_time_ms: Option<u32>,
|
|
) -> Result<LoadedMission, EngineError> {
|
|
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)?;
|
|
|
|
let land_path =
|
|
decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
|
|
EngineError::Mission {
|
|
path: mission_path.as_str().to_string(),
|
|
source,
|
|
}
|
|
})?;
|
|
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 {
|
|
path: land_msh_path.as_str().to_string(),
|
|
source,
|
|
}
|
|
})?;
|
|
let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?).map_err(|source| {
|
|
EngineError::Nres {
|
|
path: land_map_path.as_str().to_string(),
|
|
source,
|
|
}
|
|
})?;
|
|
let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
|
|
let build_dat = read_vfs(&vfs, &build_dat_path)?;
|
|
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),
|
|
}
|
|
})?;
|
|
let mission = decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| {
|
|
EngineError::Mission {
|
|
path: mission_path.as_str().to_string(),
|
|
source,
|
|
}
|
|
})?;
|
|
let mut loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?;
|
|
let repository = CachedResourceRepository::with_payload_cache_budget(
|
|
vfs.clone(),
|
|
MISSION_DECODED_PAYLOAD_CACHE_ENTRIES,
|
|
);
|
|
let graph_roots: Vec<_> = mission
|
|
.objects
|
|
.iter()
|
|
.map(|object| resource_name(&object.resource_name.raw))
|
|
.collect();
|
|
let scoped_graph_roots = match options.asset_scope {
|
|
MissionAssetScope::Full => graph_roots.as_slice(),
|
|
MissionAssetScope::FirstMeshPreview => graph_roots.get(..1).unwrap_or_default(),
|
|
MissionAssetScope::PreviewRoots(root_count) => graph_roots
|
|
.get(..root_count.get().min(graph_roots.len()))
|
|
.unwrap_or_default(),
|
|
};
|
|
let (mut prototype_graph, resolved_prototypes, mut prototype_report) =
|
|
build_prototype_graph_report(&repository, vfs.as_ref(), scoped_graph_roots);
|
|
extend_graph_report_with_visual_dependencies(
|
|
&repository,
|
|
&mut prototype_report,
|
|
&mut prototype_graph,
|
|
&resolved_prototypes,
|
|
);
|
|
if !prototype_report.is_success() {
|
|
return Err(EngineError::PrototypeGraph {
|
|
failures: prototype_report.failures.clone(),
|
|
});
|
|
}
|
|
let asset_manager = AssetManager::new(repository);
|
|
let mission_assets = match options.asset_scope {
|
|
MissionAssetScope::Full => asset_manager.prepare_mission_assets(
|
|
&prototype_graph.root_prototype_request_spans,
|
|
&resolved_prototypes,
|
|
),
|
|
MissionAssetScope::FirstMeshPreview => prepare_first_preview_assets(
|
|
&asset_manager,
|
|
&prototype_graph.root_prototype_request_spans,
|
|
&resolved_prototypes,
|
|
),
|
|
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,
|
|
),
|
|
}
|
|
.map_err(|source| EngineError::AssetPreparation {
|
|
mission: request.key.clone(),
|
|
source,
|
|
})?;
|
|
let mission_asset_plan = mission_assets.to_plan();
|
|
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(),
|
|
raw_after_resource: object.raw_after_resource,
|
|
identity_or_clan_raw: object.identity_or_clan_raw,
|
|
raw_after_name: object.raw_after_name,
|
|
unit_dat_header_flags: prototype_graph
|
|
.root_unit_dat_header_flags
|
|
.get(index)
|
|
.copied()
|
|
.flatten(),
|
|
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(),
|
|
unit_components: prototype_graph
|
|
.root_unit_components
|
|
.get(index)
|
|
.cloned()
|
|
.unwrap_or_default(),
|
|
})
|
|
.collect();
|
|
let prepared_behavior_profiles = if options.asset_scope == MissionAssetScope::Full {
|
|
prepare_behavior_profile_states(&vfs, &object_drafts)?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let mut new_runtime_world = new_world(WorldConfig);
|
|
let mut handles = Vec::with_capacity(mission.objects.len());
|
|
for (index, object) in mission.objects.iter().enumerate() {
|
|
let original_id = u32::try_from(index).ok().map(OriginalObjectId);
|
|
let handle = construct_object(&mut new_runtime_world, ObjectDraft { original_id })?;
|
|
set_transform(
|
|
&mut new_runtime_world,
|
|
handle,
|
|
TransformState {
|
|
position: object.position.map(f32::to_bits),
|
|
orientation: object.orientation.map(f32::to_bits),
|
|
scale: object.scale.map(f32::to_bits),
|
|
},
|
|
)?;
|
|
handles.push(handle);
|
|
}
|
|
let behavior_profiles = prepared_behavior_profiles
|
|
.into_iter()
|
|
.zip(&handles)
|
|
.filter_map(|(profile, handle)| {
|
|
profile.map(|profile| BehaviorProfileState {
|
|
original_id: profile.original_id,
|
|
handle: *handle,
|
|
class_flags: profile.class_flags,
|
|
profile_entry: profile.profile_entry,
|
|
transfer_ore_off_board_bits: profile.transfer_ore_off_board_bits,
|
|
transfer_power_out_bits: profile.transfer_power_out_bits,
|
|
})
|
|
})
|
|
.collect();
|
|
let mut registered_objects = 0;
|
|
for handle in &handles {
|
|
if options.fail_after_registered_objects == Some(registered_objects) {
|
|
let report = fparkan_world::shutdown(new_runtime_world);
|
|
return Err(EngineError::RegistrationTeardown {
|
|
registered_objects,
|
|
released_objects: report.released_objects.len(),
|
|
managers_released: report.managers_released,
|
|
});
|
|
}
|
|
register_object(&mut new_runtime_world, *handle)?;
|
|
registered_objects += 1;
|
|
}
|
|
|
|
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;
|
|
};
|
|
add_behavior_registered_object(
|
|
&mut distributors[clan_index],
|
|
BehaviorRegisteredObject {
|
|
original_id,
|
|
handle: *handle,
|
|
selector: draft.raw_after_resource,
|
|
class_flags,
|
|
share: 0.0,
|
|
native_tail: [0; 5],
|
|
},
|
|
);
|
|
}
|
|
distributors
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let behavior_ore_states = if options.asset_scope == MissionAssetScope::Full {
|
|
// Property callbacks also run for Unit DAT objects that do not join a distributor.
|
|
object_drafts
|
|
.iter()
|
|
.zip(&handles)
|
|
.filter_map(|(draft, handle)| {
|
|
Some(initialize_behavior_ore_state(
|
|
draft.original_id?,
|
|
*handle,
|
|
draft.unit_dat_header_flags?,
|
|
&draft.properties,
|
|
))
|
|
})
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let control_device_tables = if options.asset_scope == MissionAssetScope::Full {
|
|
object_drafts
|
|
.iter()
|
|
.zip(&handles)
|
|
.enumerate()
|
|
.map(|(object_index, (draft, handle))| {
|
|
let projection = build_control_device_projection(
|
|
object_index,
|
|
draft,
|
|
&mission_assets,
|
|
prototype_graph
|
|
.root_prototype_request_spans
|
|
.get(object_index),
|
|
&resolved_prototypes,
|
|
);
|
|
match projection {
|
|
Ok(projection) => MissionControlDeviceTableState {
|
|
original_id: draft.original_id,
|
|
handle: *handle,
|
|
status: MissionControlDeviceTableStatus::Available(projection.devices),
|
|
node_provider_status: projection.node_provider_status,
|
|
},
|
|
Err(reason) => MissionControlDeviceTableState {
|
|
original_id: draft.original_id,
|
|
handle: *handle,
|
|
status: MissionControlDeviceTableStatus::Unavailable(reason.clone()),
|
|
node_provider_status:
|
|
MissionControlNodeProviderTableStatus::Unavailable(
|
|
MissionControlNodeProviderTableUnavailableReason::DeviceTableUnavailable(
|
|
reason,
|
|
),
|
|
),
|
|
},
|
|
}
|
|
})
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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,
|
|
graph_node_count: prototype_graph.nodes.len(),
|
|
graph_edge_count: prototype_graph.edges.len(),
|
|
asset_visual_count: mission_asset_plan.visual_count,
|
|
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,
|
|
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,
|
|
script_bundle_count: loaded_scripts.bundles.len(),
|
|
script_event_count: loaded_scripts
|
|
.bundles
|
|
.iter()
|
|
.map(|bundle| bundle.package.events.len())
|
|
.sum(),
|
|
script_varset_declaration_count: loaded_scripts
|
|
.varset
|
|
.as_ref()
|
|
.map_or(0, |varset| varset.declarations.declarations.len()),
|
|
script_init_state_count: loaded_scripts.init_states.len(),
|
|
script_varset_state_count: loaded_scripts.varset_states.len(),
|
|
};
|
|
|
|
engine.world = new_runtime_world;
|
|
engine.behavior_rng_state = next_behavior_rng_state;
|
|
engine.loaded = Some(LoadedMissionState {
|
|
summary: summary.clone(),
|
|
mission,
|
|
terrain,
|
|
build_categories,
|
|
prototype_graph,
|
|
prototype_report,
|
|
mission_assets,
|
|
asset_plan: mission_asset_plan,
|
|
object_drafts,
|
|
behavior_distributors,
|
|
behavior_ore_states,
|
|
behavior_profiles,
|
|
control_device_tables,
|
|
script_bundles: loaded_scripts.bundles,
|
|
script_varset: loaded_scripts.varset,
|
|
script_init_states: loaded_scripts.init_states,
|
|
script_varset_states: loaded_scripts.varset_states,
|
|
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,
|
|
script_outcome: MissionScriptOutcome::Active,
|
|
});
|
|
Ok(summary)
|
|
}
|
|
|
|
fn prepare_first_preview_assets<R: ResourceRepository>(
|
|
asset_manager: &AssetManager<R>,
|
|
root_spans: &[std::ops::Range<usize>],
|
|
prototypes: &[fparkan_prototype::EffectivePrototype],
|
|
) -> Result<MissionAssets, AssetPreparationError> {
|
|
for span in root_spans {
|
|
let assets =
|
|
asset_manager.prepare_mission_assets(std::slice::from_ref(span), prototypes)?;
|
|
if !assets.models.is_empty() {
|
|
return Ok(assets);
|
|
}
|
|
}
|
|
Ok(MissionAssets::default())
|
|
}
|
|
|
|
/// Steps the world and due Behavior resource distributors at an explicit queue clock.
|
|
///
|
|
/// Resource updates are prepared before queued world commands and deferred deletion,
|
|
/// and published only after the world step succeeds. This does not yet execute the
|
|
/// separate Control clock, movement, or general mission script handlers.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`EngineError`] when the resource or world step fails; neither stage
|
|
/// publishes partial state on failure.
|
|
pub fn step_headless(
|
|
engine: &mut Engine,
|
|
input: InputSnapshot,
|
|
queue_time_ms: u32,
|
|
) -> Result<FrameResult, EngineError> {
|
|
let resources = prepare_loaded_mission_resources(engine, queue_time_ms)?;
|
|
let snapshot = step(&mut engine.world, &input)?;
|
|
if let Some(resources) = resources {
|
|
resources.apply(engine);
|
|
}
|
|
Ok(FrameResult { snapshot })
|
|
}
|
|
|
|
/// Steps rendered mode with the same queue-clock semantics as [`step_headless`].
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`EngineError`] when the resource or world step fails.
|
|
pub fn frame(engine: &mut Engine, queue_time_ms: u32) -> Result<FrameResult, EngineError> {
|
|
step_headless(engine, InputSnapshot, queue_time_ms)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
/// 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
|
|
/// 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.
|
|
///
|
|
/// # 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)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Applies the recovered `Iron3D` host-callback guards and records its outcome.
|
|
///
|
|
/// 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`.
|
|
///
|
|
/// # 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(
|
|
state.script_host_mode,
|
|
&mut state.script_host_status,
|
|
&mut state.script_outcome,
|
|
command,
|
|
))
|
|
}
|
|
|
|
fn apply_recovered_script_host_callback(
|
|
native_mode: MissionMode,
|
|
status: &mut u32,
|
|
outcome: &mut MissionScriptOutcome,
|
|
command: VmHostCallbackCommand,
|
|
) -> MissionScriptHostEffect {
|
|
if native_mode == MissionMode::AutoDemo || command.mode != 0 || command.first != 0 {
|
|
return MissionScriptHostEffect::Unhandled;
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
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(),
|
|
})
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Returns the prepared mission asset plan for the loaded mission.
|
|
#[must_use]
|
|
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)
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
/// Returns native ore properties attached to full Unit DAT mission objects.
|
|
#[must_use]
|
|
pub fn loaded_mission_behavior_ore_states(engine: &Engine) -> Option<&[BehaviorOreState]> {
|
|
engine
|
|
.loaded
|
|
.as_ref()
|
|
.map(|state| state.behavior_ore_states.as_slice())
|
|
}
|
|
|
|
/// Returns profile-derived Behavior rates attached to full Unit DAT objects.
|
|
#[must_use]
|
|
pub fn loaded_mission_behavior_profiles(engine: &Engine) -> Option<&[BehaviorProfileState]> {
|
|
engine
|
|
.loaded
|
|
.as_ref()
|
|
.map(|state| state.behavior_profiles.as_slice())
|
|
}
|
|
|
|
/// Returns the base Control device table projections for loaded mission objects.
|
|
#[must_use]
|
|
pub fn loaded_mission_control_device_tables(
|
|
engine: &Engine,
|
|
) -> Option<&[MissionControlDeviceTableState]> {
|
|
engine
|
|
.loaded
|
|
.as_ref()
|
|
.map(|state| state.control_device_tables.as_slice())
|
|
}
|
|
|
|
/// Queries native Control global-table selector 1 (weighted ratio) or 2 (sum).
|
|
/// `Ok(None)` preserves the original false/no-result behavior for an empty or
|
|
/// zero-denominator table. Other unsupported native paths are reported as errors.
|
|
/// Inputs and intermediates must be finite and normal, or exact zero.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error for an invalid object handle, unavailable mission/table,
|
|
/// unsupported selector or factory, missing state, or an unsupported float range.
|
|
pub fn query_loaded_mission_control_resource(
|
|
engine: &Engine,
|
|
handle: ObjectHandle,
|
|
selector: u32,
|
|
) -> Result<Option<f32>, ControlResourceAccessError> {
|
|
transform_state(&engine.world, handle).map_err(ControlResourceAccessError::World)?;
|
|
let loaded = engine
|
|
.loaded
|
|
.as_ref()
|
|
.ok_or(ControlResourceAccessError::MissionUnavailable)?;
|
|
let table = loaded
|
|
.control_device_tables
|
|
.iter()
|
|
.find(|table| table.handle == handle)
|
|
.ok_or(ControlResourceAccessError::ObjectUnavailable { handle })?;
|
|
query_control_device_table_resource(table, selector)
|
|
}
|
|
|
|
/// Queries one loaded Control device through its original factory getter.
|
|
///
|
|
/// Selector `0xe00` is currently modeled for current descriptor tag 26 with
|
|
/// factory tag 26. The loaded NDP provider value is its initial native state;
|
|
/// later Control updates are not synchronized into this snapshot.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error for an invalid handle, unavailable mission/table/provider,
|
|
/// an out-of-range device/provider key, or a factory/tag/float path that is not
|
|
/// supported by the current reconstruction.
|
|
pub fn query_loaded_mission_control_device_selector(
|
|
engine: &Engine,
|
|
handle: ObjectHandle,
|
|
device_index: usize,
|
|
selector: u32,
|
|
) -> Result<Option<f32>, ControlResourceAccessError> {
|
|
transform_state(&engine.world, handle).map_err(ControlResourceAccessError::World)?;
|
|
let loaded = engine
|
|
.loaded
|
|
.as_ref()
|
|
.ok_or(ControlResourceAccessError::MissionUnavailable)?;
|
|
let table = loaded
|
|
.control_device_tables
|
|
.iter()
|
|
.find(|table| table.handle == handle)
|
|
.ok_or(ControlResourceAccessError::ObjectUnavailable { handle })?;
|
|
query_control_device_table_selector(table, device_index, selector)
|
|
}
|
|
|
|
/// Queries the recovered Behavior resource selectors `0x1002` and `0x1005`.
|
|
///
|
|
/// Selector `0x1005` returns the loaded profile's `Transfer_Power_Out` value.
|
|
/// Selector `0x1002` sums supported Control `0xe00` results in native table
|
|
/// order, rounding each addition to `f32`, then multiplies by the profile's
|
|
/// `Transfer_Ore_OffBoard` value. Unsupported table/query paths return an error.
|
|
/// Profile and provider values are initial full-load snapshots; later damage,
|
|
/// Control updates, and profile changes are not synchronized into this state.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error for an invalid handle, unavailable mission/profile/table,
|
|
/// unsupported selector or Control query path, or an unsupported float range.
|
|
pub fn query_loaded_mission_behavior_resource(
|
|
engine: &Engine,
|
|
handle: ObjectHandle,
|
|
selector: u32,
|
|
) -> Result<f32, ControlResourceAccessError> {
|
|
transform_state(&engine.world, handle).map_err(ControlResourceAccessError::World)?;
|
|
let loaded = engine
|
|
.loaded
|
|
.as_ref()
|
|
.ok_or(ControlResourceAccessError::MissionUnavailable)?;
|
|
let profile = loaded
|
|
.behavior_profiles
|
|
.iter()
|
|
.find(|profile| profile.handle == handle)
|
|
.ok_or(ControlResourceAccessError::ObjectUnavailable { handle })?;
|
|
match selector {
|
|
0x1005 => checked_control_float(
|
|
0,
|
|
"Transfer_Power_Out",
|
|
f32::from_bits(profile.transfer_power_out_bits),
|
|
),
|
|
0x1002 => {
|
|
let table = loaded
|
|
.control_device_tables
|
|
.iter()
|
|
.find(|table| table.handle == handle)
|
|
.ok_or(ControlResourceAccessError::ObjectUnavailable { handle })?;
|
|
query_behavior_profile_ore_rate(table, profile.transfer_ore_off_board_bits)
|
|
}
|
|
_ => Err(ControlResourceAccessError::UnsupportedSelector(selector)),
|
|
}
|
|
}
|
|
|
|
/// Runs due native Behavior resource-distributor ticks against the loaded mission.
|
|
///
|
|
/// `queue_time_ms` is an explicit sample from the original wrapping 32-bit
|
|
/// queue clock. The method updates the distributor schedule, its five totals,
|
|
/// per-object row values, CurrentOre, and supported Control selector-1 state
|
|
/// atomically. Preview missions and loaded missions without visible Behavior
|
|
/// bundles have no due distributors and return `Ok(0)`. Otherwise it returns
|
|
/// the number of distributors whose schedule was due; this includes a due
|
|
/// tick with zero elapsed time, which leaves resource rows unchanged.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error when a live registered object reaches an unsupported
|
|
/// Behavior profile, Control query, or property state. On error, all
|
|
/// schedules, RNG state, totals, rows, ore values, and Control values remain
|
|
/// unchanged.
|
|
pub fn advance_loaded_mission_resources(
|
|
engine: &mut Engine,
|
|
queue_time_ms: u32,
|
|
) -> Result<usize, EngineError> {
|
|
let Some(resources) = prepare_loaded_mission_resources(engine, queue_time_ms)? else {
|
|
return Ok(0);
|
|
};
|
|
let scheduled_ticks = resources.scheduled_ticks;
|
|
resources.apply(engine);
|
|
Ok(scheduled_ticks)
|
|
}
|
|
|
|
struct PreparedMissionResources {
|
|
distributors: Vec<BehaviorDistributorState>,
|
|
ore_states: Vec<BehaviorOreState>,
|
|
control_tables: Vec<MissionControlDeviceTableState>,
|
|
warm_starts: Vec<Option<BehaviorWarmStartState>>,
|
|
rng_state: u32,
|
|
scheduled_ticks: usize,
|
|
}
|
|
|
|
impl PreparedMissionResources {
|
|
fn apply(self, engine: &mut Engine) {
|
|
let loaded = engine
|
|
.loaded
|
|
.as_mut()
|
|
.expect("prepared resources require a loaded mission");
|
|
loaded.behavior_distributors = self.distributors;
|
|
loaded.behavior_ore_states = self.ore_states;
|
|
loaded.control_device_tables = self.control_tables;
|
|
for (bundle, warm_start) in loaded.script_bundles.iter_mut().zip(self.warm_starts) {
|
|
bundle.behavior_warm_start = warm_start;
|
|
}
|
|
engine.behavior_rng_state = self.rng_state;
|
|
}
|
|
}
|
|
|
|
fn prepare_loaded_mission_resources(
|
|
engine: &Engine,
|
|
queue_time_ms: u32,
|
|
) -> Result<Option<PreparedMissionResources>, EngineError> {
|
|
let Some(loaded) = engine.loaded.as_ref() else {
|
|
return Ok(None);
|
|
};
|
|
if loaded
|
|
.behavior_distributors
|
|
.iter()
|
|
.filter(|distributor| distributor.super_ai_visible)
|
|
.all(|distributor| {
|
|
loaded
|
|
.script_bundles
|
|
.iter()
|
|
.find(|bundle| bundle.clan_index == distributor.clan_index)
|
|
.and_then(|bundle| bundle.behavior_warm_start)
|
|
.is_some_and(|schedule| {
|
|
schedule.first_time_ms != 0
|
|
&& schedule.next_deadline_ms != 0
|
|
&& queue_time_ms <= schedule.next_deadline_ms
|
|
})
|
|
})
|
|
{
|
|
return Ok(None);
|
|
}
|
|
|
|
let mut distributors = loaded.behavior_distributors.clone();
|
|
let mut ore_states = loaded.behavior_ore_states.clone();
|
|
let mut control_tables = loaded.control_device_tables.clone();
|
|
let mut warm_starts = loaded
|
|
.script_bundles
|
|
.iter()
|
|
.map(|bundle| bundle.behavior_warm_start)
|
|
.collect::<Vec<_>>();
|
|
let profiles = &loaded.behavior_profiles;
|
|
let world = &engine.world;
|
|
let mut rng_state = engine.behavior_rng_state;
|
|
let mut scheduled_ticks = 0;
|
|
|
|
for distributor in distributors.iter_mut() {
|
|
if !distributor.super_ai_visible {
|
|
continue;
|
|
}
|
|
let bundle_index = loaded
|
|
.script_bundles
|
|
.iter()
|
|
.position(|bundle| bundle.clan_index == distributor.clan_index)
|
|
.ok_or_else(|| EngineError::BehaviorDistributorTick {
|
|
clan_index: distributor.clan_index,
|
|
message: "visible distributor has no matching script bundle".to_string(),
|
|
})?;
|
|
let Some(schedule) = warm_starts.get_mut(bundle_index).and_then(Option::as_mut) else {
|
|
return Err(EngineError::BehaviorDistributorTick {
|
|
clan_index: distributor.clan_index,
|
|
message: "visible distributor has no warm-start schedule".to_string(),
|
|
});
|
|
};
|
|
|
|
let delta_seconds =
|
|
schedule_behavior_distributor_tick(schedule, queue_time_ms, &mut rng_state);
|
|
let Some(delta_seconds) = delta_seconds else {
|
|
continue;
|
|
};
|
|
scheduled_ticks += 1;
|
|
if delta_seconds == 0.0 {
|
|
continue;
|
|
}
|
|
|
|
let totals = advance_behavior_distributor_tick(
|
|
distributor,
|
|
delta_seconds,
|
|
world,
|
|
&mut ore_states,
|
|
profiles,
|
|
&mut control_tables,
|
|
)
|
|
.map_err(|message| EngineError::BehaviorDistributorTick {
|
|
clan_index: distributor.clan_index,
|
|
message,
|
|
})?;
|
|
schedule.cleared_words = totals;
|
|
}
|
|
|
|
Ok(Some(PreparedMissionResources {
|
|
distributors,
|
|
ore_states,
|
|
control_tables,
|
|
warm_starts,
|
|
rng_state,
|
|
scheduled_ticks,
|
|
}))
|
|
}
|
|
|
|
/// Sets native Control global-table selector 1 on rows whose selector-0 value
|
|
/// is positive. Validation is completed before any row is changed.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error for an invalid object handle, unavailable mission/table,
|
|
/// missing state, a non-finite or subnormal value, or an unsupported float range.
|
|
pub fn set_loaded_mission_control_multiplier(
|
|
engine: &mut Engine,
|
|
handle: ObjectHandle,
|
|
value: f32,
|
|
) -> Result<(), ControlResourceAccessError> {
|
|
transform_state(&engine.world, handle).map_err(ControlResourceAccessError::World)?;
|
|
let loaded = engine
|
|
.loaded
|
|
.as_mut()
|
|
.ok_or(ControlResourceAccessError::MissionUnavailable)?;
|
|
let table = loaded
|
|
.control_device_tables
|
|
.iter_mut()
|
|
.find(|table| table.handle == handle)
|
|
.ok_or(ControlResourceAccessError::ObjectUnavailable { handle })?;
|
|
set_control_device_table_multiplier(table, value)
|
|
}
|
|
|
|
fn query_control_device_table_resource(
|
|
table: &MissionControlDeviceTableState,
|
|
selector: u32,
|
|
) -> Result<Option<f32>, ControlResourceAccessError> {
|
|
if !matches!(selector, 1 | 2) {
|
|
return Err(ControlResourceAccessError::UnsupportedSelector(selector));
|
|
}
|
|
let entries = match &table.status {
|
|
MissionControlDeviceTableStatus::Available(entries) => entries,
|
|
MissionControlDeviceTableStatus::Unavailable(reason) => {
|
|
return Err(ControlResourceAccessError::TableUnavailable(reason.clone()));
|
|
}
|
|
};
|
|
|
|
let mut numerator = 0.0_f32;
|
|
let mut denominator = 0.0_f32;
|
|
for entry in entries.iter().filter(|entry| entry.descriptor.tag == 19) {
|
|
let base = control_device_selector_zero(entry)?;
|
|
if base < 0.0 {
|
|
return Ok(Some(if selector == 1 { 1.0 } else { base }));
|
|
}
|
|
if selector == 2 {
|
|
denominator = checked_control_sum(entry.factory_tag, "sum", denominator, base)?;
|
|
} else {
|
|
let multiplier = control_device_float(entry, entry.multiplier_bits, "multiplier")?;
|
|
let weighted = checked_control_product(
|
|
entry.factory_tag,
|
|
"weighted value",
|
|
control_device_coefficient(entry)?,
|
|
multiplier,
|
|
)?;
|
|
numerator = checked_control_sum(entry.factory_tag, "numerator", numerator, weighted)?;
|
|
denominator = checked_control_sum(entry.factory_tag, "denominator", denominator, base)?;
|
|
}
|
|
}
|
|
|
|
if selector == 2 {
|
|
return Ok((denominator > 0.0).then_some(denominator));
|
|
}
|
|
if denominator <= 0.0 {
|
|
return Ok(None);
|
|
}
|
|
checked_control_ratio("ratio", numerator, denominator).map(Some)
|
|
}
|
|
|
|
fn query_control_device_table_selector(
|
|
table: &MissionControlDeviceTableState,
|
|
device_index: usize,
|
|
selector: u32,
|
|
) -> Result<Option<f32>, ControlResourceAccessError> {
|
|
if selector != 0xe00 {
|
|
return Err(ControlResourceAccessError::UnsupportedSelector(selector));
|
|
}
|
|
let devices = match &table.status {
|
|
MissionControlDeviceTableStatus::Available(devices) => devices,
|
|
MissionControlDeviceTableStatus::Unavailable(reason) => {
|
|
return Err(ControlResourceAccessError::TableUnavailable(reason.clone()));
|
|
}
|
|
};
|
|
let device =
|
|
devices
|
|
.get(device_index)
|
|
.ok_or(ControlResourceAccessError::DeviceIndexOutOfRange {
|
|
device_index,
|
|
device_count: devices.len(),
|
|
})?;
|
|
match device.descriptor.tag {
|
|
26 => {}
|
|
5 | 10 | 21 => {
|
|
return Err(ControlResourceAccessError::UnsupportedCurrentTag {
|
|
tag: device.descriptor.tag,
|
|
});
|
|
}
|
|
_ => return Ok(None),
|
|
}
|
|
if device.factory_tag != 26 {
|
|
return Err(ControlResourceAccessError::UnsupportedFactoryTag {
|
|
factory_tag: device.factory_tag,
|
|
});
|
|
}
|
|
|
|
let providers = match &table.node_provider_status {
|
|
MissionControlNodeProviderTableStatus::Available(providers) => providers,
|
|
MissionControlNodeProviderTableStatus::Unavailable(reason) => {
|
|
return Err(ControlResourceAccessError::NodeProviderTableUnavailable(
|
|
reason.clone(),
|
|
));
|
|
}
|
|
};
|
|
let key = usize::try_from(device.key).map_err(|_| {
|
|
ControlResourceAccessError::NodeProviderKeyOutOfRange {
|
|
key: device.key,
|
|
row_count: providers.len(),
|
|
}
|
|
})?;
|
|
let provider =
|
|
providers
|
|
.get(key)
|
|
.ok_or(ControlResourceAccessError::NodeProviderKeyOutOfRange {
|
|
key: device.key,
|
|
row_count: providers.len(),
|
|
})?;
|
|
let provider_value = checked_control_float(
|
|
device.factory_tag,
|
|
"node provider value",
|
|
f32::from_bits(provider.initial_type1_value_bits),
|
|
)?;
|
|
let coefficient = control_device_coefficient(device)?;
|
|
let multiplier = control_device_float(device, device.multiplier_bits, "multiplier")?;
|
|
let provider_weighted = checked_control_product(
|
|
device.factory_tag,
|
|
"provider times coefficient",
|
|
provider_value,
|
|
coefficient,
|
|
)?;
|
|
checked_control_product(
|
|
device.factory_tag,
|
|
"then multiplier",
|
|
provider_weighted,
|
|
multiplier,
|
|
)
|
|
.map(Some)
|
|
}
|
|
|
|
fn query_behavior_profile_ore_rate(
|
|
table: &MissionControlDeviceTableState,
|
|
transfer_ore_off_board_bits: u32,
|
|
) -> Result<f32, ControlResourceAccessError> {
|
|
let devices = match &table.status {
|
|
MissionControlDeviceTableStatus::Available(devices) => devices,
|
|
MissionControlDeviceTableStatus::Unavailable(reason) => {
|
|
return Err(ControlResourceAccessError::TableUnavailable(reason.clone()));
|
|
}
|
|
};
|
|
let mut total = 0.0_f32;
|
|
for (device_index, device) in devices.iter().enumerate() {
|
|
if device.descriptor.tag != 26 {
|
|
continue;
|
|
}
|
|
if let Some(value) = query_control_device_table_selector(table, device_index, 0xe00)? {
|
|
total = checked_control_sum(26, "Behavior resource sum", total, value)?;
|
|
}
|
|
}
|
|
checked_control_product(
|
|
26,
|
|
"Transfer_Ore_OffBoard",
|
|
total,
|
|
checked_control_float(
|
|
26,
|
|
"Transfer_Ore_OffBoard",
|
|
f32::from_bits(transfer_ore_off_board_bits),
|
|
)?,
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Default)]
|
|
struct BehaviorTickTotals {
|
|
power_available: f32,
|
|
ore_available: f32,
|
|
power_weighted_demand: f32,
|
|
power_demand: f32,
|
|
ore_weighted_demand: f32,
|
|
ore_demand: f32,
|
|
ore_in_buffer: f32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Default)]
|
|
struct BehaviorAllocation {
|
|
priority: f32,
|
|
residual: f32,
|
|
consumption: f32,
|
|
}
|
|
|
|
fn behavior_difference(lhs: f32, rhs: f32, field: &'static str) -> Result<f32, String> {
|
|
let range_probe = f64::from(lhs) - f64::from(rhs);
|
|
check_control_range_probe(None, field, range_probe).map_err(|error| format!("{error:?}"))?;
|
|
checked_control_float(0, field, lhs - rhs).map_err(|error| format!("{error:?}"))
|
|
}
|
|
|
|
fn behavior_product(lhs: f32, rhs: f32, field: &'static str) -> Result<f32, String> {
|
|
checked_control_product(0, field, lhs, rhs).map_err(|error| format!("{error:?}"))
|
|
}
|
|
|
|
fn behavior_allocation(
|
|
available: f32,
|
|
demand: f32,
|
|
weighted_demand: f32,
|
|
) -> Result<BehaviorAllocation, String> {
|
|
if available <= 0.0 || demand <= 0.0 {
|
|
Ok(BehaviorAllocation::default())
|
|
} else if available <= weighted_demand {
|
|
Ok(BehaviorAllocation {
|
|
priority: checked_control_ratio("priority allocation", available, weighted_demand)
|
|
.map_err(|error| format!("{error:?}"))?,
|
|
residual: 0.0,
|
|
consumption: 1.0,
|
|
})
|
|
} else if available < demand {
|
|
let residual_available =
|
|
behavior_difference(available, weighted_demand, "residual available")?;
|
|
let residual_demand = behavior_difference(demand, weighted_demand, "residual demand")?;
|
|
Ok(BehaviorAllocation {
|
|
priority: 1.0,
|
|
residual: checked_control_ratio(
|
|
"residual allocation",
|
|
residual_available,
|
|
residual_demand,
|
|
)
|
|
.map_err(|error| format!("{error:?}"))?,
|
|
consumption: 1.0,
|
|
})
|
|
} else {
|
|
Ok(BehaviorAllocation {
|
|
priority: 1.0,
|
|
residual: 1.0,
|
|
consumption: checked_control_ratio("source consumption", demand, available)
|
|
.map_err(|error| format!("{error:?}"))?,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn behavior_add_total(total: &mut f32, value: f32, field: &'static str) -> Result<(), String> {
|
|
*total = checked_control_sum(0, field, *total, value).map_err(|error| format!("{error:?}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn behavior_profile_for_handle<'a>(
|
|
profiles: &'a [BehaviorProfileState],
|
|
handle: ObjectHandle,
|
|
) -> Result<&'a BehaviorProfileState, String> {
|
|
profiles
|
|
.iter()
|
|
.find(|profile| profile.handle == handle)
|
|
.ok_or_else(|| format!("Behavior profile for object {handle:?} is unavailable"))
|
|
}
|
|
|
|
fn behavior_ore_index_for_handle(
|
|
ore_states: &[BehaviorOreState],
|
|
handle: ObjectHandle,
|
|
) -> Result<usize, String> {
|
|
ore_states
|
|
.iter()
|
|
.position(|state| state.handle == handle)
|
|
.ok_or_else(|| format!("CurrentOre property for object {handle:?} is unavailable"))
|
|
}
|
|
|
|
fn behavior_control_index_for_handle(
|
|
control_tables: &[MissionControlDeviceTableState],
|
|
handle: ObjectHandle,
|
|
) -> Result<usize, String> {
|
|
let index = control_tables
|
|
.iter()
|
|
.position(|table| table.handle == handle)
|
|
.ok_or_else(|| format!("Control interface for object {handle:?} is unavailable"))?;
|
|
match &control_tables[index].status {
|
|
MissionControlDeviceTableStatus::Available(_) => Ok(index),
|
|
MissionControlDeviceTableStatus::Unavailable(reason) => Err(format!(
|
|
"Control interface for object {handle:?} is unresolved: {reason:?}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn behavior_checked_property(state: &BehaviorOreState, maximum: bool) -> Result<f32, String> {
|
|
let (field, bits) = if maximum {
|
|
("MaximumOre", state.maximum_ore_bits)
|
|
} else {
|
|
("CurrentOre", state.current_ore_bits)
|
|
};
|
|
checked_control_float(state.class_flags, field, f32::from_bits(bits))
|
|
.map_err(|error| format!("{error:?}"))
|
|
}
|
|
|
|
fn behavior_tick_resource_rate(
|
|
class_flags: u32,
|
|
handle: ObjectHandle,
|
|
selector: u32,
|
|
profiles: &[BehaviorProfileState],
|
|
control_tables: &[MissionControlDeviceTableState],
|
|
) -> Result<f32, String> {
|
|
let profile = behavior_profile_for_handle(profiles, handle)?;
|
|
match selector {
|
|
0x1005 => checked_control_float(
|
|
class_flags,
|
|
"Transfer_Power_Out",
|
|
f32::from_bits(profile.transfer_power_out_bits),
|
|
)
|
|
.map_err(|error| format!("{error:?}")),
|
|
0x1002 => {
|
|
let table_index = behavior_control_index_for_handle(control_tables, handle)?;
|
|
query_behavior_profile_ore_rate(
|
|
&control_tables[table_index],
|
|
profile.transfer_ore_off_board_bits,
|
|
)
|
|
.map_err(|error| format!("{error:?}"))
|
|
}
|
|
_ => Err(format!(
|
|
"unsupported Behavior resource selector {selector:#x}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn behavior_query_control(
|
|
control_tables: &[MissionControlDeviceTableState],
|
|
handle: ObjectHandle,
|
|
selector: u32,
|
|
) -> Result<f32, String> {
|
|
let table_index = behavior_control_index_for_handle(control_tables, handle)?;
|
|
query_control_device_table_resource(&control_tables[table_index], selector)
|
|
.map(|value| value.unwrap_or(0.0))
|
|
.map_err(|error| format!("{error:?}"))
|
|
}
|
|
|
|
fn advance_behavior_distributor_tick(
|
|
distributor: &mut BehaviorDistributorState,
|
|
delta_seconds: f32,
|
|
world: &World,
|
|
ore_states: &mut [BehaviorOreState],
|
|
profiles: &[BehaviorProfileState],
|
|
control_tables: &mut [MissionControlDeviceTableState],
|
|
) -> Result<[u32; 5], String> {
|
|
let mut totals = BehaviorTickTotals::default();
|
|
|
|
// Native pass one measures supply and demand, then records per-row demand.
|
|
for object in &mut distributor.registered_objects {
|
|
let Some(live_handle) = handle_by_original_id(world, object.original_id) else {
|
|
object.native_tail[1] = 0;
|
|
object.native_tail[3] = 0;
|
|
continue;
|
|
};
|
|
if live_handle != object.handle {
|
|
return Err(format!(
|
|
"live original object {} maps to {live_handle:?}, expected {:?}",
|
|
object.original_id.0, object.handle
|
|
));
|
|
}
|
|
let share = checked_control_float(object.class_flags, "Behavior share", object.share)
|
|
.map_err(|error| format!("{error:?}"))?;
|
|
|
|
let power_source = matches!(
|
|
object.class_flags,
|
|
0x8000_0002 | 0x8001_0000 | 0x8002_0000 | 0x8004_0000
|
|
);
|
|
let power_demand = if power_source {
|
|
let rate = behavior_tick_resource_rate(
|
|
object.class_flags,
|
|
live_handle,
|
|
0x1005,
|
|
profiles,
|
|
control_tables,
|
|
)?;
|
|
let production = behavior_product(delta_seconds, rate, "power production")?;
|
|
behavior_add_total(&mut totals.power_available, production, "power available")?;
|
|
0.0
|
|
} else {
|
|
let total_power = behavior_query_control(control_tables, live_handle, 2)?;
|
|
let power_ratio = behavior_query_control(control_tables, live_handle, 1)?;
|
|
if total_power > 0.0 {
|
|
let used_power = behavior_product(power_ratio, total_power, "used power")?;
|
|
behavior_difference(total_power, used_power, "power demand")?
|
|
} else {
|
|
0.0
|
|
}
|
|
};
|
|
object.native_tail[1] = power_demand.to_bits();
|
|
behavior_add_total(&mut totals.power_demand, power_demand, "power demand")?;
|
|
behavior_add_total(
|
|
&mut totals.power_weighted_demand,
|
|
behavior_product(power_demand, share, "weighted power demand")?,
|
|
"weighted power demand",
|
|
)?;
|
|
|
|
let ore_source = matches!(object.class_flags, 0x8000_0004 | 0x8000_0008);
|
|
let ore_index = behavior_ore_index_for_handle(ore_states, live_handle)?;
|
|
let current = behavior_checked_property(&ore_states[ore_index], false)?;
|
|
let ore_demand = if ore_source {
|
|
let rate = behavior_tick_resource_rate(
|
|
object.class_flags,
|
|
live_handle,
|
|
0x1002,
|
|
profiles,
|
|
control_tables,
|
|
)?;
|
|
let production = behavior_product(delta_seconds, rate, "ore production")?;
|
|
let available = if current < production {
|
|
current
|
|
} else {
|
|
production
|
|
};
|
|
behavior_add_total(&mut totals.ore_available, available, "ore available")?;
|
|
behavior_add_total(&mut totals.ore_in_buffer, current, "ore in buffer")?;
|
|
0.0
|
|
} else {
|
|
let maximum = behavior_checked_property(&ore_states[ore_index], true)?;
|
|
let deficit = behavior_difference(maximum, current, "ore deficit")?;
|
|
if deficit >= f32::from_bits(0x3c23_d70a) {
|
|
deficit
|
|
} else {
|
|
ore_states[ore_index].current_ore_bits = maximum.to_bits();
|
|
0.0
|
|
}
|
|
};
|
|
object.native_tail[3] = ore_demand.to_bits();
|
|
behavior_add_total(&mut totals.ore_demand, ore_demand, "ore demand")?;
|
|
behavior_add_total(
|
|
&mut totals.ore_weighted_demand,
|
|
behavior_product(ore_demand, share, "weighted ore demand")?,
|
|
"weighted ore demand",
|
|
)?;
|
|
}
|
|
|
|
let power_allocation = behavior_allocation(
|
|
totals.power_available,
|
|
totals.power_demand,
|
|
totals.power_weighted_demand,
|
|
)?;
|
|
let ore_allocation = behavior_allocation(
|
|
totals.ore_available,
|
|
totals.ore_demand,
|
|
totals.ore_weighted_demand,
|
|
)?;
|
|
|
|
// Native pass two re-reads callbacks before applying each row in order.
|
|
for object in &mut distributor.registered_objects {
|
|
let Some(live_handle) = handle_by_original_id(world, object.original_id) else {
|
|
object.native_tail[2] = 0;
|
|
object.native_tail[4] = 0;
|
|
continue;
|
|
};
|
|
if live_handle != object.handle {
|
|
return Err(format!(
|
|
"live original object {} maps to {live_handle:?}, expected {:?}",
|
|
object.original_id.0, object.handle
|
|
));
|
|
}
|
|
object.native_tail[2] = 0;
|
|
object.native_tail[4] = 0;
|
|
|
|
let power_source = matches!(
|
|
object.class_flags,
|
|
0x8000_0002 | 0x8001_0000 | 0x8002_0000 | 0x8004_0000
|
|
);
|
|
if power_source {
|
|
let table_index = behavior_control_index_for_handle(control_tables, live_handle)?;
|
|
set_control_device_table_multiplier(&mut control_tables[table_index], 1.0)
|
|
.map_err(|error| format!("{error:?}"))?;
|
|
} else {
|
|
let table_index = behavior_control_index_for_handle(control_tables, live_handle)?;
|
|
let total_power = behavior_query_control(control_tables, live_handle, 2)?;
|
|
if total_power > 0.0 {
|
|
let power_ratio = behavior_query_control(control_tables, live_handle, 1)?;
|
|
let used_power = behavior_product(power_ratio, total_power, "used power")?;
|
|
let demand = behavior_difference(total_power, used_power, "power demand")?;
|
|
let share = object.share;
|
|
let priority_share =
|
|
behavior_product(share, power_allocation.priority, "priority power share")?;
|
|
let residual_share = behavior_product(
|
|
behavior_difference(1.0, share, "remaining share")?,
|
|
power_allocation.residual,
|
|
"residual power share",
|
|
)?;
|
|
let fraction = checked_control_sum(
|
|
0,
|
|
"power allocation fraction",
|
|
residual_share,
|
|
priority_share,
|
|
)
|
|
.map_err(|error| format!("{error:?}"))?;
|
|
let allocated = behavior_product(demand, fraction, "power allocation")?;
|
|
let updated =
|
|
checked_control_sum(0, "used plus allocated power", used_power, allocated)
|
|
.map_err(|error| format!("{error:?}"))?;
|
|
let new_ratio =
|
|
checked_control_ratio("new Control selector 1", updated, total_power)
|
|
.map_err(|error| format!("{error:?}"))?;
|
|
set_control_device_table_multiplier(&mut control_tables[table_index], new_ratio)
|
|
.map_err(|error| format!("{error:?}"))?;
|
|
object.native_tail[2] = allocated.to_bits();
|
|
}
|
|
}
|
|
|
|
let ore_source = matches!(object.class_flags, 0x8000_0004 | 0x8000_0008);
|
|
let ore_index = behavior_ore_index_for_handle(ore_states, live_handle)?;
|
|
let current = behavior_checked_property(&ore_states[ore_index], false)?;
|
|
if ore_source {
|
|
let rate = behavior_tick_resource_rate(
|
|
object.class_flags,
|
|
live_handle,
|
|
0x1002,
|
|
profiles,
|
|
control_tables,
|
|
)?;
|
|
let production = behavior_product(delta_seconds, rate, "ore withdrawal rate")?;
|
|
let bounded_production = if current < production {
|
|
current
|
|
} else {
|
|
production
|
|
};
|
|
let taken = behavior_product(
|
|
bounded_production,
|
|
ore_allocation.consumption,
|
|
"ore withdrawal",
|
|
)?;
|
|
ore_states[ore_index].current_ore_bits =
|
|
behavior_difference(current, taken, "remaining ore")?.to_bits();
|
|
} else {
|
|
let maximum = behavior_checked_property(&ore_states[ore_index], true)?;
|
|
let capacity = behavior_difference(maximum, current, "ore capacity")?;
|
|
let share = object.share;
|
|
let priority_share =
|
|
behavior_product(share, ore_allocation.priority, "priority ore share")?;
|
|
let residual_share = behavior_product(
|
|
behavior_difference(1.0, share, "remaining share")?,
|
|
ore_allocation.residual,
|
|
"residual ore share",
|
|
)?;
|
|
let fraction =
|
|
checked_control_sum(0, "ore allocation fraction", residual_share, priority_share)
|
|
.map_err(|error| format!("{error:?}"))?;
|
|
let allocated = behavior_product(capacity, fraction, "ore allocation")?;
|
|
ore_states[ore_index].current_ore_bits =
|
|
checked_control_sum(0, "current plus allocated ore", current, allocated)
|
|
.map_err(|error| format!("{error:?}"))?
|
|
.to_bits();
|
|
object.native_tail[4] = allocated.to_bits();
|
|
}
|
|
}
|
|
|
|
Ok([
|
|
totals.power_available.to_bits(),
|
|
totals.ore_available.to_bits(),
|
|
totals.ore_in_buffer.to_bits(),
|
|
totals.power_demand.to_bits(),
|
|
totals.ore_demand.to_bits(),
|
|
])
|
|
}
|
|
|
|
fn set_control_device_table_multiplier(
|
|
table: &mut MissionControlDeviceTableState,
|
|
value: f32,
|
|
) -> Result<(), ControlResourceAccessError> {
|
|
let entries = match &mut table.status {
|
|
MissionControlDeviceTableStatus::Available(entries) => entries,
|
|
MissionControlDeviceTableStatus::Unavailable(reason) => {
|
|
return Err(ControlResourceAccessError::TableUnavailable(reason.clone()));
|
|
}
|
|
};
|
|
|
|
let mut has_target = false;
|
|
let mut target_factory_tag = 0;
|
|
for entry in entries.iter() {
|
|
if entry.descriptor.tag != 19 {
|
|
continue;
|
|
}
|
|
let base = control_device_selector_zero(entry)?;
|
|
if base > 0.0 {
|
|
has_target = true;
|
|
target_factory_tag = entry.factory_tag;
|
|
}
|
|
}
|
|
if !has_target {
|
|
return Ok(());
|
|
}
|
|
checked_control_float(target_factory_tag, "multiplier", value)?;
|
|
for entry in entries
|
|
.iter_mut()
|
|
.filter(|entry| entry.descriptor.tag == 19)
|
|
{
|
|
if control_device_selector_zero(entry)? > 0.0 {
|
|
entry.multiplier_bits = Some(value.to_bits());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn control_device_selector_zero(
|
|
entry: &EffectiveControlDevice,
|
|
) -> Result<f32, ControlResourceAccessError> {
|
|
let value = match entry.factory_tag {
|
|
9 => {
|
|
let coefficient = control_device_coefficient(entry)?;
|
|
let scale = control_device_float(
|
|
entry,
|
|
entry.class9_selector0_scale_bits,
|
|
"class-9 selector-0 scale",
|
|
)?;
|
|
checked_control_product(entry.factory_tag, "selector-0 value", coefficient, scale)?
|
|
}
|
|
15 | 19 | 25 => control_device_coefficient(entry)?,
|
|
factory_tag => {
|
|
return Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag });
|
|
}
|
|
};
|
|
checked_control_float(entry.factory_tag, "selector-0 value", value)
|
|
}
|
|
|
|
fn control_device_coefficient(
|
|
entry: &EffectiveControlDevice,
|
|
) -> Result<f32, ControlResourceAccessError> {
|
|
let words = entry
|
|
.coefficient_bits
|
|
.ok_or(ControlResourceAccessError::UnsupportedField {
|
|
factory_tag: entry.factory_tag,
|
|
field: "coefficient",
|
|
})?;
|
|
control_device_float(entry, Some(words[0]), "coefficient")
|
|
}
|
|
|
|
fn control_device_float(
|
|
entry: &EffectiveControlDevice,
|
|
bits: Option<u32>,
|
|
field: &'static str,
|
|
) -> Result<f32, ControlResourceAccessError> {
|
|
let bits = bits.ok_or(ControlResourceAccessError::UnsupportedField {
|
|
factory_tag: entry.factory_tag,
|
|
field,
|
|
})?;
|
|
checked_control_float(entry.factory_tag, field, f32::from_bits(bits))
|
|
}
|
|
|
|
fn checked_control_float(
|
|
factory_tag: u32,
|
|
field: &'static str,
|
|
value: f32,
|
|
) -> Result<f32, ControlResourceAccessError> {
|
|
if !value.is_finite() {
|
|
return Err(ControlResourceAccessError::NonFiniteValue { factory_tag, field });
|
|
}
|
|
if value.is_subnormal() {
|
|
return Err(ControlResourceAccessError::SubnormalValue { factory_tag, field });
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn checked_control_product(
|
|
factory_tag: u32,
|
|
field: &'static str,
|
|
lhs: f32,
|
|
rhs: f32,
|
|
) -> Result<f32, ControlResourceAccessError> {
|
|
let range_probe = f64::from(lhs) * f64::from(rhs);
|
|
check_control_range_probe(Some(factory_tag), field, range_probe)?;
|
|
checked_control_float(factory_tag, field, lhs * rhs)
|
|
}
|
|
|
|
fn checked_control_ratio(
|
|
field: &'static str,
|
|
numerator: f32,
|
|
denominator: f32,
|
|
) -> Result<f32, ControlResourceAccessError> {
|
|
let range_probe = f64::from(numerator) / f64::from(denominator);
|
|
check_control_range_probe(None, field, range_probe)?;
|
|
checked_control_float(0, field, numerator / denominator)
|
|
}
|
|
|
|
fn checked_control_sum(
|
|
factory_tag: u32,
|
|
field: &'static str,
|
|
lhs: f32,
|
|
rhs: f32,
|
|
) -> Result<f32, ControlResourceAccessError> {
|
|
let range_probe = f64::from(lhs) + f64::from(rhs);
|
|
check_control_range_probe(Some(factory_tag), field, range_probe)?;
|
|
checked_control_float(factory_tag, field, lhs + rhs)
|
|
}
|
|
|
|
fn check_control_range_probe(
|
|
factory_tag: Option<u32>,
|
|
field: &'static str,
|
|
range_probe: f64,
|
|
) -> Result<(), ControlResourceAccessError> {
|
|
let absolute = range_probe.abs();
|
|
let minimum_normal = f64::from(f32::MIN_POSITIVE);
|
|
let maximum = f64::from(f32::MAX);
|
|
if range_probe != 0.0 && (absolute < minimum_normal || absolute > maximum) {
|
|
return Err(ControlResourceAccessError::UnsupportedFloatRange { factory_tag, field });
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn native_behavior_ore_defaults(class_flags: u32) -> (u32, u32) {
|
|
match class_flags {
|
|
0x8000_0004 => (0, 0x43fa_0000),
|
|
0x8000_0008 => (0, 0x457a_0000),
|
|
0x0100_2000 => (0, 0x44fa_0000),
|
|
0x0100_4000 => (0x44fa_0000, 0x44fa_0000),
|
|
_ => (0, 0),
|
|
}
|
|
}
|
|
|
|
fn behavior_profile_entry_for_class(class_flags: u32) -> Option<&'static str> {
|
|
match class_flags {
|
|
0x8000_0004 => Some("prof_mine.var"),
|
|
0x8000_0008 => Some("prof_storage.var"),
|
|
0x8000_0002 => Some("prof_generator.var"),
|
|
0x8000_0010 => Some("prof_plant.var"),
|
|
0x8000_0040 => Some("prof_angar.var"),
|
|
0x8000_0200 => Some("prof_mtp.var"),
|
|
0x8000_0400 => Some("prof_institute.var"),
|
|
0x8001_0000 | 0x8002_0000 | 0x8004_0000 => Some("prof_bunker.var"),
|
|
0x8010_0000 | 0x8020_0000 => Some("prof_tower.var"),
|
|
0x0100_2000 => Some("prof_trn.var"),
|
|
0x0100_4000 => Some("prof_bld.var"),
|
|
0x0100_8000 => Some("prof_war.var"),
|
|
0x0101_0000 => Some("prof_hq.var"),
|
|
0x0102_0000 => Some("prof_hero.var"),
|
|
0x2000_0000 => Some("prof_animal.var"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn prepare_behavior_profile_states(
|
|
vfs: &Arc<dyn Vfs>,
|
|
object_drafts: &[MissionObjectDraft],
|
|
) -> Result<Vec<Option<PreparedBehaviorProfileState>>, EngineError> {
|
|
const DEFAULT_TRANSFER_ORE_OFF_BOARD_BITS: u32 = 0x40a0_0000;
|
|
const DEFAULT_TRANSFER_POWER_OUT_BITS: u32 = 0x42c8_0000;
|
|
|
|
let has_known_profile = object_drafts.iter().any(|draft| {
|
|
draft
|
|
.unit_dat_header_flags
|
|
.and_then(behavior_profile_entry_for_class)
|
|
.is_some()
|
|
});
|
|
let archive_path = has_known_profile
|
|
.then(|| normalize_engine_path("Behavior profile archive", "behpsp.res"))
|
|
.transpose()?;
|
|
let profile_archive = archive_path
|
|
.as_ref()
|
|
.map(|path| {
|
|
let bytes = read_vfs(vfs, path)?;
|
|
decode_nres_payload(bytes).map_err(|source| EngineError::Nres {
|
|
path: path.as_str().to_string(),
|
|
source,
|
|
})
|
|
})
|
|
.transpose()?;
|
|
let mut rates_by_entry = HashMap::<&'static str, BehaviorProfileRates>::new();
|
|
let mut states = Vec::with_capacity(object_drafts.len());
|
|
|
|
for draft in object_drafts {
|
|
let Some(class_flags) = draft.unit_dat_header_flags else {
|
|
states.push(None);
|
|
continue;
|
|
};
|
|
let Some(original_id) = draft.original_id else {
|
|
states.push(None);
|
|
continue;
|
|
};
|
|
let profile_entry = behavior_profile_entry_for_class(class_flags);
|
|
let (transfer_ore_off_board_bits, transfer_power_out_bits) = if let Some(entry_name) =
|
|
profile_entry
|
|
{
|
|
let rates = if let Some(rates) = rates_by_entry.get(entry_name) {
|
|
*rates
|
|
} else {
|
|
let path = archive_path
|
|
.as_ref()
|
|
.expect("known profiles require the profile archive path");
|
|
let archive = profile_archive
|
|
.as_ref()
|
|
.expect("known profiles require the decoded profile archive");
|
|
let entry_id =
|
|
archive
|
|
.find(entry_name)
|
|
.ok_or_else(|| EngineError::BehaviorProfile {
|
|
path: path.as_str().to_string(),
|
|
entry: entry_name.to_string(),
|
|
message: "entry was not found".to_string(),
|
|
})?;
|
|
let payload = archive
|
|
.payload(entry_id)
|
|
.map_err(|source| EngineError::Nres {
|
|
path: path.as_str().to_string(),
|
|
source,
|
|
})?;
|
|
let rates = fparkan_assets::decode_behavior_profile_rates(payload).map_err(
|
|
|source: BehaviorProfileVarError| EngineError::BehaviorProfile {
|
|
path: path.as_str().to_string(),
|
|
entry: entry_name.to_string(),
|
|
message: source.to_string(),
|
|
},
|
|
)?;
|
|
rates_by_entry.insert(entry_name, rates);
|
|
rates
|
|
};
|
|
(
|
|
rates.transfer_ore_off_board_bits,
|
|
rates.transfer_power_out_bits,
|
|
)
|
|
} else {
|
|
(
|
|
DEFAULT_TRANSFER_ORE_OFF_BOARD_BITS,
|
|
DEFAULT_TRANSFER_POWER_OUT_BITS,
|
|
)
|
|
};
|
|
states.push(Some(PreparedBehaviorProfileState {
|
|
original_id,
|
|
class_flags,
|
|
profile_entry,
|
|
transfer_ore_off_board_bits,
|
|
transfer_power_out_bits,
|
|
}));
|
|
}
|
|
Ok(states)
|
|
}
|
|
|
|
fn initialize_behavior_ore_state(
|
|
original_id: OriginalObjectId,
|
|
handle: ObjectHandle,
|
|
class_flags: u32,
|
|
properties: &[MissionObjectProperty],
|
|
) -> BehaviorOreState {
|
|
let (mut current_ore_bits, maximum_ore_bits) = native_behavior_ore_defaults(class_flags);
|
|
if let Some(property) = properties
|
|
.iter()
|
|
.find(|property| property.name_raw.as_slice() == b"CurrentOre")
|
|
{
|
|
current_ore_bits = property.raw_value[1];
|
|
}
|
|
BehaviorOreState {
|
|
original_id,
|
|
handle,
|
|
class_flags,
|
|
current_ore_bits,
|
|
maximum_ore_bits,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ControlTableTarget {
|
|
Append,
|
|
Override(u32),
|
|
}
|
|
|
|
fn native_control_target_action(target: u32) -> ControlTableTarget {
|
|
if target == u32::MAX {
|
|
ControlTableTarget::Append
|
|
} else {
|
|
ControlTableTarget::Override(target)
|
|
}
|
|
}
|
|
|
|
fn native_component_target_index(
|
|
source_type: Option<u32>,
|
|
devices: &[EffectiveControlDevice],
|
|
parent_component_id: u32,
|
|
raw_local_index: i32,
|
|
) -> u32 {
|
|
if source_type == Some(PROTOTYPE_TYPE_EXTO) {
|
|
// AniMesh writes EXTO's mount node to descriptor C4; C8 remains -1,
|
|
// the Control append sentinel.
|
|
u32::MAX
|
|
} else {
|
|
native_into_table_target_index(devices, parent_component_id, raw_local_index)
|
|
}
|
|
}
|
|
|
|
fn native_component_mount_node(
|
|
tree: &[UnitComponentTreeNode],
|
|
component_ids: &[Option<u32>],
|
|
first_global_nodes: &[Option<u32>],
|
|
component_index: usize,
|
|
raw_local_index: i32,
|
|
) -> Option<u32> {
|
|
tree.get(component_index)
|
|
.and_then(|node| node.parent_index)
|
|
.and_then(|parent_index| {
|
|
let parent_id = component_ids.get(parent_index).copied().flatten()?;
|
|
let parent_first = first_global_nodes
|
|
.get(parent_index)
|
|
.copied()
|
|
.flatten()
|
|
.unwrap_or(u32::MAX);
|
|
Some(
|
|
parent_first
|
|
.wrapping_add(raw_local_index as u32)
|
|
.wrapping_sub(u32::from(parent_id != 0)),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn native_into_table_target_index(
|
|
devices: &[EffectiveControlDevice],
|
|
parent_component_id: u32,
|
|
raw_local_index: i32,
|
|
) -> u32 {
|
|
let first_parent_device = devices
|
|
.iter()
|
|
.position(|device| device.component_id == parent_component_id)
|
|
.and_then(|index| u32::try_from(index).ok())
|
|
.unwrap_or(u32::MAX);
|
|
first_parent_device.wrapping_add(raw_local_index as u32)
|
|
}
|
|
|
|
fn native_into_append_key(raw_key: u32) -> u32 {
|
|
if raw_key == 0 {
|
|
u32::MAX
|
|
} else {
|
|
raw_key
|
|
}
|
|
}
|
|
|
|
fn native_ex_to_append_key(
|
|
raw_key: u32,
|
|
component_id: u32,
|
|
mount_node: Option<u32>,
|
|
first_global_node: Option<u32>,
|
|
) -> Option<u32> {
|
|
if raw_key == 0 {
|
|
return mount_node;
|
|
}
|
|
if component_id == 0 {
|
|
return Some(raw_key);
|
|
}
|
|
let mount_node = mount_node?;
|
|
if mount_node == u32::MAX {
|
|
return Some(raw_key);
|
|
}
|
|
let first_global_node = first_global_node.unwrap_or(u32::MAX);
|
|
Some(raw_key.wrapping_add(first_global_node).wrapping_sub(1))
|
|
}
|
|
|
|
fn active_unit_component_order(
|
|
records: &[UnitComponentRecord],
|
|
tree: &[UnitComponentTreeNode],
|
|
) -> Vec<usize> {
|
|
if records.is_empty() || tree.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let mut order = vec![0];
|
|
let mut pending = tree[0].children.iter().rev().copied().collect::<Vec<_>>();
|
|
while let Some(node_index) = pending.pop() {
|
|
let Some(node) = tree.get(node_index) else {
|
|
continue;
|
|
};
|
|
let component_index = node.record_index;
|
|
let Some(component) = records.get(component_index) else {
|
|
continue;
|
|
};
|
|
if component.kind == 0 {
|
|
// The native raw-kind guard returns before visiting this subtree.
|
|
continue;
|
|
}
|
|
order.push(component_index);
|
|
pending.extend(node.children.iter().rev().copied());
|
|
}
|
|
order
|
|
}
|
|
|
|
fn control_device_record_provenance(
|
|
component_index: usize,
|
|
record_index: usize,
|
|
control: &PreparedComponentControl,
|
|
) -> ControlDeviceRecordProvenance {
|
|
ControlDeviceRecordProvenance {
|
|
unit_component_index: component_index,
|
|
typed_record_index: record_index,
|
|
source: control.source.clone(),
|
|
}
|
|
}
|
|
|
|
fn append_effective_control_device(
|
|
devices: &mut Vec<EffectiveControlDevice>,
|
|
component_id: u32,
|
|
key: u32,
|
|
component_index: usize,
|
|
record_index: usize,
|
|
control: &PreparedComponentControl,
|
|
descriptor: &PreparedControlTypedRecord,
|
|
) {
|
|
let provenance = control_device_record_provenance(component_index, record_index, control);
|
|
let mut coefficient_words = [0; 16];
|
|
coefficient_words.copy_from_slice(&descriptor.fixed_words[11..27]);
|
|
let coefficient_bits =
|
|
control_factory_uses_common_setter(descriptor.tag).then_some(coefficient_words);
|
|
let (multiplier_bits, default_word_50) = native_control_factory_defaults(descriptor.tag);
|
|
let native_word_50_bits = if descriptor.fixed_words[6] == u32::MAX {
|
|
default_word_50
|
|
} else {
|
|
Some(descriptor.fixed_words[6])
|
|
};
|
|
devices.push(EffectiveControlDevice {
|
|
factory_tag: descriptor.tag,
|
|
component_id,
|
|
key,
|
|
coefficient_bits,
|
|
multiplier_bits,
|
|
class9_selector0_scale_bits: (descriptor.tag == 9).then_some(0x3f80_0000),
|
|
native_word_50_bits,
|
|
descriptor: descriptor.clone(),
|
|
factory_provenance: provenance.clone(),
|
|
descriptor_provenance: provenance,
|
|
});
|
|
}
|
|
|
|
fn apply_effective_control_override(
|
|
device: &mut EffectiveControlDevice,
|
|
descriptor: &PreparedControlTypedRecord,
|
|
component_index: usize,
|
|
record_index: usize,
|
|
control: &PreparedComponentControl,
|
|
) {
|
|
let mut coefficient_words = [0; 16];
|
|
coefficient_words.copy_from_slice(&descriptor.fixed_words[11..27]);
|
|
device.coefficient_bits = Some(coefficient_words);
|
|
if descriptor.fixed_words[6] != u32::MAX {
|
|
device.native_word_50_bits = Some(descriptor.fixed_words[6]);
|
|
}
|
|
device.descriptor = descriptor.clone();
|
|
device.descriptor_provenance =
|
|
control_device_record_provenance(component_index, record_index, control);
|
|
}
|
|
|
|
fn control_factory_uses_common_setter(factory_tag: u32) -> bool {
|
|
matches!(
|
|
factory_tag,
|
|
1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 15 | 17 | 19 | 21 | 25 | 26 | 27 | 30
|
|
)
|
|
}
|
|
|
|
fn native_control_factory_defaults(factory_tag: u32) -> (Option<u32>, Option<u32>) {
|
|
match factory_tag {
|
|
1 | 4 => (Some(0x3f80_0000), Some(0x200)),
|
|
3 | 5 | 8 | 21 | 26 => (Some(0x3f80_0000), Some(0x5)),
|
|
10 => (Some(0x3f80_0000), Some(0x2000)),
|
|
17 | 19 => (Some(0x3f80_0000), Some(0x20)),
|
|
2 | 27 => (Some(0x3f80_0000), Some(0)),
|
|
9 | 15 | 25 => (Some(0x3f80_0000), None),
|
|
_ => (None, None),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn build_control_device_table(
|
|
object_index: usize,
|
|
draft: &MissionObjectDraft,
|
|
assets: &MissionAssets,
|
|
prototype_span: Option<&std::ops::Range<usize>>,
|
|
prototypes: &[fparkan_prototype::EffectivePrototype],
|
|
) -> Result<Vec<EffectiveControlDevice>, MissionControlDeviceTableUnavailableReason> {
|
|
build_control_device_projection(object_index, draft, assets, prototype_span, prototypes)
|
|
.map(|projection| projection.devices)
|
|
}
|
|
|
|
fn build_control_device_projection(
|
|
object_index: usize,
|
|
draft: &MissionObjectDraft,
|
|
assets: &MissionAssets,
|
|
prototype_span: Option<&std::ops::Range<usize>>,
|
|
prototypes: &[fparkan_prototype::EffectivePrototype],
|
|
) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> {
|
|
use MissionControlDeviceTableUnavailableReason as Unavailable;
|
|
|
|
draft
|
|
.unit_dat_header_flags
|
|
.ok_or(Unavailable::NoUnitDatTree)?;
|
|
if draft.unit_components.is_empty() {
|
|
return Err(Unavailable::InvalidUnitComponentTree);
|
|
}
|
|
let tree = match unit_component_tree(&draft.unit_components) {
|
|
Ok(tree) if !tree.is_empty() => tree,
|
|
_ => return Err(Unavailable::InvalidUnitComponentTree),
|
|
};
|
|
let span = prototype_span.ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
let component_controls = assets
|
|
.object_component_controls
|
|
.get(object_index)
|
|
.ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
let object_visuals = assets
|
|
.object_visuals
|
|
.get(object_index)
|
|
.ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
if span.start > span.end
|
|
|| span.end > prototypes.len()
|
|
|| span.end - span.start != draft.unit_components.len()
|
|
|| component_controls.len() != draft.unit_components.len()
|
|
|| object_visuals.len() != draft.unit_components.len()
|
|
{
|
|
return Err(Unavailable::ComponentAlignmentMismatch);
|
|
}
|
|
|
|
let prototype_for = |component_index: usize| {
|
|
span.start
|
|
.checked_add(component_index)
|
|
.and_then(|index| prototypes.get(index))
|
|
};
|
|
let active_components = active_unit_component_order(&draft.unit_components, &tree);
|
|
if active_components.first() != Some(&0) {
|
|
return Err(Unavailable::InvalidUnitComponentTree);
|
|
}
|
|
|
|
let has_ex_to = active_components.iter().any(|component_index| {
|
|
prototype_for(*component_index)
|
|
.is_some_and(|prototype| prototype.source_type == Some(PROTOTYPE_TYPE_EXTO))
|
|
});
|
|
if has_ex_to {
|
|
for component_index in active_components
|
|
.iter()
|
|
.copied()
|
|
.filter(|index| *index != 0)
|
|
{
|
|
let prototype =
|
|
prototype_for(component_index).ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
if prototype.source_type.is_none() {
|
|
return Err(Unavailable::UnknownComponentKindForExTo {
|
|
unit_component_index: component_index,
|
|
source_type: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut component_ids = vec![None; draft.unit_components.len()];
|
|
component_ids[0] = Some(0_u32);
|
|
let mut next_component_id = 1_u32;
|
|
for component_index in active_components.iter().copied().skip(1) {
|
|
component_ids[component_index] = Some(next_component_id);
|
|
let next = next_component_id
|
|
.checked_add(1)
|
|
.ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
next_component_id = next;
|
|
}
|
|
|
|
let mut first_global_nodes = vec![None; draft.unit_components.len()];
|
|
if has_ex_to {
|
|
let root_prototype = prototype_for(0).ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
let root_is_mesh = matches!(
|
|
root_prototype.geometry,
|
|
fparkan_prototype::PrototypeGeometry::Mesh(_)
|
|
);
|
|
let root_node_count = object_visuals
|
|
.first()
|
|
.and_then(|visual_id| assets.visual_by_id(*visual_id))
|
|
.map(|visual| visual.model_nodes)
|
|
.ok_or(Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: 0,
|
|
})?;
|
|
if !root_is_mesh || root_node_count == 0 {
|
|
return Err(Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: 0,
|
|
});
|
|
}
|
|
let mut global_node_cursor = u32::try_from(root_node_count).map_err(|_| {
|
|
Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: 0,
|
|
}
|
|
})?;
|
|
first_global_nodes[0] = Some(0_u32);
|
|
for component_index in active_components.iter().copied().skip(1) {
|
|
let prototype =
|
|
prototype_for(component_index).ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
match prototype.source_type {
|
|
Some(PROTOTYPE_TYPE_EXTO) => {
|
|
if !matches!(
|
|
prototype.geometry,
|
|
fparkan_prototype::PrototypeGeometry::Mesh(_)
|
|
) {
|
|
return Err(Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: component_index,
|
|
});
|
|
}
|
|
let Some(node_count) = object_visuals
|
|
.get(component_index)
|
|
.and_then(|visual_id| assets.visual_by_id(*visual_id))
|
|
.map(|visual| visual.model_nodes)
|
|
else {
|
|
return Err(Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: component_index,
|
|
});
|
|
};
|
|
if node_count == 0 {
|
|
return Err(Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: component_index,
|
|
});
|
|
}
|
|
if node_count > 1 {
|
|
first_global_nodes[component_index] = Some(global_node_cursor);
|
|
}
|
|
let contribution = u32::try_from(node_count - 1).map_err(|_| {
|
|
Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: component_index,
|
|
}
|
|
})?;
|
|
let next_cursor =
|
|
global_node_cursor
|
|
.checked_add(contribution)
|
|
.ok_or_else(|| Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: component_index,
|
|
})?;
|
|
global_node_cursor = next_cursor;
|
|
}
|
|
Some(_) => {}
|
|
None => {
|
|
return Err(Unavailable::UnknownComponentKindForExTo {
|
|
unit_component_index: component_index,
|
|
source_type: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut global_mesh_states = Vec::new();
|
|
let mut global_node_cursor = 0_usize;
|
|
for component_index in active_components.iter().copied() {
|
|
let prototype =
|
|
prototype_for(component_index).ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
let is_root = component_index == 0;
|
|
let is_ex_to = !is_root && prototype.source_type == Some(PROTOTYPE_TYPE_EXTO);
|
|
if !is_root && !is_ex_to {
|
|
continue;
|
|
}
|
|
|
|
let visual = object_visuals
|
|
.get(component_index)
|
|
.and_then(|visual_id| assets.visual_by_id(*visual_id));
|
|
let node_count = visual.map_or(0, |visual| visual.model_nodes);
|
|
let model = visual
|
|
.and_then(|visual| visual.model_id)
|
|
.and_then(|model_id| assets.model_by_id(model_id));
|
|
let parent_base = if is_ex_to {
|
|
i32::try_from(global_node_cursor)
|
|
.ok()
|
|
.and_then(|count| count.checked_sub(1))
|
|
.ok_or(Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: component_index,
|
|
})?
|
|
} else {
|
|
0
|
|
};
|
|
let mount_node = if is_root {
|
|
Some(0)
|
|
} else {
|
|
native_component_mount_node(
|
|
&tree,
|
|
&component_ids,
|
|
&first_global_nodes,
|
|
component_index,
|
|
draft.unit_components[component_index].parent_or_link,
|
|
)
|
|
};
|
|
let first_source_node = usize::from(is_ex_to);
|
|
for source_node_index in first_source_node..node_count {
|
|
global_mesh_states.push(model.and_then(|model| {
|
|
initial_control_mesh_state(model, source_node_index, parent_base, mount_node)
|
|
}));
|
|
}
|
|
let contribution = node_count.saturating_sub(first_source_node);
|
|
global_node_cursor = global_node_cursor
|
|
.checked_add(contribution)
|
|
.ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
}
|
|
|
|
let mut devices = Vec::new();
|
|
let mut node_provider_status = MissionControlNodeProviderTableStatus::Available(Vec::new());
|
|
let mut has_control_resource = false;
|
|
for component_index in active_components {
|
|
let prototype =
|
|
prototype_for(component_index).ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
if prototype.control_resource.is_none() {
|
|
continue;
|
|
}
|
|
has_control_resource = true;
|
|
if matches!(
|
|
prototype.geometry,
|
|
fparkan_prototype::PrototypeGeometry::NonGeometric
|
|
) {
|
|
return Err(Unavailable::NonGeometricControlResource {
|
|
unit_component_index: component_index,
|
|
});
|
|
}
|
|
let control = component_controls[component_index].as_ref().ok_or(
|
|
Unavailable::ControlResourceUnavailable {
|
|
unit_component_index: component_index,
|
|
},
|
|
)?;
|
|
if let PreparedControlEffectSection::Unsupported {
|
|
typed_record_index: Some(first_unparsed_record),
|
|
..
|
|
} = &control.effect_section
|
|
{
|
|
return Err(Unavailable::IncompleteTypedRecordList {
|
|
unit_component_index: component_index,
|
|
first_unparsed_record: *first_unparsed_record,
|
|
});
|
|
}
|
|
|
|
let component_id =
|
|
component_ids[component_index].ok_or(Unavailable::ComponentAlignmentMismatch)?;
|
|
let is_root = component_index == 0;
|
|
let is_ex_to = prototype.source_type == Some(PROTOTYPE_TYPE_EXTO);
|
|
let raw_local_index = draft.unit_components[component_index].parent_or_link;
|
|
let parent_component_id = tree
|
|
.get(component_index)
|
|
.and_then(|node| node.parent_index)
|
|
.and_then(|parent_index| component_ids.get(parent_index).copied().flatten())
|
|
.unwrap_or(u32::MAX);
|
|
let mount_node = if is_ex_to {
|
|
native_component_mount_node(
|
|
&tree,
|
|
&component_ids,
|
|
&first_global_nodes,
|
|
component_index,
|
|
raw_local_index,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
if is_root {
|
|
append_control_node_provider_rows(
|
|
&mut node_provider_status,
|
|
component_index,
|
|
prototype.node_data_resource.as_ref(),
|
|
control.node_data.as_ref(),
|
|
false,
|
|
);
|
|
} else if is_ex_to && mount_node.is_some_and(|node| node != u32::MAX) {
|
|
append_control_node_provider_rows(
|
|
&mut node_provider_status,
|
|
component_index,
|
|
prototype.node_data_resource.as_ref(),
|
|
control.node_data.as_ref(),
|
|
true,
|
|
);
|
|
}
|
|
if is_root {
|
|
for (record_index, descriptor) in control.typed_records.iter().enumerate() {
|
|
append_effective_control_device(
|
|
&mut devices,
|
|
component_id,
|
|
descriptor.fixed_words[1],
|
|
component_index,
|
|
record_index,
|
|
control,
|
|
descriptor,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let native_target = native_component_target_index(
|
|
prototype.source_type,
|
|
&devices,
|
|
parent_component_id,
|
|
raw_local_index,
|
|
);
|
|
match native_control_target_action(native_target) {
|
|
ControlTableTarget::Append => {
|
|
let first_global_node = first_global_nodes.get(component_index).copied().flatten();
|
|
for (record_index, descriptor) in control.typed_records.iter().enumerate() {
|
|
let raw_key = descriptor.fixed_words[1];
|
|
let key = if is_ex_to {
|
|
native_ex_to_append_key(
|
|
raw_key,
|
|
component_id,
|
|
mount_node,
|
|
first_global_node,
|
|
)
|
|
.ok_or(
|
|
Unavailable::UnsupportedExToNodeMapping {
|
|
unit_component_index: component_index,
|
|
},
|
|
)?
|
|
} else {
|
|
native_into_append_key(raw_key)
|
|
};
|
|
append_effective_control_device(
|
|
&mut devices,
|
|
component_id,
|
|
key,
|
|
component_index,
|
|
record_index,
|
|
control,
|
|
descriptor,
|
|
);
|
|
}
|
|
}
|
|
ControlTableTarget::Override(target_index) => {
|
|
let descriptor =
|
|
control
|
|
.typed_records
|
|
.first()
|
|
.ok_or(Unavailable::MissingOverrideRecord {
|
|
unit_component_index: component_index,
|
|
})?;
|
|
let target_index_usize = usize::try_from(target_index).map_err(|_| {
|
|
Unavailable::OverrideIndexOutOfRange {
|
|
unit_component_index: component_index,
|
|
target_index,
|
|
}
|
|
})?;
|
|
let device = devices.get_mut(target_index_usize).ok_or(
|
|
Unavailable::OverrideIndexOutOfRange {
|
|
unit_component_index: component_index,
|
|
target_index,
|
|
},
|
|
)?;
|
|
if !matches!(
|
|
device.factory_tag,
|
|
2 | 3 | 4 | 5 | 8 | 9 | 10 | 15 | 19 | 21 | 25 | 27
|
|
) {
|
|
return Err(Unavailable::UnsupportedOverrideReceiver {
|
|
factory_tag: device.factory_tag,
|
|
});
|
|
}
|
|
apply_effective_control_override(device, descriptor, component_index, 0, control);
|
|
}
|
|
}
|
|
}
|
|
if !has_control_resource {
|
|
return Err(Unavailable::NoControlResource);
|
|
}
|
|
if let MissionControlNodeProviderTableStatus::Available(rows) = &mut node_provider_status {
|
|
attach_initial_mesh_states_by_global_ordinal(rows, &global_mesh_states);
|
|
}
|
|
Ok(ControlDeviceProjection {
|
|
devices,
|
|
node_provider_status,
|
|
})
|
|
}
|
|
|
|
fn append_control_node_provider_rows(
|
|
status: &mut MissionControlNodeProviderTableStatus,
|
|
unit_component_index: usize,
|
|
requested_source: Option<&ResourceKey>,
|
|
node_data: Option<&PreparedControlNodeDataTable>,
|
|
skip_first_row: bool,
|
|
) {
|
|
let MissionControlNodeProviderTableStatus::Available(rows) = status else {
|
|
return;
|
|
};
|
|
let Some(node_data) = node_data else {
|
|
*status = MissionControlNodeProviderTableStatus::Unavailable(
|
|
MissionControlNodeProviderTableUnavailableReason::MissingNodeData {
|
|
unit_component_index,
|
|
source: requested_source.cloned(),
|
|
},
|
|
);
|
|
return;
|
|
};
|
|
if skip_first_row && node_data.rows.is_empty() {
|
|
*status = MissionControlNodeProviderTableStatus::Unavailable(
|
|
MissionControlNodeProviderTableUnavailableReason::MissingExToRootRow {
|
|
unit_component_index,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
let first_row = usize::from(skip_first_row);
|
|
for (source_row_index, source_row) in node_data.rows.iter().enumerate().skip(first_row) {
|
|
rows.push(MissionControlNodeProviderRow {
|
|
source: node_data.source.clone(),
|
|
unit_component_index,
|
|
source_row_index,
|
|
source_row: source_row.clone(),
|
|
initial_amount_bits: source_row.max_volume.to_bits(),
|
|
initial_type1_value_bits: 0x3f80_0000,
|
|
initial_mesh_state: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn control_flags_from_node38_header(header_flags: u16) -> u32 {
|
|
let mut flags = 0;
|
|
if header_flags & 0x0200 != 0 {
|
|
flags |= 0x01;
|
|
}
|
|
if header_flags & 0x0100 != 0 {
|
|
flags |= 0x02;
|
|
}
|
|
if header_flags & 0x0400 != 0 {
|
|
flags |= 0x04;
|
|
}
|
|
if header_flags & 0x0001 != 0 && header_flags & 0x0080 == 0 {
|
|
flags |= 0x08;
|
|
}
|
|
flags
|
|
}
|
|
|
|
fn native_mesh_parent_index(
|
|
parent_or_link_raw: u16,
|
|
parent_base: i32,
|
|
mount_node: Option<u32>,
|
|
) -> Option<i32> {
|
|
if parent_or_link_raw == 0 {
|
|
i32::try_from(mount_node?).ok()
|
|
} else {
|
|
let relative_parent = if parent_or_link_raw == u16::MAX {
|
|
-1
|
|
} else {
|
|
i32::from(parent_or_link_raw)
|
|
};
|
|
parent_base.checked_add(relative_parent)
|
|
}
|
|
}
|
|
|
|
fn initial_control_mesh_state(
|
|
model: &fparkan_assets::PreparedModel,
|
|
source_node_index: usize,
|
|
parent_base: i32,
|
|
mount_node: Option<u32>,
|
|
) -> Option<MissionControlNodeInitialMeshState> {
|
|
let node_index = u32::try_from(source_node_index).ok()?;
|
|
let node = node38_metadata(&model.validated, NodeId(node_index))?;
|
|
let stage_prefix = (0..3_u8)
|
|
.take_while(|state| {
|
|
selected_slot(&model.validated, NodeId(node_index), Lod(*state), Group(0)).is_some()
|
|
})
|
|
.count();
|
|
Some(MissionControlNodeInitialMeshState {
|
|
parent_index: native_mesh_parent_index(node.parent_or_link_raw, parent_base, mount_node)?,
|
|
flags: control_flags_from_node38_header(node.header_flags),
|
|
stage_count: u32::try_from(stage_prefix.max(1)).ok()?,
|
|
})
|
|
}
|
|
|
|
fn attach_initial_mesh_states_by_global_ordinal(
|
|
provider_rows: &mut [MissionControlNodeProviderRow],
|
|
global_mesh_states: &[Option<MissionControlNodeInitialMeshState>],
|
|
) {
|
|
for (ordinal, row) in provider_rows.iter_mut().enumerate() {
|
|
row.initial_mesh_state = global_mesh_states.get(ordinal).copied().flatten();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
})
|
|
}
|
|
|
|
fn load_mission_scripts(
|
|
vfs: &Arc<dyn Vfs>,
|
|
mission: &MissionDocument,
|
|
asset_scope: MissionAssetScope,
|
|
) -> Result<LoadedMissionScripts, EngineError> {
|
|
let script_bases = mission_script_bundle_bases(mission);
|
|
let mut bundles = Vec::with_capacity(script_bases.len());
|
|
let mut first_base_path = None;
|
|
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(),
|
|
})?;
|
|
if first_base_path.is_none() {
|
|
first_base_path = Some(base_path.clone());
|
|
}
|
|
bundles.push(MissionScriptBundle {
|
|
clan_index: script_base.clan_index,
|
|
base_path,
|
|
script_path: normalized.as_str().to_string(),
|
|
package,
|
|
init_execution: MissionScriptInitExecution::Missing,
|
|
behavior_warm_start: None,
|
|
});
|
|
}
|
|
let varset = first_base_path
|
|
.as_deref()
|
|
.map(|base_path| load_script_varset(vfs, base_path))
|
|
.transpose()?;
|
|
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(
|
|
&mission
|
|
.clans
|
|
.iter()
|
|
.map(|clan| clan.anchor)
|
|
.collect::<Vec<_>>(),
|
|
&mut bundles,
|
|
varset,
|
|
asset_scope,
|
|
)?;
|
|
let varset_states = materialize_script_varset_states(&bundles, varset, &init_states)?;
|
|
(init_states, varset_states)
|
|
} else {
|
|
(Vec::new(), Vec::new())
|
|
};
|
|
Ok(LoadedMissionScripts {
|
|
bundles,
|
|
varset,
|
|
init_states,
|
|
varset_states,
|
|
})
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
fn resolve_handler19_init_states(
|
|
clan_anchors: &[[f32; 2]],
|
|
bundles: &mut [MissionScriptBundle],
|
|
varset: &MissionScriptVarSet,
|
|
asset_scope: MissionAssetScope,
|
|
) -> Result<Vec<MissionScriptInitState>, EngineError> {
|
|
let mut states = Vec::new();
|
|
for bundle in bundles {
|
|
if !bundle
|
|
.package
|
|
.events
|
|
.iter()
|
|
.any(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]))
|
|
{
|
|
bundle.init_execution = MissionScriptInitExecution::Missing;
|
|
continue;
|
|
}
|
|
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 {
|
|
first_x87_word: handler19_anchor_word(anchor[0], bundle.clan_index, "x")?,
|
|
second_x87_word: handler19_anchor_word(anchor[1], bundle.clan_index, "y")?,
|
|
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(),
|
|
})?,
|
|
};
|
|
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;
|
|
}
|
|
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 {
|
|
clan_index: bundle.clan_index,
|
|
path: bundle.script_path.clone(),
|
|
event_index,
|
|
instruction_index,
|
|
source: error,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(states)
|
|
}
|
|
|
|
fn handler19_anchor_word(
|
|
value: f32,
|
|
clan_index: usize,
|
|
axis: &'static str,
|
|
) -> Result<u32, EngineError> {
|
|
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 {
|
|
return Err(EngineError::ScriptInit {
|
|
clan_index,
|
|
message: format!(
|
|
"ClanBase{axis}={value:?} is outside the modeled x87 FISTP qword range"
|
|
),
|
|
});
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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::*;
|
|
use fparkan_script::ScriptDispatchSelector;
|
|
use fparkan_vfs::{DirectoryVfs, MemoryVfs, OverlayVfs, VfsEntry, VfsMetadata};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
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(),
|
|
raw_after_resource: 0,
|
|
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(),
|
|
}
|
|
}
|
|
|
|
fn synthetic_resource_key(index: usize) -> ResourceKey {
|
|
ResourceKey {
|
|
archive: normalize_engine_path("test", &format!("synthetic/{index}.rlb"))
|
|
.expect("synthetic archive path"),
|
|
name: fparkan_path::ResourceName(format!("entry{index}").into_bytes()),
|
|
type_id: None,
|
|
}
|
|
}
|
|
|
|
fn synthetic_control_record(
|
|
tag: u32,
|
|
key: u32,
|
|
first_coefficient: u32,
|
|
native_word_50: Option<u32>,
|
|
) -> PreparedControlTypedRecord {
|
|
let mut fixed_words = [0; 44];
|
|
fixed_words[1] = key;
|
|
fixed_words[6] = native_word_50.unwrap_or(u32::MAX);
|
|
fixed_words[11] = first_coefficient;
|
|
PreparedControlTypedRecord {
|
|
tag,
|
|
fixed_words,
|
|
values: Vec::new(),
|
|
string_bytes: Vec::new(),
|
|
gate_provider: fparkan_assets::PreparedControlGateProvider::NotApplicable,
|
|
}
|
|
}
|
|
|
|
fn synthetic_component_control(
|
|
index: usize,
|
|
typed_records: Vec<PreparedControlTypedRecord>,
|
|
effect_section: PreparedControlEffectSection,
|
|
) -> PreparedComponentControl {
|
|
PreparedComponentControl {
|
|
source: synthetic_resource_key(100 + index),
|
|
model_node_count: 0,
|
|
bindings: Vec::new(),
|
|
timeline_rows: Vec::new(),
|
|
typed_records,
|
|
node_data: None,
|
|
effect_section,
|
|
initial_effect_group: None,
|
|
point_list: None,
|
|
}
|
|
}
|
|
|
|
fn synthetic_control_node_data_table(
|
|
index: usize,
|
|
row_count: usize,
|
|
) -> PreparedControlNodeDataTable {
|
|
PreparedControlNodeDataTable {
|
|
source: synthetic_resource_key(300 + index),
|
|
rows: (0..row_count)
|
|
.map(|row_index| PreparedControlNodeDataRow {
|
|
raw_flags: u32::try_from(row_index).expect("row index fits u32"),
|
|
max_volume: (100 + row_index) as f32,
|
|
mobility_per_volume: (200 + row_index) as f32,
|
|
effect: PreparedControlResourcePair {
|
|
archive: [0; 32],
|
|
name: [0; 32],
|
|
},
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn control_with_synthetic_node_data(
|
|
index: usize,
|
|
typed_records: Vec<PreparedControlTypedRecord>,
|
|
row_count: usize,
|
|
) -> PreparedComponentControl {
|
|
let mut control = synthetic_component_control(
|
|
index,
|
|
typed_records,
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
);
|
|
control.node_data = Some(synthetic_control_node_data_table(index, row_count));
|
|
control
|
|
}
|
|
|
|
fn synthetic_node_provider_row(
|
|
component_index: usize,
|
|
source_row_index: usize,
|
|
initial_type1_value_bits: u32,
|
|
) -> MissionControlNodeProviderRow {
|
|
MissionControlNodeProviderRow {
|
|
source: synthetic_resource_key(300 + component_index),
|
|
unit_component_index: component_index,
|
|
source_row_index,
|
|
source_row: PreparedControlNodeDataRow {
|
|
raw_flags: u32::try_from(source_row_index).expect("row index fits u32"),
|
|
max_volume: 100.0,
|
|
mobility_per_volume: 200.0,
|
|
effect: PreparedControlResourcePair {
|
|
archive: [0; 32],
|
|
name: [0; 32],
|
|
},
|
|
},
|
|
initial_amount_bits: 100.0_f32.to_bits(),
|
|
initial_type1_value_bits,
|
|
initial_mesh_state: None,
|
|
}
|
|
}
|
|
|
|
fn synthetic_node38(
|
|
header_flags: u16,
|
|
parent_or_link: u16,
|
|
group0_states: [bool; 3],
|
|
) -> [u8; 38] {
|
|
let mut node = [0xff; 38];
|
|
node[0..2].copy_from_slice(&header_flags.to_le_bytes());
|
|
node[2..4].copy_from_slice(&parent_or_link.to_le_bytes());
|
|
node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
|
|
node[6..8].copy_from_slice(&0_u16.to_le_bytes());
|
|
for (state, present) in group0_states.into_iter().enumerate() {
|
|
let slot = if present { 0_u16 } else { u16::MAX };
|
|
let offset = 8 + state * 10;
|
|
node[offset..offset + 2].copy_from_slice(&slot.to_le_bytes());
|
|
}
|
|
node
|
|
}
|
|
|
|
fn synthetic_model_asset(nodes: &[[u8; 38]]) -> fparkan_msh::ModelAsset {
|
|
let mut nodes_raw = Vec::with_capacity(nodes.len() * 38);
|
|
for node in nodes {
|
|
nodes_raw.extend_from_slice(node);
|
|
}
|
|
fparkan_msh::ModelAsset {
|
|
node_stride: 38,
|
|
node_count: nodes.len(),
|
|
nodes_raw,
|
|
bounding_sphere_center: [0.0; 3],
|
|
bounding_sphere_radius: 0.0,
|
|
slots: vec![fparkan_msh::Slot {
|
|
tri_start: 0,
|
|
tri_count: 0,
|
|
batch_start: 0,
|
|
batch_count: 0,
|
|
aabb_min: [0.0; 3],
|
|
aabb_max: [0.0; 3],
|
|
sphere_center: [0.0; 3],
|
|
sphere_radius: 0.0,
|
|
opaque: [0; 5],
|
|
}],
|
|
positions: Vec::new(),
|
|
normals: None,
|
|
uv0: None,
|
|
uv1: None,
|
|
indices: Vec::new(),
|
|
batches: Vec::new(),
|
|
node_names: None,
|
|
animation: None,
|
|
}
|
|
}
|
|
|
|
fn attach_synthetic_model(
|
|
assets: &mut MissionAssets,
|
|
component_index: usize,
|
|
nodes: &[[u8; 38]],
|
|
) {
|
|
let model_id = AssetId::<fparkan_assets::PreparedModel>::new(100 + component_index as u64);
|
|
assets.visuals[component_index].model_id = Some(model_id);
|
|
assets.models.push(fparkan_assets::PreparedModel {
|
|
id: model_id,
|
|
source: synthetic_resource_key(500 + component_index),
|
|
validated: synthetic_model_asset(nodes),
|
|
mesh_dependencies: Vec::new(),
|
|
});
|
|
}
|
|
|
|
fn synthetic_control_fixture(
|
|
source_types: &[Option<u32>],
|
|
kinds: &[u32],
|
|
child_counts: &[u32],
|
|
parent_or_links: &[i32],
|
|
model_nodes: &[usize],
|
|
controls: Vec<Option<PreparedComponentControl>>,
|
|
) -> (
|
|
MissionObjectDraft,
|
|
MissionAssets,
|
|
Vec<fparkan_prototype::EffectivePrototype>,
|
|
) {
|
|
let component_count = source_types.len();
|
|
assert_eq!(kinds.len(), component_count);
|
|
assert_eq!(child_counts.len(), component_count);
|
|
assert_eq!(parent_or_links.len(), component_count);
|
|
assert_eq!(model_nodes.len(), component_count);
|
|
assert_eq!(controls.len(), component_count);
|
|
|
|
let unit_components = (0..component_count)
|
|
.map(|index| UnitComponentRecord {
|
|
archive_raw: [0; 32],
|
|
resource_raw: [0; 32],
|
|
kind: kinds[index],
|
|
parent_or_link: parent_or_links[index],
|
|
description_raw: [0; 32],
|
|
tail0: 0,
|
|
immediate_child_count: child_counts[index],
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let mut assets = MissionAssets::default();
|
|
let mut prototypes = Vec::with_capacity(component_count);
|
|
let mut visuals = Vec::with_capacity(component_count);
|
|
let mut visual_ids = Vec::with_capacity(component_count);
|
|
for index in 0..component_count {
|
|
let mesh = synthetic_resource_key(200 + index);
|
|
let visual_id = AssetId::new(index as u64);
|
|
visual_ids.push(visual_id);
|
|
visuals.push(PreparedVisual {
|
|
id: visual_id,
|
|
mesh: Some(mesh.clone()),
|
|
source_type: source_types[index],
|
|
model_id: None,
|
|
wear_id: None,
|
|
model_nodes: model_nodes[index],
|
|
model_slots: 0,
|
|
model_batches: 0,
|
|
material_count: 0,
|
|
material_ids: Vec::new(),
|
|
texture_ids: Vec::new(),
|
|
lightmap_ids: Vec::new(),
|
|
texture_count: 0,
|
|
lightmap_count: 0,
|
|
});
|
|
prototypes.push(fparkan_prototype::EffectivePrototype {
|
|
key: fparkan_prototype::PrototypeKey(fparkan_path::ResourceName(
|
|
format!("prototype{index}").into_bytes(),
|
|
)),
|
|
geometry: fparkan_prototype::PrototypeGeometry::Mesh(mesh),
|
|
source: fparkan_prototype::PrototypeSource::ObjectsRegistry,
|
|
source_type: source_types[index],
|
|
dependencies: Vec::new(),
|
|
control_resource: controls[index]
|
|
.as_ref()
|
|
.map(|control| control.source.clone()),
|
|
node_data_resource: controls[index]
|
|
.as_ref()
|
|
.and_then(|control| control.node_data.as_ref())
|
|
.map(|node_data| node_data.source.clone()),
|
|
point_resource: None,
|
|
bas_resource: None,
|
|
});
|
|
}
|
|
assets.visuals = visuals;
|
|
assets.object_visuals = vec![visual_ids.clone()];
|
|
assets.object_component_controls = vec![controls];
|
|
let draft = MissionObjectDraft {
|
|
original_id: Some(OriginalObjectId(0)),
|
|
resource_name_raw: b"TEST/ROOT.DAT".to_vec(),
|
|
raw_after_resource: 0,
|
|
identity_or_clan_raw: 0,
|
|
raw_after_name: 0,
|
|
unit_dat_header_flags: Some(0),
|
|
position: [0.0; 3],
|
|
orientation_raw: [0.0; 3],
|
|
scale: [1.0; 3],
|
|
visual_ids,
|
|
properties: Vec::new(),
|
|
unit_components,
|
|
};
|
|
(draft, assets, prototypes)
|
|
}
|
|
|
|
fn synthetic_control_table(
|
|
source_types: &[Option<u32>],
|
|
kinds: &[u32],
|
|
child_counts: &[u32],
|
|
parent_or_links: &[i32],
|
|
model_nodes: &[usize],
|
|
controls: Vec<Option<PreparedComponentControl>>,
|
|
) -> MissionControlDeviceTableStatus {
|
|
let (draft, assets, prototypes) = synthetic_control_fixture(
|
|
source_types,
|
|
kinds,
|
|
child_counts,
|
|
parent_or_links,
|
|
model_nodes,
|
|
controls,
|
|
);
|
|
build_control_device_table(
|
|
0,
|
|
&draft,
|
|
&assets,
|
|
Some(&(0..prototypes.len())),
|
|
&prototypes,
|
|
)
|
|
.map_or_else(
|
|
MissionControlDeviceTableStatus::Unavailable,
|
|
MissionControlDeviceTableStatus::Available,
|
|
)
|
|
}
|
|
|
|
fn synthetic_control_projection(
|
|
source_types: &[Option<u32>],
|
|
kinds: &[u32],
|
|
child_counts: &[u32],
|
|
parent_or_links: &[i32],
|
|
model_nodes: &[usize],
|
|
controls: Vec<Option<PreparedComponentControl>>,
|
|
) -> (
|
|
MissionControlDeviceTableStatus,
|
|
MissionControlNodeProviderTableStatus,
|
|
) {
|
|
let (draft, assets, prototypes) = synthetic_control_fixture(
|
|
source_types,
|
|
kinds,
|
|
child_counts,
|
|
parent_or_links,
|
|
model_nodes,
|
|
controls,
|
|
);
|
|
let (status, node_provider_status) = match build_control_device_projection(
|
|
0,
|
|
&draft,
|
|
&assets,
|
|
Some(&(0..prototypes.len())),
|
|
&prototypes,
|
|
) {
|
|
Ok(projection) => (
|
|
MissionControlDeviceTableStatus::Available(projection.devices),
|
|
projection.node_provider_status,
|
|
),
|
|
Err(reason) => (
|
|
MissionControlDeviceTableStatus::Unavailable(reason.clone()),
|
|
MissionControlNodeProviderTableStatus::Unavailable(
|
|
MissionControlNodeProviderTableUnavailableReason::DeviceTableUnavailable(
|
|
reason,
|
|
),
|
|
),
|
|
),
|
|
};
|
|
(status, node_provider_status)
|
|
}
|
|
|
|
fn synthetic_effective_control_device(
|
|
factory_tag: u32,
|
|
descriptor_tag: u32,
|
|
coefficient_bits: u32,
|
|
multiplier_bits: Option<u32>,
|
|
class9_scale_bits: Option<u32>,
|
|
) -> EffectiveControlDevice {
|
|
let descriptor = synthetic_control_record(descriptor_tag, 2, coefficient_bits, None);
|
|
let control = synthetic_component_control(
|
|
0,
|
|
vec![descriptor.clone()],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
);
|
|
let provenance = control_device_record_provenance(0, 0, &control);
|
|
let mut coefficients = [0; 16];
|
|
coefficients[0] = coefficient_bits;
|
|
EffectiveControlDevice {
|
|
factory_tag,
|
|
component_id: 3,
|
|
key: 2,
|
|
coefficient_bits: Some(coefficients),
|
|
multiplier_bits,
|
|
class9_selector0_scale_bits: class9_scale_bits,
|
|
native_word_50_bits: None,
|
|
descriptor,
|
|
factory_provenance: provenance.clone(),
|
|
descriptor_provenance: provenance,
|
|
}
|
|
}
|
|
|
|
fn synthetic_control_device_table(
|
|
status: MissionControlDeviceTableStatus,
|
|
) -> MissionControlDeviceTableState {
|
|
synthetic_control_device_table_with_providers(
|
|
status,
|
|
MissionControlNodeProviderTableStatus::Available(Vec::new()),
|
|
)
|
|
}
|
|
|
|
fn synthetic_control_device_table_with_providers(
|
|
status: MissionControlDeviceTableStatus,
|
|
node_provider_status: MissionControlNodeProviderTableStatus,
|
|
) -> MissionControlDeviceTableState {
|
|
MissionControlDeviceTableState {
|
|
original_id: Some(OriginalObjectId(7)),
|
|
handle: ObjectHandle {
|
|
generation: 5,
|
|
slot: 7,
|
|
},
|
|
status,
|
|
node_provider_status,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_profile_selector_1002_sums_control_e00_rows_then_multiplies_profile_rate() {
|
|
let table = synthetic_control_device_table_with_providers(
|
|
MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
26,
|
|
26,
|
|
3.0_f32.to_bits(),
|
|
Some(4.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
5,
|
|
5,
|
|
100.0_f32.to_bits(),
|
|
Some(100.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
10,
|
|
10,
|
|
100.0_f32.to_bits(),
|
|
Some(100.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
21,
|
|
21,
|
|
100.0_f32.to_bits(),
|
|
Some(100.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
26,
|
|
26,
|
|
2.0_f32.to_bits(),
|
|
Some(2.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
]),
|
|
MissionControlNodeProviderTableStatus::Available(
|
|
(0..3)
|
|
.map(|index| synthetic_node_provider_row(0, index, 2.0_f32.to_bits()))
|
|
.collect(),
|
|
),
|
|
);
|
|
assert_eq!(
|
|
query_behavior_profile_ore_rate(&table, 1.5_f32.to_bits())
|
|
.expect("supported Behavior ore query")
|
|
.to_bits(),
|
|
48.0_f32.to_bits()
|
|
);
|
|
|
|
let mut factory_mismatch = table.clone();
|
|
let MissionControlDeviceTableStatus::Available(devices) = &mut factory_mismatch.status
|
|
else {
|
|
unreachable!();
|
|
};
|
|
devices[0].factory_tag = 9;
|
|
assert_eq!(
|
|
query_behavior_profile_ore_rate(&factory_mismatch, 1.5_f32.to_bits()),
|
|
Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag: 9 })
|
|
);
|
|
|
|
let unavailable =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Unavailable(
|
|
MissionControlDeviceTableUnavailableReason::NoControlResource,
|
|
));
|
|
assert_eq!(
|
|
query_behavior_profile_ore_rate(&unavailable, 1.0_f32.to_bits()),
|
|
Err(ControlResourceAccessError::TableUnavailable(
|
|
MissionControlDeviceTableUnavailableReason::NoControlResource
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn control_resource_queries_filter_current_tag_and_use_factory_getters() {
|
|
let table =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
9,
|
|
19,
|
|
2.0_f32.to_bits(),
|
|
Some(3.0_f32.to_bits()),
|
|
Some(0.5_f32.to_bits()),
|
|
),
|
|
synthetic_effective_control_device(
|
|
19,
|
|
9,
|
|
100.0_f32.to_bits(),
|
|
Some(77.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
15,
|
|
19,
|
|
4.0_f32.to_bits(),
|
|
Some(0.5_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
1.0_f32.to_bits(),
|
|
Some(4.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
|
|
assert_eq!(
|
|
query_control_device_table_resource(&table, 1)
|
|
.expect("selector 1")
|
|
.map(f32::to_bits),
|
|
Some(2.0_f32.to_bits())
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_resource(&table, 2)
|
|
.expect("selector 2")
|
|
.map(f32::to_bits),
|
|
Some(6.0_f32.to_bits())
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_resource(&table, 3),
|
|
Err(ControlResourceAccessError::UnsupportedSelector(3))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn control_resource_setter_updates_matching_rows_atomically_and_round_trips() {
|
|
let mut table =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
9,
|
|
19,
|
|
2.0_f32.to_bits(),
|
|
Some(3.0_f32.to_bits()),
|
|
Some(0.5_f32.to_bits()),
|
|
),
|
|
synthetic_effective_control_device(
|
|
19,
|
|
9,
|
|
100.0_f32.to_bits(),
|
|
Some(77.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
15,
|
|
19,
|
|
4.0_f32.to_bits(),
|
|
Some(0.5_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
1.0_f32.to_bits(),
|
|
Some(4.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
|
|
set_control_device_table_multiplier(&mut table, 7.5).expect("setter 1");
|
|
let MissionControlDeviceTableStatus::Available(entries) = &table.status else {
|
|
panic!("synthetic table is available");
|
|
};
|
|
assert_eq!(
|
|
entries
|
|
.iter()
|
|
.map(|entry| entry.multiplier_bits)
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
Some(7.5_f32.to_bits()),
|
|
Some(77.0_f32.to_bits()),
|
|
Some(7.5_f32.to_bits()),
|
|
Some(7.5_f32.to_bits())
|
|
]
|
|
);
|
|
assert_eq!(
|
|
entries[0].class9_selector0_scale_bits,
|
|
Some(0.5_f32.to_bits())
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_resource(&table, 1)
|
|
.expect("updated selector 1")
|
|
.map(f32::to_bits),
|
|
Some(8.75_f32.to_bits())
|
|
);
|
|
|
|
let mut unsupported =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
2.0_f32.to_bits(),
|
|
Some(1.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(12, 19, 1.0_f32.to_bits(), None, None),
|
|
]));
|
|
assert_eq!(
|
|
set_control_device_table_multiplier(&mut unsupported, 2.0),
|
|
Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag: 12 })
|
|
);
|
|
let MissionControlDeviceTableStatus::Available(entries) = &unsupported.status else {
|
|
panic!("synthetic table is available");
|
|
};
|
|
assert_eq!(entries[0].multiplier_bits, Some(1.0_f32.to_bits()));
|
|
}
|
|
|
|
#[test]
|
|
fn control_resource_queries_preserve_native_no_result_and_negative_rules() {
|
|
let empty =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new()));
|
|
assert_eq!(query_control_device_table_resource(&empty, 1), Ok(None));
|
|
assert_eq!(query_control_device_table_resource(&empty, 2), Ok(None));
|
|
assert!(set_control_device_table_multiplier(&mut empty.clone(), f32::NAN).is_ok());
|
|
|
|
let zero_denominator =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
0.0_f32.to_bits(),
|
|
Some(2.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
assert_eq!(
|
|
query_control_device_table_resource(&zero_denominator, 1),
|
|
Ok(None)
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_resource(&zero_denominator, 2),
|
|
Ok(None)
|
|
);
|
|
|
|
let nonmatching =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
9,
|
|
4.0_f32.to_bits(),
|
|
Some(2.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
assert_eq!(
|
|
query_control_device_table_resource(&nonmatching, 1),
|
|
Ok(None)
|
|
);
|
|
|
|
let no_multiplier =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(19, 19, 2.0_f32.to_bits(), None, None),
|
|
]));
|
|
assert_eq!(
|
|
query_control_device_table_resource(&no_multiplier, 2)
|
|
.expect("selector 2 does not read multiplier")
|
|
.map(f32::to_bits),
|
|
Some(2.0_f32.to_bits())
|
|
);
|
|
|
|
let negative =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(19, 19, (-1.0_f32).to_bits(), None, None),
|
|
]));
|
|
assert_eq!(
|
|
query_control_device_table_resource(&negative, 1)
|
|
.expect("negative selector 1")
|
|
.map(f32::to_bits),
|
|
Some(1.0_f32.to_bits())
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_resource(&negative, 2)
|
|
.expect("negative selector 2")
|
|
.map(f32::to_bits),
|
|
Some((-1.0_f32).to_bits())
|
|
);
|
|
|
|
let rounded =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
1.0_f32.to_bits(),
|
|
Some(16_777_216.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
1.0_f32.to_bits(),
|
|
Some(1.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
1.0_f32.to_bits(),
|
|
Some((-16_777_216.0_f32).to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
assert_eq!(
|
|
query_control_device_table_resource(&rounded, 1)
|
|
.expect("rounded selector 1")
|
|
.map(f32::to_bits),
|
|
Some(0.0_f32.to_bits())
|
|
);
|
|
|
|
let mut replaceable =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
1.0_f32.to_bits(),
|
|
Some(f32::INFINITY.to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
set_control_device_table_multiplier(&mut replaceable, 2.0)
|
|
.expect("setter does not read the old multiplier");
|
|
let MissionControlDeviceTableStatus::Available(entries) = &replaceable.status else {
|
|
panic!("synthetic table is available");
|
|
};
|
|
assert_eq!(entries[0].multiplier_bits, Some(2.0_f32.to_bits()));
|
|
}
|
|
|
|
#[test]
|
|
fn control_resource_access_reports_unavailable_and_nonfinite_inputs() {
|
|
let unavailable =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Unavailable(
|
|
MissionControlDeviceTableUnavailableReason::NoControlResource,
|
|
));
|
|
assert_eq!(
|
|
query_control_device_table_resource(&unavailable, 1),
|
|
Err(ControlResourceAccessError::TableUnavailable(
|
|
MissionControlDeviceTableUnavailableReason::NoControlResource
|
|
))
|
|
);
|
|
|
|
let nonfinite =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
f32::INFINITY.to_bits(),
|
|
Some(1.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
assert!(matches!(
|
|
query_control_device_table_resource(&nonfinite, 1),
|
|
Err(ControlResourceAccessError::NonFiniteValue {
|
|
factory_tag: 19,
|
|
field: "coefficient"
|
|
})
|
|
));
|
|
|
|
let subnormal =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(19, 19, 1, Some(1.0_f32.to_bits()), None),
|
|
]));
|
|
assert!(matches!(
|
|
query_control_device_table_resource(&subnormal, 2),
|
|
Err(ControlResourceAccessError::SubnormalValue {
|
|
factory_tag: 19,
|
|
field: "coefficient"
|
|
})
|
|
));
|
|
|
|
let mut finite =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
1.0_f32.to_bits(),
|
|
Some(1.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
assert!(matches!(
|
|
set_control_device_table_multiplier(&mut finite, f32::INFINITY),
|
|
Err(ControlResourceAccessError::NonFiniteValue {
|
|
factory_tag: 19,
|
|
field: "multiplier"
|
|
})
|
|
));
|
|
|
|
let underflowing_product =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
f32::MIN_POSITIVE.to_bits(),
|
|
Some(f32::MIN_POSITIVE.to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
assert!(matches!(
|
|
query_control_device_table_resource(&underflowing_product, 1),
|
|
Err(ControlResourceAccessError::UnsupportedFloatRange {
|
|
factory_tag: Some(19),
|
|
field: "weighted value"
|
|
})
|
|
));
|
|
|
|
let underflowing_ratio =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
f32::MIN_POSITIVE.to_bits(),
|
|
Some(1.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
f32::MAX.to_bits(),
|
|
Some(0.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
]));
|
|
assert!(matches!(
|
|
query_control_device_table_resource(&underflowing_ratio, 1),
|
|
Err(ControlResourceAccessError::UnsupportedFloatRange {
|
|
factory_tag: None,
|
|
field: "ratio"
|
|
})
|
|
));
|
|
|
|
let overflowing_sum =
|
|
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(19, 19, f32::MAX.to_bits(), None, None),
|
|
synthetic_effective_control_device(19, 19, f32::MAX.to_bits(), None, None),
|
|
]));
|
|
assert!(matches!(
|
|
query_control_device_table_resource(&overflowing_sum, 2),
|
|
Err(ControlResourceAccessError::UnsupportedFloatRange {
|
|
factory_tag: Some(19),
|
|
field: "sum"
|
|
})
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn control_resource_api_checks_live_world_handle_before_loaded_state() {
|
|
let mut engine = create(EngineServices::default()).expect("engine");
|
|
let handle = construct_object(
|
|
&mut engine.world,
|
|
ObjectDraft {
|
|
original_id: Some(OriginalObjectId(4)),
|
|
},
|
|
)
|
|
.expect("live world object");
|
|
assert_eq!(
|
|
query_loaded_mission_control_resource(&engine, handle, 1),
|
|
Err(ControlResourceAccessError::MissionUnavailable)
|
|
);
|
|
assert_eq!(
|
|
query_loaded_mission_control_device_selector(&engine, handle, 0, 0xe00),
|
|
Err(ControlResourceAccessError::MissionUnavailable)
|
|
);
|
|
assert_eq!(
|
|
query_loaded_mission_behavior_resource(&engine, handle, 0x1005),
|
|
Err(ControlResourceAccessError::MissionUnavailable)
|
|
);
|
|
|
|
let stale = ObjectHandle {
|
|
generation: handle.generation.wrapping_add(1),
|
|
slot: handle.slot,
|
|
};
|
|
assert_eq!(
|
|
query_loaded_mission_control_resource(&engine, stale, 1),
|
|
Err(ControlResourceAccessError::World(
|
|
fparkan_world::WorldError::StaleHandle
|
|
))
|
|
);
|
|
assert_eq!(
|
|
query_loaded_mission_control_device_selector(&engine, stale, 0, 0xe00),
|
|
Err(ControlResourceAccessError::World(
|
|
fparkan_world::WorldError::StaleHandle
|
|
))
|
|
);
|
|
assert_eq!(
|
|
query_loaded_mission_behavior_resource(&engine, stale, 0x1005),
|
|
Err(ControlResourceAccessError::World(
|
|
fparkan_world::WorldError::StaleHandle
|
|
))
|
|
);
|
|
assert_eq!(
|
|
set_loaded_mission_control_multiplier(&mut engine, stale, 2.0),
|
|
Err(ControlResourceAccessError::World(
|
|
fparkan_world::WorldError::StaleHandle
|
|
))
|
|
);
|
|
|
|
fparkan_world::request_delete(&mut engine.world, handle).expect("delete object");
|
|
assert_eq!(
|
|
query_loaded_mission_control_resource(&engine, handle, 1),
|
|
Err(ControlResourceAccessError::World(
|
|
fparkan_world::WorldError::StaleHandle
|
|
))
|
|
);
|
|
assert_eq!(
|
|
query_loaded_mission_control_device_selector(&engine, handle, 0, 0xe00),
|
|
Err(ControlResourceAccessError::World(
|
|
fparkan_world::WorldError::StaleHandle
|
|
))
|
|
);
|
|
assert_eq!(
|
|
query_loaded_mission_behavior_resource(&engine, handle, 0x1005),
|
|
Err(ControlResourceAccessError::World(
|
|
fparkan_world::WorldError::StaleHandle
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn common_factory_into_overrides_preserve_native_constructor_defaults() {
|
|
const INTO: u32 = u32::from_le_bytes(*b"INTO");
|
|
for (factory_tag, word_50) in [
|
|
(2, 0),
|
|
(3, 5),
|
|
(4, 0x200),
|
|
(5, 5),
|
|
(8, 5),
|
|
(10, 0x2000),
|
|
(21, 5),
|
|
(27, 0),
|
|
] {
|
|
let mut root = synthetic_control_record(factory_tag, 2, 0x20, None);
|
|
root.fixed_words[6] = u32::MAX;
|
|
let mut replacement = synthetic_control_record(19, 99, 0x30, None);
|
|
replacement.fixed_words[6] = u32::MAX;
|
|
let status = synthetic_control_table(
|
|
&[None, Some(INTO)],
|
|
&[1, 1],
|
|
&[1, 0],
|
|
&[0, 0],
|
|
&[1, 1],
|
|
vec![
|
|
Some(synthetic_component_control(
|
|
0,
|
|
vec![root],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
Some(synthetic_component_control(
|
|
1,
|
|
vec![replacement],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
],
|
|
);
|
|
let MissionControlDeviceTableStatus::Available(devices) = status else {
|
|
panic!("{status:?}")
|
|
};
|
|
assert_eq!(devices.len(), 1);
|
|
let device = &devices[0];
|
|
assert_eq!(
|
|
(device.factory_tag, device.descriptor.tag, device.key),
|
|
(factory_tag, 19, 2)
|
|
);
|
|
assert_eq!(device.multiplier_bits, Some(1.0_f32.to_bits()));
|
|
assert_eq!(device.native_word_50_bits, Some(word_50));
|
|
assert_eq!(device.coefficient_bits.unwrap()[0], 0x30);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn control_factory_multiplier_defaults_preserve_unknown_word_50() {
|
|
for factory_tag in [9, 15, 25] {
|
|
assert_eq!(
|
|
native_control_factory_defaults(factory_tag),
|
|
(Some(0x3f80_0000), None)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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
|
|
);
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
|
|
#[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 behavior_elapsed_seconds_preserve_native_qword_conversion_rounding() {
|
|
let vectors = [
|
|
(1, 0x3a83_126f),
|
|
(1_001, 0x3f80_20c5),
|
|
(16_777_217, 0x4683_1270),
|
|
(16_777_219, 0x4683_1271),
|
|
(u32::MAX, 0x4a83_126f),
|
|
(u32::MAX - 1, 0x4a83_126f),
|
|
];
|
|
for (elapsed_ms, expected_bits) in vectors {
|
|
assert_eq!(
|
|
behavior_elapsed_seconds(elapsed_ms).to_bits(),
|
|
expected_bits,
|
|
"elapsed={elapsed_ms}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_distributor_scheduler_requires_a_strictly_later_unsigned_time() {
|
|
let original = BehaviorWarmStartState {
|
|
first_time_ms: 100,
|
|
next_deadline_ms: 200,
|
|
cleared_words: [0x11; 5],
|
|
};
|
|
let mut state = original;
|
|
let mut rng_state = 1;
|
|
|
|
assert_eq!(
|
|
schedule_behavior_distributor_tick(&mut state, 200, &mut rng_state),
|
|
None
|
|
);
|
|
assert_eq!(state, original);
|
|
assert_eq!(rng_state, 1);
|
|
|
|
let elapsed = schedule_behavior_distributor_tick(&mut state, 201, &mut rng_state)
|
|
.expect("deadline is passed");
|
|
assert_eq!(elapsed.to_bits(), behavior_elapsed_seconds(101).to_bits());
|
|
assert_eq!(state.first_time_ms, 201);
|
|
assert_eq!(state.next_deadline_ms, 403);
|
|
assert_eq!(state.cleared_words, [0x11; 5]);
|
|
assert_eq!(rng_state, 0x0029_e2c0);
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_distributor_scheduler_wraps_elapsed_but_keeps_unsigned_deadline_order() {
|
|
let mut state = BehaviorWarmStartState {
|
|
first_time_ms: u32::MAX - 15,
|
|
next_deadline_ms: 5,
|
|
cleared_words: [0; 5],
|
|
};
|
|
let mut rng_state = 1;
|
|
|
|
assert_eq!(
|
|
schedule_behavior_distributor_tick(&mut state, 5, &mut rng_state),
|
|
None
|
|
);
|
|
assert_eq!(state.next_deadline_ms, 5);
|
|
let elapsed = schedule_behavior_distributor_tick(&mut state, 6, &mut rng_state)
|
|
.expect("current time is unsigned-greater than deadline");
|
|
assert_eq!(elapsed.to_bits(), behavior_elapsed_seconds(22).to_bits());
|
|
assert_eq!(state.first_time_ms, 6);
|
|
|
|
let before = state;
|
|
assert_eq!(
|
|
schedule_behavior_distributor_tick(&mut state, 1, &mut rng_state),
|
|
None,
|
|
"unsigned 1 is not strictly later than the current deadline"
|
|
);
|
|
assert_eq!(state, before);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
|
|
let mut engine = create(EngineServices::default()).expect("engine");
|
|
let before = step_headless(&mut engine, InputSnapshot, 0).expect("step");
|
|
let err = load_mission(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
0,
|
|
)
|
|
.expect_err("missing VFS");
|
|
assert!(matches!(err, EngineError::MissingVfs));
|
|
let after = step_headless(&mut engine, InputSnapshot, 0).expect("step");
|
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
|
}
|
|
|
|
#[test]
|
|
fn into_table_target_uses_native_wrapping_and_append_sentinel() {
|
|
let no_parent_devices = [];
|
|
let local_one = native_into_table_target_index(&no_parent_devices, 9, 1);
|
|
assert_eq!(local_one, 0);
|
|
assert_eq!(
|
|
native_control_target_action(local_one),
|
|
ControlTableTarget::Override(0)
|
|
);
|
|
|
|
let local_zero = native_into_table_target_index(&no_parent_devices, 9, 0);
|
|
assert_eq!(local_zero, u32::MAX);
|
|
assert_eq!(
|
|
native_control_target_action(local_zero),
|
|
ControlTableTarget::Append
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn into_missing_parent_append_maps_only_zero_key_to_native_minus_one() {
|
|
assert_eq!(native_into_append_key(0), u32::MAX);
|
|
assert_eq!(native_into_append_key(0x1234), 0x1234);
|
|
}
|
|
|
|
#[test]
|
|
fn exto_key_mapping_obeys_native_component_and_mount_sentinels() {
|
|
assert_eq!(native_ex_to_append_key(2, 0, None, Some(20)), Some(2));
|
|
assert_eq!(
|
|
native_ex_to_append_key(2, 1, Some(u32::MAX), Some(20)),
|
|
Some(2)
|
|
);
|
|
assert_eq!(
|
|
native_ex_to_append_key(0, 1, Some(u32::MAX), None),
|
|
Some(u32::MAX)
|
|
);
|
|
assert_eq!(native_ex_to_append_key(2, 1, Some(4), Some(10)), Some(11));
|
|
assert_eq!(native_ex_to_append_key(2, 1, Some(4), None), Some(0));
|
|
assert_eq!(native_ex_to_append_key(1, 5, Some(9), Some(22)), Some(22));
|
|
}
|
|
|
|
#[test]
|
|
fn synthetic_ex_to_builder_appends_remapped_rows_and_skips_inactive_subtree() {
|
|
let controls = vec![
|
|
Some(synthetic_component_control(
|
|
0,
|
|
vec![synthetic_control_record(19, 7, 0x10, None)],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
Some(synthetic_component_control(
|
|
1,
|
|
vec![
|
|
synthetic_control_record(19, 0, 0x20, None),
|
|
synthetic_control_record(19, 2, 0x21, None),
|
|
],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
Some(synthetic_component_control(
|
|
2,
|
|
vec![
|
|
synthetic_control_record(19, 0, 0x30, None),
|
|
synthetic_control_record(19, 2, 0x31, None),
|
|
],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
Some(synthetic_component_control(
|
|
3,
|
|
vec![synthetic_control_record(19, 9, 0x40, None)],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
Some(synthetic_component_control(
|
|
4,
|
|
vec![synthetic_control_record(19, 10, 0x50, None)],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
Some(synthetic_component_control(
|
|
5,
|
|
vec![synthetic_control_record(19, 2, 0x60, None)],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
];
|
|
let status = synthetic_control_table(
|
|
&[
|
|
Some(PROTOTYPE_TYPE_EXTO),
|
|
Some(PROTOTYPE_TYPE_EXTO),
|
|
Some(PROTOTYPE_TYPE_EXTO),
|
|
None,
|
|
None,
|
|
Some(PROTOTYPE_TYPE_EXTO),
|
|
],
|
|
&[1, 1, 1, 0, 1, 1],
|
|
&[3, 1, 0, 1, 0, 0],
|
|
&[0, 3, 2, 0, 0, 4],
|
|
&[5, 3, 2, 1, 1, 2],
|
|
controls,
|
|
);
|
|
let MissionControlDeviceTableStatus::Available(entries) = status else {
|
|
panic!("synthetic EXTO table should be available: {status:?}");
|
|
};
|
|
assert_eq!(entries.len(), 6);
|
|
// Root EXTO keys remain raw. Child keys map to mount/global nodes.
|
|
assert_eq!(
|
|
entries.iter().map(|entry| entry.key).collect::<Vec<_>>(),
|
|
[7, 3, 6, 6, 8, 9]
|
|
);
|
|
assert_eq!(
|
|
entries
|
|
.iter()
|
|
.map(|entry| entry.component_id)
|
|
.collect::<Vec<_>>(),
|
|
[0, 1, 1, 2, 2, 3]
|
|
);
|
|
assert_eq!(
|
|
entries
|
|
.iter()
|
|
.map(|entry| entry.descriptor_provenance.unit_component_index)
|
|
.collect::<Vec<_>>(),
|
|
[0, 1, 1, 2, 2, 5]
|
|
);
|
|
assert_ne!(
|
|
entries[5].component_id as usize,
|
|
entries[5].descriptor_provenance.unit_component_index
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn synthetic_node_provider_projection_keeps_native_rows_separate_from_mesh_keys() {
|
|
let controls = vec![
|
|
Some(control_with_synthetic_node_data(
|
|
0,
|
|
vec![synthetic_control_record(26, 1, 2.0_f32.to_bits(), None)],
|
|
2,
|
|
)),
|
|
Some(control_with_synthetic_node_data(
|
|
1,
|
|
vec![synthetic_control_record(26, 0, 3.0_f32.to_bits(), None)],
|
|
7,
|
|
)),
|
|
Some(control_with_synthetic_node_data(
|
|
2,
|
|
vec![synthetic_control_record(26, 5, 4.0_f32.to_bits(), None)],
|
|
3,
|
|
)),
|
|
Some(control_with_synthetic_node_data(
|
|
3,
|
|
vec![synthetic_control_record(26, 0, 5.0_f32.to_bits(), None)],
|
|
2,
|
|
)),
|
|
Some(control_with_synthetic_node_data(
|
|
4,
|
|
vec![synthetic_control_record(26, 0, 6.0_f32.to_bits(), None)],
|
|
6,
|
|
)),
|
|
Some(control_with_synthetic_node_data(
|
|
5,
|
|
vec![synthetic_control_record(26, 1, 6.0_f32.to_bits(), None)],
|
|
5,
|
|
)),
|
|
Some(control_with_synthetic_node_data(
|
|
6,
|
|
vec![synthetic_control_record(26, 1, 7.0_f32.to_bits(), None)],
|
|
4,
|
|
)),
|
|
];
|
|
let (status, node_provider_status) = synthetic_control_projection(
|
|
&[
|
|
None,
|
|
Some(PROTOTYPE_TYPE_INTO),
|
|
Some(PROTOTYPE_TYPE_EXTO),
|
|
Some(PROTOTYPE_TYPE_EXTO),
|
|
Some(PROTOTYPE_TYPE_EXTO),
|
|
None,
|
|
None,
|
|
],
|
|
&[1, 1, 1, 1, 1, 0, 1],
|
|
&[4, 0, 1, 0, 0, 1, 0],
|
|
&[0, -1, 2, 0, -1, 0, 0],
|
|
&[1, 1, 4, 2, 1, 1, 1],
|
|
controls,
|
|
);
|
|
let table = synthetic_control_device_table_with_providers(status, node_provider_status);
|
|
let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status
|
|
else {
|
|
panic!(
|
|
"synthetic provider rows should be available: {:?}",
|
|
table.node_provider_status
|
|
);
|
|
};
|
|
|
|
// Root contributes all rows, INTO contributes none, and each mounted
|
|
// EXTO skips its local row zero. The EXTO with C4=FFFFFFFF contributes
|
|
// no provider rows. The kind-zero component and its child are absent
|
|
// from the native active-component walk.
|
|
assert_eq!(rows.len(), 5);
|
|
assert_eq!(
|
|
rows.iter()
|
|
.map(|row| (row.unit_component_index, row.source_row_index))
|
|
.collect::<Vec<_>>(),
|
|
[(0, 0), (0, 1), (2, 1), (2, 2), (3, 1)]
|
|
);
|
|
assert!(rows
|
|
.iter()
|
|
.all(|row| row.initial_type1_value_bits == 1.0_f32.to_bits()));
|
|
|
|
let MissionControlDeviceTableStatus::Available(devices) = &table.status else {
|
|
panic!("synthetic devices should be available: {:?}", table.status);
|
|
};
|
|
assert_eq!(
|
|
devices.iter().map(|device| device.key).collect::<Vec<_>>(),
|
|
[1, u32::MAX, 5, 0, u32::MAX]
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 0, 0xe00)
|
|
.expect("root tag-26 selector")
|
|
.map(f32::to_bits),
|
|
Some(2.0_f32.to_bits())
|
|
);
|
|
// Device keys still use the confirmed MSH mapping. The provider list
|
|
// is the real concatenated NDP row count, so no model-node padding or
|
|
// second key remap hides an out-of-range key.
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 2, 0xe00),
|
|
Err(ControlResourceAccessError::NodeProviderKeyOutOfRange {
|
|
key: 5,
|
|
row_count: 5
|
|
})
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 3, 0xe00),
|
|
Ok(Some(5.0))
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 4, 0xe00),
|
|
Err(ControlResourceAccessError::NodeProviderKeyOutOfRange {
|
|
key: u32::MAX,
|
|
row_count: 5
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn synthetic_node_provider_projection_joins_flat_mesh_metadata_by_global_ordinal() {
|
|
let mut controls = vec![
|
|
Some(control_with_synthetic_node_data(0, Vec::new(), 3)),
|
|
Some(control_with_synthetic_node_data(1, Vec::new(), 3)),
|
|
];
|
|
controls[0]
|
|
.as_mut()
|
|
.expect("root component control")
|
|
.node_data
|
|
.as_mut()
|
|
.expect("root NDP")
|
|
.rows[0]
|
|
.max_volume = 0.0;
|
|
let (draft, mut assets, prototypes) = synthetic_control_fixture(
|
|
// Even if the root's registry prototype is typed EXTO, its model
|
|
// contributes every source node; only mounted EXTO rows skip node 0.
|
|
&[Some(PROTOTYPE_TYPE_EXTO), Some(PROTOTYPE_TYPE_EXTO)],
|
|
&[1, 1],
|
|
&[1, 0],
|
|
&[0, 1],
|
|
&[2, 3],
|
|
controls,
|
|
);
|
|
attach_synthetic_model(
|
|
&mut assets,
|
|
0,
|
|
&[
|
|
synthetic_node38(0x0701, 0xffff, [true, true, false]),
|
|
synthetic_node38(0x0081, 0, [false, true, true]),
|
|
],
|
|
);
|
|
attach_synthetic_model(
|
|
&mut assets,
|
|
1,
|
|
&[
|
|
synthetic_node38(0, 0xffff, [true, false, false]),
|
|
synthetic_node38(0x0200, 0, [true, false, true]),
|
|
synthetic_node38(0x0400, 0xffff, [true, true, true]),
|
|
],
|
|
);
|
|
|
|
let projection = build_control_device_projection(
|
|
0,
|
|
&draft,
|
|
&assets,
|
|
Some(&(0..prototypes.len())),
|
|
&prototypes,
|
|
)
|
|
.expect("synthetic Control projection");
|
|
let MissionControlNodeProviderTableStatus::Available(rows) =
|
|
projection.node_provider_status
|
|
else {
|
|
panic!("synthetic provider rows should be available");
|
|
};
|
|
|
|
assert_eq!(rows.len(), 5);
|
|
assert_eq!(
|
|
rows.iter()
|
|
.map(|row| (row.unit_component_index, row.source_row_index))
|
|
.collect::<Vec<_>>(),
|
|
[(0, 0), (0, 1), (0, 2), (1, 1), (1, 2)]
|
|
);
|
|
assert_eq!(
|
|
rows.iter()
|
|
.map(|row| row.initial_mesh_state)
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
Some(MissionControlNodeInitialMeshState {
|
|
parent_index: -1,
|
|
flags: 0x0f,
|
|
stage_count: 2,
|
|
}),
|
|
Some(MissionControlNodeInitialMeshState {
|
|
parent_index: 0,
|
|
flags: 0,
|
|
stage_count: 1,
|
|
}),
|
|
// This row is still from the root NDP. The native global
|
|
// ordinal has moved on to the first non-root EXTO mesh node.
|
|
Some(MissionControlNodeInitialMeshState {
|
|
parent_index: 1,
|
|
flags: 1,
|
|
stage_count: 1,
|
|
}),
|
|
Some(MissionControlNodeInitialMeshState {
|
|
parent_index: 0,
|
|
flags: 4,
|
|
stage_count: 3,
|
|
}),
|
|
None,
|
|
]
|
|
);
|
|
assert!(rows[..4].iter().all(|row| row.initial_mesh_state.is_some()));
|
|
assert_eq!(
|
|
rows.iter()
|
|
.map(|row| row.initial_amount_bits)
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
0.0_f32.to_bits(),
|
|
101.0_f32.to_bits(),
|
|
102.0_f32.to_bits(),
|
|
101.0_f32.to_bits(),
|
|
102.0_f32.to_bits(),
|
|
]
|
|
);
|
|
assert_eq!(rows[0].initial_type1_value_bits, 1.0_f32.to_bits());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_initial_mesh_metadata_keeps_provider_query_available() {
|
|
let mut device = synthetic_effective_control_device(
|
|
26,
|
|
26,
|
|
2.0_f32.to_bits(),
|
|
Some(3.0_f32.to_bits()),
|
|
None,
|
|
);
|
|
device.key = 0;
|
|
device.descriptor.fixed_words[1] = 0;
|
|
let provider = synthetic_node_provider_row(0, 0, 2.0_f32.to_bits());
|
|
assert_eq!(provider.initial_mesh_state, None);
|
|
let table = synthetic_control_device_table_with_providers(
|
|
MissionControlDeviceTableStatus::Available(vec![device]),
|
|
MissionControlNodeProviderTableStatus::Available(vec![provider]),
|
|
);
|
|
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 0, 0xe00)
|
|
.expect("NDP query remains independent of the MSH projection"),
|
|
Some(12.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn native_mesh_metadata_maps_control_flags_and_parent_words() {
|
|
assert_eq!(control_flags_from_node38_header(0x0701), 0x0f);
|
|
assert_eq!(control_flags_from_node38_header(0x0781), 0x07);
|
|
|
|
assert_eq!(native_mesh_parent_index(0xffff, 0, None), Some(-1));
|
|
assert_eq!(native_mesh_parent_index(0, 0, Some(0)), Some(0));
|
|
assert_eq!(native_mesh_parent_index(0xffff, 21, None), Some(20));
|
|
assert_eq!(native_mesh_parent_index(1, 21, None), Some(22));
|
|
assert_eq!(native_mesh_parent_index(0xfffe, 21, None), Some(65_555));
|
|
assert_eq!(native_mesh_parent_index(0x8000, 21, None), Some(32_789));
|
|
assert_eq!(native_mesh_parent_index(0, 21, Some(7)), Some(7));
|
|
assert_eq!(native_mesh_parent_index(0, 21, None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_node_provider_does_not_hide_aggregate_queries() {
|
|
let controls = vec![Some(synthetic_component_control(
|
|
0,
|
|
vec![
|
|
synthetic_control_record(26, 0, 2.0_f32.to_bits(), None),
|
|
synthetic_control_record(19, 1, 4.0_f32.to_bits(), None),
|
|
],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
))];
|
|
let (status, node_provider_status) =
|
|
synthetic_control_projection(&[None], &[1], &[0], &[0], &[1], controls);
|
|
let table = synthetic_control_device_table_with_providers(status, node_provider_status);
|
|
assert!(matches!(
|
|
&table.node_provider_status,
|
|
MissionControlNodeProviderTableStatus::Unavailable(
|
|
MissionControlNodeProviderTableUnavailableReason::MissingNodeData {
|
|
unit_component_index: 0,
|
|
..
|
|
}
|
|
)
|
|
));
|
|
assert_eq!(
|
|
query_control_device_table_resource(&table, 1)
|
|
.expect("aggregate selector 1 remains available")
|
|
.map(f32::to_bits),
|
|
Some(1.0_f32.to_bits())
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_resource(&table, 2)
|
|
.expect("aggregate selector 2 remains available")
|
|
.map(f32::to_bits),
|
|
Some(4.0_f32.to_bits())
|
|
);
|
|
assert!(matches!(
|
|
query_control_device_table_selector(&table, 0, 0xe00),
|
|
Err(ControlResourceAccessError::NodeProviderTableUnavailable(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn mounted_exto_requires_a_row_before_native_skip_first() {
|
|
let controls = vec![
|
|
Some(control_with_synthetic_node_data(
|
|
0,
|
|
vec![synthetic_control_record(26, 0, 1.0_f32.to_bits(), None)],
|
|
0,
|
|
)),
|
|
Some(control_with_synthetic_node_data(1, Vec::new(), 0)),
|
|
];
|
|
let (_, node_provider_status) = synthetic_control_projection(
|
|
&[None, Some(PROTOTYPE_TYPE_EXTO)],
|
|
&[1, 1],
|
|
&[1, 0],
|
|
&[0, 0],
|
|
&[2, 2],
|
|
controls,
|
|
);
|
|
assert_eq!(
|
|
node_provider_status,
|
|
MissionControlNodeProviderTableStatus::Unavailable(
|
|
MissionControlNodeProviderTableUnavailableReason::MissingExToRootRow {
|
|
unit_component_index: 1,
|
|
}
|
|
)
|
|
);
|
|
|
|
// An empty root table is legal, and a mounted EXTO with exactly its
|
|
// root row skips that row and contributes an empty range.
|
|
let controls = vec![
|
|
Some(control_with_synthetic_node_data(
|
|
0,
|
|
vec![synthetic_control_record(26, 0, 1.0_f32.to_bits(), None)],
|
|
0,
|
|
)),
|
|
Some(control_with_synthetic_node_data(1, Vec::new(), 1)),
|
|
];
|
|
let (_, node_provider_status) = synthetic_control_projection(
|
|
&[None, Some(PROTOTYPE_TYPE_EXTO)],
|
|
&[1, 1],
|
|
&[1, 0],
|
|
&[0, 0],
|
|
&[2, 2],
|
|
controls,
|
|
);
|
|
assert_eq!(
|
|
node_provider_status,
|
|
MissionControlNodeProviderTableStatus::Available(Vec::new())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn indexed_node_provider_query_handles_current_tags_and_factory_scope() {
|
|
let available = MissionControlNodeProviderTableStatus::Available(vec![
|
|
synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()),
|
|
synthetic_node_provider_row(0, 1, 1.0_f32.to_bits()),
|
|
synthetic_node_provider_row(0, 2, 1.0_f32.to_bits()),
|
|
]);
|
|
let table = synthetic_control_device_table_with_providers(
|
|
MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
26,
|
|
26,
|
|
2.0_f32.to_bits(),
|
|
Some(3.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
26,
|
|
19,
|
|
4.0_f32.to_bits(),
|
|
Some(5.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
26,
|
|
5,
|
|
1.0_f32.to_bits(),
|
|
Some(1.0_f32.to_bits()),
|
|
None,
|
|
),
|
|
synthetic_effective_control_device(
|
|
9,
|
|
26,
|
|
1.0_f32.to_bits(),
|
|
Some(1.0_f32.to_bits()),
|
|
Some(0.5_f32.to_bits()),
|
|
),
|
|
]),
|
|
available,
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 0, 0xe00)
|
|
.expect("tag-26 query")
|
|
.map(f32::to_bits),
|
|
Some(6.0_f32.to_bits())
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 1, 0xe00),
|
|
Ok(None)
|
|
);
|
|
for tag in [5, 10, 21] {
|
|
let mut unsupported = table.clone();
|
|
let MissionControlDeviceTableStatus::Available(entries) = &mut unsupported.status
|
|
else {
|
|
unreachable!();
|
|
};
|
|
entries[1].descriptor.tag = tag;
|
|
assert_eq!(
|
|
query_control_device_table_selector(&unsupported, 1, 0xe00),
|
|
Err(ControlResourceAccessError::UnsupportedCurrentTag { tag })
|
|
);
|
|
}
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 3, 0xe00),
|
|
Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag: 9 })
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 0, 1),
|
|
Err(ControlResourceAccessError::UnsupportedSelector(1))
|
|
);
|
|
assert_eq!(
|
|
query_control_device_table_selector(&table, 8, 0xe00),
|
|
Err(ControlResourceAccessError::DeviceIndexOutOfRange {
|
|
device_index: 8,
|
|
device_count: 4
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn synthetic_into_builder_appends_all_rows_and_overrides_only_first_record() {
|
|
let append = synthetic_control_table(
|
|
&[None, Some(PROTOTYPE_TYPE_INTO)],
|
|
&[1, 1],
|
|
&[1, 0],
|
|
&[0, 0],
|
|
&[1, 0],
|
|
vec![
|
|
None,
|
|
Some(synthetic_component_control(
|
|
1,
|
|
vec![
|
|
synthetic_control_record(19, 0, 0x10, None),
|
|
synthetic_control_record(19, 2, 0x20, None),
|
|
],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
],
|
|
);
|
|
let MissionControlDeviceTableStatus::Available(appended) = append else {
|
|
panic!("synthetic INTO sentinel should append: {append:?}");
|
|
};
|
|
assert_eq!(appended.len(), 2);
|
|
assert_eq!(
|
|
appended.iter().map(|entry| entry.key).collect::<Vec<_>>(),
|
|
[u32::MAX, 2]
|
|
);
|
|
assert_eq!(
|
|
appended
|
|
.iter()
|
|
.map(|entry| entry.descriptor_provenance.typed_record_index)
|
|
.collect::<Vec<_>>(),
|
|
[0, 1]
|
|
);
|
|
|
|
let override_table = synthetic_control_table(
|
|
&[None, Some(PROTOTYPE_TYPE_INTO), Some(PROTOTYPE_TYPE_INTO)],
|
|
&[1, 1, 1],
|
|
&[2, 0, 0],
|
|
&[0, 0, 1],
|
|
&[1, 0, 0],
|
|
vec![
|
|
Some(synthetic_component_control(
|
|
0,
|
|
vec![
|
|
synthetic_control_record(19, 11, 0x10, None),
|
|
synthetic_control_record(19, 12, 0x20, None),
|
|
],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
Some(synthetic_component_control(
|
|
1,
|
|
vec![
|
|
synthetic_control_record(21, 99, 0xA1, Some(0x77)),
|
|
synthetic_control_record(25, 88, 0xA2, Some(0x88)),
|
|
],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
Some(synthetic_component_control(
|
|
2,
|
|
vec![synthetic_control_record(21, 77, 0xB1, None)],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
)),
|
|
],
|
|
);
|
|
let MissionControlDeviceTableStatus::Available(overridden) = override_table else {
|
|
panic!("synthetic INTO overrides should be available: {override_table:?}");
|
|
};
|
|
assert_eq!(overridden.len(), 2);
|
|
assert_eq!(
|
|
overridden
|
|
.iter()
|
|
.map(|entry| (entry.factory_tag, entry.key, entry.component_id))
|
|
.collect::<Vec<_>>(),
|
|
[(19, 11, 0), (19, 12, 0)]
|
|
);
|
|
assert_eq!(
|
|
overridden
|
|
.iter()
|
|
.map(|entry| entry.coefficient_bits.map(|words| words[0]))
|
|
.collect::<Vec<_>>(),
|
|
[Some(0xA1), Some(0xB1)]
|
|
);
|
|
assert_eq!(
|
|
overridden
|
|
.iter()
|
|
.map(|entry| entry.native_word_50_bits)
|
|
.collect::<Vec<_>>(),
|
|
[Some(0x77), Some(0x20)]
|
|
);
|
|
assert_eq!(
|
|
overridden
|
|
.iter()
|
|
.map(|entry| entry.multiplier_bits)
|
|
.collect::<Vec<_>>(),
|
|
[Some(0x3F80_0000); 2]
|
|
);
|
|
assert_eq!(
|
|
overridden
|
|
.iter()
|
|
.map(|entry| entry.descriptor_provenance.unit_component_index)
|
|
.collect::<Vec<_>>(),
|
|
[1, 2]
|
|
);
|
|
assert_eq!(
|
|
overridden
|
|
.iter()
|
|
.map(|entry| entry.descriptor_provenance.typed_record_index)
|
|
.collect::<Vec<_>>(),
|
|
[0, 0]
|
|
);
|
|
assert_eq!(overridden[0].descriptor.tag, 21);
|
|
|
|
let mut live_device = overridden[0].clone();
|
|
live_device.multiplier_bits = Some(0x4020_0000); // Live native multiplier 2.5.
|
|
let replacement = synthetic_control_record(25, 100, 0xC1, None);
|
|
let replacement_control = synthetic_component_control(
|
|
3,
|
|
vec![replacement.clone()],
|
|
PreparedControlEffectSection::Parsed(Vec::new()),
|
|
);
|
|
apply_effective_control_override(
|
|
&mut live_device,
|
|
&replacement,
|
|
3,
|
|
0,
|
|
&replacement_control,
|
|
);
|
|
assert_eq!(live_device.factory_tag, 19);
|
|
assert_eq!(live_device.key, 11);
|
|
assert_eq!(live_device.component_id, 0);
|
|
assert_eq!(live_device.multiplier_bits, Some(0x4020_0000));
|
|
assert_eq!(
|
|
live_device.coefficient_bits,
|
|
Some([0xC1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn synthetic_incomplete_typed_list_does_not_expose_partial_table() {
|
|
let status = synthetic_control_table(
|
|
&[None],
|
|
&[1],
|
|
&[0],
|
|
&[0],
|
|
&[1],
|
|
vec![Some(synthetic_component_control(
|
|
0,
|
|
vec![synthetic_control_record(19, 1, 0x10, None)],
|
|
PreparedControlEffectSection::Unsupported {
|
|
typed_record_index: Some(1),
|
|
typed_record_tag: Some(0xDEAD),
|
|
},
|
|
))],
|
|
);
|
|
assert_eq!(
|
|
status,
|
|
MissionControlDeviceTableStatus::Unavailable(
|
|
MissionControlDeviceTableUnavailableReason::IncompleteTypedRecordList {
|
|
unit_component_index: 0,
|
|
first_unparsed_record: 1,
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn full_load_builds_effective_control_device_table_for_mine_unit_dat() {
|
|
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);
|
|
}
|
|
fn single_unit_tma(land_path: &[u8]) -> Vec<u8> {
|
|
let mut bytes = Vec::new();
|
|
push_u32(&mut bytes, 1); // TMA format version.
|
|
push_u32(&mut bytes, 0); // Paths.
|
|
push_u32(&mut bytes, 6); // Clan section version.
|
|
push_u32(&mut bytes, 0); // Clans.
|
|
push_u32(&mut bytes, 10); // Object section version.
|
|
push_u32(&mut bytes, 1); // One Unit DAT object.
|
|
push_u32(&mut bytes, 1); // Raw object kind.
|
|
push_u32(&mut bytes, 0x8000_0002); // Strict object class/flags word.
|
|
push_lp(&mut bytes, b"UNITS/BUILDS/MINE/min_l_n1.dat");
|
|
push_u32(&mut bytes, 0); // Raw word after resource.
|
|
push_u32(&mut bytes, 0); // Identity/clan.
|
|
for value in [0.0_f32, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] {
|
|
bytes.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
push_lp(&mut bytes, b"mine-control-table-test");
|
|
push_u32(&mut bytes, 0); // Raw word after name.
|
|
push_u32(&mut bytes, 0); // Link 0.
|
|
push_u32(&mut bytes, 0); // Link 1.
|
|
push_u32(&mut bytes, 1); // Property schema version.
|
|
push_u32(&mut bytes, 0); // Properties.
|
|
push_lp(&mut bytes, land_path);
|
|
push_u32(&mut bytes, 0); // Mission flag.
|
|
push_lp(&mut bytes, b""); // Description.
|
|
push_u32(&mut bytes, 1); // Extra section version.
|
|
push_u32(&mut bytes, 0); // Extras.
|
|
bytes
|
|
}
|
|
|
|
let root = licensed_root("IS");
|
|
let source_tma = std::fs::read(root.join("MISSIONS/Autodemo.00/data.tma"))
|
|
.expect("licensed source mission");
|
|
let land_path = decode_mission_land_path(&source_tma, TmaProfile::Strict)
|
|
.expect("source mission land path")
|
|
.raw;
|
|
// The TMA keeps its strict marker; only the Unit DAT class header is
|
|
// overridden in the in-memory VFS to exercise both native profiles.
|
|
let load_full_fixture = |class_flags: u32| {
|
|
let mut overlay = MemoryVfs::default();
|
|
overlay.insert(
|
|
normalize_engine_path("test mission", "MISSIONS/TEST/MINE/data.tma")
|
|
.expect("mission path"),
|
|
Arc::from(single_unit_tma(&land_path)),
|
|
);
|
|
let mut unit_dat = std::fs::read(root.join("UNITS/BUILDS/MINE/min_l_n1.dat"))
|
|
.expect("licensed Unit DAT");
|
|
let original_class_flags =
|
|
u32::from_le_bytes(unit_dat[4..8].try_into().expect("Unit DAT header word"));
|
|
assert_eq!(original_class_flags, 0x8000_0004);
|
|
if class_flags != original_class_flags {
|
|
unit_dat[4..8].copy_from_slice(&class_flags.to_le_bytes());
|
|
}
|
|
overlay.insert(
|
|
normalize_engine_path("test Unit DAT", "UNITS/BUILDS/MINE/min_l_n1.dat")
|
|
.expect("Unit DAT path"),
|
|
Arc::from(unit_dat),
|
|
);
|
|
let vfs = Arc::new(OverlayVfs::from_layers(vec![
|
|
Arc::new(overlay),
|
|
Arc::new(DirectoryVfs::new(&root)),
|
|
]));
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
|
let loaded = load_mission(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: "MISSIONS/TEST/MINE/data.tma".to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
0,
|
|
)
|
|
.expect("full normal mission load");
|
|
assert_eq!(loaded.object_count, 1);
|
|
engine
|
|
};
|
|
let mut engine = load_full_fixture(0x8000_0002);
|
|
let mine_engine = load_full_fixture(0x8000_0004);
|
|
|
|
let draft = &loaded_mission_object_drafts(&engine).expect("object drafts")[0];
|
|
assert_eq!(draft.unit_components.len(), 6);
|
|
assert_eq!(draft.unit_components[0].immediate_child_count, 5);
|
|
let tables = loaded_mission_control_device_tables(&engine).expect("Control tables");
|
|
assert_eq!(tables.len(), 1);
|
|
assert_eq!(tables[0].original_id, Some(OriginalObjectId(0)));
|
|
let control_handle = tables[0].handle;
|
|
assert_eq!(
|
|
query_loaded_mission_control_resource(&engine, control_handle, 1)
|
|
.expect("licensed selector 1")
|
|
.map(f32::to_bits),
|
|
Some(1.0_f32.to_bits())
|
|
);
|
|
assert_eq!(
|
|
query_loaded_mission_control_resource(&engine, control_handle, 2)
|
|
.expect("licensed selector 2")
|
|
.map(f32::to_bits),
|
|
Some(16.0_f32.to_bits())
|
|
);
|
|
let MissionControlDeviceTableStatus::Available(entries) = &tables[0].status else {
|
|
panic!(
|
|
"licensed mine table should be available: {:?}",
|
|
tables[0].status
|
|
);
|
|
};
|
|
assert_eq!(entries.len(), 11);
|
|
assert_eq!(
|
|
entries
|
|
.iter()
|
|
.map(|entry| entry.factory_tag)
|
|
.collect::<Vec<_>>(),
|
|
[19, 19, 9, 15, 12, 12, 13, 13, 26, 25, 21]
|
|
);
|
|
assert_eq!(
|
|
entries
|
|
.iter()
|
|
.map(|entry| entry.descriptor.tag)
|
|
.collect::<Vec<_>>(),
|
|
[19, 19, 9, 15, 12, 12, 13, 13, 26, 25, 21]
|
|
);
|
|
assert_eq!(
|
|
entries.iter().map(|entry| entry.key).collect::<Vec<_>>(),
|
|
[2, 2, 1, 1, 18, 21, 15, 16, 2, 1, 22]
|
|
);
|
|
assert_eq!(
|
|
(
|
|
entries[0].coefficient_bits.map(|words| words[0]),
|
|
entries[1].coefficient_bits.map(|words| words[0])
|
|
),
|
|
(Some(0x4100_0000), Some(0x4100_0000))
|
|
);
|
|
assert_eq!(
|
|
(entries[0].multiplier_bits, entries[0].native_word_50_bits),
|
|
(Some(0x3f80_0000), Some(0x20))
|
|
);
|
|
for index in [2, 3, 9] {
|
|
assert_eq!(entries[index].multiplier_bits, Some(0x3f80_0000));
|
|
}
|
|
assert_eq!(entries[2].class9_selector0_scale_bits, Some(0x3f80_0000));
|
|
assert_eq!(
|
|
entries
|
|
.iter()
|
|
.map(|entry| entry.descriptor_provenance.unit_component_index)
|
|
.collect::<Vec<_>>(),
|
|
[1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 5]
|
|
);
|
|
assert_eq!(entries[8].descriptor_provenance.typed_record_index, 8);
|
|
assert_eq!(
|
|
query_loaded_mission_control_device_selector(&engine, control_handle, 8, 0xe00)
|
|
.expect("licensed tag-26 selector")
|
|
.map(f32::to_bits),
|
|
Some(1.0_f32.to_bits())
|
|
);
|
|
let profiles = loaded_mission_behavior_profiles(&engine).expect("Behavior profiles");
|
|
assert_eq!(profiles.len(), 1);
|
|
assert_eq!(profiles[0].original_id, OriginalObjectId(0));
|
|
assert_eq!(profiles[0].handle, control_handle);
|
|
assert_eq!(profiles[0].class_flags, 0x8000_0002);
|
|
assert_eq!(profiles[0].profile_entry, Some("prof_generator.var"));
|
|
assert_eq!(profiles[0].transfer_ore_off_board_bits, 0);
|
|
assert_eq!(profiles[0].transfer_power_out_bits, 10.0_f32.to_bits());
|
|
assert_eq!(
|
|
query_loaded_mission_behavior_resource(&engine, control_handle, 0x1005)
|
|
.expect("licensed profile power-out rate")
|
|
.to_bits(),
|
|
10.0_f32.to_bits()
|
|
);
|
|
assert_eq!(
|
|
query_loaded_mission_behavior_resource(&engine, control_handle, 0x1002)
|
|
.expect("licensed profile ore-off-board rate")
|
|
.to_bits(),
|
|
0.0_f32.to_bits()
|
|
);
|
|
let mine_profile =
|
|
loaded_mission_behavior_profiles(&mine_engine).expect("mine Behavior profile")[0];
|
|
assert_eq!(mine_profile.class_flags, 0x8000_0004);
|
|
assert_eq!(mine_profile.profile_entry, Some("prof_mine.var"));
|
|
assert_eq!(mine_profile.transfer_ore_off_board_bits, 1.0_f32.to_bits());
|
|
assert_eq!(mine_profile.transfer_power_out_bits, 0.0_f32.to_bits());
|
|
assert_eq!(
|
|
query_loaded_mission_behavior_resource(&mine_engine, mine_profile.handle, 0x1002)
|
|
.expect("mine profile ore resource query")
|
|
.to_bits(),
|
|
1.0_f32.to_bits()
|
|
);
|
|
let prepared_controls = &engine
|
|
.loaded
|
|
.as_ref()
|
|
.expect("loaded mission state")
|
|
.mission_assets
|
|
.object_component_controls[0];
|
|
let root_node_data = prepared_controls[0]
|
|
.as_ref()
|
|
.and_then(|control| control.node_data.as_ref())
|
|
.expect("root NDP sidecar");
|
|
let ext_node_data = prepared_controls[5]
|
|
.as_ref()
|
|
.and_then(|control| control.node_data.as_ref())
|
|
.expect("EXTO NDP sidecar");
|
|
let provider_rows = match &tables[0].node_provider_status {
|
|
MissionControlNodeProviderTableStatus::Available(rows) => rows,
|
|
MissionControlNodeProviderTableStatus::Unavailable(reason) => {
|
|
panic!("licensed provider table should be available: {reason:?}");
|
|
}
|
|
};
|
|
assert_eq!(root_node_data.rows.len(), 22);
|
|
assert_eq!(ext_node_data.rows.len(), 4);
|
|
assert_eq!(provider_rows.len(), 25);
|
|
assert_eq!(provider_rows[0].initial_amount_bits, 1.0_f32.to_bits());
|
|
assert_eq!(provider_rows[22].initial_amount_bits, 6000.0_f32.to_bits());
|
|
assert_eq!(
|
|
provider_rows[0].initial_amount_bits,
|
|
provider_rows[0].source_row.max_volume.to_bits()
|
|
);
|
|
assert_eq!(
|
|
provider_rows[22].initial_amount_bits,
|
|
provider_rows[22].source_row.max_volume.to_bits()
|
|
);
|
|
let mission_assets = &engine
|
|
.loaded
|
|
.as_ref()
|
|
.expect("loaded mission state")
|
|
.mission_assets;
|
|
let visual_root_node_count = mission_assets
|
|
.visual_by_id(mission_assets.object_visuals[0][0])
|
|
.expect("root visual")
|
|
.model_nodes;
|
|
assert_eq!(visual_root_node_count, 22);
|
|
let assert_mesh_node =
|
|
|component_index: usize,
|
|
source_node_index: usize,
|
|
expected_header: u16,
|
|
expected_parent: u16,
|
|
expected_slots: [u16; 15],
|
|
expected_state: MissionControlNodeInitialMeshState| {
|
|
let visual_id = mission_assets.object_visuals[0][component_index];
|
|
let visual = mission_assets
|
|
.visual_by_id(visual_id)
|
|
.expect("component visual");
|
|
let model = mission_assets
|
|
.model_by_id(visual.model_id.expect("component model"))
|
|
.expect("prepared model");
|
|
let offset = source_node_index * model.validated.node_stride;
|
|
let raw_flags = u16::from_le_bytes(
|
|
model.validated.nodes_raw[offset..offset + 2]
|
|
.try_into()
|
|
.expect("raw header flags"),
|
|
);
|
|
let raw_parent = u16::from_le_bytes(
|
|
model.validated.nodes_raw[offset + 2..offset + 4]
|
|
.try_into()
|
|
.expect("raw parent"),
|
|
);
|
|
let raw_slots = std::array::from_fn::<_, 15, _>(|slot| {
|
|
u16::from_le_bytes(
|
|
model.validated.nodes_raw[offset + 8 + slot * 2..offset + 10 + slot * 2]
|
|
.try_into()
|
|
.expect("raw slot word"),
|
|
)
|
|
});
|
|
let global_index = if component_index == 0 {
|
|
source_node_index
|
|
} else {
|
|
visual_root_node_count + source_node_index - 1
|
|
};
|
|
assert_eq!(raw_flags, expected_header);
|
|
assert_eq!(raw_parent, expected_parent);
|
|
assert_eq!(raw_slots, expected_slots);
|
|
assert_eq!(
|
|
provider_rows[global_index].initial_mesh_state,
|
|
Some(expected_state)
|
|
);
|
|
};
|
|
assert_mesh_node(
|
|
0,
|
|
0,
|
|
0x0000,
|
|
0xffff,
|
|
[0xffff; 15],
|
|
MissionControlNodeInitialMeshState {
|
|
parent_index: -1,
|
|
flags: 0,
|
|
stage_count: 1,
|
|
},
|
|
);
|
|
assert_mesh_node(
|
|
0,
|
|
1,
|
|
0x0110,
|
|
0x0000,
|
|
[
|
|
0x000b, 0x0025, 0x0026, 0xffff, 0xffff, 0x0013, 0x0027, 0x0028, 0xffff, 0xffff,
|
|
0xffff, 0xffff, 0xffff, 0xffff, 0xffff,
|
|
],
|
|
MissionControlNodeInitialMeshState {
|
|
parent_index: 0,
|
|
flags: 2,
|
|
stage_count: 2,
|
|
},
|
|
);
|
|
assert_mesh_node(
|
|
5,
|
|
1,
|
|
0x0000,
|
|
0x0000,
|
|
[
|
|
0x0000, 0x0001, 0x0002, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff,
|
|
0xffff, 0xffff, 0xffff, 0xffff, 0xffff,
|
|
],
|
|
MissionControlNodeInitialMeshState {
|
|
parent_index: 9,
|
|
flags: 0,
|
|
stage_count: 1,
|
|
},
|
|
);
|
|
assert_mesh_node(
|
|
5,
|
|
2,
|
|
0x0010,
|
|
0x0001,
|
|
[
|
|
0x0004, 0x0007, 0x0008, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff,
|
|
0xffff, 0xffff, 0xffff, 0xffff, 0xffff,
|
|
],
|
|
MissionControlNodeInitialMeshState {
|
|
parent_index: 22,
|
|
flags: 0,
|
|
stage_count: 1,
|
|
},
|
|
);
|
|
let expected_provider_sources = (0..root_node_data.rows.len())
|
|
.map(|row| (0, row))
|
|
.chain((1..ext_node_data.rows.len()).map(|row| (5, row)))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
provider_rows
|
|
.iter()
|
|
.map(|row| (row.unit_component_index, row.source_row_index))
|
|
.collect::<Vec<_>>(),
|
|
expected_provider_sources
|
|
);
|
|
assert!(provider_rows
|
|
.iter()
|
|
.all(|row| row.initial_type1_value_bits == 1.0_f32.to_bits()));
|
|
assert!(provider_rows
|
|
.iter()
|
|
.all(|row| row.initial_mesh_state.is_some()));
|
|
assert_eq!(
|
|
(
|
|
entries[9].factory_tag,
|
|
entries[9].component_id,
|
|
entries[9].key
|
|
),
|
|
(25, 0, 1)
|
|
);
|
|
assert_eq!(entries[9].descriptor.tag, 25);
|
|
assert_eq!(
|
|
(
|
|
entries[10].factory_tag,
|
|
entries[10].component_id,
|
|
entries[10].key
|
|
),
|
|
(21, 5, 22)
|
|
);
|
|
assert_eq!(entries[10].descriptor.tag, 21);
|
|
set_loaded_mission_control_multiplier(&mut engine, control_handle, 2.5)
|
|
.expect("licensed setter 1");
|
|
assert_eq!(
|
|
query_loaded_mission_control_resource(&engine, control_handle, 1)
|
|
.expect("updated licensed selector 1")
|
|
.map(f32::to_bits),
|
|
Some(2.5_f32.to_bits())
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
));
|
|
}
|
|
|
|
#[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,
|
|
};
|
|
let mut bundles = [MissionScriptBundle {
|
|
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 {
|
|
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
|
|
references: vec![0, 1, 2],
|
|
}],
|
|
}],
|
|
trailing_bytes: Vec::new(),
|
|
raw: Arc::from(&b""[..]),
|
|
},
|
|
init_execution: MissionScriptInitExecution::Missing,
|
|
behavior_warm_start: None,
|
|
}];
|
|
|
|
let states = resolve_handler19_init_states(
|
|
&[[500.0, 752.0], [728.0, 449.0]],
|
|
&mut bundles,
|
|
&varset,
|
|
MissionAssetScope::Full,
|
|
)
|
|
.expect("exact Init execution");
|
|
assert_eq!(
|
|
states,
|
|
vec![MissionScriptInitState {
|
|
clan_index: 1,
|
|
event_index: 0,
|
|
event_word: 0x1234_5678,
|
|
writes: vec![
|
|
Handler19DwordWrite {
|
|
index: 0,
|
|
value: 728
|
|
},
|
|
Handler19DwordWrite {
|
|
index: 1,
|
|
value: 449
|
|
},
|
|
Handler19DwordWrite { index: 2, value: 1 },
|
|
],
|
|
}]
|
|
);
|
|
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),
|
|
],
|
|
}]
|
|
);
|
|
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,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
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,
|
|
behavior_warm_start: None,
|
|
};
|
|
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()];
|
|
assert!(matches!(
|
|
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),
|
|
},
|
|
..
|
|
})
|
|
));
|
|
|
|
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")
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
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;
|
|
let mut outcome = MissionScriptOutcome::Active;
|
|
assert_eq!(
|
|
apply_recovered_script_host_callback(
|
|
MissionMode::SingleMission,
|
|
&mut status,
|
|
&mut outcome,
|
|
command(0),
|
|
),
|
|
MissionScriptHostEffect::MissionFailed
|
|
);
|
|
assert_eq!((status, outcome), (1, MissionScriptOutcome::Failed));
|
|
outcome = MissionScriptOutcome::Completed;
|
|
assert_eq!(
|
|
apply_recovered_script_host_callback(
|
|
MissionMode::SingleMission,
|
|
&mut status,
|
|
&mut outcome,
|
|
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),
|
|
),
|
|
MissionScriptHostEffect::MissionCompleted
|
|
);
|
|
assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed));
|
|
|
|
status = 5;
|
|
outcome = MissionScriptOutcome::Active;
|
|
assert_eq!(
|
|
apply_recovered_script_host_callback(
|
|
MissionMode::AutoDemo,
|
|
&mut status,
|
|
&mut outcome,
|
|
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));
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
},
|
|
100_000,
|
|
)
|
|
.expect("load licensed AutoDemo");
|
|
|
|
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);
|
|
}
|
|
|
|
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 ore_states = loaded_mission_behavior_ore_states(&engine).expect("Behavior ore state");
|
|
let unit_dat_drafts = drafts
|
|
.iter()
|
|
.filter(|draft| draft.unit_dat_header_flags.is_some())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(ore_states.len(), unit_dat_drafts.len());
|
|
assert_eq!(ore_states.len(), 8);
|
|
for state in ore_states {
|
|
let draft = unit_dat_drafts
|
|
.iter()
|
|
.find(|draft| draft.original_id == Some(state.original_id))
|
|
.expect("ore state has a Unit DAT source");
|
|
assert_eq!(
|
|
state.handle,
|
|
handle_by_original_id(&engine.world, state.original_id).unwrap()
|
|
);
|
|
assert_eq!(state.class_flags, draft.unit_dat_header_flags.unwrap());
|
|
assert_eq!(state.current_ore_bits, 0);
|
|
assert_eq!(state.maximum_ore_bits, 0);
|
|
}
|
|
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,
|
|
object.share.to_bits(),
|
|
object.native_tail,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(
|
|
0,
|
|
OriginalObjectId(4),
|
|
&b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"[..],
|
|
0,
|
|
0x8000_0010,
|
|
0x3f00_0000,
|
|
[0; 5],
|
|
),
|
|
(
|
|
0,
|
|
OriginalObjectId(6),
|
|
&b"UNITS\\BUILDS\\GENER\\gener01.dat"[..],
|
|
0,
|
|
0x8000_0002,
|
|
0x3f00_0000,
|
|
[0; 5],
|
|
),
|
|
(
|
|
1,
|
|
OriginalObjectId(5),
|
|
&b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"[..],
|
|
1,
|
|
0x8000_0010,
|
|
0x3f00_0000,
|
|
[0; 5],
|
|
),
|
|
(
|
|
1,
|
|
OriginalObjectId(7),
|
|
&b"UNITS\\BUILDS\\GENER\\gener01.dat"[..],
|
|
1,
|
|
0x8000_0002,
|
|
0x3f00_0000,
|
|
[0; 5],
|
|
),
|
|
]
|
|
);
|
|
assert_eq!(registered.len(), 4);
|
|
assert!(ore_states.iter().any(|state| {
|
|
state.class_flags == 0x0100_8000
|
|
&& !distributors.iter().any(|distributor| {
|
|
distributor
|
|
.registered_objects
|
|
.iter()
|
|
.any(|object| object.handle == state.handle)
|
|
})
|
|
}));
|
|
for (_, object) in registered {
|
|
assert_eq!(
|
|
handle_by_original_id(&engine.world, object.original_id),
|
|
Some(object.handle)
|
|
);
|
|
}
|
|
|
|
assert_eq!(
|
|
mission
|
|
.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 }));
|
|
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]
|
|
);
|
|
|
|
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),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn full_load_builds_control_tables_for_autodemo_robots_and_buildings() {
|
|
let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
|
|
licensed_root("IS"),
|
|
))))
|
|
.unwrap();
|
|
load_mission(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: "MISSIONS/Autodemo.00/data.tma".into(),
|
|
mode: MissionMode::AutoDemo,
|
|
},
|
|
100_000,
|
|
)
|
|
.unwrap();
|
|
let tables = loaded_mission_control_device_tables(&engine).unwrap();
|
|
let expected_counts = [14, 12, 15, 13, 12, 12, 11, 11];
|
|
assert_eq!(tables.len(), expected_counts.len());
|
|
for (table, expected_count) in tables.iter().zip(expected_counts) {
|
|
let MissionControlDeviceTableStatus::Available(devices) = &table.status else {
|
|
panic!("object {:?}: {:?}", table.original_id, table.status);
|
|
};
|
|
assert_eq!(
|
|
devices.len(),
|
|
expected_count,
|
|
"object {:?}",
|
|
table.original_id
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn headless_step_runs_resource_schedule_and_rolls_back_both_error_paths() {
|
|
let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
|
|
licensed_root("IS"),
|
|
))))
|
|
.expect("engine");
|
|
load_mission(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: "MISSIONS/Autodemo.00/data.tma".into(),
|
|
mode: MissionMode::AutoDemo,
|
|
},
|
|
100_000,
|
|
)
|
|
.expect("licensed mission");
|
|
let schedules = |engine: &Engine| {
|
|
loaded_mission_script_bundles(engine)
|
|
.unwrap()
|
|
.iter()
|
|
.map(|bundle| bundle.behavior_warm_start)
|
|
.collect::<Vec<_>>()
|
|
};
|
|
let initial = schedules(&engine);
|
|
frame(&mut engine, 100_200).expect("deadline equality");
|
|
assert_eq!(schedules(&engine), initial);
|
|
let result = step_headless(&mut engine, InputSnapshot, 100_201).expect("due clan 1");
|
|
assert_eq!(result.snapshot.tick.0, 2);
|
|
let after = schedules(&engine);
|
|
assert_eq!(after[0], initial[0]);
|
|
let updated = after[1].expect("clan 1 schedule");
|
|
assert_eq!(updated.first_time_ms, 100_201);
|
|
assert!(f32::from_bits(updated.cleared_words[0]) > 0.0);
|
|
|
|
let resources = |engine: &Engine| {
|
|
let state = engine.loaded.as_ref().unwrap();
|
|
(
|
|
state.behavior_distributors.clone(),
|
|
state.behavior_ore_states.clone(),
|
|
state.control_device_tables.clone(),
|
|
schedules(engine),
|
|
engine.behavior_rng_state,
|
|
)
|
|
};
|
|
let saved_controls = engine
|
|
.loaded
|
|
.as_ref()
|
|
.unwrap()
|
|
.control_device_tables
|
|
.clone();
|
|
let plant = engine
|
|
.loaded
|
|
.as_ref()
|
|
.unwrap()
|
|
.behavior_distributors
|
|
.iter()
|
|
.find(|distributor| distributor.clan_index == 1)
|
|
.unwrap()
|
|
.registered_objects
|
|
.iter()
|
|
.find(|object| object.class_flags == 0x8000_0010)
|
|
.unwrap()
|
|
.handle;
|
|
engine
|
|
.loaded
|
|
.as_mut()
|
|
.unwrap()
|
|
.control_device_tables
|
|
.retain(|table| table.handle != plant);
|
|
let before = resources(&engine);
|
|
let world_before = fparkan_world::canonical_state_hash(&engine.world);
|
|
assert!(matches!(
|
|
step_headless(&mut engine, InputSnapshot, 100_500),
|
|
Err(EngineError::BehaviorDistributorTick { clan_index: 1, .. })
|
|
));
|
|
assert_eq!(resources(&engine), before);
|
|
assert_eq!(
|
|
fparkan_world::canonical_state_hash(&engine.world),
|
|
world_before
|
|
);
|
|
|
|
engine.loaded.as_mut().unwrap().control_device_tables = saved_controls;
|
|
let stale = construct_object(&mut engine.world, ObjectDraft { original_id: None }).unwrap();
|
|
register_object(&mut engine.world, stale).unwrap();
|
|
fparkan_world::enqueue(
|
|
&mut engine.world,
|
|
fparkan_world::WorldCommand {
|
|
sequence: 0,
|
|
target: Some(stale),
|
|
},
|
|
)
|
|
.unwrap();
|
|
fparkan_world::request_delete(&mut engine.world, stale).unwrap();
|
|
let before = resources(&engine);
|
|
let world_before = fparkan_world::canonical_state_hash(&engine.world);
|
|
assert!(matches!(
|
|
step_headless(&mut engine, InputSnapshot, 100_500),
|
|
Err(EngineError::World(_))
|
|
));
|
|
assert_eq!(
|
|
resources(&engine),
|
|
before,
|
|
"a failed world step must not publish staged resource ticks"
|
|
);
|
|
assert_eq!(
|
|
fparkan_world::canonical_state_hash(&engine.world),
|
|
world_before
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn licensed_autodemo_resource_ticks_use_queue_time_and_real_generator_profile() {
|
|
let root = licensed_root("IS");
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
|
load_mission(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
mode: MissionMode::AutoDemo,
|
|
},
|
|
100_000,
|
|
)
|
|
.expect("load licensed AutoDemo");
|
|
|
|
let deadlines = loaded_mission_script_bundles(&engine)
|
|
.expect("script bundles")
|
|
.iter()
|
|
.map(|bundle| {
|
|
(
|
|
bundle.clan_index,
|
|
bundle
|
|
.behavior_warm_start
|
|
.expect("full load warm-start")
|
|
.next_deadline_ms,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(deadlines, [(0, 100_202), (1, 100_200)]);
|
|
assert_eq!(
|
|
advance_loaded_mission_resources(&mut engine, 100_000)
|
|
.expect("no due resource tick at warm start"),
|
|
0
|
|
);
|
|
assert_eq!(
|
|
advance_loaded_mission_resources(&mut engine, 100_200)
|
|
.expect("strict deadline equality is not due"),
|
|
0
|
|
);
|
|
|
|
assert_eq!(
|
|
advance_loaded_mission_resources(&mut engine, 100_201).expect("clan 1 resource tick"),
|
|
1
|
|
);
|
|
let first_totals = loaded_mission_script_bundles(&engine)
|
|
.expect("bundles after first tick")
|
|
.iter()
|
|
.find(|bundle| bundle.clan_index == 1)
|
|
.and_then(|bundle| bundle.behavior_warm_start)
|
|
.expect("clan 1 scheduler")
|
|
.cleared_words;
|
|
assert!(
|
|
f32::from_bits(first_totals[0]) > 0.0,
|
|
"clan 1's real prof_generator.var contributes power"
|
|
);
|
|
assert_eq!(
|
|
advance_loaded_mission_resources(&mut engine, 100_202)
|
|
.expect("clan 0 deadline equality is not due"),
|
|
0
|
|
);
|
|
|
|
assert_eq!(
|
|
advance_loaded_mission_resources(&mut engine, 100_203).expect("clan 0 resource tick"),
|
|
1
|
|
);
|
|
let second_totals = loaded_mission_script_bundles(&engine)
|
|
.expect("bundles after second tick")
|
|
.iter()
|
|
.find(|bundle| bundle.clan_index == 0)
|
|
.and_then(|bundle| bundle.behavior_warm_start)
|
|
.expect("clan 0 scheduler")
|
|
.cleared_words;
|
|
assert!(
|
|
f32::from_bits(second_totals[0]) > 0.0,
|
|
"clan 0's real prof_generator.var contributes power"
|
|
);
|
|
|
|
let first_tick_time = 100_500;
|
|
let deadlines = loaded_mission_script_bundles(&engine)
|
|
.expect("bundles after one tick per clan")
|
|
.iter()
|
|
.map(|bundle| {
|
|
bundle
|
|
.behavior_warm_start
|
|
.expect("full load warm-start")
|
|
.next_deadline_ms
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert!(deadlines.iter().all(|deadline| *deadline < first_tick_time));
|
|
assert_eq!(
|
|
advance_loaded_mission_resources(&mut engine, first_tick_time)
|
|
.expect("second resource tick for each clan"),
|
|
2
|
|
);
|
|
let totals_at_100500 = loaded_mission_script_bundles(&engine)
|
|
.expect("bundles at 100500")
|
|
.iter()
|
|
.map(|bundle| {
|
|
(
|
|
bundle.clan_index,
|
|
bundle.behavior_warm_start.expect("full load warm-start"),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
for (clan, warm_start) in &totals_at_100500 {
|
|
assert_eq!(warm_start.first_time_ms, first_tick_time);
|
|
let elapsed = if *clan == 0 { 297 } else { 299 };
|
|
assert_eq!(
|
|
warm_start.cleared_words[0],
|
|
(behavior_elapsed_seconds(elapsed) * 10.0).to_bits(),
|
|
"clan {clan} recomputes power availability from the elapsed interval"
|
|
);
|
|
}
|
|
|
|
assert_eq!(
|
|
advance_loaded_mission_resources(&mut engine, 101_000)
|
|
.expect("third resource tick for each clan"),
|
|
2
|
|
);
|
|
let totals_at_101000 = loaded_mission_script_bundles(&engine)
|
|
.expect("bundles at 101000")
|
|
.iter()
|
|
.map(|bundle| {
|
|
(
|
|
bundle.clan_index,
|
|
bundle.behavior_warm_start.expect("full load warm-start"),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
for (clan, warm_start) in &totals_at_101000 {
|
|
assert_eq!(warm_start.first_time_ms, 101_000);
|
|
assert_eq!(
|
|
warm_start.cleared_words[0],
|
|
5.0_f32.to_bits(),
|
|
"clan {clan} replaces totals with the latest interval"
|
|
);
|
|
let previous = totals_at_100500
|
|
.iter()
|
|
.find(|(previous_clan, _)| previous_clan == clan)
|
|
.expect("same clan totals at previous tick")
|
|
.1;
|
|
assert_ne!(warm_start.cleared_words[0], previous.cleared_words[0]);
|
|
}
|
|
|
|
assert!(loaded_mission_behavior_profiles(&engine)
|
|
.expect("loaded profiles")
|
|
.iter()
|
|
.filter(|profile| profile.class_flags == 0x8000_0002)
|
|
.all(|profile| profile.transfer_power_out_bits == 10.0_f32.to_bits()));
|
|
|
|
for clan in [0, 1] {
|
|
let distributor = loaded_mission_behavior_distributors(&engine)
|
|
.expect("loaded distributors")
|
|
.iter()
|
|
.find(|distributor| distributor.clan_index == clan)
|
|
.expect("loaded clan distributor");
|
|
assert_eq!(distributor.registered_objects.len(), 2);
|
|
let generator = loaded_mission_behavior_distributors(&engine)
|
|
.expect("loaded distributors")
|
|
.iter()
|
|
.find(|distributor| distributor.clan_index == clan)
|
|
.and_then(|distributor| {
|
|
distributor
|
|
.registered_objects
|
|
.iter()
|
|
.find(|object| object.class_flags == 0x8000_0002)
|
|
})
|
|
.expect("licensed AutoDemo generator row");
|
|
let profile = loaded_mission_behavior_profiles(&engine)
|
|
.expect("loaded Behavior profiles")
|
|
.iter()
|
|
.find(|profile| profile.handle == generator.handle)
|
|
.expect("generator profile");
|
|
let totals = totals_at_101000
|
|
.iter()
|
|
.find(|(candidate, _)| *candidate == clan)
|
|
.expect("generator clan totals")
|
|
.1
|
|
.cleared_words;
|
|
println!(
|
|
"licensed AutoDemo Behavior tick: clan={clan} ObjectId={} profile={} Transfer_Power_Out={} PowerAvailable={} at queue times 100201/100203,100500,101000; registered rows={}",
|
|
generator.original_id.0,
|
|
profile.profile_entry.as_deref().unwrap_or("<defaults>"),
|
|
f32::from_bits(profile.transfer_power_out_bits),
|
|
f32::from_bits(totals[0]),
|
|
distributor.registered_objects.len(),
|
|
);
|
|
}
|
|
|
|
let second_plant = {
|
|
let loaded = engine.loaded.as_mut().expect("loaded mission state");
|
|
let second = loaded
|
|
.behavior_distributors
|
|
.iter()
|
|
.find(|distributor| distributor.clan_index == 1)
|
|
.expect("clan 1 distributor");
|
|
let plant = second
|
|
.registered_objects
|
|
.iter()
|
|
.find(|object| object.class_flags == 0x8000_0010)
|
|
.expect("clan 1 plant")
|
|
.clone();
|
|
loaded
|
|
.control_device_tables
|
|
.retain(|table| table.handle != plant.handle);
|
|
plant
|
|
};
|
|
assert_eq!(second_plant.class_flags, 0x8000_0010);
|
|
let before_distributors = loaded_mission_behavior_distributors(&engine)
|
|
.expect("rollback distributor snapshot")
|
|
.to_vec();
|
|
let before_ore_states = loaded_mission_behavior_ore_states(&engine)
|
|
.expect("rollback ore snapshot")
|
|
.to_vec();
|
|
let before_controls = loaded_mission_control_device_tables(&engine)
|
|
.expect("rollback Control snapshot")
|
|
.to_vec();
|
|
let before_warm_starts = loaded_mission_script_bundles(&engine)
|
|
.expect("rollback bundles")
|
|
.iter()
|
|
.map(|bundle| bundle.behavior_warm_start)
|
|
.collect::<Vec<_>>();
|
|
let before_rng = engine.behavior_rng_state;
|
|
let error = advance_loaded_mission_resources(&mut engine, 102_000)
|
|
.expect_err("missing clan 1 plant Control table fails after clan 0 stages a tick");
|
|
assert!(matches!(
|
|
error,
|
|
EngineError::BehaviorDistributorTick { clan_index: 1, .. }
|
|
));
|
|
assert_eq!(
|
|
loaded_mission_behavior_distributors(&engine),
|
|
Some(before_distributors.as_slice())
|
|
);
|
|
assert_eq!(
|
|
loaded_mission_behavior_ore_states(&engine),
|
|
Some(before_ore_states.as_slice())
|
|
);
|
|
assert_eq!(
|
|
loaded_mission_control_device_tables(&engine),
|
|
Some(before_controls.as_slice())
|
|
);
|
|
assert_eq!(
|
|
loaded_mission_script_bundles(&engine)
|
|
.expect("bundles after failed transaction")
|
|
.iter()
|
|
.map(|bundle| bundle.behavior_warm_start)
|
|
.collect::<Vec<_>>(),
|
|
before_warm_starts
|
|
);
|
|
assert_eq!(engine.behavior_rng_state, before_rng);
|
|
}
|
|
|
|
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_ore_initialization_uses_first_exact_current_value_and_read_only_maximum() {
|
|
let handle = ObjectHandle {
|
|
generation: 7,
|
|
slot: 19,
|
|
};
|
|
let state = initialize_behavior_ore_state(
|
|
OriginalObjectId(19),
|
|
handle,
|
|
0x0100_2000,
|
|
&[
|
|
MissionObjectProperty {
|
|
name_raw: b"currentore".to_vec(),
|
|
raw_value: [0x1111_1111, 0xdead_beef, 0, 0],
|
|
},
|
|
MissionObjectProperty {
|
|
name_raw: b"CurrentOre".to_vec(),
|
|
raw_value: [0x3f80_0000, 0x7fc1_2345, 0, 0],
|
|
},
|
|
MissionObjectProperty {
|
|
name_raw: b"CurrentOre".to_vec(),
|
|
raw_value: [0, 0x4400_0000, 0, 0],
|
|
},
|
|
MissionObjectProperty {
|
|
name_raw: b"MaximumOre".to_vec(),
|
|
raw_value: [0, 0x4123_4567, 0, 0],
|
|
},
|
|
],
|
|
);
|
|
|
|
assert_eq!(state.original_id, OriginalObjectId(19));
|
|
assert_eq!(state.handle, handle);
|
|
assert_eq!(state.class_flags, 0x0100_2000);
|
|
assert_eq!(state.current_ore_bits, 0x7fc1_2345);
|
|
assert_eq!(state.maximum_ore_bits, 0x44fa_0000);
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_ore_defaults_follow_native_class_dispatch() {
|
|
assert_eq!(native_behavior_ore_defaults(0x8000_0004), (0, 0x43fa_0000));
|
|
assert_eq!(native_behavior_ore_defaults(0x8000_0008), (0, 0x457a_0000));
|
|
assert_eq!(native_behavior_ore_defaults(0x0100_2000), (0, 0x44fa_0000));
|
|
assert_eq!(
|
|
native_behavior_ore_defaults(0x0100_4000),
|
|
(0x44fa_0000, 0x44fa_0000)
|
|
);
|
|
assert_eq!(native_behavior_ore_defaults(0x0100_8000), (0, 0));
|
|
}
|
|
|
|
#[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_profile_mapper_matches_native_classes_and_unknowns_keep_constructor_defaults() {
|
|
let mapped = [
|
|
(0x8000_0004, "prof_mine.var"),
|
|
(0x8000_0008, "prof_storage.var"),
|
|
(0x8000_0002, "prof_generator.var"),
|
|
(0x8000_0010, "prof_plant.var"),
|
|
(0x8000_0040, "prof_angar.var"),
|
|
(0x8000_0200, "prof_mtp.var"),
|
|
(0x8000_0400, "prof_institute.var"),
|
|
(0x8001_0000, "prof_bunker.var"),
|
|
(0x8002_0000, "prof_bunker.var"),
|
|
(0x8004_0000, "prof_bunker.var"),
|
|
(0x8010_0000, "prof_tower.var"),
|
|
(0x8020_0000, "prof_tower.var"),
|
|
(0x0100_2000, "prof_trn.var"),
|
|
(0x0100_4000, "prof_bld.var"),
|
|
(0x0100_8000, "prof_war.var"),
|
|
(0x0101_0000, "prof_hq.var"),
|
|
(0x0102_0000, "prof_hero.var"),
|
|
(0x2000_0000, "prof_animal.var"),
|
|
];
|
|
for (class_flags, entry) in mapped {
|
|
assert_eq!(behavior_profile_entry_for_class(class_flags), Some(entry));
|
|
}
|
|
assert_eq!(behavior_profile_entry_for_class(0xdead_beef), None);
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(MemoryVfs::default());
|
|
let drafts = [basement_draft(Some(0xdead_beef), 0)];
|
|
let states = prepare_behavior_profile_states(&vfs, &drafts)
|
|
.expect("unknown profile class preserves native constructor defaults");
|
|
assert_eq!(states.len(), 1);
|
|
assert_eq!(
|
|
states[0].map(|state| (
|
|
state.profile_entry,
|
|
state.transfer_ore_off_board_bits,
|
|
state.transfer_power_out_bits,
|
|
)),
|
|
Some((None, 0x40a0_0000, 0x42c8_0000))
|
|
);
|
|
}
|
|
|
|
#[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]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_allocation_matches_shortage_middle_surplus_and_equality_branches() {
|
|
let shortage = behavior_allocation(1.0, 6.0, 3.0).expect("shortage allocation");
|
|
assert_eq!(shortage.priority.to_bits(), 0x3eaa_aaab);
|
|
assert_eq!(shortage.residual.to_bits(), 0);
|
|
assert_eq!(shortage.consumption.to_bits(), 1.0_f32.to_bits());
|
|
|
|
let middle = behavior_allocation(0.25, 0.375, 0.1875).expect("middle allocation");
|
|
assert_eq!(middle.priority.to_bits(), 1.0_f32.to_bits());
|
|
assert_eq!(middle.residual.to_bits(), 0x3eaa_aaab);
|
|
assert_eq!(middle.consumption.to_bits(), 1.0_f32.to_bits());
|
|
|
|
let surplus = behavior_allocation(2.0, 0.375, 0.1875).expect("surplus allocation");
|
|
assert_eq!(surplus.priority.to_bits(), 1.0_f32.to_bits());
|
|
assert_eq!(surplus.residual.to_bits(), 1.0_f32.to_bits());
|
|
assert_eq!(surplus.consumption.to_bits(), 0x3e40_0000);
|
|
|
|
let exact = behavior_allocation(0.375, 0.375, 0.1875).expect("exact allocation");
|
|
assert_eq!(exact.priority.to_bits(), 1.0_f32.to_bits());
|
|
assert_eq!(exact.residual.to_bits(), 1.0_f32.to_bits());
|
|
assert_eq!(exact.consumption.to_bits(), 1.0_f32.to_bits());
|
|
}
|
|
|
|
fn behavior_tick_test_object(
|
|
world: &mut World,
|
|
original_id: u32,
|
|
class_flags: u32,
|
|
share: f32,
|
|
) -> BehaviorRegisteredObject {
|
|
let original_id = OriginalObjectId(original_id);
|
|
let handle = construct_object(
|
|
world,
|
|
ObjectDraft {
|
|
original_id: Some(original_id),
|
|
},
|
|
)
|
|
.expect("construct Behavior tick object");
|
|
register_object(world, handle).expect("register Behavior tick object");
|
|
BehaviorRegisteredObject {
|
|
original_id,
|
|
handle,
|
|
selector: 0,
|
|
class_flags,
|
|
share,
|
|
native_tail: [0; 5],
|
|
}
|
|
}
|
|
|
|
fn behavior_tick_test_control_table(
|
|
original_id: u32,
|
|
handle: ObjectHandle,
|
|
) -> MissionControlDeviceTableState {
|
|
let device = synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
0.75_f32.to_bits(),
|
|
Some(0.5_f32.to_bits()),
|
|
None,
|
|
);
|
|
MissionControlDeviceTableState {
|
|
original_id: Some(OriginalObjectId(original_id)),
|
|
handle,
|
|
status: MissionControlDeviceTableStatus::Available(vec![device]),
|
|
node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_power_source_also_contributes_ore_demand() {
|
|
let mut world = new_world(WorldConfig);
|
|
let producer = behavior_tick_test_object(&mut world, 1, 0x8000_0002, 0.5);
|
|
let recipient = behavior_tick_test_object(&mut world, 2, 0x8000_0001, 0.5);
|
|
let mut distributor = BehaviorDistributorState {
|
|
clan_index: 0,
|
|
super_ai_visible: true,
|
|
registered_objects: vec![producer.clone(), recipient.clone()],
|
|
};
|
|
let mut ore_states = [producer.clone(), recipient.clone()]
|
|
.into_iter()
|
|
.map(|object| BehaviorOreState {
|
|
original_id: object.original_id,
|
|
handle: object.handle,
|
|
class_flags: object.class_flags,
|
|
current_ore_bits: 1.0_f32.to_bits(),
|
|
maximum_ore_bits: 10.0_f32.to_bits(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let profiles = [BehaviorProfileState {
|
|
original_id: OriginalObjectId(1),
|
|
handle: producer.handle,
|
|
class_flags: producer.class_flags,
|
|
profile_entry: None,
|
|
transfer_ore_off_board_bits: 0x40a0_0000,
|
|
transfer_power_out_bits: 4.0_f32.to_bits(),
|
|
}];
|
|
let mut control_tables = vec![
|
|
behavior_tick_test_control_table(1, producer.handle),
|
|
behavior_tick_test_control_table(2, recipient.handle),
|
|
];
|
|
|
|
let totals = advance_behavior_distributor_tick(
|
|
&mut distributor,
|
|
1.0,
|
|
&world,
|
|
&mut ore_states,
|
|
&profiles,
|
|
&mut control_tables,
|
|
)
|
|
.expect("tick supported power source and generic receiver");
|
|
|
|
assert_eq!(
|
|
totals[4],
|
|
18.0_f32.to_bits(),
|
|
"both native classes compute the non-source ore demand"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_ore_source_also_contributes_power_demand() {
|
|
let mut world = new_world(WorldConfig);
|
|
let producer = behavior_tick_test_object(&mut world, 3, 0x8000_0004, 1.0);
|
|
let mut distributor = BehaviorDistributorState {
|
|
clan_index: 0,
|
|
super_ai_visible: true,
|
|
registered_objects: vec![producer.clone()],
|
|
};
|
|
let mut ore_states = [producer.clone()]
|
|
.into_iter()
|
|
.map(|object| BehaviorOreState {
|
|
original_id: object.original_id,
|
|
handle: object.handle,
|
|
class_flags: object.class_flags,
|
|
current_ore_bits: 0.25_f32.to_bits(),
|
|
maximum_ore_bits: 10.0_f32.to_bits(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let profiles = [BehaviorProfileState {
|
|
original_id: OriginalObjectId(3),
|
|
handle: producer.handle,
|
|
class_flags: producer.class_flags,
|
|
profile_entry: None,
|
|
transfer_ore_off_board_bits: 4.0_f32.to_bits(),
|
|
transfer_power_out_bits: 0.0_f32.to_bits(),
|
|
}];
|
|
let mut ore_device = synthetic_effective_control_device(
|
|
26,
|
|
26,
|
|
1.0_f32.to_bits(),
|
|
Some(1.0_f32.to_bits()),
|
|
None,
|
|
);
|
|
ore_device.key = 0;
|
|
let producer_table = MissionControlDeviceTableState {
|
|
original_id: Some(OriginalObjectId(3)),
|
|
handle: producer.handle,
|
|
status: MissionControlDeviceTableStatus::Available(vec![
|
|
synthetic_effective_control_device(
|
|
19,
|
|
19,
|
|
0.75_f32.to_bits(),
|
|
Some(0.5_f32.to_bits()),
|
|
None,
|
|
),
|
|
ore_device,
|
|
]),
|
|
node_provider_status: MissionControlNodeProviderTableStatus::Available(vec![
|
|
synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()),
|
|
]),
|
|
};
|
|
let mut control_tables = vec![producer_table];
|
|
|
|
let totals = advance_behavior_distributor_tick(
|
|
&mut distributor,
|
|
1.0,
|
|
&world,
|
|
&mut ore_states,
|
|
&profiles,
|
|
&mut control_tables,
|
|
)
|
|
.expect("tick supported ore source");
|
|
|
|
assert_eq!(
|
|
totals[3],
|
|
0.375_f32.to_bits(),
|
|
"ore producers still query generic Control power demand"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn behavior_first_pass_clamps_tiny_deficits_and_overfull_stores() {
|
|
for (original_id, current, maximum, clamps) in [
|
|
(4, 0.995_f32, 1.0_f32, true),
|
|
(5, 1.25, 1.0, true),
|
|
(6, 0.0, f32::from_bits(0x3c23_d70a), false),
|
|
] {
|
|
let mut world = new_world(WorldConfig);
|
|
let object = behavior_tick_test_object(&mut world, original_id, 0x8000_0001, 0.5);
|
|
let mut distributor = BehaviorDistributorState {
|
|
clan_index: 0,
|
|
super_ai_visible: true,
|
|
registered_objects: vec![object.clone()],
|
|
};
|
|
let mut ore_states = [BehaviorOreState {
|
|
original_id: object.original_id,
|
|
handle: object.handle,
|
|
class_flags: object.class_flags,
|
|
current_ore_bits: current.to_bits(),
|
|
maximum_ore_bits: maximum.to_bits(),
|
|
}];
|
|
let mut control_tables = [MissionControlDeviceTableState {
|
|
original_id: Some(object.original_id),
|
|
handle: object.handle,
|
|
status: MissionControlDeviceTableStatus::Available(Vec::new()),
|
|
node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()),
|
|
}];
|
|
|
|
advance_behavior_distributor_tick(
|
|
&mut distributor,
|
|
1.0,
|
|
&world,
|
|
&mut ore_states,
|
|
&[],
|
|
&mut control_tables,
|
|
)
|
|
.expect("native low-deficit clamp path");
|
|
|
|
let expected = if clamps { maximum } else { current };
|
|
assert_eq!(ore_states[0].current_ore_bits, expected.to_bits());
|
|
}
|
|
}
|
|
|
|
#[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");
|
|
assert!(loaded_mission_behavior_distributors(&engine)
|
|
.expect("preview Behavior distributors")
|
|
.is_empty());
|
|
assert!(loaded_mission_behavior_ore_states(&engine)
|
|
.expect("preview Behavior ore state")
|
|
.is_empty());
|
|
assert!(loaded_mission_behavior_profiles(&engine)
|
|
.expect("preview Behavior profiles")
|
|
.is_empty());
|
|
assert!(bundles
|
|
.iter()
|
|
.all(|bundle| bundle.behavior_warm_start.is_none()));
|
|
assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE);
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn malformed_known_behavior_profile_keeps_previous_mission_and_world() {
|
|
let root = licensed_root("IS");
|
|
let mut overlay = MemoryVfs::default();
|
|
overlay.insert(
|
|
normalize_engine_path("broken Behavior archive", "behpsp.res")
|
|
.expect("profile archive path"),
|
|
Arc::from(&b"bad!"[..]),
|
|
);
|
|
let vfs: Arc<dyn Vfs> = Arc::new(OverlayVfs::from_layers(vec![
|
|
Arc::new(overlay),
|
|
Arc::new(DirectoryVfs::new(&root)),
|
|
]));
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
|
let request = || MissionRequest {
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
};
|
|
load_mission_static_preview_roots(
|
|
&mut engine,
|
|
request(),
|
|
NonZeroUsize::new(1).expect("one root"),
|
|
)
|
|
.expect("static preview does not require Behavior profile parsing");
|
|
let previous_mission = loaded_mission(&engine).expect("loaded preview").clone();
|
|
let before = step_headless(&mut engine, InputSnapshot, 0)
|
|
.expect("step preview world")
|
|
.snapshot
|
|
.objects;
|
|
|
|
let error = load_mission(&mut engine, request(), 0)
|
|
.expect_err("malformed profile archive prevents full mission load");
|
|
assert!(matches!(
|
|
error,
|
|
EngineError::Nres { ref path, .. } if path == "behpsp.res"
|
|
));
|
|
assert_eq!(loaded_mission(&engine), Some(&previous_mission));
|
|
let after = step_headless(&mut engine, InputSnapshot, 0)
|
|
.expect("step retained preview world")
|
|
.snapshot
|
|
.objects;
|
|
assert_eq!(before, after);
|
|
}
|
|
|
|
#[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,
|
|
VmHostCallbackCommand {
|
|
mode: 0,
|
|
first: 0,
|
|
second: 0,
|
|
},
|
|
)
|
|
.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"),
|
|
MissionScriptHostEffect::NoChange
|
|
);
|
|
assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1);
|
|
assert_eq!(
|
|
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,
|
|
VmHostCallbackCommand {
|
|
mode: 0,
|
|
first: 0,
|
|
second: 1,
|
|
},
|
|
)
|
|
.expect("apply AutoDemo callback"),
|
|
MissionScriptHostEffect::Unhandled
|
|
);
|
|
assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 5);
|
|
assert_eq!(
|
|
loaded_mission_script_outcome(&engine),
|
|
Some(MissionScriptOutcome::Active)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reference_movement_rejects_non_finite_or_non_positive_input() {
|
|
let mut engine = create(EngineServices::default()).expect("engine");
|
|
|
|
let err =
|
|
advance_reference_movement(&mut engine, OriginalObjectId(0), [f32::NAN, 0.0], 1.0)
|
|
.expect_err("non-finite target must fail");
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"reference movement: target and max_step must be finite; max_step must be positive"
|
|
);
|
|
|
|
assert!(matches!(
|
|
advance_reference_movement(&mut engine, OriginalObjectId(0), [0.0, 0.0], 0.0),
|
|
Err(EngineError::Movement(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn reference_movement_snaps_a_live_mission_object_to_terrain() {
|
|
let root = licensed_root("IS");
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
|
load_mission(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
0,
|
|
)
|
|
.expect("load mission");
|
|
|
|
let (original_id, start_xy, target_xy, expected_xy, expected_height) = {
|
|
let loaded = engine.loaded.as_ref().expect("loaded mission");
|
|
loaded
|
|
.object_drafts
|
|
.iter()
|
|
.find_map(|draft| {
|
|
let original_id = draft.original_id?;
|
|
let start_xy = [draft.position[0], draft.position[1]];
|
|
[[1.0, 0.0], [-1.0, 0.0], [0.0, 1.0], [0.0, -1.0]]
|
|
.into_iter()
|
|
.find_map(|offset| {
|
|
let target_xy = [start_xy[0] + offset[0], start_xy[1] + offset[1]];
|
|
let expected_xy =
|
|
[start_xy[0] + offset[0] * 0.5, start_xy[1] + offset[1] * 0.5];
|
|
let height = loaded.terrain.height_at(expected_xy).ok()??;
|
|
loaded
|
|
.terrain
|
|
.height_at(target_xy)
|
|
.ok()??
|
|
.is_finite()
|
|
.then_some((original_id, start_xy, target_xy, expected_xy, height))
|
|
})
|
|
})
|
|
.expect("mission object with a movable terrain-neighbour target")
|
|
};
|
|
|
|
assert!(
|
|
!advance_reference_movement(&mut engine, original_id, target_xy, 0.5)
|
|
.expect("bounded reference movement")
|
|
);
|
|
let handle = handle_by_original_id(&engine.world, original_id).expect("world object");
|
|
let transform = transform_state(&engine.world, handle).expect("world transform");
|
|
assert_eq!(
|
|
transform.position,
|
|
[
|
|
expected_xy[0].to_bits(),
|
|
expected_xy[1].to_bits(),
|
|
expected_height.to_bits()
|
|
]
|
|
);
|
|
assert_ne!(start_xy, expected_xy);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn missing_map_and_missing_reachable_resource_fail_before_registration() {
|
|
let root = licensed_root("IS");
|
|
for (denied, mission) in [
|
|
(
|
|
DenyRule::Suffix("Land.map"),
|
|
MissionRequest {
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
),
|
|
(
|
|
DenyRule::Suffix("objects.rlb"),
|
|
MissionRequest {
|
|
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
),
|
|
] {
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DenyVfs {
|
|
inner: DirectoryVfs::new(&root),
|
|
denied,
|
|
});
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
|
let before = step_headless(&mut engine, InputSnapshot, 0).expect("before");
|
|
let err = load_mission(&mut engine, mission, 0).expect_err("load error");
|
|
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, 0).expect("after");
|
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() {
|
|
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 before = step_headless(&mut engine, InputSnapshot, 0).expect("before");
|
|
|
|
let err = load_mission_with_options(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
MissionLoadOptions {
|
|
fail_after_registered_objects: Some(1),
|
|
..MissionLoadOptions::default()
|
|
},
|
|
Some(0),
|
|
)
|
|
.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, 0).expect("after");
|
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
|
}
|
|
|
|
#[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();
|
|
let previous_distributors = loaded_mission_behavior_distributors(&engine)
|
|
.expect("previous Behavior distributors")
|
|
.to_vec();
|
|
let previous_rng = engine.behavior_rng_state;
|
|
let before = step_headless(&mut engine, InputSnapshot, 0)
|
|
.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())
|
|
);
|
|
assert_eq!(
|
|
loaded_mission_behavior_distributors(&engine),
|
|
Some(previous_distributors.as_slice())
|
|
);
|
|
assert_eq!(engine.behavior_rng_state, previous_rng);
|
|
let after = step_headless(&mut engine, InputSnapshot, 0)
|
|
.expect("step retained world")
|
|
.snapshot
|
|
.objects;
|
|
assert_eq!(before, after);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
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: [
|
|
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,
|
|
],
|
|
},
|
|
HeadlessCase {
|
|
root: "IS2",
|
|
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
|
object_count: 10,
|
|
expected_hash: [
|
|
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,
|
|
],
|
|
},
|
|
] {
|
|
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);
|
|
}
|
|
}
|
|
|
|
#[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));
|
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
|
let loaded = load_mission(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: case.1.to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
0,
|
|
)
|
|
.expect("load mission");
|
|
let drafts = loaded_mission_object_drafts(&engine).expect("object drafts");
|
|
let assets = loaded_mission_assets(&engine).expect("mission assets");
|
|
let graph = loaded_prototype_graph(&engine).expect("prototype graph");
|
|
|
|
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));
|
|
assert_eq!(draft.unit_components, graph.root_unit_components[index]);
|
|
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()));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn licensed_corpus_part1_loads_all_mission_foundations() {
|
|
let part1 = load_all(&licensed_root("IS"));
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn licensed_corpus_part2_loads_all_mission_foundations() {
|
|
let part2 = load_all(&licensed_root("IS2"));
|
|
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 {
|
|
let root = licensed_root(case.root);
|
|
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: case.mission.to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
0,
|
|
)
|
|
.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, 0)
|
|
.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 {
|
|
let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
|
|
let loaded = load_mission(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: mission,
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
0,
|
|
)
|
|
.expect("load mission foundation");
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum DenyRule {
|
|
Suffix(&'static str),
|
|
}
|
|
|
|
struct DenyVfs {
|
|
inner: DirectoryVfs,
|
|
denied: DenyRule,
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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()
|
|
})
|
|
}
|
|
}
|
|
}
|