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() {
@@ -23,6 +23,9 @@ internal static class BehaviorDistributorTickOracle {
private const int IndexedPropertySetRva = 0x0b470;
private const int IndexedPropertyGetRva = 0x0b5b0;
private const int OreInitializationRva = 0x08790;
private const int BehaviorProfileMapRva = 0x08a80;
private const int BehaviorVarBlockConstructorRva = 0x22d90;
private const int BehaviorVarFieldRegisterRva = 0x22e20;
private const int GetSystemArealMapIatRva = 0x59008;
private const int ControlResourceDeviceGetterRva = 0x21d00;
private const int ControlResourceAggregateQueryRva = 0x2b410;
@@ -45,6 +48,10 @@ internal static class BehaviorDistributorTickOracle {
private const int ControlResourceNoOpStringPairRva = 0x0bcd0;
private const int ControlResourceTag19VtableRva = 0x3c608;
private const int ControlResourceTag19StringPairNoOpRva = 0x0bcd0;
private const int BehaviorVarTableVtableRva = 0x591b0;
private const int BehaviorVarArrayLoadRva = 0x49fa0;
private const int BehaviorVarFieldBindRva = 0x49090;
private const int BehaviorVarApplyRva = 0x49170;
private const uint ResourceProperty = 0x02000100;
private static Fixture active;
@@ -74,6 +81,13 @@ internal static class BehaviorDistributorTickOracle {
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void IndexedPropertySet(IntPtr self, uint index, uint valueBits);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int IndexedPropertyGet(IntPtr self, uint index, IntPtr resultBits);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeOreInitialization(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate int NativeBehaviorVarArrayLoad(IntPtr self, IntPtr bytes, IntPtr cursorOut);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorVarFieldBind(IntPtr self, IntPtr typeName, IntPtr fieldName, IntPtr destination);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorVarApply(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorProfileMap(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorVarBlockConstructor(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorVarFieldRegister(IntPtr self, IntPtr table);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeBehaviorProfileLoadCallback(IntPtr owner, uint selector, IntPtr archive, IntPtr entry);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate float NativeControlResourceDeviceGet(IntPtr self, uint selector);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeFastcallComponentBuilder(IntPtr owner, IntPtr node, uint parentId);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeArealMapEvent(IntPtr owner, uint message, uint subcode, IntPtr descriptor);
@@ -111,6 +125,15 @@ internal static class BehaviorDistributorTickOracle {
private static IntPtr x87ControlWordCell;
private static X87ControlWordReader x87ControlWordReader;
private static X87ControlWordWriter x87ControlWordWriter;
private static readonly NativeBehaviorProfileLoadCallback BehaviorProfileLoadCallback = CaptureBehaviorProfileLoad;
private static readonly List<BehaviorProfileSelection> behaviorProfileSelections = new List<BehaviorProfileSelection>();
private sealed class BehaviorProfileSelection {
public IntPtr Owner;
public uint Selector;
public string Archive;
public string Entry;
}
private sealed class NativeObject {
public IntPtr Self;
@@ -533,6 +556,9 @@ internal static class BehaviorDistributorTickOracle {
RunNativeControlResourceDeviceCase(controlModule);
RunNativeControlProviderLiveStateCase(controlModule);
RunNativeControlAggregateResourceCases(controlModule);
RunNativeBehaviorProfileMapCases(module);
RunNativeBehaviorVarSyntheticCase(module);
RunNativeBehaviorRealProfileCases(module);
} finally {
SetX87ControlWord(savedControlWord);
Console.WriteLine("x87-control-word-restored=0x" + CurrentX87ControlWord().ToString("x4"));
@@ -1092,6 +1118,243 @@ internal static class BehaviorDistributorTickOracle {
Console.WriteLine("native-behavior-ore-initialization: classes=80000004,80000008,01002000,01004000,01008000; defaults=verified");
}
private static void CaptureBehaviorProfileLoad(IntPtr owner, uint selector, IntPtr archive, IntPtr entry) {
behaviorProfileSelections.Add(new BehaviorProfileSelection {
Owner = owner,
Selector = selector,
Archive = Marshal.PtrToStringAnsi(archive),
Entry = Marshal.PtrToStringAnsi(entry)
});
}
private static void RunNativeBehaviorProfileMapCases(IntPtr module) {
NativeBehaviorProfileMap map = (NativeBehaviorProfileMap)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, BehaviorProfileMapRva), typeof(NativeBehaviorProfileMap));
string[][] cases = new string[][] {
new string[] { "80000004", "prof_mine.var" },
new string[] { "80000008", "prof_storage.var" },
new string[] { "80000002", "prof_generator.var" },
new string[] { "80000010", "prof_plant.var" },
new string[] { "80000040", "prof_angar.var" },
new string[] { "80000200", "prof_mtp.var" },
new string[] { "80000400", "prof_institute.var" },
new string[] { "80010000", "prof_bunker.var" },
new string[] { "80020000", "prof_bunker.var" },
new string[] { "80040000", "prof_bunker.var" },
new string[] { "80100000", "prof_tower.var" },
new string[] { "80200000", "prof_tower.var" },
new string[] { "01002000", "prof_trn.var" },
new string[] { "01004000", "prof_bld.var" },
new string[] { "01008000", "prof_war.var" },
new string[] { "01010000", "prof_hq.var" },
new string[] { "01020000", "prof_hero.var" },
new string[] { "20000000", "prof_animal.var" }
};
using (Fixture fixture = NewFixture()) {
IntPtr owner = fixture.Allocate(0x0b00);
IntPtr vtable = fixture.Allocate(0x68);
Marshal.WriteIntPtr(owner, vtable);
Marshal.WriteIntPtr(vtable, 0x64, Marshal.GetFunctionPointerForDelegate(BehaviorProfileLoadCallback));
foreach (string[] test in cases) {
behaviorProfileSelections.Clear();
Marshal.WriteInt32(owner, 0x0afc, unchecked((int)Convert.ToUInt32(test[0], 16)));
map(owner);
Expect(behaviorProfileSelections.Count == 1, "profile mapper callback count for class 0x" + test[0]);
BehaviorProfileSelection selection = behaviorProfileSelections[0];
Expect(selection.Owner == owner && selection.Selector == 2 && selection.Archive == "behpsp.res" &&
selection.Entry == test[1], "profile mapper result for class 0x" + test[0]);
}
string[] noMatch = new string[] { "deadbeef", "8000000c" };
foreach (string test in noMatch) {
behaviorProfileSelections.Clear();
Marshal.WriteInt32(owner, 0x0afc, unchecked((int)Convert.ToUInt32(test, 16)));
map(owner);
Expect(behaviorProfileSelections.Count == 0, "profile mapper leaves unknown/combined class 0x" + test + " unconfigured");
}
}
Console.WriteLine("native-behavior-profile-map: mapped-classes=" + cases.Length + " (including three bunker and two tower codes); unknown-and-combined=no callback");
}
private static void RunNativeBehaviorVarSyntheticCase(IntPtr module) {
NativeBehaviorVarArrayLoad load = (NativeBehaviorVarArrayLoad)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, BehaviorVarArrayLoadRva), typeof(NativeBehaviorVarArrayLoad));
NativeBehaviorVarFieldBind bind = (NativeBehaviorVarFieldBind)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, BehaviorVarFieldBindRva), typeof(NativeBehaviorVarFieldBind));
NativeBehaviorVarApply apply = (NativeBehaviorVarApply)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, BehaviorVarApplyRva), typeof(NativeBehaviorVarApply));
byte[] bytes = SerializeOneBehaviorVarField("Transfer_Ore_OffBoard", "float", 0x3f000000, 0x41200000);
using (Fixture fixture = NewFixture()) {
IntPtr table = fixture.Allocate(0x18);
IntPtr target = fixture.Allocate(sizeof(uint));
IntPtr input = fixture.Allocate(bytes.Length);
IntPtr cursorOut = fixture.Allocate(IntPtr.Size);
IntPtr fieldName = fixture.Allocate(32);
IntPtr typeName = fixture.Allocate(8);
CopyBytes(input, bytes);
WriteFixedAnsi(fieldName, 0, "Transfer_Ore_OffBoard", 32);
WriteFixedAnsi(typeName, 0, "float", 8);
Marshal.WriteIntPtr(table, IntPtr.Add(module, BehaviorVarTableVtableRva));
Marshal.WriteInt32(input, 0, 1);
Expect(load(table, input, cursorOut) == 1, "native .var parser accepts the synthetic one-row profile");
Expect(Marshal.ReadInt32(table, 4) == 1 && Marshal.ReadIntPtr(cursorOut) == IntPtr.Add(input, bytes.Length),
"native .var parser consumes the full synthetic profile");
bind(table, typeName, fieldName, target);
apply(table);
Expect(unchecked((uint)Marshal.ReadInt32(target)) == 0x41200000,
"native .var apply uses the second value blob after binding");
Console.WriteLine("native-behavior-var-synthetic: first=0x3f000000 second=0x41200000 applied=0x" +
unchecked((uint)Marshal.ReadInt32(target)).ToString("x8"));
}
}
private static void RunNativeBehaviorRealProfileCases(IntPtr module) {
string archivePath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_BEHAVIOR_PROFILE_RES");
if (String.IsNullOrEmpty(archivePath)) archivePath = @"C:\GOG Games\Parkan - Iron Strategy\behpsp.res";
if (!File.Exists(archivePath)) throw new FileNotFoundException(
"Set FPARKAN_ORIGINAL_BEHAVIOR_PROFILE_RES to the installed behpsp.res.", archivePath);
byte[] archiveBytes = File.ReadAllBytes(archivePath);
NativeBehaviorProfileMap map = (NativeBehaviorProfileMap)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, BehaviorProfileMapRva), typeof(NativeBehaviorProfileMap));
NativeBehaviorVarArrayLoad load = (NativeBehaviorVarArrayLoad)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, BehaviorVarArrayLoadRva), typeof(NativeBehaviorVarArrayLoad));
NativeBehaviorVarBlockConstructor construct = (NativeBehaviorVarBlockConstructor)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, BehaviorVarBlockConstructorRva), typeof(NativeBehaviorVarBlockConstructor));
NativeBehaviorVarFieldRegister register = (NativeBehaviorVarFieldRegister)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, BehaviorVarFieldRegisterRva), typeof(NativeBehaviorVarFieldRegister));
NativeBehaviorVarApply apply = (NativeBehaviorVarApply)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, BehaviorVarApplyRva), typeof(NativeBehaviorVarApply));
string selectedEntry = SelectBehaviorProfile(map, 0x80000004);
Expect(selectedEntry == "prof_mine.var", "mine profile is selected by native class mapper");
byte[] profile = ReadNresEntry(archiveBytes, selectedEntry);
Expect(ReadUInt32(profile, 0) == 27, "native mine profile contains all 27 rows");
uint expectedOreOffBoard = FindBehaviorVarSecondBlob(profile, "Transfer_Ore_OffBoard");
uint expectedPowerOut = FindBehaviorVarSecondBlob(profile, "Transfer_Power_Out");
using (Fixture fixture = NewFixture()) {
IntPtr table = fixture.Allocate(0x18);
IntPtr rawProfile = fixture.Allocate(profile.Length);
IntPtr cursorOut = fixture.Allocate(IntPtr.Size);
IntPtr block = fixture.Allocate(0x80);
CopyBytes(rawProfile, profile);
Marshal.WriteIntPtr(table, IntPtr.Add(module, BehaviorVarTableVtableRva));
Expect(load(table, rawProfile, cursorOut) == 1, "native parser accepts the licensed mine profile");
Expect(Marshal.ReadInt32(table, 4) == 27 && Marshal.ReadIntPtr(cursorOut) == IntPtr.Add(rawProfile, profile.Length),
"native parser consumes all rows in the licensed mine profile");
construct(block);
Expect(unchecked((uint)Marshal.ReadInt32(block, 0x58)) == 0x40a00000 &&
unchecked((uint)Marshal.ReadInt32(block, 0x60)) == 0x42c80000,
"native variable block starts with transfer defaults 5 and 100");
register(block, table);
apply(table);
Expect(unchecked((uint)Marshal.ReadInt32(block, 0x58)) == expectedOreOffBoard &&
unchecked((uint)Marshal.ReadInt32(block, 0x60)) == expectedPowerOut,
"native profile bind/apply copies second blobs into Transfer_Ore_OffBoard and Transfer_Power_Out");
Console.WriteLine("native-behavior-real-profile: class=80000004 entry=" + selectedEntry + " rows=27 Transfer_Ore_OffBoard=" +
Hex(expectedOreOffBoard) + " Transfer_Power_Out=" + Hex(expectedPowerOut) + "; native-bind-and-apply=verified");
}
}
private static string SelectBehaviorProfile(NativeBehaviorProfileMap map, uint classCode) {
using (Fixture fixture = NewFixture()) {
IntPtr owner = fixture.Allocate(0x0b00);
IntPtr vtable = fixture.Allocate(0x68);
Marshal.WriteIntPtr(owner, vtable);
Marshal.WriteIntPtr(vtable, 0x64, Marshal.GetFunctionPointerForDelegate(BehaviorProfileLoadCallback));
behaviorProfileSelections.Clear();
Marshal.WriteInt32(owner, 0x0afc, unchecked((int)classCode));
map(owner);
Expect(behaviorProfileSelections.Count == 1, "native mapper returned a single profile for class 0x" + classCode.ToString("x8"));
BehaviorProfileSelection selection = behaviorProfileSelections[0];
Expect(selection.Owner == owner && selection.Selector == 2 && selection.Archive == "behpsp.res",
"native mapper selected the behavior profile archive and selector");
return selection.Entry;
}
}
private static byte[] SerializeOneBehaviorVarField(string fieldName, string typeName, uint firstBits, uint secondBits) {
byte[] field = Encoding.ASCII.GetBytes(fieldName);
byte[] type = Encoding.ASCII.GetBytes(typeName);
using (MemoryStream stream = new MemoryStream()) using (BinaryWriter writer = new BinaryWriter(stream)) {
writer.Write((uint)1); // row count
writer.Write((uint)4); // value size
writer.Write((uint)3); // float type index
writer.Write((uint)0); // flags
writer.Write((uint)field.Length); writer.Write(field);
writer.Write((uint)type.Length); writer.Write(type);
writer.Write(firstBits); writer.Write(secondBits);
writer.Write((uint)0); writer.Write((uint)0); // trailing row metadata
return stream.ToArray();
}
}
private static byte[] ReadNresEntry(byte[] archive, string name) {
if (archive.Length < 16) throw new InvalidDataException("NRes archive is shorter than its header");
uint count = ReadUInt32(archive, 8);
if (count > Int32.MaxValue / 64) throw new InvalidDataException("NRes directory count is too large");
int directory = checked(archive.Length - (int)count * 64);
if (directory < 16) throw new InvalidDataException("NRes directory falls inside the header");
for (int index = 0; index < (int)count; index++) {
int record = checked(directory + index * 64);
int nameEnd = record + 20;
while (nameEnd < record + 56 && archive[nameEnd] != 0) nameEnd++;
string entryName = Encoding.ASCII.GetString(archive, record + 20, nameEnd - (record + 20));
if (!String.Equals(entryName, name, StringComparison.Ordinal)) continue;
uint size = ReadUInt32(archive, record + 12);
uint dataOffset = ReadUInt32(archive, record + 56);
if (size > Int32.MaxValue || dataOffset > Int32.MaxValue || dataOffset + size > archive.Length)
throw new InvalidDataException("NRes entry range is outside the archive");
byte[] data = new byte[(int)size];
Buffer.BlockCopy(archive, (int)dataOffset, data, 0, data.Length);
return data;
}
throw new InvalidDataException("NRes archive does not contain entry " + name);
}
private static uint FindBehaviorVarSecondBlob(byte[] profile, string fieldName) {
if (profile.Length < 4) throw new InvalidDataException("Behavior profile is shorter than its row count");
uint count = ReadUInt32(profile, 0);
if (count > 1024) throw new InvalidDataException("Behavior profile row count is unreasonable");
int cursor = 4;
for (int index = 0; index < (int)count; index++) {
EnsureRange(profile, cursor, 12);
uint size = ReadUInt32(profile, cursor);
if (size > Int32.MaxValue) throw new InvalidDataException("Behavior variable size is too large");
uint type = ReadUInt32(profile, cursor + 4);
cursor += 12;
uint fieldLength = ReadUInt32(profile, cursor);
if (fieldLength > Int32.MaxValue) throw new InvalidDataException("Behavior field name is too large");
cursor += 4; EnsureRange(profile, cursor, (int)fieldLength);
string name = Encoding.ASCII.GetString(profile, cursor, (int)fieldLength);
cursor += (int)fieldLength;
uint typeLength = ReadUInt32(profile, cursor);
if (typeLength > Int32.MaxValue) throw new InvalidDataException("Behavior variable type name is too large");
cursor += 4; EnsureRange(profile, cursor, (int)typeLength);
cursor += (int)typeLength;
EnsureRange(profile, cursor, checked((int)size * 2 + 8));
int secondBlob = checked(cursor + (int)size);
if (String.Equals(name, fieldName, StringComparison.Ordinal)) {
if (type != 3 || size != 4) throw new InvalidDataException("Expected float profile field " + fieldName);
return ReadUInt32(profile, secondBlob);
}
cursor = checked(cursor + (int)size * 2 + 8);
}
throw new InvalidDataException("Behavior profile does not contain field " + fieldName);
}
private static uint ReadUInt32(byte[] data, int offset) {
EnsureRange(data, offset, 4);
return unchecked((uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24)));
}
private static void EnsureRange(byte[] data, int offset, int size) {
if (offset < 0 || size < 0 || offset > data.Length - size) throw new InvalidDataException("Behavior profile range is outside its input");
}
private static void CopyBytes(IntPtr destination, byte[] source) { Marshal.Copy(source, 0, destination, source.Length); }
private static ushort CurrentX87ControlWord() {
if (x87ControlWordReader == null || x87ControlWordCell == IntPtr.Zero)
throw new InvalidOperationException("x87 control-word reader is not initialized");