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

+239
View File
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)]
//! Asset manager ports and transactional preparation models.
use fparkan_binary::{checked_count_bytes, read_lp_bytes, Cursor, DecodeError};
use fparkan_material::{
decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable,
MAT0_KIND, WEAR_KIND,
@@ -62,6 +63,150 @@ pub struct PreparedMaterialPhase {
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 =
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.
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 {
let mut fixed_words = [0; 44];
fixed_words[1] = key;