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

Generated
+1
View File
@@ -469,6 +469,7 @@ version = "0.1.0"
name = "fparkan-assets" name = "fparkan-assets"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fparkan-binary",
"fparkan-material", "fparkan-material",
"fparkan-mission-format", "fparkan-mission-format",
"fparkan-msh", "fparkan-msh",
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-material = { path = "../fparkan-material" } fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" } fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres" }
+239
View File
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Asset manager ports and transactional preparation models. //! Asset manager ports and transactional preparation models.
use fparkan_binary::{checked_count_bytes, read_lp_bytes, Cursor, DecodeError};
use fparkan_material::{ use fparkan_material::{
decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable, decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable,
MAT0_KIND, WEAR_KIND, MAT0_KIND, WEAR_KIND,
@@ -62,6 +63,150 @@ pub struct PreparedMaterialPhase {
pub page_uv_transform: [f32; 4], pub page_uv_transform: [f32; 4],
} }
/// The two Behavior profile values currently used by recovered resource queries.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BehaviorProfileRates {
/// Second raw value blob for `Transfer_Ore_OffBoard`.
pub transfer_ore_off_board_bits: u32,
/// Second raw value blob for `Transfer_Power_Out`.
pub transfer_power_out_bits: u32,
}
/// Error while reading a bounded binary Behavior `.var` profile.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BehaviorProfileVarError {
/// The binary record framing is malformed.
Decode(DecodeError),
/// A bound for one of the two fields used here is not a 32-bit float.
InvalidFloatField {
name: &'static str,
size: u32,
type_index: u32,
},
/// A registered runtime field was absent from the profile.
MissingField { name: &'static str },
}
impl fmt::Display for BehaviorProfileVarError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Decode(source) => write!(f, "{source}"),
Self::InvalidFloatField {
name,
size,
type_index,
} => write!(
f,
"field {name} has size {size} and type index {type_index}, expected a 4-byte float"
),
Self::MissingField { name } => write!(f, "required field {name} is missing"),
}
}
}
impl std::error::Error for BehaviorProfileVarError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Decode(source) => Some(source),
Self::InvalidFloatField { .. } | Self::MissingField { .. } => None,
}
}
}
impl From<DecodeError> for BehaviorProfileVarError {
fn from(value: DecodeError) -> Self {
Self::Decode(value)
}
}
/// Reads the two Behavior rate fields from a native binary `.var` profile.
///
/// Native records contain two equally sized value blobs. After binding a
/// registered destination, the original applies the second blob. Field names
/// are matched case-sensitively as C strings (up to the first NUL), and the
/// first occurrence wins. Other fields are consumed but their meanings remain
/// outside this decoder.
///
/// # Errors
///
/// Returns [`BehaviorProfileVarError`] when record framing is truncated,
/// contains trailing bytes, omits a registered field, or declares a selected
/// field that is not a float row.
pub fn decode_behavior_profile_rates(
bytes: &[u8],
) -> Result<BehaviorProfileRates, BehaviorProfileVarError> {
let mut cursor = Cursor::new(bytes);
let count = cursor.read_u32_le()?;
checked_count_bytes(u64::from(count), 28, cursor.remaining() as u64)?;
let mut transfer_ore_off_board_bits = None;
let mut transfer_power_out_bits = None;
for _ in 0..count {
let size = cursor.read_u32_le()?;
let type_index = cursor.read_u32_le()?;
let _flags = cursor.read_u32_le()?;
let field_name = read_lp_bytes(&mut cursor, 64 * 1024)?;
let c_string_name = field_name
.split(|byte| *byte == 0)
.next()
.unwrap_or_default();
let target_name = match c_string_name {
b"Transfer_Ore_OffBoard" => Some("Transfer_Ore_OffBoard"),
b"Transfer_Power_Out" => Some("Transfer_Power_Out"),
_ => None,
};
let _type_name = read_lp_bytes(&mut cursor, 64 * 1024)?;
let value_size = usize::try_from(size).map_err(|_| DecodeError::IntegerOverflow)?;
cursor.read_exact(value_size)?;
let capture_target = target_name.is_some_and(|name| {
if name == "Transfer_Ore_OffBoard" {
transfer_ore_off_board_bits.is_none()
} else {
transfer_power_out_bits.is_none()
}
});
let second_value_bits = if capture_target {
let name = target_name.expect("capture requires a target field");
if size != 4 || type_index != 3 {
return Err(BehaviorProfileVarError::InvalidFloatField {
name,
size,
type_index,
});
}
let value = cursor.read_exact(value_size)?;
Some(u32::from_le_bytes(
value.try_into().expect("validated four-byte float row"),
))
} else {
cursor.read_exact(value_size)?;
None
};
cursor.read_exact(8)?;
if let (Some(name), Some(value_bits)) = (target_name, second_value_bits) {
if name == "Transfer_Ore_OffBoard" {
transfer_ore_off_board_bits = Some(value_bits);
} else {
transfer_power_out_bits = Some(value_bits);
}
}
}
cursor.require_eof()?;
Ok(BehaviorProfileRates {
transfer_ore_off_board_bits: transfer_ore_off_board_bits.ok_or(
BehaviorProfileVarError::MissingField {
name: "Transfer_Ore_OffBoard",
},
)?,
transfer_power_out_bits: transfer_power_out_bits.ok_or(
BehaviorProfileVarError::MissingField {
name: "Transfer_Power_Out",
},
)?,
})
}
type WearValidationCache = type WearValidationCache =
HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>; HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>;
@@ -4222,6 +4367,100 @@ mod tests {
// Preserve the authored CTLD fixture bits; this is not the `f32` value of tau. // Preserve the authored CTLD fixture bits; this is not the `f32` value of tau.
const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3); const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3);
fn behavior_var_row(name: &[u8], type_index: u32, first: u32, second: u32) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&4_u32.to_le_bytes());
bytes.extend_from_slice(&type_index.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(&(name.len() as u32).to_le_bytes());
bytes.extend_from_slice(name);
bytes.extend_from_slice(&0_u32.to_le_bytes()); // Native transfer rows use an empty type-name.
bytes.extend_from_slice(&first.to_le_bytes());
bytes.extend_from_slice(&second.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes
}
fn behavior_var(rows: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(rows.len() as u32).to_le_bytes());
for row in rows {
bytes.extend_from_slice(row);
}
bytes
}
#[test]
fn behavior_profile_decoder_uses_first_exact_rows_second_blob_and_float_kind() {
let profile = behavior_var(&[
behavior_var_row(b"transfer_ore_off_board", 3, 0x1111_1111, 0x2222_2222),
behavior_var_row(
b"Transfer_Ore_OffBoard\0legacy-suffix",
3,
0x3333_3333,
0x3f80_0000,
),
behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0x4444_4444, 0x4000_0000),
behavior_var_row(b"Transfer_Power_Out", 3, 0x5555_5555, 0x4120_0000),
]);
assert_eq!(
decode_behavior_profile_rates(&profile),
Ok(BehaviorProfileRates {
transfer_ore_off_board_bits: 0x3f80_0000,
transfer_power_out_bits: 0x4120_0000,
})
);
}
#[test]
fn behavior_profile_decoder_rejects_every_truncation_and_trailing_bytes() {
let valid = behavior_var(&[
behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0, 0x3f80_0000),
behavior_var_row(b"Transfer_Power_Out", 3, 0, 0x4120_0000),
]);
for end in 0..valid.len() {
assert!(
decode_behavior_profile_rates(&valid[..end]).is_err(),
"prefix ending at byte {end} was accepted"
);
}
let mut with_trailing = valid.clone();
with_trailing.push(0);
assert!(matches!(
decode_behavior_profile_rates(&with_trailing),
Err(BehaviorProfileVarError::Decode(
fparkan_binary::DecodeError::TrailingBytes { .. }
))
));
}
#[test]
fn behavior_profile_decoder_requires_both_registered_float_fields() {
let profile = behavior_var(&[behavior_var_row(b"Transfer_Power_Out", 3, 0, 0x4120_0000)]);
assert_eq!(
decode_behavior_profile_rates(&profile),
Err(BehaviorProfileVarError::MissingField {
name: "Transfer_Ore_OffBoard"
})
);
let profile = behavior_var(&[
behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0, 0x3f80_0000),
behavior_var_row(b"Transfer_Power_Out", 1, 0, 0x4120_0000),
]);
assert!(matches!(
decode_behavior_profile_rates(&profile),
Err(BehaviorProfileVarError::InvalidFloatField {
name: "Transfer_Power_Out",
size: 4,
type_index: 1,
})
));
}
fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord { fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
let mut fixed_words = [0; 44]; let mut fixed_words = [0; 44];
fixed_words[1] = key; fixed_words[1] = key;
+526 -26
View File
@@ -7,10 +7,11 @@ use fparkan_assets::{
decode_mission_land_path, decode_mission_payload, decode_nres_payload, decode_mission_land_path, decode_mission_payload, decode_nres_payload,
derive_mission_land_paths, extend_graph_report_with_visual_dependencies, derive_mission_land_paths, extend_graph_report_with_visual_dependencies,
mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError, mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError,
AssetId, AssetManager, BuildCategory, MissionAssetPlan, MissionDocument, MissionError, AssetId, AssetManager, BehaviorProfileRates, BehaviorProfileVarError, BuildCategory,
MissionTerrainPaths, NresError, PreparedComponentControl, PreparedControlEffectSection, MissionAssetPlan, MissionDocument, MissionError, MissionTerrainPaths, NresError,
PreparedControlNodeDataRow, PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedComponentControl, PreparedControlEffectSection, PreparedControlNodeDataRow,
PreparedVisual, TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile, PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedVisual, TerrainFormatError,
TerrainPreparationError, TerrainWorld, TmaProfile,
}; };
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{ use fparkan_prototype::{
@@ -30,6 +31,7 @@ use fparkan_world::{
step, transform_state, InputSnapshot, ObjectDraft, ObjectHandle, OriginalObjectId, step, transform_state, InputSnapshot, ObjectDraft, ObjectHandle, OriginalObjectId,
TransformState, World, WorldConfig, WorldSnapshot, TransformState, World, WorldConfig, WorldSnapshot,
}; };
use std::collections::HashMap;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::sync::Arc; use std::sync::Arc;
@@ -153,6 +155,23 @@ pub struct BehaviorOreState {
pub maximum_ore_bits: u32, 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. /// Origin of a Control descriptor retained in the effective table projection.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct ControlDeviceRecordProvenance { pub struct ControlDeviceRecordProvenance {
@@ -246,7 +265,7 @@ pub enum ControlResourceAccessError {
ObjectUnavailable { handle: ObjectHandle }, ObjectUnavailable { handle: ObjectHandle },
/// The object's Control table could not be completely projected. /// The object's Control table could not be completely projected.
TableUnavailable(MissionControlDeviceTableUnavailableReason), TableUnavailable(MissionControlDeviceTableUnavailableReason),
/// This wrapper only implements native resource selectors 1 and 2. /// The requested resource selector is not implemented for this operation.
UnsupportedSelector(u32), UnsupportedSelector(u32),
/// A selected row has a factory class whose getter has not been checked. /// A selected row has a factory class whose getter has not been checked.
UnsupportedFactoryTag { factory_tag: u32 }, UnsupportedFactoryTag { factory_tag: u32 },
@@ -355,6 +374,15 @@ struct ControlDeviceProjection {
node_provider_status: MissionControlNodeProviderTableStatus, 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. /// Resource distributor state associated with one TMA clan's constructed AI.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct BehaviorDistributorState { pub struct BehaviorDistributorState {
@@ -688,6 +716,7 @@ struct LoadedMissionState {
object_drafts: Vec<MissionObjectDraft>, object_drafts: Vec<MissionObjectDraft>,
behavior_distributors: Vec<BehaviorDistributorState>, behavior_distributors: Vec<BehaviorDistributorState>,
behavior_ore_states: Vec<BehaviorOreState>, behavior_ore_states: Vec<BehaviorOreState>,
behavior_profiles: Vec<BehaviorProfileState>,
control_device_tables: Vec<MissionControlDeviceTableState>, control_device_tables: Vec<MissionControlDeviceTableState>,
script_bundles: Vec<MissionScriptBundle>, script_bundles: Vec<MissionScriptBundle>,
script_varset: Option<MissionScriptVarSet>, script_varset: Option<MissionScriptVarSet>,
@@ -795,6 +824,15 @@ pub enum EngineError {
/// Source error. /// Source error.
source: AssetPreparationError, 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 error.
World(fparkan_world::WorldError), World(fparkan_world::WorldError),
/// Reference movement input or terrain query failed. /// Reference movement input or terrain query failed.
@@ -917,6 +955,11 @@ impl std::fmt::Display for EngineError {
Self::AssetPreparation { mission, source } => { Self::AssetPreparation { mission, source } => {
write!(f, "{mission}: asset preparation failed: {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::World(source) => write!(f, "{source}"),
Self::Movement(message) => write!(f, "reference movement: {message}"), Self::Movement(message) => write!(f, "reference movement: {message}"),
Self::RegistrationTeardown { Self::RegistrationTeardown {
@@ -950,6 +993,7 @@ impl std::error::Error for EngineError {
| Self::ScriptRuntime { .. } | Self::ScriptRuntime { .. }
| Self::Movement(_) | Self::Movement(_)
| Self::PrototypeGraph { .. } | Self::PrototypeGraph { .. }
| Self::BehaviorProfile { .. }
| Self::RegistrationTeardown { .. } => None, | Self::RegistrationTeardown { .. } => None,
} }
} }
@@ -1185,6 +1229,11 @@ fn load_mission_with_options(
.unwrap_or_default(), .unwrap_or_default(),
}) })
.collect(); .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 new_runtime_world = new_world(WorldConfig);
let mut handles = Vec::with_capacity(mission.objects.len()); let mut handles = Vec::with_capacity(mission.objects.len());
for (index, object) in mission.objects.iter().enumerate() { for (index, object) in mission.objects.iter().enumerate() {
@@ -1201,6 +1250,20 @@ fn load_mission_with_options(
)?; )?;
handles.push(handle); 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; let mut registered_objects = 0;
for handle in &handles { for handle in &handles {
if options.fail_after_registered_objects == Some(registered_objects) { if options.fail_after_registered_objects == Some(registered_objects) {
@@ -1404,6 +1467,7 @@ fn load_mission_with_options(
object_drafts, object_drafts,
behavior_distributors, behavior_distributors,
behavior_ore_states, behavior_ore_states,
behavior_profiles,
control_device_tables, control_device_tables,
script_bundles: loaded_scripts.bundles, script_bundles: loaded_scripts.bundles,
script_varset: loaded_scripts.varset, 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()) .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. /// Returns the base Control device table projections for loaded mission objects.
#[must_use] #[must_use]
pub fn loaded_mission_control_device_tables( 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) 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 /// Sets native Control global-table selector 1 on rows whose selector-0 value
/// is positive. Validation is completed before any row is changed. /// is positive. Validation is completed before any row is changed.
/// ///
@@ -1910,6 +2028,37 @@ fn query_control_device_table_selector(
.map(Some) .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( fn set_control_device_table_multiplier(
table: &mut MissionControlDeviceTableState, table: &mut MissionControlDeviceTableState,
value: f32, 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( fn initialize_behavior_ore_state(
original_id: OriginalObjectId, original_id: OriginalObjectId,
handle: ObjectHandle, 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] #[test]
fn control_resource_queries_filter_current_tag_and_use_factory_getters() { fn control_resource_queries_filter_current_tag_and_use_factory_getters() {
let table = let table =
@@ -3777,6 +4119,10 @@ mod tests {
query_loaded_mission_control_device_selector(&engine, handle, 0, 0xe00), query_loaded_mission_control_device_selector(&engine, handle, 0, 0xe00),
Err(ControlResourceAccessError::MissionUnavailable) Err(ControlResourceAccessError::MissionUnavailable)
); );
assert_eq!(
query_loaded_mission_behavior_resource(&engine, handle, 0x1005),
Err(ControlResourceAccessError::MissionUnavailable)
);
let stale = ObjectHandle { let stale = ObjectHandle {
generation: handle.generation.wrapping_add(1), generation: handle.generation.wrapping_add(1),
@@ -3794,6 +4140,12 @@ mod tests {
fparkan_world::WorldError::StaleHandle fparkan_world::WorldError::StaleHandle
)) ))
); );
assert_eq!(
query_loaded_mission_behavior_resource(&engine, stale, 0x1005),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
))
);
assert_eq!( assert_eq!(
set_loaded_mission_control_multiplier(&mut engine, stale, 2.0), set_loaded_mission_control_multiplier(&mut engine, stale, 2.0),
Err(ControlResourceAccessError::World( Err(ControlResourceAccessError::World(
@@ -3814,6 +4166,12 @@ mod tests {
fparkan_world::WorldError::StaleHandle fparkan_world::WorldError::StaleHandle
)) ))
); );
assert_eq!(
query_loaded_mission_behavior_resource(&engine, handle, 0x1005),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
))
);
} }
#[test] #[test]
@@ -4650,27 +5008,47 @@ mod tests {
let land_path = decode_mission_land_path(&source_tma, TmaProfile::Strict) let land_path = decode_mission_land_path(&source_tma, TmaProfile::Strict)
.expect("source mission land path") .expect("source mission land path")
.raw; .raw;
let mut overlay = MemoryVfs::default(); // The TMA keeps its strict marker; only the Unit DAT class header is
overlay.insert( // overridden in the in-memory VFS to exercise both native profiles.
normalize_engine_path("test mission", "MISSIONS/TEST/MINE/data.tma") let load_full_fixture = |class_flags: u32| {
.expect("mission path"), let mut overlay = MemoryVfs::default();
Arc::from(single_unit_tma(&land_path)), overlay.insert(
); normalize_engine_path("test mission", "MISSIONS/TEST/MINE/data.tma")
let vfs = Arc::new(OverlayVfs::from_layers(vec![ .expect("mission path"),
Arc::new(overlay), Arc::from(single_unit_tma(&land_path)),
Arc::new(DirectoryVfs::new(&root)), );
])); let mut unit_dat = std::fs::read(root.join("UNITS/BUILDS/MINE/min_l_n1.dat"))
let mut engine = create(EngineServices::new(vfs)).expect("engine"); .expect("licensed Unit DAT");
let loaded = load_mission( let original_class_flags =
&mut engine, u32::from_le_bytes(unit_dat[4..8].try_into().expect("Unit DAT header word"));
MissionRequest { assert_eq!(original_class_flags, 0x8000_0004);
key: "MISSIONS/TEST/MINE/data.tma".to_string(), if class_flags != original_class_flags {
mode: MissionMode::SingleMission, unit_dat[4..8].copy_from_slice(&class_flags.to_le_bytes());
}, }
0, overlay.insert(
) normalize_engine_path("test Unit DAT", "UNITS/BUILDS/MINE/min_l_n1.dat")
.expect("full normal mission load"); .expect("Unit DAT path"),
assert_eq!(loaded.object_count, 1); 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]; let draft = &loaded_mission_object_drafts(&engine).expect("object drafts")[0];
assert_eq!(draft.unit_components.len(), 6); assert_eq!(draft.unit_components.len(), 6);
@@ -4745,6 +5123,38 @@ mod tests {
.map(f32::to_bits), .map(f32::to_bits),
Some(1.0_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 let prepared_controls = &engine
.loaded .loaded
.as_ref() .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] #[test]
fn behavior_distributor_normalize_matches_native_24_bit_vectors() { fn behavior_distributor_normalize_matches_native_24_bit_vectors() {
let mut pinned = BehaviorDistributorState { let mut pinned = BehaviorDistributorState {
@@ -5645,6 +6097,9 @@ mod tests {
assert!(loaded_mission_behavior_ore_states(&engine) assert!(loaded_mission_behavior_ore_states(&engine)
.expect("preview Behavior ore state") .expect("preview Behavior ore state")
.is_empty()); .is_empty());
assert!(loaded_mission_behavior_profiles(&engine)
.expect("preview Behavior profiles")
.is_empty());
assert!(bundles assert!(bundles
.iter() .iter()
.all(|bundle| bundle.behavior_warm_start.is_none())); .all(|bundle| bundle.behavior_warm_start.is_none()));
@@ -5677,6 +6132,51 @@ mod tests {
assert!(loaded.script_bundle_count >= 1); 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] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
fn loaded_host_callback_uses_requested_mode_and_constructor_status() { 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 IndexedPropertySetRva = 0x0b470;
private const int IndexedPropertyGetRva = 0x0b5b0; private const int IndexedPropertyGetRva = 0x0b5b0;
private const int OreInitializationRva = 0x08790; 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 GetSystemArealMapIatRva = 0x59008;
private const int ControlResourceDeviceGetterRva = 0x21d00; private const int ControlResourceDeviceGetterRva = 0x21d00;
private const int ControlResourceAggregateQueryRva = 0x2b410; private const int ControlResourceAggregateQueryRva = 0x2b410;
@@ -45,6 +48,10 @@ internal static class BehaviorDistributorTickOracle {
private const int ControlResourceNoOpStringPairRva = 0x0bcd0; private const int ControlResourceNoOpStringPairRva = 0x0bcd0;
private const int ControlResourceTag19VtableRva = 0x3c608; private const int ControlResourceTag19VtableRva = 0x3c608;
private const int ControlResourceTag19StringPairNoOpRva = 0x0bcd0; 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 const uint ResourceProperty = 0x02000100;
private static Fixture active; 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 void IndexedPropertySet(IntPtr self, uint index, uint valueBits);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int IndexedPropertyGet(IntPtr self, uint index, IntPtr resultBits); [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 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.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 void NativeFastcallComponentBuilder(IntPtr owner, IntPtr node, uint parentId);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeArealMapEvent(IntPtr owner, uint message, uint subcode, IntPtr descriptor); [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 IntPtr x87ControlWordCell;
private static X87ControlWordReader x87ControlWordReader; private static X87ControlWordReader x87ControlWordReader;
private static X87ControlWordWriter x87ControlWordWriter; 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 { private sealed class NativeObject {
public IntPtr Self; public IntPtr Self;
@@ -533,6 +556,9 @@ internal static class BehaviorDistributorTickOracle {
RunNativeControlResourceDeviceCase(controlModule); RunNativeControlResourceDeviceCase(controlModule);
RunNativeControlProviderLiveStateCase(controlModule); RunNativeControlProviderLiveStateCase(controlModule);
RunNativeControlAggregateResourceCases(controlModule); RunNativeControlAggregateResourceCases(controlModule);
RunNativeBehaviorProfileMapCases(module);
RunNativeBehaviorVarSyntheticCase(module);
RunNativeBehaviorRealProfileCases(module);
} finally { } finally {
SetX87ControlWord(savedControlWord); SetX87ControlWord(savedControlWord);
Console.WriteLine("x87-control-word-restored=0x" + CurrentX87ControlWord().ToString("x4")); 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"); 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() { private static ushort CurrentX87ControlWord() {
if (x87ControlWordReader == null || x87ControlWordCell == IntPtr.Zero) if (x87ControlWordReader == null || x87ControlWordCell == IntPtr.Zero)
throw new InvalidOperationException("x87 control-word reader is not initialized"); throw new InvalidOperationException("x87 control-word reader is not initialized");
+13 -3
View File
@@ -330,9 +330,19 @@ factory paths остаются явными границами. Snapshot пок
`FUN_10010f30` (RVA `0x10f30`) изменяет и ограничивает provider amount, а `FUN_10010f30` (RVA `0x10f30`) изменяет и ограничивает provider amount, а
связанные callbacks переносят его между строками; их общий переход в Rust ещё связанные callbacks переносят его между строками; их общий переход в Rust ещё
не реализован. Поэтому вызов нельзя считать живым ресурсным симулятором. не реализован. Поэтому вызов нельзя считать живым ресурсным симулятором.
Behavior selector `0x1002` остаётся отдельной задачей: его множитель Behavior selectors `0x1002` и `0x1005` используют значения выбранного
`Transfer_Ore_OffBoard` приходит из блока `+0x820`, но источник фактических профиля из `behpsp.res`, разобранного до создания нового мира. `0x1002` складывает
текущих настроек для полного runtime-пути пока не подтверждён. только текущие descriptor tag `26` запросы `0xe00`, затем применяет
`Transfer_Ore_OffBoard`; `0x1005` возвращает `Transfer_Power_Out`. Это начальный
снимок: он не отражает последующие изменения provider-строк, Control tick-а или
профиля. Полная загрузка сохраняет результат у `ObjectHandle` и возвращает
ошибку, если известный код класса не имеет корректной записи; класс,
отсутствующий в таблице выбора, сохраняет начальные значения конструктора.
Preview и объекты без полного Unit DAT не получают профильное состояние.
Native oracle подтвердил соответствия кода класса выбранной записи, разбор
`.var` и применение второй области данных;
оригинальный aggregate getter Behavior RVA `0xa490` остаётся статически
исследованным, но не исполнен в oracle.
В x86 oracle при x87 control word `0x007f` проверены порядок, signed zero, В x86 oracle при x87 control word `0x007f` проверены порядок, signed zero,
отрицательные входы, расширенный промежуточный диапазон и subnormal return. отрицательные входы, расширенный промежуточный диапазон и subnormal return.
+66 -16
View File
@@ -755,27 +755,77 @@ NDP, а `FUN_1000a100` (RVA `0xa100`) добавляет их в общую та
`FUN_100198e0` (RVA `0x198e0`) суммирует успешные запросы IID `0x204` с selector `FUN_100198e0` (RVA `0x198e0`) суммирует успешные запросы IID `0x204` с selector
`0xe00`. В парной `Control.dll` выше `FUN_1002bb40` (RVA `0x2bb40`) для типа `0xe00`. В парной `Control.dll` выше `FUN_1002bb40` (RVA `0x2bb40`) для типа
`0x1a` вызывает selector `0x300`, описанный в этом разделе. Затем Behavior query `0x1a` вызывает selector `0x300`, описанный в этом разделе. Затем Behavior query
`0x1002` (RVA `0xa490`) умножает полученную сумму на текущее значение block `0x1002` (RVA `0xa490`) умножает полученную сумму на поле `+0x58`
`+0x820`, поле `+0x58` `Transfer_Ore_OffBoard` (имя — RVA `0x22e20`, default `Transfer_Ore_OffBoard` блока `+0x820`; отдельный selector `0x1005` возвращает
5.0 — RVA `0x22d90`; config selector 2 загружает блок через RVA `0x0a1a0`). `+0x60` `Transfer_Power_Out`. Имена регистрируются в RVA `0x22e20`, а конструктор
Отдельный query `0x1005` (RVA `0xa490`) возвращает поле `+0x60` RVA `0x22d90` задаёт начальные значения 5.0 и 100.0. Восемнадцать точных class
`Transfer_Power_Out` (имя — RVA `0x22e20`, default 100.0 — RVA `0x22d90`; codes выбирают записи `.var` из `behpsp.res`: например, `0x80000004` выбирает
setter — RVA `0xaa40`). В полной загрузке FParkan начальные provider-строки `prof_mine.var`, `0x80000002` — `prof_generator.var`, а три кода bunker-класса
хранятся вместе с таблицей Control конкретного `ObjectHandle`. Запрос выбирают `prof_bunker.var`. Таблицу этих соответствий задаёт mapper
`0xe00` проходит по ключу устройства в этот снимок и пока поддержан только для `FUN_10008a80` (RVA `0x8a80`) в Behavior.dll SHA-256
текущего descriptor tag `26` с factory tag `26`; для него результат равен `ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`.
Установленные соответствия mapper-а:
| Class flags | Запись `behpsp.res` |
| --- | --- |
| `0x80000004` | `prof_mine.var` |
| `0x80000008` | `prof_storage.var` |
| `0x80000002` | `prof_generator.var` |
| `0x80000010` | `prof_plant.var` |
| `0x80000040` | `prof_angar.var` |
| `0x80000200` | `prof_mtp.var` |
| `0x80000400` | `prof_institute.var` |
| `0x80010000`, `0x80020000`, `0x80040000` | `prof_bunker.var` |
| `0x80100000`, `0x80200000` | `prof_tower.var` |
| `0x01002000` | `prof_trn.var` |
| `0x01004000` | `prof_bld.var` |
| `0x01008000` | `prof_war.var` |
| `0x01010000` | `prof_hq.var` |
| `0x01020000` | `prof_hero.var` |
| `0x20000000` | `prof_animal.var` |
У бинарной строки `.var` есть имя, индекс типа, размер и две области данных.
Native parser RVA `0x49fa0` читает запись, binder RVA `0x49090` связывает её с
зарегистрированным полем, а apply-loop RVA `0x49170` копирует в поле именно
вторую область. В профиле используются точные C-string имена
`Transfer_Ore_OffBoard` и `Transfer_Power_Out`; регистр учитывается, первый
совпавший ряд побеждает. Rust ограничивает чтение исходной записи NRes её
границами и сохраняет значения как исходные 32-битные слова у соответствующего
`ObjectHandle`. Разбор профиля для полной миссии завершается до создания нового
мира: отсутствие или повреждение профиля известного класса прерывает загрузку,
не подменяя предыдущую миссию. Если mapper не знает class code, остаются
конструкторские значения 5.0 и 100.0; это не догадка о неизвестном профиле.
Preview не загружает профили.
При вычислении `0x1002` native `FUN_100186b0` (RVA `0x186b0`) добавляет в список
только текущие Control descriptor-и tag `26`, после чего
`FUN_100198e0` (RVA `0x198e0`) суммирует их успешные `0xe00` запросы. FParkan
делает то же для загруженной таблицы в её исходном порядке и затем умножает
сумму на профильный `Transfer_Ore_OffBoard`. Записи tag `5`, `10` и `21` в общей
таблице в этот список не попадают. Запрос `0xe00` проходит по ключу устройства
в исходную provider-таблицу и пока поддержан только для текущего descriptor
tag `26` с factory tag `26`; для него результат равен
`provider * fixed_words[11] * multiplier`. Лицензированный тест загружает `provider * fixed_words[11] * multiplier`. Лицензированный тест загружает
оригинальные CTL/NDP и подтверждает, что tag-26 запись с ключом `2` читает оригинальные CTL/NDP и полную миссию с этим Unit DAT. В установленном
начальное значение строки `1.0`. Это проверка начального состояния после `min_l_n1.dat` class word равен `0x80000004`, поэтому исходный прогон выбирает
конструкторов, не результат живого игрового тика. `prof_mine.var`. Для проверки другого отображения второй прогон оставляет тот же
TMA и компоненты, но во временном VFS заменяет слово класса заголовка DAT на
`0x80000002`; это тестовая подмена, а не содержимое лицензированного файла.
Профиль mine даёт `Transfer_Power_Out` 0.0 и `Transfer_Ore_OffBoard` 1.0;
оригинальный профиль генератора — 10.0 и 0.0 соответственно. Для исходного
tag-26 ряда получается `1.0` provider × `1.0` coefficient × `1.0` multiplier;
профиль mine умножает эту сумму на `1.0`, поэтому selector `0x1002` возвращает
`1.0`. Эти запросы используют начальные provider-значения после конструкторов,
а не результаты живого игрового тика.
Поведение provider-строк меняется и после загрузки: например, Поведение provider-строк меняется и после загрузки: например,
`Control.dll!FUN_10010f30` (RVA `0x10f30`) обновляет и ограничивает текущее `Control.dll!FUN_10010f30` (RVA `0x10f30`) обновляет и ограничивает текущее
значение, а соседние процедуры распространяют его между связанными строками. значение, а соседние процедуры распространяют его между связанными строками.
Эти изменения пока не отражены в Rust-снимке. Поэтому `0xe00` не является Эти изменения пока не отражены в Rust-снимке. Поэтому запрос Control `0xe00` и
исполнением всего Control tick-а, а Behavior query `0x1002` не подключён к Behavior selector `0x1002`, который его суммирует, используют исходное состояние
этому API; его множитель `Transfer_Ore_OffBoard` требует отдельного источника полной загрузки. Selector `0x1005` прямо возвращает загруженную ставку профиля.
текущей конфигурации. Ни один из них пока не исполняет Control tick или последующие изменения профиля.
На x87 control word `0x007f` каждое умножение округляет мантиссу до 24 бит, На x87 control word `0x007f` каждое умножение округляет мантиссу до 24 бит,
но промежуточная степень может выйти за диапазон `f32` и вернуться в него при но промежуточная степень может выйти за диапазон `f32` и вернуться в него при