diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-22 14:30:41 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-22 14:30:41 +0300 |
| commit | 83db371742ad3865bf4dc534ae38429c07a936d3 (patch) | |
| tree | abb39dd412c6dd3bada1125d2a3c7ba78284cc1b /crates/fparkan-runtime | |
| parent | 29b81b87237d0e6a3b7ffac913a0b882738eed7c (diff) | |
| download | fparkan-83db371742ad3865bf4dc534ae38429c07a936d3.tar.xz fparkan-83db371742ad3865bf4dc534ae38429c07a936d3.zip | |
feat(runtime): load Behavior profiles for resource queriestrunk
Diffstat (limited to 'crates/fparkan-runtime')
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 552 | ||||
| -rw-r--r-- | crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs | 263 |
2 files changed, 789 insertions, 26 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index cd7f2c2..b4c13df 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -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, @@ -3389,6 +3655,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 = synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ @@ -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), @@ -3795,6 +4141,12 @@ mod tests { )) ); 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 @@ -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() @@ -5545,6 +5955,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 { clan_index: 0, @@ -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())); @@ -5679,6 +6134,51 @@ mod tests { #[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() { let root = licensed_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); diff --git a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs index 5e89934..610e8db 100644 --- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs +++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs @@ -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"); |
