diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-22 13:19:25 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-22 13:19:25 +0300 |
| commit | b9a4d1b478d9ff6dff457617f7296c54db70532e (patch) | |
| tree | e839d5892f2bf09092cd09a79ab47582719c3517 /crates/fparkan-runtime | |
| parent | 40e43921385740b6e2cb502f1cb5e3d8347dc2d7 (diff) | |
| download | fparkan-b9a4d1b478d9ff6dff457617f7296c54db70532e.tar.xz fparkan-b9a4d1b478d9ff6dff457617f7296c54db70532e.zip | |
feat(runtime): query and update loaded Control resources
Diffstat (limited to 'crates/fparkan-runtime')
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 791 | ||||
| -rw-r--r-- | crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs | 118 |
2 files changed, 901 insertions, 8 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index a4985a6..cc77d52 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -178,6 +178,8 @@ pub struct EffectiveControlDevice { pub coefficient_bits: Option<[u32; 16]>, /// Multiplier bits when the factory default is confirmed. pub multiplier_bits: Option<u32>, + /// Class-9-only scale used by its selector-0 getter. + pub class9_selector0_scale_bits: Option<u32>, /// Current native word at `+0x50` when it is known. pub native_word_50_bits: Option<u32>, /// Current raw descriptor, including typed values and string bytes. @@ -188,6 +190,43 @@ pub struct EffectiveControlDevice { pub descriptor_provenance: ControlDeviceRecordProvenance, } +/// Why a loaded Control resource query or write could not be represented. +#[derive(Debug, Eq, PartialEq)] +pub enum ControlResourceAccessError { + /// The requested world object handle is invalid, stale, or deleted. + World(fparkan_world::WorldError), + /// No mission is currently loaded. + MissionUnavailable, + /// The live object is not part of the loaded mission projection. + 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. + UnsupportedSelector(u32), + /// A selected row has a factory class whose getter has not been checked. + UnsupportedFactoryTag { factory_tag: u32 }, + /// A selected row lacks a value needed by the native getter or setter. + UnsupportedField { + factory_tag: u32, + field: &'static str, + }, + /// A native float operand or intermediate is not finite. + NonFiniteValue { + factory_tag: u32, + field: &'static str, + }, + /// A native float operand is subnormal and is outside the checked domain. + SubnormalValue { + factory_tag: u32, + field: &'static str, + }, + /// A range probe places a nonzero arithmetic result outside normal f32 range. + UnsupportedFloatRange { + factory_tag: Option<u32>, + field: &'static str, + }, +} + /// Why the base Control device table could not be projected for an object. #[derive(Clone, Debug, Eq, PartialEq)] pub enum MissionControlDeviceTableUnavailableReason { @@ -1587,6 +1626,244 @@ pub fn loaded_mission_control_device_tables( .map(|state| state.control_device_tables.as_slice()) } +/// Queries native Control global-table selector 1 (weighted ratio) or 2 (sum). +/// `Ok(None)` preserves the original false/no-result behavior for an empty or +/// zero-denominator table. Other unsupported native paths are reported as errors. +/// Inputs and intermediates must be finite and normal, or exact zero. +/// +/// # Errors +/// Returns an error for an invalid object handle, unavailable mission/table, +/// unsupported selector or factory, missing state, or an unsupported float range. +pub fn query_loaded_mission_control_resource( + engine: &Engine, + handle: ObjectHandle, + selector: u32, +) -> Result<Option<f32>, ControlResourceAccessError> { + transform_state(&engine.world, handle).map_err(ControlResourceAccessError::World)?; + let loaded = engine + .loaded + .as_ref() + .ok_or(ControlResourceAccessError::MissionUnavailable)?; + let table = loaded + .control_device_tables + .iter() + .find(|table| table.handle == handle) + .ok_or(ControlResourceAccessError::ObjectUnavailable { handle })?; + query_control_device_table_resource(table, selector) +} + +/// Sets native Control global-table selector 1 on rows whose selector-0 value +/// is positive. Validation is completed before any row is changed. +/// +/// # Errors +/// Returns an error for an invalid object handle, unavailable mission/table, +/// missing state, a non-finite or subnormal value, or an unsupported float range. +pub fn set_loaded_mission_control_multiplier( + engine: &mut Engine, + handle: ObjectHandle, + value: f32, +) -> Result<(), ControlResourceAccessError> { + transform_state(&engine.world, handle).map_err(ControlResourceAccessError::World)?; + let loaded = engine + .loaded + .as_mut() + .ok_or(ControlResourceAccessError::MissionUnavailable)?; + let table = loaded + .control_device_tables + .iter_mut() + .find(|table| table.handle == handle) + .ok_or(ControlResourceAccessError::ObjectUnavailable { handle })?; + set_control_device_table_multiplier(table, value) +} + +fn query_control_device_table_resource( + table: &MissionControlDeviceTableState, + selector: u32, +) -> Result<Option<f32>, ControlResourceAccessError> { + if !matches!(selector, 1 | 2) { + return Err(ControlResourceAccessError::UnsupportedSelector(selector)); + } + let entries = match &table.status { + MissionControlDeviceTableStatus::Available(entries) => entries, + MissionControlDeviceTableStatus::Unavailable(reason) => { + return Err(ControlResourceAccessError::TableUnavailable(reason.clone())); + } + }; + + let mut numerator = 0.0_f32; + let mut denominator = 0.0_f32; + for entry in entries.iter().filter(|entry| entry.descriptor.tag == 19) { + let base = control_device_selector_zero(entry)?; + if base < 0.0 { + return Ok(Some(if selector == 1 { 1.0 } else { base })); + } + if selector == 2 { + denominator = checked_control_sum(entry.factory_tag, "sum", denominator, base)?; + } else { + let multiplier = control_device_float(entry, entry.multiplier_bits, "multiplier")?; + let weighted = checked_control_product( + entry.factory_tag, + "weighted value", + control_device_coefficient(entry)?, + multiplier, + )?; + numerator = checked_control_sum(entry.factory_tag, "numerator", numerator, weighted)?; + denominator = checked_control_sum(entry.factory_tag, "denominator", denominator, base)?; + } + } + + if selector == 2 { + return Ok((denominator > 0.0).then_some(denominator)); + } + if denominator <= 0.0 { + return Ok(None); + } + checked_control_ratio("ratio", numerator, denominator).map(Some) +} + +fn set_control_device_table_multiplier( + table: &mut MissionControlDeviceTableState, + value: f32, +) -> Result<(), ControlResourceAccessError> { + let entries = match &mut table.status { + MissionControlDeviceTableStatus::Available(entries) => entries, + MissionControlDeviceTableStatus::Unavailable(reason) => { + return Err(ControlResourceAccessError::TableUnavailable(reason.clone())); + } + }; + + let mut has_target = false; + let mut target_factory_tag = 0; + for entry in entries.iter() { + if entry.descriptor.tag != 19 { + continue; + } + let base = control_device_selector_zero(entry)?; + if base > 0.0 { + has_target = true; + target_factory_tag = entry.factory_tag; + } + } + if !has_target { + return Ok(()); + } + checked_control_float(target_factory_tag, "multiplier", value)?; + for entry in entries + .iter_mut() + .filter(|entry| entry.descriptor.tag == 19) + { + if control_device_selector_zero(entry)? > 0.0 { + entry.multiplier_bits = Some(value.to_bits()); + } + } + Ok(()) +} + +fn control_device_selector_zero( + entry: &EffectiveControlDevice, +) -> Result<f32, ControlResourceAccessError> { + let value = match entry.factory_tag { + 9 => { + let coefficient = control_device_coefficient(entry)?; + let scale = control_device_float( + entry, + entry.class9_selector0_scale_bits, + "class-9 selector-0 scale", + )?; + checked_control_product(entry.factory_tag, "selector-0 value", coefficient, scale)? + } + 15 | 19 | 25 => control_device_coefficient(entry)?, + factory_tag => { + return Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag }); + } + }; + checked_control_float(entry.factory_tag, "selector-0 value", value) +} + +fn control_device_coefficient( + entry: &EffectiveControlDevice, +) -> Result<f32, ControlResourceAccessError> { + let words = entry + .coefficient_bits + .ok_or(ControlResourceAccessError::UnsupportedField { + factory_tag: entry.factory_tag, + field: "coefficient", + })?; + control_device_float(entry, Some(words[0]), "coefficient") +} + +fn control_device_float( + entry: &EffectiveControlDevice, + bits: Option<u32>, + field: &'static str, +) -> Result<f32, ControlResourceAccessError> { + let bits = bits.ok_or(ControlResourceAccessError::UnsupportedField { + factory_tag: entry.factory_tag, + field, + })?; + checked_control_float(entry.factory_tag, field, f32::from_bits(bits)) +} + +fn checked_control_float( + factory_tag: u32, + field: &'static str, + value: f32, +) -> Result<f32, ControlResourceAccessError> { + if !value.is_finite() { + return Err(ControlResourceAccessError::NonFiniteValue { factory_tag, field }); + } + if value.is_subnormal() { + return Err(ControlResourceAccessError::SubnormalValue { factory_tag, field }); + } + Ok(value) +} + +fn checked_control_product( + factory_tag: u32, + field: &'static str, + lhs: f32, + rhs: f32, +) -> Result<f32, ControlResourceAccessError> { + let range_probe = f64::from(lhs) * f64::from(rhs); + check_control_range_probe(Some(factory_tag), field, range_probe)?; + checked_control_float(factory_tag, field, lhs * rhs) +} + +fn checked_control_ratio( + field: &'static str, + numerator: f32, + denominator: f32, +) -> Result<f32, ControlResourceAccessError> { + let range_probe = f64::from(numerator) / f64::from(denominator); + check_control_range_probe(None, field, range_probe)?; + checked_control_float(0, field, numerator / denominator) +} + +fn checked_control_sum( + factory_tag: u32, + field: &'static str, + lhs: f32, + rhs: f32, +) -> Result<f32, ControlResourceAccessError> { + let range_probe = f64::from(lhs) + f64::from(rhs); + check_control_range_probe(Some(factory_tag), field, range_probe)?; + checked_control_float(factory_tag, field, lhs + rhs) +} + +fn check_control_range_probe( + factory_tag: Option<u32>, + field: &'static str, + range_probe: f64, +) -> Result<(), ControlResourceAccessError> { + let absolute = range_probe.abs(); + let minimum_normal = f64::from(f32::MIN_POSITIVE); + let maximum = f64::from(f32::MAX); + if range_probe != 0.0 && (absolute < minimum_normal || absolute > maximum) { + return Err(ControlResourceAccessError::UnsupportedFloatRange { factory_tag, field }); + } + Ok(()) +} + fn behavior_clan_for_selector( selector: u32, clan_count: usize, @@ -1770,6 +2047,7 @@ fn append_effective_control_device( key, coefficient_bits, multiplier_bits, + class9_selector0_scale_bits: (descriptor.tag == 9).then_some(0x3f80_0000), native_word_50_bits, descriptor: descriptor.clone(), factory_provenance: provenance.clone(), @@ -1803,14 +2081,14 @@ fn control_factory_uses_common_setter(factory_tag: u32) -> bool { } fn native_control_factory_defaults(factory_tag: u32) -> (Option<u32>, Option<u32>) { - let flags = match factory_tag { - 1 | 4 => 0x200, - 5 | 8 | 26 => 0x5, - 10 => 0x2000, - 17 | 19 => 0x20, - _ => return (None, None), - }; - (Some(0x3f80_0000), Some(flags)) + match factory_tag { + 1 | 4 => (Some(0x3f80_0000), Some(0x200)), + 5 | 8 | 26 => (Some(0x3f80_0000), Some(0x5)), + 10 => (Some(0x3f80_0000), Some(0x2000)), + 17 | 19 => (Some(0x3f80_0000), Some(0x20)), + 9 | 15 | 25 => (Some(0x3f80_0000), None), + _ => (None, None), + } } fn build_control_device_table( @@ -2678,6 +2956,471 @@ mod tests { ) } + fn synthetic_effective_control_device( + factory_tag: u32, + descriptor_tag: u32, + coefficient_bits: u32, + multiplier_bits: Option<u32>, + class9_scale_bits: Option<u32>, + ) -> EffectiveControlDevice { + let descriptor = synthetic_control_record(descriptor_tag, 2, coefficient_bits, None); + let control = synthetic_component_control( + 0, + vec![descriptor.clone()], + PreparedControlEffectSection::Parsed(Vec::new()), + ); + let provenance = control_device_record_provenance(0, 0, &control); + let mut coefficients = [0; 16]; + coefficients[0] = coefficient_bits; + EffectiveControlDevice { + factory_tag, + component_id: 3, + key: 2, + coefficient_bits: Some(coefficients), + multiplier_bits, + class9_selector0_scale_bits: class9_scale_bits, + native_word_50_bits: None, + descriptor, + factory_provenance: provenance.clone(), + descriptor_provenance: provenance, + } + } + + fn synthetic_control_device_table( + status: MissionControlDeviceTableStatus, + ) -> MissionControlDeviceTableState { + MissionControlDeviceTableState { + original_id: Some(OriginalObjectId(7)), + handle: ObjectHandle { + generation: 5, + slot: 7, + }, + status, + } + } + + #[test] + fn control_resource_queries_filter_current_tag_and_use_factory_getters() { + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 9, + 19, + 2.0_f32.to_bits(), + Some(3.0_f32.to_bits()), + Some(0.5_f32.to_bits()), + ), + synthetic_effective_control_device( + 19, + 9, + 100.0_f32.to_bits(), + Some(77.0_f32.to_bits()), + None, + ), + synthetic_effective_control_device( + 15, + 19, + 4.0_f32.to_bits(), + Some(0.5_f32.to_bits()), + None, + ), + synthetic_effective_control_device( + 19, + 19, + 1.0_f32.to_bits(), + Some(4.0_f32.to_bits()), + None, + ), + ])); + + assert_eq!( + query_control_device_table_resource(&table, 1) + .expect("selector 1") + .map(f32::to_bits), + Some(2.0_f32.to_bits()) + ); + assert_eq!( + query_control_device_table_resource(&table, 2) + .expect("selector 2") + .map(f32::to_bits), + Some(6.0_f32.to_bits()) + ); + assert_eq!( + query_control_device_table_resource(&table, 3), + Err(ControlResourceAccessError::UnsupportedSelector(3)) + ); + } + + #[test] + fn control_resource_setter_updates_matching_rows_atomically_and_round_trips() { + let mut table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 9, + 19, + 2.0_f32.to_bits(), + Some(3.0_f32.to_bits()), + Some(0.5_f32.to_bits()), + ), + synthetic_effective_control_device( + 19, + 9, + 100.0_f32.to_bits(), + Some(77.0_f32.to_bits()), + None, + ), + synthetic_effective_control_device( + 15, + 19, + 4.0_f32.to_bits(), + Some(0.5_f32.to_bits()), + None, + ), + synthetic_effective_control_device( + 19, + 19, + 1.0_f32.to_bits(), + Some(4.0_f32.to_bits()), + None, + ), + ])); + + set_control_device_table_multiplier(&mut table, 7.5).expect("setter 1"); + let MissionControlDeviceTableStatus::Available(entries) = &table.status else { + panic!("synthetic table is available"); + }; + assert_eq!( + entries + .iter() + .map(|entry| entry.multiplier_bits) + .collect::<Vec<_>>(), + [ + Some(7.5_f32.to_bits()), + Some(77.0_f32.to_bits()), + Some(7.5_f32.to_bits()), + Some(7.5_f32.to_bits()) + ] + ); + assert_eq!( + entries[0].class9_selector0_scale_bits, + Some(0.5_f32.to_bits()) + ); + assert_eq!( + query_control_device_table_resource(&table, 1) + .expect("updated selector 1") + .map(f32::to_bits), + Some(8.75_f32.to_bits()) + ); + + let mut unsupported = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 19, + 19, + 2.0_f32.to_bits(), + Some(1.0_f32.to_bits()), + None, + ), + synthetic_effective_control_device(12, 19, 1.0_f32.to_bits(), None, None), + ])); + assert_eq!( + set_control_device_table_multiplier(&mut unsupported, 2.0), + Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag: 12 }) + ); + let MissionControlDeviceTableStatus::Available(entries) = &unsupported.status else { + panic!("synthetic table is available"); + }; + assert_eq!(entries[0].multiplier_bits, Some(1.0_f32.to_bits())); + } + + #[test] + fn control_resource_queries_preserve_native_no_result_and_negative_rules() { + let empty = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new())); + assert_eq!(query_control_device_table_resource(&empty, 1), Ok(None)); + assert_eq!(query_control_device_table_resource(&empty, 2), Ok(None)); + assert!(set_control_device_table_multiplier(&mut empty.clone(), f32::NAN).is_ok()); + + let zero_denominator = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 19, + 19, + 0.0_f32.to_bits(), + Some(2.0_f32.to_bits()), + None, + ), + ])); + assert_eq!( + query_control_device_table_resource(&zero_denominator, 1), + Ok(None) + ); + assert_eq!( + query_control_device_table_resource(&zero_denominator, 2), + Ok(None) + ); + + let nonmatching = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 19, + 9, + 4.0_f32.to_bits(), + Some(2.0_f32.to_bits()), + None, + ), + ])); + assert_eq!( + query_control_device_table_resource(&nonmatching, 1), + Ok(None) + ); + + let no_multiplier = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device(19, 19, 2.0_f32.to_bits(), None, None), + ])); + assert_eq!( + query_control_device_table_resource(&no_multiplier, 2) + .expect("selector 2 does not read multiplier") + .map(f32::to_bits), + Some(2.0_f32.to_bits()) + ); + + let negative = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device(19, 19, (-1.0_f32).to_bits(), None, None), + ])); + assert_eq!( + query_control_device_table_resource(&negative, 1) + .expect("negative selector 1") + .map(f32::to_bits), + Some(1.0_f32.to_bits()) + ); + assert_eq!( + query_control_device_table_resource(&negative, 2) + .expect("negative selector 2") + .map(f32::to_bits), + Some((-1.0_f32).to_bits()) + ); + + let rounded = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 19, + 19, + 1.0_f32.to_bits(), + Some(16_777_216.0_f32.to_bits()), + None, + ), + synthetic_effective_control_device( + 19, + 19, + 1.0_f32.to_bits(), + Some(1.0_f32.to_bits()), + None, + ), + synthetic_effective_control_device( + 19, + 19, + 1.0_f32.to_bits(), + Some((-16_777_216.0_f32).to_bits()), + None, + ), + ])); + assert_eq!( + query_control_device_table_resource(&rounded, 1) + .expect("rounded selector 1") + .map(f32::to_bits), + Some(0.0_f32.to_bits()) + ); + + let mut replaceable = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 19, + 19, + 1.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + None, + ), + ])); + set_control_device_table_multiplier(&mut replaceable, 2.0) + .expect("setter does not read the old multiplier"); + let MissionControlDeviceTableStatus::Available(entries) = &replaceable.status else { + panic!("synthetic table is available"); + }; + assert_eq!(entries[0].multiplier_bits, Some(2.0_f32.to_bits())); + } + + #[test] + fn control_resource_access_reports_unavailable_and_nonfinite_inputs() { + let unavailable = + synthetic_control_device_table(MissionControlDeviceTableStatus::Unavailable( + MissionControlDeviceTableUnavailableReason::NoControlResource, + )); + assert_eq!( + query_control_device_table_resource(&unavailable, 1), + Err(ControlResourceAccessError::TableUnavailable( + MissionControlDeviceTableUnavailableReason::NoControlResource + )) + ); + + let nonfinite = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 19, + 19, + f32::INFINITY.to_bits(), + Some(1.0_f32.to_bits()), + None, + ), + ])); + assert!(matches!( + query_control_device_table_resource(&nonfinite, 1), + Err(ControlResourceAccessError::NonFiniteValue { + factory_tag: 19, + field: "coefficient" + }) + )); + + let subnormal = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device(19, 19, 1, Some(1.0_f32.to_bits()), None), + ])); + assert!(matches!( + query_control_device_table_resource(&subnormal, 2), + Err(ControlResourceAccessError::SubnormalValue { + factory_tag: 19, + field: "coefficient" + }) + )); + + let mut finite = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 19, + 19, + 1.0_f32.to_bits(), + Some(1.0_f32.to_bits()), + None, + ), + ])); + assert!(matches!( + set_control_device_table_multiplier(&mut finite, f32::INFINITY), + Err(ControlResourceAccessError::NonFiniteValue { + factory_tag: 19, + field: "multiplier" + }) + )); + + let underflowing_product = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 19, + 19, + f32::MIN_POSITIVE.to_bits(), + Some(f32::MIN_POSITIVE.to_bits()), + None, + ), + ])); + assert!(matches!( + query_control_device_table_resource(&underflowing_product, 1), + Err(ControlResourceAccessError::UnsupportedFloatRange { + factory_tag: Some(19), + field: "weighted value" + }) + )); + + let underflowing_ratio = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device( + 19, + 19, + f32::MIN_POSITIVE.to_bits(), + Some(1.0_f32.to_bits()), + None, + ), + synthetic_effective_control_device( + 19, + 19, + f32::MAX.to_bits(), + Some(0.0_f32.to_bits()), + None, + ), + ])); + assert!(matches!( + query_control_device_table_resource(&underflowing_ratio, 1), + Err(ControlResourceAccessError::UnsupportedFloatRange { + factory_tag: None, + field: "ratio" + }) + )); + + let overflowing_sum = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device(19, 19, f32::MAX.to_bits(), None, None), + synthetic_effective_control_device(19, 19, f32::MAX.to_bits(), None, None), + ])); + assert!(matches!( + query_control_device_table_resource(&overflowing_sum, 2), + Err(ControlResourceAccessError::UnsupportedFloatRange { + factory_tag: Some(19), + field: "sum" + }) + )); + } + + #[test] + fn control_resource_api_checks_live_world_handle_before_loaded_state() { + let mut engine = create(EngineServices::default()).expect("engine"); + let handle = construct_object( + &mut engine.world, + ObjectDraft { + original_id: Some(OriginalObjectId(4)), + }, + ) + .expect("live world object"); + assert_eq!( + query_loaded_mission_control_resource(&engine, handle, 1), + Err(ControlResourceAccessError::MissionUnavailable) + ); + + let stale = ObjectHandle { + generation: handle.generation.wrapping_add(1), + slot: handle.slot, + }; + assert_eq!( + query_loaded_mission_control_resource(&engine, stale, 1), + 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 + )) + ); + + fparkan_world::request_delete(&mut engine.world, handle).expect("delete object"); + assert_eq!( + query_loaded_mission_control_resource(&engine, handle, 1), + Err(ControlResourceAccessError::World( + fparkan_world::WorldError::StaleHandle + )) + ); + } + + #[test] + fn control_factory_multiplier_defaults_preserve_unknown_word_50() { + for factory_tag in [9, 15, 25] { + assert_eq!( + native_control_factory_defaults(factory_tag), + (Some(0x3f80_0000), None) + ); + } + } + #[test] fn native_basement_mode_uses_unit_header_gate_not_builds_path() { assert_eq!( @@ -3245,6 +3988,19 @@ mod tests { let tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); assert_eq!(tables.len(), 1); assert_eq!(tables[0].original_id, Some(OriginalObjectId(0))); + let control_handle = tables[0].handle; + assert_eq!( + query_loaded_mission_control_resource(&engine, control_handle, 1) + .expect("licensed selector 1") + .map(f32::to_bits), + Some(1.0_f32.to_bits()) + ); + assert_eq!( + query_loaded_mission_control_resource(&engine, control_handle, 2) + .expect("licensed selector 2") + .map(f32::to_bits), + Some(16.0_f32.to_bits()) + ); let MissionControlDeviceTableStatus::Available(entries) = &tables[0].status else { panic!( "licensed mine table should be available: {:?}", @@ -3260,6 +4016,13 @@ mod tests { [19, 19, 9, 15, 12, 12, 13, 13, 26, 25, 21] ); assert_eq!( + entries + .iter() + .map(|entry| entry.descriptor.tag) + .collect::<Vec<_>>(), + [19, 19, 9, 15, 12, 12, 13, 13, 26, 25, 21] + ); + assert_eq!( entries.iter().map(|entry| entry.key).collect::<Vec<_>>(), [2, 2, 1, 1, 18, 21, 15, 16, 2, 1, 22] ); @@ -3274,6 +4037,10 @@ mod tests { (entries[0].multiplier_bits, entries[0].native_word_50_bits), (Some(0x3f80_0000), Some(0x20)) ); + for index in [2, 3, 9] { + assert_eq!(entries[index].multiplier_bits, Some(0x3f80_0000)); + } + assert_eq!(entries[2].class9_selector0_scale_bits, Some(0x3f80_0000)); assert_eq!( entries .iter() @@ -3300,6 +4067,14 @@ mod tests { (21, 5, 22) ); assert_eq!(entries[10].descriptor.tag, 21); + set_loaded_mission_control_multiplier(&mut engine, control_handle, 2.5) + .expect("licensed setter 1"); + assert_eq!( + query_loaded_mission_control_resource(&engine, control_handle, 1) + .expect("updated licensed selector 1") + .map(f32::to_bits), + Some(2.5_f32.to_bits()) + ); } #[test] 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 5f5f139..6e7c241 100644 --- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs +++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs @@ -25,6 +25,8 @@ internal static class BehaviorDistributorTickOracle { private const int OreInitializationRva = 0x08790; private const int GetSystemArealMapIatRva = 0x59008; private const int ControlResourceDeviceGetterRva = 0x21d00; + private const int ControlResourceAggregateQueryRva = 0x2b410; + private const int ControlResourceAggregateSetRva = 0x2ba30; private const int ControlProviderInterfaceVtableRva = 0x3d254; private const int ControlProviderType1GetterRva = 0x0dc40; private const int ArealMapUnitDatComponentBuilderRva = 0x14b90; @@ -75,6 +77,8 @@ internal static class BehaviorDistributorTickOracle { [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeArealMapEvent(IntPtr owner, uint message, uint subcode, IntPtr descriptor); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr NativeControlResourceDeviceConstructor(IntPtr self); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr NativeControlResourceComponentOverride(IntPtr self, int deviceIndex, IntPtr typedRecord); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeControlAggregateQuery(IntPtr self, uint selector, IntPtr result); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeControlAggregateSet(IntPtr self, uint selector, IntPtr value); private static readonly AreaMapGetter AreaMapGetterCallback = GetAreaMap; private static readonly VectorInsert VectorInsertCallback = InsertVector; @@ -510,6 +514,7 @@ internal static class BehaviorDistributorTickOracle { RunNativeArealMapComponentBuilderCase(arealMapModule); RunNativeControlIntoOverrideCase(controlModule); RunNativeControlResourceDeviceCase(controlModule); + RunNativeControlAggregateResourceCases(controlModule); } finally { SetX87ControlWord(savedControlWord); Console.WriteLine("x87-control-word-restored=0x" + CurrentX87ControlWord().ToString("x4")); @@ -589,6 +594,118 @@ internal static class BehaviorDistributorTickOracle { } } + private static void RunNativeControlAggregateResourceCases(IntPtr module) { + NativeControlAggregateQuery query = (NativeControlAggregateQuery)Marshal.GetDelegateForFunctionPointer( + WriteStdCallToFastcallThunk(0x1c0, IntPtr.Add(module, ControlResourceAggregateQueryRva)), + typeof(NativeControlAggregateQuery)); + NativeControlAggregateSet set = (NativeControlAggregateSet)Marshal.GetDelegateForFunctionPointer( + WriteStdCallToFastcallThunk(0x200, IntPtr.Add(module, ControlResourceAggregateSetRva)), + typeof(NativeControlAggregateSet)); + using (Fixture fixture = NewFixture()) { + IntPtr owner = fixture.Allocate(0x5b8); + IntPtr devices = fixture.Allocate(4 * IntPtr.Size); + IntPtr result = fixture.Allocate(sizeof(uint)); + IntPtr value = fixture.Allocate(sizeof(uint)); + + IntPtr tag9Current19 = AddNativeControlAggregateDevice(fixture, module, 9, 19, + 0x120, ControlResourceTag9ConstructorRva, ControlResourceTag9VtableRva, 2.0f, 3.0f, 0.5f); + IntPtr tag19Current9 = AddNativeControlAggregateDevice(fixture, module, 19, 9, + 0x94, ControlResourceDeviceConstructorRva, ControlResourceTag19VtableRva, 100.0f, 77.0f, 1.0f); + IntPtr tag15Current19 = AddNativeControlAggregateDevice(fixture, module, 15, 19, + 0x98, ControlResourceTag15ConstructorRva, ControlResourceTag15VtableRva, 4.0f, 0.5f, 1.0f); + IntPtr tag19Current19 = AddNativeControlAggregateDevice(fixture, module, 19, 19, + 0x94, ControlResourceDeviceConstructorRva, ControlResourceTag19VtableRva, 1.0f, 4.0f, 1.0f); + + Marshal.WriteIntPtr(devices, 0, tag9Current19); + Marshal.WriteIntPtr(devices, IntPtr.Size, tag19Current9); + Marshal.WriteIntPtr(devices, 2 * IntPtr.Size, tag15Current19); + Marshal.WriteIntPtr(devices, 3 * IntPtr.Size, tag19Current19); + Marshal.WriteIntPtr(owner, 0x5b0, devices); + Marshal.WriteInt32(owner, 0x5b4, 4); + + Expect(NativeBoolean(query(owner, 1, result)) == 1 && unchecked((uint)Marshal.ReadInt32(result)) == 0x40000000, + "Control aggregate selector 1 includes descriptor-19 entries and dispatches their original factory vtables"); + Expect(NativeBoolean(query(owner, 2, result)) == 1 && unchecked((uint)Marshal.ReadInt32(result)) == 0x40c00000, + "Control aggregate selector 2 sums base values for descriptor-19 entries"); + + Marshal.WriteInt32(value, 0, FloatBits(7.5f)); + Expect(NativeBoolean(set(owner, 1, value)) == 1, + "Control aggregate setter selector 1 reports success for its recognized selector"); + Expect(unchecked((uint)Marshal.ReadInt32(tag9Current19, 0x4c)) == 0x40f00000 && + unchecked((uint)Marshal.ReadInt32(tag15Current19, 0x4c)) == 0x40f00000 && + unchecked((uint)Marshal.ReadInt32(tag19Current19, 0x4c)) == 0x40f00000 && + unchecked((uint)Marshal.ReadInt32(tag19Current9, 0x4c)) == FloatBits(77.0f), + "Control setter selector 1 updates matching positive devices regardless of factory class, and skips current descriptor tag 9"); + Expect(NativeBoolean(query(owner, 1, result)) == 1 && unchecked((uint)Marshal.ReadInt32(result)) == 0x410c0000, + "Control aggregate selector 1 reflects updated per-device multipliers"); + + // Factory tag 9's virtual selector 0 includes +0x11c. A zero base keeps + // this selected descriptor out of the native setter's positive-only write. + Marshal.WriteInt32(tag9Current19, 0x11c, FloatBits(0.0f)); + Marshal.WriteInt32(value, 0, FloatBits(9.0f)); + Expect(NativeBoolean(set(owner, 1, value)) == 1 && + unchecked((uint)Marshal.ReadInt32(tag9Current19, 0x4c)) == 0x40f00000, + "Control setter selector 1 leaves the multiplier unchanged when the original factory getter returns zero"); + + Marshal.WriteInt32(owner, 0x5b4, 0); + Marshal.WriteInt32(result, 0, unchecked((int)0x3f123456)); + Expect(NativeBoolean(query(owner, 1, result)) == 0 && unchecked((uint)Marshal.ReadInt32(result)) == 0x3f123456, + "Control aggregate selector 1 reports no result and leaves output untouched when the table is empty"); + Expect(NativeBoolean(query(owner, 2, result)) == 0 && unchecked((uint)Marshal.ReadInt32(result)) == 0x3f123456, + "Control aggregate selector 2 reports no result and leaves output untouched when the table is empty"); + Expect(NativeBoolean(set(owner, 1, value)) == 1, + "Control aggregate setter selector 1 reports success when the table is empty"); + + // The aggregate accumulators spill back to float precision every row. + Marshal.WriteInt32(owner, 0x5b4, 3); + Marshal.WriteInt32(Marshal.ReadIntPtr(tag19Current9, 0x48), 0, 19); + Marshal.WriteInt32(tag9Current19, 0x54, FloatBits(1.0f)); + Marshal.WriteInt32(tag9Current19, 0x4c, FloatBits(16777216.0f)); + Marshal.WriteInt32(tag9Current19, 0x11c, FloatBits(1.0f)); + Marshal.WriteInt32(tag19Current9, 0x54, FloatBits(1.0f)); + Marshal.WriteInt32(tag19Current9, 0x4c, FloatBits(1.0f)); + Marshal.WriteInt32(tag15Current19, 0x54, FloatBits(1.0f)); + Marshal.WriteInt32(tag15Current19, 0x4c, FloatBits(-16777216.0f)); + Expect(NativeBoolean(query(owner, 1, result)) == 1 && Marshal.ReadInt32(result) == 0, + "Control aggregate selector 1 rounds each accumulator to float after each row"); + + Marshal.WriteInt32(owner, 0x5b4, 1); + Marshal.WriteInt32(tag9Current19, 0x54, FloatBits(-1.0f)); + Marshal.WriteInt32(tag9Current19, 0x4c, FloatBits(2.0f)); + Marshal.WriteInt32(result, 0, unchecked((int)0x3f123456)); + Expect(NativeBoolean(query(owner, 1, result)) == 1 && Marshal.ReadInt32(result) == FloatBits(1.0f), + "Control aggregate selector 1 returns one immediately when a selected base value is negative"); + Expect(NativeBoolean(query(owner, 2, result)) == 1 && Marshal.ReadInt32(result) == FloatBits(-1.0f), + "Control aggregate selector 2 returns a selected negative base value immediately"); + + Console.WriteLine("native-control-global-resource-table: controlHash=" + ExpectedControlSha256 + + " getterRva=0x2b410 setterRva=0x2ba30 selectors=1,2 descriptorFilter=19" + + " factory19-descriptor9=excluded factory9-descriptor19-scale=0.5" + + " factory15-descriptor19=included initial=[2,6] multiplier=7.5 post-set-getter1=8.75" + + " empty=[false/unchanged,false/unchanged,set=true] rounding=0 negative=[1,-1]"); + GC.KeepAlive(query); + GC.KeepAlive(set); + } + } + + private static IntPtr AddNativeControlAggregateDevice(Fixture fixture, IntPtr module, + int factoryTag, int currentDescriptorTag, int objectSize, int constructorRva, int vtableRva, + float coefficient, float multiplier, float class9Scale) { + IntPtr device = fixture.Allocate(objectSize); + NativeControlResourceDeviceConstructor construct = (NativeControlResourceDeviceConstructor)Marshal.GetDelegateForFunctionPointer( + IntPtr.Add(module, constructorRva), typeof(NativeControlResourceDeviceConstructor)); + IntPtr expectedVtable = IntPtr.Add(module, vtableRva); + Expect(construct(device) == device && Marshal.ReadIntPtr(device) == expectedVtable, + "native factory tag " + factoryTag + " constructor installs its original class vtable"); + IntPtr descriptor = fixture.Allocate(sizeof(uint)); + Marshal.WriteInt32(descriptor, 0, currentDescriptorTag); + Marshal.WriteIntPtr(device, 0x48, descriptor); + Marshal.WriteInt32(device, 0x4c, FloatBits(multiplier)); + Marshal.WriteInt32(device, 0x54, FloatBits(coefficient)); + if (factoryTag == 9) Marshal.WriteInt32(device, 0x11c, FloatBits(class9Scale)); + return device; + } + private static void RunNativeArealMapComponentBuilderCase(IntPtr module) { using (ArealMapFixture fixture = new ArealMapFixture()) { fixture.OwnerVtable = fixture.Allocate(0x40); @@ -1276,6 +1393,7 @@ internal static class BehaviorDistributorTickOracle { foreach (byte value in digest) hex.Append(value.ToString("x2")); return hex.ToString(); } } + private static int NativeBoolean(int eax) { return eax & 0xff; } private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original native oracle failed: " + message); } private static void ExpectLookupOrder(Fixture fixture, params string[] keys) { List<string> actual = new List<string>(); |
