feat(runtime): load Behavior profiles for resource queries
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m14s

This commit is contained in:
Valentin Popov committed 2026-10-22 15:30:41 +04:00
1 parent 29b81b8723
commit 83db371742
7 files changed
+1109 -45

No files matched your search

+526 -26
View File
@@ -7,10 +7,11 @@ 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, BuildCategory, MissionAssetPlan, MissionDocument, MissionError,
MissionTerrainPaths, NresError, PreparedComponentControl, PreparedControlEffectSection,
PreparedControlNodeDataRow, PreparedControlNodeDataTable, PreparedControlTypedRecord,
PreparedVisual, TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile,
AssetId, AssetManager, BehaviorProfileRates, BehaviorProfileVarError, BuildCategory,
MissionAssetPlan, MissionDocument, MissionError, MissionTerrainPaths, NresError,
PreparedComponentControl, PreparedControlEffectSection, PreparedControlNodeDataRow,
PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedVisual, TerrainFormatError,
TerrainPreparationError, TerrainWorld, TmaProfile,
};
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{
@@ -30,6 +31,7 @@ use fparkan_world::{
step, transform_state, InputSnapshot, ObjectDraft, ObjectHandle, OriginalObjectId,
TransformState, World, WorldConfig, WorldSnapshot,
};
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::Arc;
@@ -153,6 +155,23 @@ pub struct BehaviorOreState {
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 {
@@ -246,7 +265,7 @@ pub enum ControlResourceAccessError {
ObjectUnavailable { handle: ObjectHandle },
/// The object's Control table could not be completely projected.
TableUnavailable(MissionControlDeviceTableUnavailableReason),
/// This wrapper only implements native resource selectors 1 and 2.
/// 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 },
@@ -355,6 +374,15 @@ struct ControlDeviceProjection {
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 {
@@ -688,6 +716,7 @@ struct LoadedMissionState {
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>,
@@ -795,6 +824,15 @@ pub enum EngineError {
/// 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,
},
/// World error.
World(fparkan_world::WorldError),
/// Reference movement input or terrain query failed.
@@ -917,6 +955,11 @@ impl std::fmt::Display for EngineError {
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::World(source) => write!(f, "{source}"),
Self::Movement(message) => write!(f, "reference movement: {message}"),
Self::RegistrationTeardown {
@@ -950,6 +993,7 @@ impl std::error::Error for EngineError {
| Self::ScriptRuntime { .. }
| Self::Movement(_)
| Self::PrototypeGraph { .. }
| Self::BehaviorProfile { .. }
| Self::RegistrationTeardown { .. } => None,
}
}
@@ -1185,6 +1229,11 @@ fn load_mission_with_options(
.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() {
@@ -1201,6 +1250,20 @@ fn load_mission_with_options(
)?;
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) {
@@ -1404,6 +1467,7 @@ fn load_mission_with_options(
object_drafts,
behavior_distributors,
behavior_ore_states,
behavior_profiles,
control_device_tables,
script_bundles: loaded_scripts.bundles,
script_varset: loaded_scripts.varset,
@@ -1696,6 +1760,15 @@ pub fn loaded_mission_behavior_ore_states(engine: &Engine) -> Option<&[BehaviorO
.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(
@@ -1762,6 +1835,51 @@ pub fn query_loaded_mission_control_device_selector(
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)),
}
}
/// Sets native Control global-table selector 1 on rows whose selector-0 value
/// is positive. Validation is completed before any row is changed.
///
@@ -1910,6 +2028,37 @@ fn query_control_device_table_selector(
.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),
)?,
)
}
fn set_control_device_table_multiplier(
table: &mut MissionControlDeviceTableState,
value: f32,
@@ -2079,6 +2228,123 @@ fn native_behavior_ore_defaults(class_flags: u32) -> (u32, u32) {
}
}
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,
@@ -3388,6 +3654,82 @@ mod tests {
}
}
#[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 =
@@ -3777,6 +4119,10 @@ mod tests {
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),
@@ -3794,6 +4140,12 @@ mod tests {
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(
@@ -3814,6 +4166,12 @@ mod tests {
fparkan_world::WorldError::StaleHandle
))
);
assert_eq!(
query_loaded_mission_behavior_resource(&engine, handle, 0x1005),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
))
);
}
#[test]
@@ -4650,27 +5008,47 @@ mod tests {
let land_path = decode_mission_land_path(&source_tma, TmaProfile::Strict)
.expect("source mission land path")
.raw;
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 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);
// 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);
@@ -4745,6 +5123,38 @@ mod tests {
.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()
@@ -5544,6 +5954,48 @@ mod tests {
);
}
#[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 {
@@ -5645,6 +6097,9 @@ mod tests {
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()));
@@ -5677,6 +6132,51 @@ mod tests {
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)
.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)
.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() {