feat(runtime): load Control node providers for resource queries
Docs Deploy / Build and Deploy MkDocs (push) Failing after 3h0m1s

This commit is contained in:
Valentin Popov committed 2026-10-22 14:50:54 +04:00
1 parent b9a4d1b478
commit 29b81b8723
4 files changed
+968 -64

No files matched your search

+772 -39
View File
@@ -1,14 +1,16 @@
#![forbid(unsafe_code)]
//! Runtime orchestration for mission loading and world stepping.
#[cfg(test)]
use fparkan_assets::PreparedControlResourcePair;
use fparkan_assets::{
decode_mission_land_path, decode_mission_payload, decode_nres_payload,
derive_mission_land_paths, extend_graph_report_with_visual_dependencies,
mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError,
AssetId, AssetManager, BuildCategory, MissionAssetPlan, MissionDocument, MissionError,
MissionTerrainPaths, NresError, PreparedComponentControl, PreparedControlEffectSection,
PreparedControlTypedRecord, PreparedVisual, TerrainFormatError, TerrainPreparationError,
TerrainWorld, TmaProfile,
PreparedControlNodeDataRow, PreparedControlNodeDataTable, PreparedControlTypedRecord,
PreparedVisual, TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile,
};
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{
@@ -190,6 +192,49 @@ pub struct EffectiveControlDevice {
pub descriptor_provenance: ControlDeviceRecordProvenance,
}
/// Initial row in the loaded global Control node-data provider.
///
/// The native table is indexed independently of MSH model nodes. Its rows are
/// appended in root/EXTO resource order and begin with a type-1 value of 1.0.
#[derive(Clone, Debug, PartialEq)]
pub struct MissionControlNodeProviderRow {
/// NDP resource that contributed the row.
pub source: ResourceKey,
/// Unit DAT component that contributed the row.
pub unit_component_index: usize,
/// Index in that component's original NDP table.
pub source_row_index: usize,
/// Original NDP descriptor fields and provenance for this provider row.
pub source_row: PreparedControlNodeDataRow,
/// Initial native provider selector-1 value (`+0x14`) as raw bits.
pub initial_type1_value_bits: u32,
}
/// Availability of the initial global Control node-data provider projection.
#[derive(Clone, Debug, PartialEq)]
pub enum MissionControlNodeProviderTableStatus {
/// Rows in native append order, without padding to the MSH node count.
Available(Vec<MissionControlNodeProviderRow>),
/// Provider lookup is not modeled for this object's available inputs.
Unavailable(MissionControlNodeProviderTableUnavailableReason),
}
/// Why the global Control node-data provider could not be projected.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MissionControlNodeProviderTableUnavailableReason {
/// A root or mounted EXTO Control resource has no resolved NDP sidecar.
MissingNodeData {
/// Unit DAT component needing the provider rows.
unit_component_index: usize,
/// Explicitly requested sidecar when the reference was known.
source: Option<ResourceKey>,
},
/// A mounted EXTO needs a first NDP row for the native skip-first path.
MissingExToRootRow { unit_component_index: usize },
/// The Control component traversal itself was incomplete.
DeviceTableUnavailable(MissionControlDeviceTableUnavailableReason),
}
/// Why a loaded Control resource query or write could not be represented.
#[derive(Debug, Eq, PartialEq)]
pub enum ControlResourceAccessError {
@@ -205,6 +250,24 @@ pub enum ControlResourceAccessError {
UnsupportedSelector(u32),
/// A selected row has a factory class whose getter has not been checked.
UnsupportedFactoryTag { factory_tag: u32 },
/// A device index does not exist in the object's native Control table.
DeviceIndexOutOfRange {
/// Requested global Control device index.
device_index: usize,
/// Projected device count.
device_count: usize,
},
/// The node provider table could not be projected.
NodeProviderTableUnavailable(MissionControlNodeProviderTableUnavailableReason),
/// A device's native provider key exceeds the actual appended NDP rows.
NodeProviderKeyOutOfRange {
/// Current native device key, used directly as a provider row index.
key: u32,
/// Actual global provider row count.
row_count: usize,
},
/// A native tag has an e00 callback that has not been implemented here.
UnsupportedCurrentTag { tag: u32 },
/// A selected row lacks a value needed by the native getter or setter.
UnsupportedField {
factory_tag: u32,
@@ -283,6 +346,13 @@ pub struct MissionControlDeviceTableState {
pub handle: ObjectHandle,
/// Complete base projection, or an explicit unsupported boundary.
pub status: MissionControlDeviceTableStatus,
/// Initial global NDP provider rows used by indexed Control selectors.
pub node_provider_status: MissionControlNodeProviderTableStatus,
}
struct ControlDeviceProjection {
devices: Vec<EffectiveControlDevice>,
node_provider_status: MissionControlNodeProviderTableStatus,
}
/// Resource distributor state associated with one TMA clan's constructed AI.
@@ -1223,11 +1293,8 @@ fn load_mission_with_options(
.iter()
.zip(&handles)
.enumerate()
.map(
|(object_index, (draft, handle))| MissionControlDeviceTableState {
original_id: draft.original_id,
handle: *handle,
status: build_control_device_table(
.map(|(object_index, (draft, handle))| {
let projection = build_control_device_projection(
object_index,
draft,
&mission_assets,
@@ -1235,13 +1302,27 @@ fn load_mission_with_options(
.root_prototype_request_spans
.get(object_index),
&resolved_prototypes,
)
.map_or_else(
MissionControlDeviceTableStatus::Unavailable,
MissionControlDeviceTableStatus::Available,
);
match projection {
Ok(projection) => MissionControlDeviceTableState {
original_id: draft.original_id,
handle: *handle,
status: MissionControlDeviceTableStatus::Available(projection.devices),
node_provider_status: projection.node_provider_status,
},
Err(reason) => MissionControlDeviceTableState {
original_id: draft.original_id,
handle: *handle,
status: MissionControlDeviceTableStatus::Unavailable(reason.clone()),
node_provider_status:
MissionControlNodeProviderTableStatus::Unavailable(
MissionControlNodeProviderTableUnavailableReason::DeviceTableUnavailable(
reason,
),
),
},
)
}
})
.collect()
} else {
Vec::new()
@@ -1652,6 +1733,35 @@ pub fn query_loaded_mission_control_resource(
query_control_device_table_resource(table, selector)
}
/// Queries one loaded Control device through its original factory getter.
///
/// Selector `0xe00` is currently modeled for current descriptor tag 26 with
/// factory tag 26. The loaded NDP provider value is its initial native state;
/// later Control updates are not synchronized into this snapshot.
///
/// # Errors
/// Returns an error for an invalid handle, unavailable mission/table/provider,
/// an out-of-range device/provider key, or a factory/tag/float path that is not
/// supported by the current reconstruction.
pub fn query_loaded_mission_control_device_selector(
engine: &Engine,
handle: ObjectHandle,
device_index: usize,
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_selector(table, device_index, selector)
}
/// Sets native Control global-table selector 1 on rows whose selector-0 value
/// is positive. Validation is completed before any row is changed.
///
@@ -1721,6 +1831,85 @@ fn query_control_device_table_resource(
checked_control_ratio("ratio", numerator, denominator).map(Some)
}
fn query_control_device_table_selector(
table: &MissionControlDeviceTableState,
device_index: usize,
selector: u32,
) -> Result<Option<f32>, ControlResourceAccessError> {
if selector != 0xe00 {
return Err(ControlResourceAccessError::UnsupportedSelector(selector));
}
let devices = match &table.status {
MissionControlDeviceTableStatus::Available(devices) => devices,
MissionControlDeviceTableStatus::Unavailable(reason) => {
return Err(ControlResourceAccessError::TableUnavailable(reason.clone()));
}
};
let device =
devices
.get(device_index)
.ok_or(ControlResourceAccessError::DeviceIndexOutOfRange {
device_index,
device_count: devices.len(),
})?;
match device.descriptor.tag {
26 => {}
5 | 10 | 21 => {
return Err(ControlResourceAccessError::UnsupportedCurrentTag {
tag: device.descriptor.tag,
});
}
_ => return Ok(None),
}
if device.factory_tag != 26 {
return Err(ControlResourceAccessError::UnsupportedFactoryTag {
factory_tag: device.factory_tag,
});
}
let providers = match &table.node_provider_status {
MissionControlNodeProviderTableStatus::Available(providers) => providers,
MissionControlNodeProviderTableStatus::Unavailable(reason) => {
return Err(ControlResourceAccessError::NodeProviderTableUnavailable(
reason.clone(),
));
}
};
let key = usize::try_from(device.key).map_err(|_| {
ControlResourceAccessError::NodeProviderKeyOutOfRange {
key: device.key,
row_count: providers.len(),
}
})?;
let provider =
providers
.get(key)
.ok_or(ControlResourceAccessError::NodeProviderKeyOutOfRange {
key: device.key,
row_count: providers.len(),
})?;
let provider_value = checked_control_float(
device.factory_tag,
"node provider value",
f32::from_bits(provider.initial_type1_value_bits),
)?;
let coefficient = control_device_coefficient(device)?;
let multiplier = control_device_float(device, device.multiplier_bits, "multiplier")?;
let provider_weighted = checked_control_product(
device.factory_tag,
"provider times coefficient",
provider_value,
coefficient,
)?;
checked_control_product(
device.factory_tag,
"then multiplier",
provider_weighted,
multiplier,
)
.map(Some)
}
fn set_control_device_table_multiplier(
table: &mut MissionControlDeviceTableState,
value: f32,
@@ -1941,6 +2130,30 @@ fn native_component_target_index(
}
}
fn native_component_mount_node(
tree: &[UnitComponentTreeNode],
component_ids: &[Option<u32>],
first_global_nodes: &[Option<u32>],
component_index: usize,
raw_local_index: i32,
) -> Option<u32> {
tree.get(component_index)
.and_then(|node| node.parent_index)
.and_then(|parent_index| {
let parent_id = component_ids.get(parent_index).copied().flatten()?;
let parent_first = first_global_nodes
.get(parent_index)
.copied()
.flatten()
.unwrap_or(u32::MAX);
Some(
parent_first
.wrapping_add(raw_local_index as u32)
.wrapping_sub(u32::from(parent_id != 0)),
)
})
}
fn native_into_table_target_index(
devices: &[EffectiveControlDevice],
parent_component_id: u32,
@@ -2091,6 +2304,7 @@ fn native_control_factory_defaults(factory_tag: u32) -> (Option<u32>, Option<u32
}
}
#[cfg(test)]
fn build_control_device_table(
object_index: usize,
draft: &MissionObjectDraft,
@@ -2098,6 +2312,17 @@ fn build_control_device_table(
prototype_span: Option<&std::ops::Range<usize>>,
prototypes: &[fparkan_prototype::EffectivePrototype],
) -> Result<Vec<EffectiveControlDevice>, MissionControlDeviceTableUnavailableReason> {
build_control_device_projection(object_index, draft, assets, prototype_span, prototypes)
.map(|projection| projection.devices)
}
fn build_control_device_projection(
object_index: usize,
draft: &MissionObjectDraft,
assets: &MissionAssets,
prototype_span: Option<&std::ops::Range<usize>>,
prototypes: &[fparkan_prototype::EffectivePrototype],
) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> {
use MissionControlDeviceTableUnavailableReason as Unavailable;
draft
@@ -2250,6 +2475,7 @@ fn build_control_device_table(
}
let mut devices = Vec::new();
let mut node_provider_status = MissionControlNodeProviderTableStatus::Available(Vec::new());
let mut has_control_resource = false;
for component_index in active_components {
let prototype =
@@ -2286,6 +2512,40 @@ fn build_control_device_table(
component_ids[component_index].ok_or(Unavailable::ComponentAlignmentMismatch)?;
let is_root = component_index == 0;
let is_ex_to = prototype.source_type == Some(PROTOTYPE_TYPE_EXTO);
let raw_local_index = draft.unit_components[component_index].parent_or_link;
let parent_component_id = tree
.get(component_index)
.and_then(|node| node.parent_index)
.and_then(|parent_index| component_ids.get(parent_index).copied().flatten())
.unwrap_or(u32::MAX);
let mount_node = if is_ex_to {
native_component_mount_node(
&tree,
&component_ids,
&first_global_nodes,
component_index,
raw_local_index,
)
} else {
None
};
if is_root {
append_control_node_provider_rows(
&mut node_provider_status,
component_index,
prototype.node_data_resource.as_ref(),
control.node_data.as_ref(),
false,
);
} else if is_ex_to && mount_node.is_some_and(|node| node != u32::MAX) {
append_control_node_provider_rows(
&mut node_provider_status,
component_index,
prototype.node_data_resource.as_ref(),
control.node_data.as_ref(),
true,
);
}
if is_root {
for (record_index, descriptor) in control.typed_records.iter().enumerate() {
append_effective_control_device(
@@ -2301,12 +2561,6 @@ fn build_control_device_table(
continue;
}
let raw_local_index = draft.unit_components[component_index].parent_or_link;
let parent_component_id = tree
.get(component_index)
.and_then(|node| node.parent_index)
.and_then(|parent_index| component_ids.get(parent_index).copied().flatten())
.unwrap_or(u32::MAX);
let native_target = native_component_target_index(
prototype.source_type,
&devices,
@@ -2315,25 +2569,6 @@ fn build_control_device_table(
);
match native_control_target_action(native_target) {
ControlTableTarget::Append => {
let mount_node = if is_ex_to {
tree.get(component_index)
.and_then(|node| node.parent_index)
.and_then(|parent_index| {
let parent_id = component_ids.get(parent_index).copied().flatten()?;
let parent_first = first_global_nodes
.get(parent_index)
.copied()
.flatten()
.unwrap_or(u32::MAX);
Some(
parent_first
.wrapping_add(raw_local_index as u32)
.wrapping_sub(u32::from(parent_id != 0)),
)
})
} else {
None
};
let first_global_node = first_global_nodes.get(component_index).copied().flatten();
for (record_index, descriptor) in control.typed_records.iter().enumerate() {
let raw_key = descriptor.fixed_words[1];
@@ -2395,7 +2630,49 @@ fn build_control_device_table(
if !has_control_resource {
return Err(Unavailable::NoControlResource);
}
Ok(devices)
Ok(ControlDeviceProjection {
devices,
node_provider_status,
})
}
fn append_control_node_provider_rows(
status: &mut MissionControlNodeProviderTableStatus,
unit_component_index: usize,
requested_source: Option<&ResourceKey>,
node_data: Option<&PreparedControlNodeDataTable>,
skip_first_row: bool,
) {
let MissionControlNodeProviderTableStatus::Available(rows) = status else {
return;
};
let Some(node_data) = node_data else {
*status = MissionControlNodeProviderTableStatus::Unavailable(
MissionControlNodeProviderTableUnavailableReason::MissingNodeData {
unit_component_index,
source: requested_source.cloned(),
},
);
return;
};
if skip_first_row && node_data.rows.is_empty() {
*status = MissionControlNodeProviderTableStatus::Unavailable(
MissionControlNodeProviderTableUnavailableReason::MissingExToRootRow {
unit_component_index,
},
);
return;
}
let first_row = usize::from(skip_first_row);
for (source_row_index, source_row) in node_data.rows.iter().enumerate().skip(first_row) {
rows.push(MissionControlNodeProviderRow {
source: node_data.source.clone(),
unit_component_index,
source_row_index,
source_row: source_row.clone(),
initial_type1_value_bits: 0x3f80_0000,
});
}
}
fn add_behavior_registered_object(
@@ -2837,6 +3114,62 @@ mod tests {
}
}
fn synthetic_control_node_data_table(
index: usize,
row_count: usize,
) -> PreparedControlNodeDataTable {
PreparedControlNodeDataTable {
source: synthetic_resource_key(300 + index),
rows: (0..row_count)
.map(|row_index| PreparedControlNodeDataRow {
raw_flags: u32::try_from(row_index).expect("row index fits u32"),
max_volume: (100 + row_index) as f32,
mobility_per_volume: (200 + row_index) as f32,
effect: PreparedControlResourcePair {
archive: [0; 32],
name: [0; 32],
},
})
.collect(),
}
}
fn control_with_synthetic_node_data(
index: usize,
typed_records: Vec<PreparedControlTypedRecord>,
row_count: usize,
) -> PreparedComponentControl {
let mut control = synthetic_component_control(
index,
typed_records,
PreparedControlEffectSection::Parsed(Vec::new()),
);
control.node_data = Some(synthetic_control_node_data_table(index, row_count));
control
}
fn synthetic_node_provider_row(
component_index: usize,
source_row_index: usize,
initial_type1_value_bits: u32,
) -> MissionControlNodeProviderRow {
MissionControlNodeProviderRow {
source: synthetic_resource_key(300 + component_index),
unit_component_index: component_index,
source_row_index,
source_row: PreparedControlNodeDataRow {
raw_flags: u32::try_from(source_row_index).expect("row index fits u32"),
max_volume: 100.0,
mobility_per_volume: 200.0,
effect: PreparedControlResourcePair {
archive: [0; 32],
name: [0; 32],
},
},
initial_type1_value_bits,
}
}
fn synthetic_control_fixture(
source_types: &[Option<u32>],
kinds: &[u32],
@@ -2902,8 +3235,11 @@ mod tests {
control_resource: controls[index]
.as_ref()
.map(|control| control.source.clone()),
node_data_resource: controls[index]
.as_ref()
.and_then(|control| control.node_data.as_ref())
.map(|node_data| node_data.source.clone()),
point_resource: None,
node_data_resource: None,
bas_resource: None,
});
}
@@ -2956,6 +3292,48 @@ mod tests {
)
}
fn synthetic_control_projection(
source_types: &[Option<u32>],
kinds: &[u32],
child_counts: &[u32],
parent_or_links: &[i32],
model_nodes: &[usize],
controls: Vec<Option<PreparedComponentControl>>,
) -> (
MissionControlDeviceTableStatus,
MissionControlNodeProviderTableStatus,
) {
let (draft, assets, prototypes) = synthetic_control_fixture(
source_types,
kinds,
child_counts,
parent_or_links,
model_nodes,
controls,
);
let (status, node_provider_status) = match build_control_device_projection(
0,
&draft,
&assets,
Some(&(0..prototypes.len())),
&prototypes,
) {
Ok(projection) => (
MissionControlDeviceTableStatus::Available(projection.devices),
projection.node_provider_status,
),
Err(reason) => (
MissionControlDeviceTableStatus::Unavailable(reason.clone()),
MissionControlNodeProviderTableStatus::Unavailable(
MissionControlNodeProviderTableUnavailableReason::DeviceTableUnavailable(
reason,
),
),
),
};
(status, node_provider_status)
}
fn synthetic_effective_control_device(
factory_tag: u32,
descriptor_tag: u32,
@@ -2988,6 +3366,16 @@ mod tests {
fn synthetic_control_device_table(
status: MissionControlDeviceTableStatus,
) -> MissionControlDeviceTableState {
synthetic_control_device_table_with_providers(
status,
MissionControlNodeProviderTableStatus::Available(Vec::new()),
)
}
fn synthetic_control_device_table_with_providers(
status: MissionControlDeviceTableStatus,
node_provider_status: MissionControlNodeProviderTableStatus,
) -> MissionControlDeviceTableState {
MissionControlDeviceTableState {
original_id: Some(OriginalObjectId(7)),
@@ -2996,6 +3384,7 @@ mod tests {
slot: 7,
},
status,
node_provider_status,
}
}
@@ -3384,6 +3773,10 @@ mod tests {
query_loaded_mission_control_resource(&engine, handle, 1),
Err(ControlResourceAccessError::MissionUnavailable)
);
assert_eq!(
query_loaded_mission_control_device_selector(&engine, handle, 0, 0xe00),
Err(ControlResourceAccessError::MissionUnavailable)
);
let stale = ObjectHandle {
generation: handle.generation.wrapping_add(1),
@@ -3395,6 +3788,12 @@ mod tests {
fparkan_world::WorldError::StaleHandle
))
);
assert_eq!(
query_loaded_mission_control_device_selector(&engine, stale, 0, 0xe00),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
))
);
assert_eq!(
set_loaded_mission_control_multiplier(&mut engine, stale, 2.0),
Err(ControlResourceAccessError::World(
@@ -3409,6 +3808,12 @@ mod tests {
fparkan_world::WorldError::StaleHandle
))
);
assert_eq!(
query_loaded_mission_control_device_selector(&engine, handle, 0, 0xe00),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
))
);
}
#[test]
@@ -3747,6 +4152,291 @@ mod tests {
);
}
#[test]
fn synthetic_node_provider_projection_keeps_native_rows_separate_from_mesh_keys() {
let controls = vec![
Some(control_with_synthetic_node_data(
0,
vec![synthetic_control_record(26, 1, 2.0_f32.to_bits(), None)],
2,
)),
Some(control_with_synthetic_node_data(
1,
vec![synthetic_control_record(26, 0, 3.0_f32.to_bits(), None)],
7,
)),
Some(control_with_synthetic_node_data(
2,
vec![synthetic_control_record(26, 5, 4.0_f32.to_bits(), None)],
3,
)),
Some(control_with_synthetic_node_data(
3,
vec![synthetic_control_record(26, 0, 5.0_f32.to_bits(), None)],
2,
)),
Some(control_with_synthetic_node_data(
4,
vec![synthetic_control_record(26, 0, 6.0_f32.to_bits(), None)],
6,
)),
Some(control_with_synthetic_node_data(
5,
vec![synthetic_control_record(26, 1, 6.0_f32.to_bits(), None)],
5,
)),
Some(control_with_synthetic_node_data(
6,
vec![synthetic_control_record(26, 1, 7.0_f32.to_bits(), None)],
4,
)),
];
let (status, node_provider_status) = synthetic_control_projection(
&[
None,
Some(PROTOTYPE_TYPE_INTO),
Some(PROTOTYPE_TYPE_EXTO),
Some(PROTOTYPE_TYPE_EXTO),
Some(PROTOTYPE_TYPE_EXTO),
None,
None,
],
&[1, 1, 1, 1, 1, 0, 1],
&[4, 0, 1, 0, 0, 1, 0],
&[0, -1, 2, 0, -1, 0, 0],
&[1, 1, 4, 2, 1, 1, 1],
controls,
);
let table = synthetic_control_device_table_with_providers(status, node_provider_status);
let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status
else {
panic!(
"synthetic provider rows should be available: {:?}",
table.node_provider_status
);
};
// Root contributes all rows, INTO contributes none, and each mounted
// EXTO skips its local row zero. The EXTO with C4=FFFFFFFF contributes
// no provider rows. The kind-zero component and its child are absent
// from the native active-component walk.
assert_eq!(rows.len(), 5);
assert_eq!(
rows.iter()
.map(|row| (row.unit_component_index, row.source_row_index))
.collect::<Vec<_>>(),
[(0, 0), (0, 1), (2, 1), (2, 2), (3, 1)]
);
assert!(rows
.iter()
.all(|row| row.initial_type1_value_bits == 1.0_f32.to_bits()));
let MissionControlDeviceTableStatus::Available(devices) = &table.status else {
panic!("synthetic devices should be available: {:?}", table.status);
};
assert_eq!(
devices.iter().map(|device| device.key).collect::<Vec<_>>(),
[1, u32::MAX, 5, 0, u32::MAX]
);
assert_eq!(
query_control_device_table_selector(&table, 0, 0xe00)
.expect("root tag-26 selector")
.map(f32::to_bits),
Some(2.0_f32.to_bits())
);
// Device keys still use the confirmed MSH mapping. The provider list
// is the real concatenated NDP row count, so no model-node padding or
// second key remap hides an out-of-range key.
assert_eq!(
query_control_device_table_selector(&table, 2, 0xe00),
Err(ControlResourceAccessError::NodeProviderKeyOutOfRange {
key: 5,
row_count: 5
})
);
assert_eq!(
query_control_device_table_selector(&table, 3, 0xe00),
Ok(Some(5.0))
);
assert_eq!(
query_control_device_table_selector(&table, 4, 0xe00),
Err(ControlResourceAccessError::NodeProviderKeyOutOfRange {
key: u32::MAX,
row_count: 5
})
);
}
#[test]
fn missing_node_provider_does_not_hide_aggregate_queries() {
let controls = vec![Some(synthetic_component_control(
0,
vec![
synthetic_control_record(26, 0, 2.0_f32.to_bits(), None),
synthetic_control_record(19, 1, 4.0_f32.to_bits(), None),
],
PreparedControlEffectSection::Parsed(Vec::new()),
))];
let (status, node_provider_status) =
synthetic_control_projection(&[None], &[1], &[0], &[0], &[1], controls);
let table = synthetic_control_device_table_with_providers(status, node_provider_status);
assert!(matches!(
&table.node_provider_status,
MissionControlNodeProviderTableStatus::Unavailable(
MissionControlNodeProviderTableUnavailableReason::MissingNodeData {
unit_component_index: 0,
..
}
)
));
assert_eq!(
query_control_device_table_resource(&table, 1)
.expect("aggregate selector 1 remains available")
.map(f32::to_bits),
Some(1.0_f32.to_bits())
);
assert_eq!(
query_control_device_table_resource(&table, 2)
.expect("aggregate selector 2 remains available")
.map(f32::to_bits),
Some(4.0_f32.to_bits())
);
assert!(matches!(
query_control_device_table_selector(&table, 0, 0xe00),
Err(ControlResourceAccessError::NodeProviderTableUnavailable(_))
));
}
#[test]
fn mounted_exto_requires_a_row_before_native_skip_first() {
let controls = vec![
Some(control_with_synthetic_node_data(
0,
vec![synthetic_control_record(26, 0, 1.0_f32.to_bits(), None)],
0,
)),
Some(control_with_synthetic_node_data(1, Vec::new(), 0)),
];
let (_, node_provider_status) = synthetic_control_projection(
&[None, Some(PROTOTYPE_TYPE_EXTO)],
&[1, 1],
&[1, 0],
&[0, 0],
&[2, 2],
controls,
);
assert_eq!(
node_provider_status,
MissionControlNodeProviderTableStatus::Unavailable(
MissionControlNodeProviderTableUnavailableReason::MissingExToRootRow {
unit_component_index: 1,
}
)
);
// An empty root table is legal, and a mounted EXTO with exactly its
// root row skips that row and contributes an empty range.
let controls = vec![
Some(control_with_synthetic_node_data(
0,
vec![synthetic_control_record(26, 0, 1.0_f32.to_bits(), None)],
0,
)),
Some(control_with_synthetic_node_data(1, Vec::new(), 1)),
];
let (_, node_provider_status) = synthetic_control_projection(
&[None, Some(PROTOTYPE_TYPE_EXTO)],
&[1, 1],
&[1, 0],
&[0, 0],
&[2, 2],
controls,
);
assert_eq!(
node_provider_status,
MissionControlNodeProviderTableStatus::Available(Vec::new())
);
}
#[test]
fn indexed_node_provider_query_handles_current_tags_and_factory_scope() {
let available = MissionControlNodeProviderTableStatus::Available(vec![
synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()),
synthetic_node_provider_row(0, 1, 1.0_f32.to_bits()),
synthetic_node_provider_row(0, 2, 1.0_f32.to_bits()),
]);
let table = synthetic_control_device_table_with_providers(
MissionControlDeviceTableStatus::Available(vec![
synthetic_effective_control_device(
26,
26,
2.0_f32.to_bits(),
Some(3.0_f32.to_bits()),
None,
),
synthetic_effective_control_device(
26,
19,
4.0_f32.to_bits(),
Some(5.0_f32.to_bits()),
None,
),
synthetic_effective_control_device(
26,
5,
1.0_f32.to_bits(),
Some(1.0_f32.to_bits()),
None,
),
synthetic_effective_control_device(
9,
26,
1.0_f32.to_bits(),
Some(1.0_f32.to_bits()),
Some(0.5_f32.to_bits()),
),
]),
available,
);
assert_eq!(
query_control_device_table_selector(&table, 0, 0xe00)
.expect("tag-26 query")
.map(f32::to_bits),
Some(6.0_f32.to_bits())
);
assert_eq!(
query_control_device_table_selector(&table, 1, 0xe00),
Ok(None)
);
for tag in [5, 10, 21] {
let mut unsupported = table.clone();
let MissionControlDeviceTableStatus::Available(entries) = &mut unsupported.status
else {
unreachable!();
};
entries[1].descriptor.tag = tag;
assert_eq!(
query_control_device_table_selector(&unsupported, 1, 0xe00),
Err(ControlResourceAccessError::UnsupportedCurrentTag { tag })
);
}
assert_eq!(
query_control_device_table_selector(&table, 3, 0xe00),
Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag: 9 })
);
assert_eq!(
query_control_device_table_selector(&table, 0, 1),
Err(ControlResourceAccessError::UnsupportedSelector(1))
);
assert_eq!(
query_control_device_table_selector(&table, 8, 0xe00),
Err(ControlResourceAccessError::DeviceIndexOutOfRange {
device_index: 8,
device_count: 4
})
);
}
#[test]
fn synthetic_into_builder_appends_all_rows_and_overrides_only_first_record() {
let append = synthetic_control_table(
@@ -4049,6 +4739,49 @@ mod tests {
[1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 5]
);
assert_eq!(entries[8].descriptor_provenance.typed_record_index, 8);
assert_eq!(
query_loaded_mission_control_device_selector(&engine, control_handle, 8, 0xe00)
.expect("licensed tag-26 selector")
.map(f32::to_bits),
Some(1.0_f32.to_bits())
);
let prepared_controls = &engine
.loaded
.as_ref()
.expect("loaded mission state")
.mission_assets
.object_component_controls[0];
let root_node_data = prepared_controls[0]
.as_ref()
.and_then(|control| control.node_data.as_ref())
.expect("root NDP sidecar");
let ext_node_data = prepared_controls[5]
.as_ref()
.and_then(|control| control.node_data.as_ref())
.expect("EXTO NDP sidecar");
let provider_rows = match &tables[0].node_provider_status {
MissionControlNodeProviderTableStatus::Available(rows) => rows,
MissionControlNodeProviderTableStatus::Unavailable(reason) => {
panic!("licensed provider table should be available: {reason:?}");
}
};
assert_eq!(root_node_data.rows.len(), 22);
assert_eq!(ext_node_data.rows.len(), 4);
assert_eq!(provider_rows.len(), 25);
let expected_provider_sources = (0..root_node_data.rows.len())
.map(|row| (0, row))
.chain((1..ext_node_data.rows.len()).map(|row| (5, row)))
.collect::<Vec<_>>();
assert_eq!(
provider_rows
.iter()
.map(|row| (row.unit_component_index, row.source_row_index))
.collect::<Vec<_>>(),
expected_provider_sources
);
assert!(provider_rows
.iter()
.all(|row| row.initial_type1_value_bits == 1.0_f32.to_bits()));
assert_eq!(
(
entries[9].factory_tag,
@@ -29,6 +29,8 @@ internal static class BehaviorDistributorTickOracle {
private const int ControlResourceAggregateSetRva = 0x2ba30;
private const int ControlProviderInterfaceVtableRva = 0x3d254;
private const int ControlProviderType1GetterRva = 0x0dc40;
private const int ControlProviderRowConstructorRva = 0x0b240;
private const int ControlProviderAmountUpdateRva = 0x10f30;
private const int ArealMapUnitDatComponentBuilderRva = 0x14b90;
private const int ControlResourceDeviceConstructorRva = 0x22980;
private const int ControlResourceComponentOverrideRva = 0x2d890;
@@ -79,6 +81,9 @@ internal static class BehaviorDistributorTickOracle {
[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);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlProviderAmountUpdate(IntPtr self, int nodeIndex, float delta, uint animationFlag);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlProviderRowConstructor(IntPtr self, IntPtr sourceNdpRow);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeControlNodeUpdateCallback(IntPtr self, uint nodeIndex, uint selector, uint valueBits);
private static readonly AreaMapGetter AreaMapGetterCallback = GetAreaMap;
private static readonly VectorInsert VectorInsertCallback = InsertVector;
@@ -92,7 +97,12 @@ internal static class BehaviorDistributorTickOracle {
private static readonly InterfaceGetProperty InterfaceGetCallback = GetInterfaceProperty;
private static readonly InterfaceSetProperty InterfaceSetCallback = SetInterfaceProperty;
private static readonly NativeArealMapEvent ArealMapEventCallback = OnArealMapEvent;
private static readonly NativeControlNodeUpdateCallback ControlNodeUpdateCallback = OnControlNodeUpdate;
private static ArealMapFixture activeArealMap;
private static int controlNodeUpdateCount;
private static uint controlNodeUpdateIndex;
private static uint controlNodeUpdateSelector;
private static uint controlNodeUpdateValueBits;
private static IntPtr fastcallThunks;
private static IntPtr queryObjectThunk;
private static IntPtr getInterfacePropertyThunk;
@@ -398,6 +408,12 @@ internal static class BehaviorDistributorTickOracle {
active.Trace.Add("iface-set:" + item.Key.ToString("x8") + ":" + iid.ToString("x") + ":" + selector.ToString("x") + ":" + number.ToString("R", System.Globalization.CultureInfo.InvariantCulture));
return 1;
}
private static void OnControlNodeUpdate(IntPtr self, uint nodeIndex, uint selector, uint valueBits) {
controlNodeUpdateCount++;
controlNodeUpdateIndex = nodeIndex;
controlNodeUpdateSelector = selector;
controlNodeUpdateValueBits = valueBits;
}
private static float SequenceValue(float[] sequence, int index, float fallback) {
if (sequence == null || sequence.Length == 0) return fallback;
return sequence[Math.Min(index, sequence.Length - 1)];
@@ -493,6 +509,7 @@ internal static class BehaviorDistributorTickOracle {
GC.KeepAlive(ObjectQueryCallback); GC.KeepAlive(ObjectRateCallback);
GC.KeepAlive(ObjectGetPropertyCallback); GC.KeepAlive(ObjectSetPropertyCallback);
GC.KeepAlive(InterfaceGetCallback); GC.KeepAlive(InterfaceSetCallback);
GC.KeepAlive(ControlNodeUpdateCallback);
GC.KeepAlive(ArealMapEventCallback);
if (thunksAllocated && !VirtualFree(fastcallThunks, UIntPtr.Zero, MemRelease))
throw new InvalidOperationException("VirtualFree(fastcall callback thunks) failed: " + Marshal.GetLastWin32Error());
@@ -514,6 +531,7 @@ internal static class BehaviorDistributorTickOracle {
RunNativeArealMapComponentBuilderCase(arealMapModule);
RunNativeControlIntoOverrideCase(controlModule);
RunNativeControlResourceDeviceCase(controlModule);
RunNativeControlProviderLiveStateCase(controlModule);
RunNativeControlAggregateResourceCases(controlModule);
} finally {
SetX87ControlWord(savedControlWord);
@@ -594,6 +612,100 @@ internal static class BehaviorDistributorTickOracle {
}
}
private static void RunNativeControlProviderLiveStateCase(IntPtr module) {
NativeControlProviderRowConstructor constructProviderRow = (NativeControlProviderRowConstructor)
Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, ControlProviderRowConstructorRva),
typeof(NativeControlProviderRowConstructor));
NativeControlProviderAmountUpdate update = (NativeControlProviderAmountUpdate)
Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, ControlProviderAmountUpdateRva),
typeof(NativeControlProviderAmountUpdate));
NativeControlResourceDeviceGet getter = (NativeControlResourceDeviceGet)
Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, ControlResourceDeviceGetterRva),
typeof(NativeControlResourceDeviceGet));
using (Fixture fixture = NewFixture()) {
IntPtr owner = fixture.Allocate(0x664);
IntPtr rows = fixture.Allocate(2 * 0x2c);
IntPtr ndpDescriptor = fixture.Allocate(0x4c);
IntPtr initializedRow = fixture.Allocate(0x2c);
constructProviderRow(initializedRow, ndpDescriptor);
uint[] initialWords = new uint[11];
for (int i = 0; i < initialWords.Length; i++)
initialWords[i] = unchecked((uint)Marshal.ReadInt32(initializedRow, i * 4));
uint[] expectedInitialWords = {
unchecked((uint)ndpDescriptor.ToInt32()), 0xffffffff, 0x3f800000,
0x3f800000, 0, 0x3f800000, 0, 1, 0, 0xffffffff, 0
};
Expect(String.Join(",", initialWords) == String.Join(",", expectedInitialWords),
"Control native NDP provider row constructor initializes all eleven words and type-1 current to 1.0");
IntPtr providerInterface = IntPtr.Add(owner, 4);
IntPtr providerVtable = IntPtr.Add(module, ControlProviderInterfaceVtableRva);
IntPtr callbackObject = fixture.Allocate(IntPtr.Size);
IntPtr callbackVtable = fixture.Allocate(0x38);
IntPtr device = fixture.Allocate(0x94);
IntPtr row = IntPtr.Add(rows, 0x2c); // Keep the root row untouched.
Marshal.WriteIntPtr(providerInterface, providerVtable);
Marshal.WriteIntPtr(owner, 0x55c, rows);
Marshal.WriteInt32(owner, 0x548, FloatBits(1.0f));
Marshal.WriteInt32(owner, 0x590, FloatBits(40.0f));
Marshal.WriteInt32(owner, 0x660, FloatBits(1.0f));
Marshal.WriteIntPtr(row, 0x00, ndpDescriptor);
Marshal.WriteInt32(ndpDescriptor, 0x04, FloatBits(100.0f)); // Native selector 3 maximum.
Marshal.WriteInt32(row, 0x04, -1); // Root row has no parent node.
Marshal.WriteInt32(row, 0x08, FloatBits(40.0f));
Marshal.WriteInt32(row, 0x14, FloatBits(0.4f));
Marshal.WriteIntPtr(callbackObject, 0, callbackVtable);
Marshal.WriteIntPtr(callbackVtable, 0x34, Marshal.GetFunctionPointerForDelegate(ControlNodeUpdateCallback));
Marshal.WriteIntPtr(owner, 0x20, callbackObject);
Marshal.WriteInt32(device, 0x04, 1);
Marshal.WriteInt32(device, 0x4c, FloatBits(3.0f));
Marshal.WriteInt32(device, 0x54, FloatBits(2.0f));
Marshal.WriteIntPtr(device, 0x44, providerInterface);
controlNodeUpdateCount = 0;
update(owner, 1, 20.0f, 0);
Expect(ReadFloat(row, 0x08) == 60.0f && ReadFloat(row, 0x14) == 0.6f &&
ReadFloat(owner, 0x590) == 60.0f,
"Control native amount update clamps the node amount, updates the normalized provider value, and keeps the aggregate amount in sync");
Expect(controlNodeUpdateCount == 1 && controlNodeUpdateIndex == 1 &&
controlNodeUpdateSelector == 0x200 && controlNodeUpdateValueBits == unchecked((uint)FloatBits(0.6f)),
"Control native amount update publishes the normalized provider state through the existing node callback");
Expect(unchecked((uint)FloatBits(getter(device, 0x300))) ==
unchecked((uint)FloatBits(0.6f * 2.0f * 3.0f)),
"Control tag-26 selector 0x300 observes the updated NDP provider value through the native provider interface");
update(owner, 1, -10.0f, 0);
Expect(ReadFloat(row, 0x08) == 50.0f && ReadFloat(row, 0x14) == 0.5f &&
unchecked((uint)FloatBits(getter(device, 0x300))) == 0x40400000,
"Control native amount update is reversible and tag-26 selector 0x300 tracks the changed provider row");
update(owner, 1, 1000.0f, 0);
Expect(ReadFloat(row, 0x08) == 100.0f && ReadFloat(row, 0x14) == 1.0f &&
unchecked((uint)FloatBits(getter(device, 0x300))) == 0x40c00000,
"Control native amount update clamps the provider value at its native selector-3 maximum");
update(owner, 1, -200.0f, 0);
Expect(ReadFloat(row, 0x08) == 0.0f && ReadFloat(row, 0x14) == 0.0f &&
(unchecked((uint)Marshal.ReadInt32(row, 0x28)) & 0x10) != 0 &&
unchecked((uint)FloatBits(getter(device, 0x300))) == 0,
"Control native depletion sets the exhausted flag and selector 0x300 returns the live zero provider value");
Expect(controlNodeUpdateCount == 4 && controlNodeUpdateIndex == 1 &&
controlNodeUpdateSelector == 0x200 && controlNodeUpdateValueBits == 0,
"Control native node callback receives every normalized provider update through exhaustion");
Console.WriteLine("native-control-live-ndp-provider: controlHash=" + ExpectedControlSha256 +
" rowCtorRva=0xb240 initialType1=1 rowStride=0x2c node=1 max=100 amount=0..100 tag26Selector300=live");
GC.KeepAlive(constructProviderRow);
GC.KeepAlive(update);
GC.KeepAlive(getter);
}
}
private static void RunNativeControlAggregateResourceCases(IntPtr module) {
NativeControlAggregateQuery query = (NativeControlAggregateQuery)Marshal.GetDelegateForFunctionPointer(
WriteStdCallToFastcallThunk(0x1c0, IntPtr.Add(module, ControlResourceAggregateQueryRva)),
+40 -10
View File
@@ -307,18 +307,48 @@ mappings; preview и объекты без Unit DAT root её не строят.
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) tag-26
getter selector `0x300` восстановлен локально: RVA `0x21d00` читает provider по
ключу `fixed_words[1]`, затем умножает provider, CTLD coefficient `fixed_words[11]`
и поле объекта `+0x4c` именно в таком порядке. Rust helper принимает текущие
provider и multiplier извне; он не восстанавливает их жизненный цикл или весь
Control tick. В x86 oracle при x87 control word `0x007f` проверены порядок,
signed zero, отрицательные входы, расширенный промежуточный диапазон и
subnormal return. Helper ограничивает себя normal-or-zero значениями, поэтому
явно отклоняет промежуточные случаи, где результат выходит за поддерживаемый
диапазон f32, даже если позднее умножение вернуло бы его в диапазон. Это
ограничение поддержки Rust helper-а, а не неверный native-результат.
и поле объекта `+0x4c` именно в таком порядке. Provider getter RVA `0xdc40`
читает type-1 поле `+0x14` строки, которую `FUN_10008b10` (RVA `0x8b10`)
добавляет из NDP в таблицу с шагом `0x2c`; `FUN_1000b240` (RVA `0xb240`)
инициализирует это поле как `1.0`. Начальный Rust snapshot повторяет порядок
NDP строк корневого ресурса и примонтированных `EXTO` (без первой строки
последнего), сохраняя исходные таблицу и строку как provenance. Ключ устройства
сначала меняется по подтверждённому MSH mapping для `EXTO`, затем это готовое
значение применяется напрямую как индекс provider sequence. Длина этой
sequence определяется NDP: она не дополняется по числу узлов модели и ключ не
пересчитывается ещё раз по числу provider rows. `INTO` provider строк не
добавляет. Для примонтированного `EXTO` с пустым NDP невозможно безопасно
применить native skip-first путь, поэтому Rust явно сообщает unavailable;
таблица из одной строки допустима и даёт нулевой вклад. Если обязательный NDP
sidecar недоступен, запрос `0xe00` сообщает это отдельно, оставляя независимые
агрегатные selectors `1/2` доступными.
Rust query обращается к загруженному объекту и поддерживает `0xe00` только для
текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и
factory paths остаются явными границами. Snapshot показывает состояние сразу
после native-конструкторов, но он не обновляется при Control tick-е. Native
`FUN_10010f30` (RVA `0x10f30`) изменяет и ограничивает provider amount, а
связанные callbacks переносят его между строками; их общий переход в Rust ещё
не реализован. Поэтому вызов нельзя считать живым ресурсным симулятором.
Behavior selector `0x1002` остаётся отдельной задачей: его множитель
`Transfer_Ore_OffBoard` приходит из блока `+0x820`, но источник фактических
текущих настроек для полного runtime-пути пока не подтверждён.
В x86 oracle при x87 control word `0x007f` проверены порядок, signed zero,
отрицательные входы, расширенный промежуточный диапазон и subnormal return.
Rust запрос ограничивает себя normal-or-zero значениями, поэтому явно отклоняет
промежуточные случаи, где результат выходит за поддерживаемый диапазон f32,
даже если позднее умножение вернуло бы его в диапазон. Это ограничение
поддержки Rust snapshot, а не неверный native-результат.
В Unit DAT fixture `UNITS/BUILDS/MINE/min_l_n1.dat` проверена реальная связка
`fr_l_mine.ctl` + `fr_l_mine.ndp`: tag `26`, ключ `2` в таблице из `22` строк,
коэффициент `1.0`. Это не запись живого resource-device значения в миссии.
`fr_l_mine.ctl` + `fr_l_mine.ndp`: tag `26`, ключ `2` в локальной NDP таблице
из `22` строк, коэффициент `1.0`. Игнорируемый runtime-тест выполняет полный
загрузочный путь, подтверждает root/`EXTO` размеры `22` и `4` и итоговые `25`
provider rows после пропуска первого `EXTO` row. Он вызывает Rust query по
восстановленному native правилу адресации и получает начальное значение `1.0`;
getter оригинальной DLL здесь не исполняется. Это не запись живого
resource-device значения во время игры.
Закрывающий эксперимент: трассировать `LoadControlSystem`,
`LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать
+44 -15
View File
@@ -732,8 +732,21 @@ Provider interface направляет selector `1` и ключ в Control RVA
читается текущее type-1 значение выбранной строки. Затем selector умножает
сначала provider на коэффициент, после чего на множитель: `provider * base *
multiplier`. Коэффициент приходит из CTLD; NDP связывает ключ с описанной
строкой, а текущее значение provider нельзя заменить предположением о состоянии
мира.
строкой. Native loader создаёт для этой строки отдельное runtime-состояние: у
`Control.dll` (та же сборка, SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`)
`FUN_1000b240` (RVA `0xb240`) строит 11 слов строки provider и ставит её
selector-1 поле `+0x14` в `1.0`. `FUN_10008b10` (RVA `0x8b10`) выбирает строки
NDP, а `FUN_1000a100` (RVA `0xa100`) добавляет их в общую таблицу с шагом
`0x2c`; getter `FUN_1000dc40` (RVA `0xdc40`) использует ключ устройства как
индекс этой таблицы. Для корня загружаются все строки. `INTO` не добавляет
строк, а примонтированный `EXTO` пропускает свою первую строку. Пустая
таблица такого `EXTO` не содержит строки, которую native loader должен
пропустить, поэтому Rust оставляет provider lookup недоступным; sidecar с одной
строкой даёт пустой вклад. Если mount узел не разрешился, строки этого `EXTO`
в таблицу не попадают. Это отдельная
последовательность provider-строк: её длина берётся из NDP, она не дополняется
до числа узлов MSH и не участвует во второй перенумерации ключа.
Так этот селектор включён в цепочку добычи руды оригинала. В той же сборке
`Behavior.dll` (SHA-256
@@ -747,24 +760,40 @@ multiplier`. Коэффициент приходит из CTLD; NDP связыв
5.0 — RVA `0x22d90`; config selector 2 загружает блок через RVA `0x0a1a0`).
Отдельный query `0x1005` (RVA `0xa490`) возвращает поле `+0x60`
`Transfer_Power_Out` (имя — RVA `0x22e20`, default 100.0 — RVA `0x22d90`;
setter — RVA `0xaa40`). Rust helper пока описывает один Control selector `0x300`
при явно переданных входах; он не реализует эту сумму, Behavior-умножение или
подключение к игровому циклу.
setter — RVA `0xaa40`). В полной загрузке FParkan начальные provider-строки
хранятся вместе с таблицей Control конкретного `ObjectHandle`. Запрос
`0xe00` проходит по ключу устройства в этот снимок и пока поддержан только для
текущего descriptor tag `26` с factory tag `26`; для него результат равен
`provider * fixed_words[11] * multiplier`. Лицензированный тест загружает
оригинальные CTL/NDP и подтверждает, что tag-26 запись с ключом `2` читает
начальное значение строки `1.0`. Это проверка начального состояния после
конструкторов, не результат живого игрового тика.
Поведение provider-строк меняется и после загрузки: например,
`Control.dll!FUN_10010f30` (RVA `0x10f30`) обновляет и ограничивает текущее
значение, а соседние процедуры распространяют его между связанными строками.
Эти изменения пока не отражены в Rust-снимке. Поэтому `0xe00` не является
исполнением всего Control tick-а, а Behavior query `0x1002` не подключён к
этому API; его множитель `Transfer_Ore_OffBoard` требует отдельного источника
текущей конфигурации.
На x87 control word `0x007f` каждое умножение округляет мантиссу до 24 бит,
но промежуточная степень может выйти за диапазон `f32` и вернуться в него при
следующем умножении. Rust helper поэтому принимает явные текущие значения
provider и multiplier, проверяет tag, ключ и строку NDP, сохраняет порядок
операций, но поддерживает только конечные нормализованные значения или ноль на
входе, промежуточном и конечном этапах. Остальные случаи за этим численным
диапазоном он отклоняет; полный x87 accumulator здесь не эмулируется.
следующем умножении. Native oracle проверяет такой порядок для getter-а
`0x300`. Rust запрос сохраняет тот же порядок, но поддерживает только конечные
нормализованные значения или ноль на входе, промежуточном и конечном этапах.
Остальные случаи за этим численным диапазоном он отклоняет; полный x87
accumulator здесь не эмулируется.
Лицензированный тест на Unit DAT `UNITS/BUILDS/MINE/min_l_n1.dat` проходит через
компонент `fr_l_mine.ctl` и sidecar `fr_l_mine.ndp`: у найденного tag-26
дескриптора ключ равен `2` из `22` строк, а raw коэффициент — `0x3f800000`.
Тест передаёт helper-у искусственные текущие значения `1.0` только для проверки
записи и привязки ключа; этот Unit DAT не является наблюдением живого Control
tick-а.
`fr_l_mine.ctl` и `fr_l_mine.ndp`: у найденного tag-26 descriptor ключ равен
`2` в своей записи, raw коэффициент — `0x3f800000`. Корневой NDP содержит 22
строки, sidecar примонтированного `EXTO` — 4; в общую provider-таблицу входит
25 строк после пропуска локальной строки `0`. Тест также сравнивает порядок
всех source-row индексов с порядком строк самих sidecar таблиц. Он вызывает
Rust query по восстановленному native правилу адресации, а не getter оригинальной
DLL на полной миссии. Значение `1.0` подтверждает начальное состояние по native
конструктору, но не наблюдение живого Control tick-а.
### Control system и physical model