feat(runtime): update live Control provider amounts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s

This commit is contained in:
Valentin Popov committed 2026-10-29 16:55:09 +04:00
1 parent f35c9ec1f0
commit acdf635ffb
6 files changed
+522 -34

No files matched your search

+399 -5
View File
@@ -222,7 +222,7 @@ pub struct MissionControlNodeInitialMeshState {
pub stage_count: u32, pub stage_count: u32,
} }
/// Initial row in the loaded global Control node-data provider. /// Source and current state of one loaded global Control node-data provider row.
/// ///
/// The native table is indexed independently of MSH model nodes. Its rows are /// 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. /// appended in root/EXTO resource order and begin with a type-1 value of 1.0.
@@ -246,11 +246,32 @@ pub struct MissionControlNodeProviderRow {
pub initial_amount_bits: u32, pub initial_amount_bits: u32,
/// Initial native provider selector-1 value (`+0x14`) as raw bits. /// Initial native provider selector-1 value (`+0x14`) as raw bits.
pub initial_type1_value_bits: u32, pub initial_type1_value_bits: u32,
/// Current amount at native `+0x08`.
pub current_amount_bits: u32,
/// Amount last processed by the stage updater, native `+0x0c`.
pub processed_amount_bits: u32,
/// Current normalized provider value at native `+0x14`.
pub current_type1_value_bits: u32,
/// Current native `+0x28` flags, when the initial mesh metadata is known.
pub current_flags: Option<u32>,
/// MSH state exposed at the same global ordinal, if the mesh projection exists. /// MSH state exposed at the same global ordinal, if the mesh projection exists.
pub initial_mesh_state: Option<MissionControlNodeInitialMeshState>, pub initial_mesh_state: Option<MissionControlNodeInitialMeshState>,
} }
/// Availability of the initial global Control node-data provider projection. /// Node-property publication required after a provider amount changes.
///
/// The model-node callback is native selector `0x200`; the caller must publish
/// this value separately from the later stage and visibility update.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[must_use]
pub struct ControlNodeProviderPublication {
/// Global provider/model-node ordinal.
pub key: u32,
/// Normalized provider value sent to the node callback.
pub type1_value_bits: u32,
}
/// Availability of the global Control node-data provider projection.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum MissionControlNodeProviderTableStatus { pub enum MissionControlNodeProviderTableStatus {
/// Rows in native append order, without padding to the MSH node count. /// Rows in native append order, without padding to the MSH node count.
@@ -306,6 +327,10 @@ pub enum ControlResourceAccessError {
/// Actual global provider row count. /// Actual global provider row count.
row_count: usize, row_count: usize,
}, },
/// The current amount, maximum, or aggregate is outside the supported domain.
InvalidNodeProviderAmount { key: u32 },
/// Depleting a root or critical node needs the unimplemented object lifecycle.
UnsupportedNodeLifecycle { key: u32 },
/// A native tag has an e00 callback that has not been implemented here. /// A native tag has an e00 callback that has not been implemented here.
UnsupportedCurrentTag { tag: u32 }, UnsupportedCurrentTag { tag: u32 },
/// A selected row lacks a value needed by the native getter or setter. /// A selected row lacks a value needed by the native getter or setter.
@@ -386,8 +411,12 @@ pub struct MissionControlDeviceTableState {
pub handle: ObjectHandle, pub handle: ObjectHandle,
/// Complete base projection, or an explicit unsupported boundary. /// Complete base projection, or an explicit unsupported boundary.
pub status: MissionControlDeviceTableStatus, pub status: MissionControlDeviceTableStatus,
/// Initial global NDP provider rows used by indexed Control selectors. /// Global NDP provider rows used by indexed Control selectors.
pub node_provider_status: MissionControlNodeProviderTableStatus, pub node_provider_status: MissionControlNodeProviderTableStatus,
/// Native `+0x590` amount aggregate; absent if it cannot be represented.
pub node_provider_amount_bits: Option<u32>,
/// Native `+0x598`: amount changed since the last aggregate/contact update.
pub node_provider_dirty: bool,
} }
struct ControlDeviceProjection { struct ControlDeviceProjection {
@@ -1434,12 +1463,16 @@ fn load_mission_with_options(
original_id: draft.original_id, original_id: draft.original_id,
handle: *handle, handle: *handle,
status: MissionControlDeviceTableStatus::Available(projection.devices), status: MissionControlDeviceTableStatus::Available(projection.devices),
node_provider_amount_bits: initial_node_provider_amount(&projection.node_provider_status),
node_provider_dirty: true,
node_provider_status: projection.node_provider_status, node_provider_status: projection.node_provider_status,
}, },
Err(reason) => MissionControlDeviceTableState { Err(reason) => MissionControlDeviceTableState {
original_id: draft.original_id, original_id: draft.original_id,
handle: *handle, handle: *handle,
status: MissionControlDeviceTableStatus::Unavailable(reason.clone()), status: MissionControlDeviceTableStatus::Unavailable(reason.clone()),
node_provider_amount_bits: None,
node_provider_dirty: false,
node_provider_status: node_provider_status:
MissionControlNodeProviderTableStatus::Unavailable( MissionControlNodeProviderTableStatus::Unavailable(
MissionControlNodeProviderTableUnavailableReason::DeviceTableUnavailable( MissionControlNodeProviderTableUnavailableReason::DeviceTableUnavailable(
@@ -2122,6 +2155,128 @@ pub fn set_loaded_mission_control_multiplier(
set_control_device_table_multiplier(table, value) set_control_device_table_multiplier(table, value)
} }
/// Applies the confirmed non-animation-delta path of native Control `0x10f30`.
///
/// Updates amount, ratio, aggregate, exhaustion flag, and dirty state, returning
/// the required selector-`0x200` node publication. It does not run stage propagation,
/// visibility, or the separate Control clock; the processed amount stays unchanged.
/// The loaded Unit DAT path has unity maximum multipliers.
///
/// # Errors
/// Rejects invalid handles, unavailable state, unsupported numeric ranges, or
/// depletion requiring the object's unimplemented lifecycle. No state is changed
/// on error. The returned node publication must be applied by the caller.
pub fn apply_loaded_mission_control_node_delta(
engine: &mut Engine,
handle: ObjectHandle,
key: u32,
delta: f32,
) -> Result<Option<ControlNodeProviderPublication>, 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 })?;
apply_control_node_delta(table, key, delta)
}
fn initial_node_provider_amount(status: &MissionControlNodeProviderTableStatus) -> Option<u32> {
let MissionControlNodeProviderTableStatus::Available(rows) = status else {
return None;
};
rows.iter()
.try_fold(0.0_f32, |total, row| {
let amount = checked_control_float(
0,
"initial provider amount",
f32::from_bits(row.current_amount_bits),
)?;
checked_control_sum(0, "initial provider aggregate", total, amount)
})
.ok()
.map(f32::to_bits)
}
fn apply_control_node_delta(
table: &mut MissionControlDeviceTableState,
key: u32,
delta: f32,
) -> Result<Option<ControlNodeProviderPublication>, ControlResourceAccessError> {
let rows = match &mut table.node_provider_status {
MissionControlNodeProviderTableStatus::Available(rows) => rows,
MissionControlNodeProviderTableStatus::Unavailable(reason) => {
return Err(ControlResourceAccessError::NodeProviderTableUnavailable(
reason.clone(),
))
}
};
let row_count = rows.len();
let row = usize::try_from(key)
.ok()
.and_then(|index| rows.get_mut(index))
.ok_or(ControlResourceAccessError::NodeProviderKeyOutOfRange { key, row_count })?;
checked_control_float(0, "provider delta", delta)?;
if delta == 0.0 {
return Ok(None);
}
let previous = checked_control_float(
0,
"provider amount",
f32::from_bits(row.current_amount_bits),
)?;
let maximum = checked_control_float(0, "provider maximum", row.source_row.max_volume)?;
if maximum < 0.0 || previous < 0.0 || previous > maximum {
return Err(ControlResourceAccessError::InvalidNodeProviderAmount { key });
}
let amount =
checked_control_sum(0, "provider amount plus delta", previous, delta)?.clamp(0.0, maximum);
if amount == previous {
row.current_amount_bits = amount.to_bits();
return Ok(None);
}
let flags = row
.current_flags
.ok_or(ControlResourceAccessError::UnsupportedField {
factory_tag: 0,
field: "provider flags",
})?;
if amount == 0.0 && (key == 0 || flags & 1 != 0) {
return Err(ControlResourceAccessError::UnsupportedNodeLifecycle { key });
}
let total_bits =
table
.node_provider_amount_bits
.ok_or(ControlResourceAccessError::UnsupportedField {
factory_tag: 0,
field: "provider aggregate",
})?;
let total = checked_control_float(0, "provider aggregate", f32::from_bits(total_bits))?;
if total < 0.0 {
return Err(ControlResourceAccessError::InvalidNodeProviderAmount { key });
}
let change = checked_control_sum(0, "provider amount change", amount, -previous)?;
let total = checked_control_sum(0, "provider aggregate", total, change)?.max(0.0);
let ratio = checked_control_ratio("provider ratio", amount, maximum)?;
row.current_amount_bits = amount.to_bits();
row.current_type1_value_bits = ratio.to_bits();
row.current_flags = Some(if amount == 0.0 {
flags | 0x10
} else {
flags & !0x10
});
table.node_provider_amount_bits = Some(total.to_bits());
table.node_provider_dirty = true;
Ok(Some(ControlNodeProviderPublication {
key,
type1_value_bits: ratio.to_bits(),
}))
}
fn query_control_device_table_resource( fn query_control_device_table_resource(
table: &MissionControlDeviceTableState, table: &MissionControlDeviceTableState,
selector: u32, selector: u32,
@@ -2227,7 +2382,7 @@ fn query_control_device_table_selector(
let provider_value = checked_control_float( let provider_value = checked_control_float(
device.factory_tag, device.factory_tag,
"node provider value", "node provider value",
f32::from_bits(provider.initial_type1_value_bits), f32::from_bits(provider.current_type1_value_bits),
)?; )?;
let coefficient = control_device_coefficient(device)?; let coefficient = control_device_coefficient(device)?;
let multiplier = control_device_float(device, device.multiplier_bits, "multiplier")?; let multiplier = control_device_float(device, device.multiplier_bits, "multiplier")?;
@@ -3595,6 +3750,10 @@ fn append_control_node_provider_rows(
source_row: source_row.clone(), source_row: source_row.clone(),
initial_amount_bits: source_row.max_volume.to_bits(), initial_amount_bits: source_row.max_volume.to_bits(),
initial_type1_value_bits: 0x3f80_0000, initial_type1_value_bits: 0x3f80_0000,
current_amount_bits: source_row.max_volume.to_bits(),
processed_amount_bits: source_row.max_volume.to_bits(),
current_type1_value_bits: 0x3f80_0000,
current_flags: None,
initial_mesh_state: None, initial_mesh_state: None,
}); });
} }
@@ -3660,6 +3819,7 @@ fn attach_initial_mesh_states_by_global_ordinal(
) { ) {
for (ordinal, row) in provider_rows.iter_mut().enumerate() { for (ordinal, row) in provider_rows.iter_mut().enumerate() {
row.initial_mesh_state = global_mesh_states.get(ordinal).copied().flatten(); row.initial_mesh_state = global_mesh_states.get(ordinal).copied().flatten();
row.current_flags = row.initial_mesh_state.map(|state| state.flags);
} }
} }
@@ -4156,6 +4316,10 @@ mod tests {
}, },
initial_amount_bits: 100.0_f32.to_bits(), initial_amount_bits: 100.0_f32.to_bits(),
initial_type1_value_bits, initial_type1_value_bits,
current_amount_bits: 100.0_f32.to_bits(),
processed_amount_bits: 100.0_f32.to_bits(),
current_type1_value_bits: initial_type1_value_bits,
current_flags: None,
initial_mesh_state: None, initial_mesh_state: None,
} }
} }
@@ -4440,6 +4604,8 @@ mod tests {
slot: 7, slot: 7,
}, },
status, status,
node_provider_amount_bits: initial_node_provider_amount(&node_provider_status),
node_provider_dirty: false,
node_provider_status, node_provider_status,
} }
} }
@@ -4914,6 +5080,11 @@ mod tests {
Err(ControlResourceAccessError::MissionUnavailable) Err(ControlResourceAccessError::MissionUnavailable)
); );
assert_eq!(
apply_loaded_mission_control_node_delta(&mut engine, handle, 1, -1.0),
Err(ControlResourceAccessError::MissionUnavailable)
);
let stale = ObjectHandle { let stale = ObjectHandle {
generation: handle.generation.wrapping_add(1), generation: handle.generation.wrapping_add(1),
slot: handle.slot, slot: handle.slot,
@@ -4943,7 +5114,21 @@ mod tests {
)) ))
); );
assert_eq!(
apply_loaded_mission_control_node_delta(&mut engine, stale, 1, -1.0),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
))
);
fparkan_world::request_delete(&mut engine.world, handle).expect("delete object"); fparkan_world::request_delete(&mut engine.world, handle).expect("delete object");
assert_eq!(
apply_loaded_mission_control_node_delta(&mut engine, handle, 1, -1.0),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
))
);
assert_eq!( assert_eq!(
query_loaded_mission_control_resource(&engine, handle, 1), query_loaded_mission_control_resource(&engine, handle, 1),
Err(ControlResourceAccessError::World( Err(ControlResourceAccessError::World(
@@ -4964,6 +5149,148 @@ mod tests {
); );
} }
#[test]
fn control_node_delta_matches_native_publication_and_live_resource_queries() {
let mut root = synthetic_node_provider_row(0, 0, 1.0_f32.to_bits());
root.current_amount_bits = 0;
let mut row = synthetic_node_provider_row(0, 1, 0.4_f32.to_bits());
row.current_amount_bits = 40.0_f32.to_bits();
row.processed_amount_bits = 40.0_f32.to_bits();
row.current_flags = Some(0);
let mut device = synthetic_effective_control_device(
26,
26,
2.0_f32.to_bits(),
Some(3.0_f32.to_bits()),
None,
);
device.key = 1;
let mut table = synthetic_control_device_table_with_providers(
MissionControlDeviceTableStatus::Available(vec![device]),
MissionControlNodeProviderTableStatus::Available(vec![root, row]),
);
assert_eq!(table.node_provider_amount_bits, Some(40.0_f32.to_bits()));
for (delta, amount, ratio, exhausted) in [
(20.0, 60.0_f32, 0.6_f32, false),
(-10.0, 50.0, 0.5, false),
(1000.0, 100.0, 1.0, false),
(-200.0, 0.0, 0.0, true),
(10.0, 10.0, 0.1, false),
] {
let publication = apply_control_node_delta(&mut table, 1, delta)
.unwrap()
.unwrap();
assert_eq!(publication.key, 1);
assert_eq!(publication.type1_value_bits, ratio.to_bits());
if amount == 100.0 {
let before = table.clone();
assert!(apply_control_node_delta(&mut table, 1, 1.0)
.unwrap()
.is_none());
assert_eq!(table, before);
}
assert_eq!(table.node_provider_amount_bits, Some(amount.to_bits()));
assert!(table.node_provider_dirty);
let MissionControlNodeProviderTableStatus::Available(rows) =
&table.node_provider_status
else {
panic!()
};
assert_eq!(rows[1].current_amount_bits, amount.to_bits());
assert_eq!(rows[1].processed_amount_bits, 40.0_f32.to_bits());
assert_eq!(rows[1].initial_amount_bits, 100.0_f32.to_bits());
assert_eq!(rows[1].current_flags.unwrap() & 0x10 != 0, exhausted);
let resource = (ratio * 2.0) * 3.0;
assert_eq!(
query_control_device_table_selector(&table, 0, 0xe00)
.unwrap()
.unwrap()
.to_bits(),
resource.to_bits()
);
assert_eq!(
query_behavior_profile_ore_rate(&table, 5.0_f32.to_bits())
.unwrap()
.to_bits(),
(resource * 5.0).to_bits()
);
}
let before = table.clone();
for delta in [f32::NAN, f32::INFINITY, f32::MIN_POSITIVE / 2.0] {
assert!(apply_control_node_delta(&mut table, 1, delta).is_err());
assert_eq!(table, before);
}
assert!(matches!(
apply_control_node_delta(&mut table, 2, 1.0),
Err(ControlResourceAccessError::NodeProviderKeyOutOfRange {
key: 2,
row_count: 2
})
));
assert_eq!(table, before);
let MissionControlNodeProviderTableStatus::Available(rows) =
&mut table.node_provider_status
else {
panic!()
};
rows[1].current_flags = None;
let before = table.clone();
assert!(matches!(
apply_control_node_delta(&mut table, 1, 1.0),
Err(ControlResourceAccessError::UnsupportedField {
field: "provider flags",
..
})
));
assert_eq!(table, before);
let MissionControlNodeProviderTableStatus::Available(rows) =
&mut table.node_provider_status
else {
panic!()
};
rows[1].current_flags = Some(1);
let total = table.node_provider_amount_bits.take();
let before = table.clone();
assert!(matches!(
apply_control_node_delta(&mut table, 1, 1.0),
Err(ControlResourceAccessError::UnsupportedField {
field: "provider aggregate",
..
})
));
assert_eq!(table, before);
table.node_provider_amount_bits = total;
let before = table.clone();
assert_eq!(
apply_control_node_delta(&mut table, 1, -100.0),
Err(ControlResourceAccessError::UnsupportedNodeLifecycle { key: 1 })
);
assert_eq!(table, before);
assert!(apply_control_node_delta(&mut table, 1, 0.0)
.unwrap()
.is_none());
assert_eq!(table, before);
let total = table.node_provider_amount_bits;
let MissionControlNodeProviderTableStatus::Available(rows) =
&mut table.node_provider_status
else {
panic!()
};
rows[1].current_amount_bits = (-0.0_f32).to_bits();
rows[1].current_type1_value_bits = 0.25_f32.to_bits();
assert!(apply_control_node_delta(&mut table, 1, -1.0)
.unwrap()
.is_none());
let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status
else {
panic!()
};
assert_eq!(rows[1].current_amount_bits, 0);
assert_eq!(rows[1].current_type1_value_bits, 0.25_f32.to_bits());
assert_eq!(rows[1].current_flags, Some(1));
assert_eq!(table.node_provider_amount_bits, total);
}
#[test] #[test]
fn common_factory_into_overrides_preserve_native_constructor_defaults() { fn common_factory_into_overrides_preserve_native_constructor_defaults() {
const INTO: u32 = u32::from_le_bytes(*b"INTO"); const INTO: u32 = u32::from_le_bytes(*b"INTO");
@@ -6111,7 +6438,7 @@ mod tests {
engine engine
}; };
let mut engine = load_full_fixture(0x8000_0002); let mut engine = load_full_fixture(0x8000_0002);
let mine_engine = load_full_fixture(0x8000_0004); let mut 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);
@@ -6119,6 +6446,7 @@ mod tests {
let tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); let tables = loaded_mission_control_device_tables(&engine).expect("Control tables");
assert_eq!(tables.len(), 1); assert_eq!(tables.len(), 1);
assert_eq!(tables[0].original_id, Some(OriginalObjectId(0))); assert_eq!(tables[0].original_id, Some(OriginalObjectId(0)));
assert!(tables[0].node_provider_dirty);
let control_handle = tables[0].handle; let control_handle = tables[0].handle;
assert_eq!( assert_eq!(
query_loaded_mission_control_resource(&engine, control_handle, 1) query_loaded_mission_control_resource(&engine, control_handle, 1)
@@ -6406,6 +6734,66 @@ mod tests {
.map(f32::to_bits), .map(f32::to_bits),
Some(2.5_f32.to_bits()) Some(2.5_f32.to_bits())
); );
let mine_table = &loaded_mission_control_device_tables(&mine_engine).unwrap()[0];
let MissionControlDeviceTableStatus::Available(mine_devices) = &mine_table.status else {
panic!()
};
let key = mine_devices[8].key;
let MissionControlNodeProviderTableStatus::Available(mine_rows) =
&mine_table.node_provider_status
else {
panic!()
};
let initial = mine_rows[key as usize].initial_amount_bits;
let amount = f32::from_bits(initial);
assert!(amount > 0.0);
let publication = apply_loaded_mission_control_node_delta(
&mut mine_engine,
mine_profile.handle,
key,
-amount * 0.5,
)
.unwrap()
.unwrap();
assert_eq!(
publication,
ControlNodeProviderPublication {
key,
type1_value_bits: 0.5_f32.to_bits()
}
);
assert_eq!(
query_loaded_mission_control_device_selector(
&mine_engine,
mine_profile.handle,
8,
0xe00
)
.unwrap()
.unwrap()
.to_bits(),
0.5_f32.to_bits()
);
assert_eq!(
query_loaded_mission_behavior_resource(&mine_engine, mine_profile.handle, 0x1002)
.unwrap()
.to_bits(),
0.5_f32.to_bits()
);
step_headless(&mut mine_engine, InputSnapshot, 1_000).unwrap();
assert_eq!(
query_loaded_mission_behavior_resource(&mine_engine, mine_profile.handle, 0x1002)
.unwrap()
.to_bits(),
0.5_f32.to_bits()
);
let MissionControlNodeProviderTableStatus::Available(mine_rows) =
&loaded_mission_control_device_tables(&mine_engine).unwrap()[0].node_provider_status
else {
panic!()
};
assert_eq!(mine_rows[key as usize].initial_amount_bits, initial);
assert_eq!(mine_rows[key as usize].processed_amount_bits, initial);
} }
#[test] #[test]
@@ -7721,6 +8109,8 @@ mod tests {
handle, handle,
status: MissionControlDeviceTableStatus::Available(vec![device]), status: MissionControlDeviceTableStatus::Available(vec![device]),
node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()), node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()),
node_provider_amount_bits: Some(0),
node_provider_dirty: false,
} }
} }
@@ -7825,6 +8215,8 @@ mod tests {
node_provider_status: MissionControlNodeProviderTableStatus::Available(vec![ node_provider_status: MissionControlNodeProviderTableStatus::Available(vec![
synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()), synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()),
]), ]),
node_provider_amount_bits: Some(100.0_f32.to_bits()),
node_provider_dirty: false,
}; };
let mut control_tables = vec![producer_table]; let mut control_tables = vec![producer_table];
@@ -7871,6 +8263,8 @@ mod tests {
handle: object.handle, handle: object.handle,
status: MissionControlDeviceTableStatus::Available(Vec::new()), status: MissionControlDeviceTableStatus::Available(Vec::new()),
node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()), node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()),
node_provider_amount_bits: Some(0),
node_provider_dirty: false,
}]; }];
advance_behavior_distributor_tick( advance_behavior_distributor_tick(
@@ -905,6 +905,7 @@ internal static class BehaviorDistributorTickOracle {
Marshal.WriteInt32(ndpDescriptor, 0x04, FloatBits(100.0f)); // Native selector 3 maximum. 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, 0x04, -1); // Root row has no parent node.
Marshal.WriteInt32(row, 0x08, FloatBits(40.0f)); Marshal.WriteInt32(row, 0x08, FloatBits(40.0f));
Marshal.WriteInt32(row, 0x0c, FloatBits(40.0f));
Marshal.WriteInt32(row, 0x14, FloatBits(0.4f)); Marshal.WriteInt32(row, 0x14, FloatBits(0.4f));
Marshal.WriteIntPtr(callbackObject, 0, callbackVtable); Marshal.WriteIntPtr(callbackObject, 0, callbackVtable);
@@ -947,6 +948,21 @@ internal static class BehaviorDistributorTickOracle {
controlNodeUpdateSelector == 0x200 && controlNodeUpdateValueBits == 0, controlNodeUpdateSelector == 0x200 && controlNodeUpdateValueBits == 0,
"Control native node callback receives every normalized provider update through exhaustion"); "Control native node callback receives every normalized provider update through exhaustion");
update(owner, 1, 10.0f, 0);
Expect(ReadFloat(row, 0x08) == 10.0f && ReadFloat(row, 0x14) == 0.1f &&
ReadFloat(row, 0x0c) == 40.0f && ReadFloat(owner, 0x590) == 10.0f &&
(Marshal.ReadInt32(row, 0x28) & 0x10) == 0 && controlNodeUpdateCount == 5,
"direct provider update revives a leaf without advancing its processed amount");
Marshal.WriteInt32(row, 8, unchecked((int)0x80000000));
Marshal.WriteInt32(row, 0x14, FloatBits(0.25f));
Marshal.WriteInt32(owner, 0x590, FloatBits(20.0f));
update(owner, 1, -1.0f, 0);
Expect(Marshal.ReadInt32(row, 8) == 0 && ReadFloat(row, 0x14) == 0.25f &&
ReadFloat(row, 0x0c) == 40.0f && ReadFloat(owner, 0x590) == 20.0f &&
controlNodeUpdateCount == 5 && (Marshal.ReadInt32(row, 0x28) & 0x10) == 0,
"clamping signed zero writes amount bits but leaves ratio, aggregate, flags and publication unchanged");
Console.WriteLine("native-control-live-ndp-provider: controlHash=" + ExpectedControlSha256 + Console.WriteLine("native-control-live-ndp-provider: controlHash=" + ExpectedControlSha256 +
" rowCtorRva=0xb240 initialType1=1 rowStride=0x2c node=1 max=100 amount=0..100 tag26Selector300=live"); " rowCtorRva=0xb240 initialType1=1 rowStride=0x2c node=1 max=100 amount=0..100 tag26Selector300=live");
GC.KeepAlive(constructProviderRow); GC.KeepAlive(constructProviderRow);
@@ -2226,6 +2242,8 @@ internal static class BehaviorDistributorTickOracle {
controlNodeUpdateCount = 0; controlNodeUpdateCount = 0;
controlNodeUpdateIndices.Clear(); controlNodeUpdateIndices.Clear();
initialize(owner); initialize(owner);
Expect(Marshal.ReadByte(owner, 0x598) == 1,
"Control constructor starts the provider aggregate dirty and f940 preserves it");
uint[] expectedFlags = { 0x09, 0x02, 0x04, 0x08 }; uint[] expectedFlags = { 0x09, 0x02, 0x04, 0x08 };
uint[] expectedStages = { 1, 1, 3, 1 }; uint[] expectedStages = { 1, 1, 3, 1 };
for (int nodeIndex = 0; nodeIndex < rowCount; nodeIndex++) { for (int nodeIndex = 0; nodeIndex < rowCount; nodeIndex++) {
+16 -10
View File
@@ -334,21 +334,27 @@ sidecar недоступен, запрос `0xe00` сообщает это от
проецируются из AniMesh по глобальному ordinal; точные правила, native ABI проецируются из AniMesh по глобальному ordinal; точные правила, native ABI
векторы и лицензированная root/`EXTO` fixture описаны в векторы и лицензированная root/`EXTO` fixture описаны в
[техническом подразделе](script-vm.md#начальная-msh-часть-строки-provider). [техническом подразделе](script-vm.md#начальная-msh-часть-строки-provider).
Проверки подтверждают только начальный снимок: сборка runtime parent-ов MSH-часть проверки подтверждает начальный снимок: сборка runtime parent-ов
остаётся статическим выводом по RVA `AniMesh.dll+0xa460`, а последующие остаётся статическим выводом по RVA `AniMesh.dll+0xa460`, а последующие
изменения в полном Control/animation tick-е не проверялись. изменения в полном Control/animation tick-е не проверялись.
Для provider-строки Rust также хранит одно `initial_amount_bits`. Нативный Для provider-строки Rust сохраняет исходные и текущие amount/type-1 поля
`Control.dll` `FUN_1000f940` (RVA `0xf940`) кладёт результат selector `3` в отдельно, а `processed_amount_bits` соответствует native `+0x0c`. Инициализатор
текущее и обработанное значения строки; при подтверждённых единичных множителях `Control.dll+0xf940` кладёт результат selector `3` в текущее и обработанное
это raw `max_volume` NDP. Последующее обновление меняет оба native поля, поэтому количество; при подтверждённых единичных множителях это raw `max_volume` NDP.
снимок не моделирует вместимость или изменяемый Control amount. Прямой API `apply_loaded_mission_control_node_delta` меняет amount, долю, флаг
истощения, aggregate и dirty, возвращая обязательную публикацию `0x200` для
узла. Он пока не доставляет её в AniMesh и не выполняет пересчёт стадий,
видимости, FX или время Control. Поэтому обработанное количество остаётся
прежним. Гибель root/critical узла и неизвестные flags/aggregate дают явную
ошибку до записи; неподдержанные численные диапазоны тоже отклоняются.
Rust query обращается к загруженному объекту и поддерживает `0xe00` только для Rust query обращается к загруженному объекту и поддерживает `0xe00` только для
текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и
factory paths остаются явными границами. Этот запрос возвращает начальный factory paths остаются явными границами. Запрос читает текущее type-1 значение.
type-1 provider value (`1.0`); дальнейшее изменение provider amount в Rust не Licensed `min_l_n1.dat` подтверждает изменение `0xe00` и Behavior `0x1002` с
реализовано. `1.0` до `0.5` после явного уменьшения количества вдвое; это ещё не
автоматический бюджетный цикл.
Native oracle проверил изолированные пути распределения, обновления класса `4`, Native oracle проверил изолированные пути распределения, обновления класса `4`,
стадийную арифметику `0x11220`, обход дочерних provider-строк `0x11130` и стадийную арифметику `0x11220`, обход дочерних provider-строк `0x11130` и
visibility transition `0x11920` с исходными AniMesh callbacks. Отдельный visibility transition `0x11920` с исходными AniMesh callbacks. Отдельный
@@ -389,7 +395,7 @@ World3D RVA `0x6bf0` вычисляет часы из `timeGetTime()` с игр
RVA `0xbcf0` для остальных. Это подтверждает статическую цепочку вызовов, но не RVA `0xbcf0` для остальных. Это подтверждает статическую цепочку вызовов, но не
её исполнение на миссии: связывание интерфейсов, активация устройств и shapes, её исполнение на миссии: связывание интерфейсов, активация устройств и shapes,
остальные ветви `0xbcf0` и остальные ветви `0xbcf0` и
изменение provider-снимков Rust остаются открытыми. Источник контактного коэффициента прослежен статически, автоматическое изменение provider через clock и бюджет Rust остаются открытыми. Источник контактного коэффициента прослежен статически,
но не исполнен. Подробные векторы и статические границы приведены в но не исполнен. Подробные векторы и статические границы приведены в
[техническом подразделе о provider-состоянии Control](script-vm.md#изменение-provider-состояния-control). [техническом подразделе о provider-состоянии Control](script-vm.md#изменение-provider-состояния-control).
+44 -8
View File
@@ -653,7 +653,41 @@ oracle вызывает оригинальные `FUN_10010ba0` (RVA `0x10ba0`),
через `FUN_10012ce0`. Бюджетная часть активной ветви проверена на ограниченном через `FUN_10012ce0`. Бюджетная часть активной ветви проверена на ограниченном
хосте класса `10`, как описано ниже. Источник входного времени и порядок его хосте класса `10`, как описано ниже. Источник входного времени и порядок его
доставки до обработчика установлены статически; доставки до обработчика установлены статически;
Rust продолжает читать начальные provider snapshots и не моделирует эти изменения. Rust ещё не вызывает этот бюджетный цикл; отдельно перенесён прямой путь
изменения одной строки `0x10f30`, описанный ниже.
#### Прямое изменение одной строки в Rust
`apply_loaded_mission_control_node_delta(engine, handle, key, delta)` моделирует
`0x10f30` с нулевым четвёртым аргументом: накопление анимационного изменения
`+0x10` и detached-ветвь сюда не входят. Максимум читается из исходной NDP
строки при подтверждённых единичных множителях Unit DAT. Сначала складываются
`f32` amount и delta, результат ограничивается `0..maximum`; при изменении
native `+0x590` получает `max(0, total + (new - old))`, `+0x14` — долю
`new / maximum`, а `+0x598` становится dirty. Флаг `+0x28 & 0x10` отмечает
истощение и снимается при пополнении. Количество `+0x0c` этим методом не
обновляется: native записывает его позже в `0x11220`. Конструктор владельца
`0x6bf0` начинает с `+0x598 = 1`, а `0xf940` оставляет этот dirty-флаг:
первый агрегатный проход нужен даже до новой добавки. Rust loader сохраняет
это начальное значение.
Rust сохраняет начальные и текущие поля отдельно. Возвращаемая
`ControlNodeProviderPublication` содержит ключ и значение для обязательного
callback узла с selector `0x200`; автоматической доставки в AniMesh пока нет.
Полный пересчёт стадии `0x11130`/`0x11220`, видимости и FX также не вызывается.
Истощение корня или критического узла возвращает `UnsupportedNodeLifecycle`
до записи: состояние владельца `+0x550`, отложенная гибель и сопутствующие
callbacks ещё не реализованы. Отсутствие MSH flags, provider aggregate,
неподдержанный численный диапазон или неверный handle тоже дают явную ошибку
без частичных изменений.
Rust-тест повторяет native последовательность количества
`40 → 60 → 50 → 100 → 0 → 10` при максимуме `100`, проверяет публикации,
неизменное обработанное количество и новые результаты `0xe00`/`0x1002`.
Native oracle отдельно подтверждает сохранение `+0x0c = 40` и снятие флага
истощения при последнем пополнении. Для amount `-0.0` с отрицательной добавкой
native записывает биты `+0.0`, но сравнение количеств считает их равными:
доля, флаги, итог и callback остаются прежними. Этот случай сохранён и в Rust.
#### Бюджет и обновление устройства tag 15 #### Бюджет и обновление устройства tag 15
@@ -972,8 +1006,8 @@ Runtime временно собирает mesh-состояния в поряд
их с уже упорядоченными NDP provider-строками по общему ordinal. Избыточная NDP их с уже упорядоченными NDP provider-строками по общему ordinal. Избыточная NDP
строка или отсутствующий MSH asset получают `initial_mesh_state = None`; строка или отсутствующий MSH asset получают `initial_mesh_state = None`;
совпадение по `(component_index, source_row_index)` не выполняется. Состояние совпадение по `(component_index, source_row_index)` не выполняется. Состояние
также не меняет существующий `0xe00` query: он читает начальное type-1 поле не меняет правило `0xe00` query: он читает текущее type-1 поле `+0x14`
`+0x14` provider-строки, равное `1.0` после нативного конструктора. provider-строки, которое после нативного конструктора равно `1.0`.
### Начальное количество строки provider ### Начальное количество строки provider
@@ -1005,15 +1039,17 @@ callback-и пришли в порядке `[0, 1, 2, 3]`. Отдельный п
`0x8310` имеет ветку установки scale. AniMesh constructor (`AniMesh.dll`, `0x8310` имеет ветку установки scale. AniMesh constructor (`AniMesh.dll`,
SHA-256 `65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`, SHA-256 `65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`,
RVA `0x68a0`) читает три DLL scale globals, которые после LoadLibrary в oracle RVA `0x68a0`) читает три DLL scale globals, которые после LoadLibrary в oracle
равны `1.0`. В Rust одна `initial_amount_bits` запись равны `1.0`. В Rust `initial_amount_bits` сохраняет это общее начальное
сохраняет это общее начальное количество; полный update, который меняет количество отдельно от текущего и последнего обработанного. Прямой delta API
amount и обработанное предыдущее значение, пока не моделируется. меняет только текущее количество; стадийный update пока не перенесён.
Licensed full-load regression `full_load_builds_effective_control_device_table_for_mine_unit_dat` Licensed full-load regression `full_load_builds_effective_control_device_table_for_mine_unit_dat`
проверяет значения настоящего `min_l_n1.dat`: корневой provider row `0` даёт проверяет значения настоящего `min_l_n1.dat`: корневой provider row `0` даёт
`1.0`, а row `1` примонтированного `EXTO` (общий ordinal `22`) даёт `6000.0`. `1.0`, а row `1` примонтированного `EXTO` (общий ordinal `22`) даёт `6000.0`.
Проверка использует исходные CTL/NDP/MSH и не делает выводов о динамическом Проверка использует исходные CTL/NDP/MSH, затем явно уменьшает количество
изменении этих строк. строки tag-26 устройства вдвое. Rust `0xe00` и `0x1002` дают `0.5`, и обычный
шаг runtime сохраняет эту долю. Это вызов прямого API, а не полный живой
Control tick или автоматическая публикация изменения в AniMesh.
Native-часть этих ABI проверок воспроизводит Native-часть этих ABI проверок воспроизводит
`behavior_distributor_tick_native_oracle.cs` на синтетически выделенной памяти `behavior_distributor_tick_native_oracle.cs` на синтетически выделенной памяти
+3 -1
View File
@@ -361,7 +361,9 @@ CollObject, Control и PhysicalModel, пропуская отсутствующ
значений сам. Таблицы, адреса и различие путей классов приведены в значений сам. Таблицы, адреса и различие путей классов приведены в
[техническом разборе](../appendices/script-vm.md#доставка-игровых-часов-в-control). [техническом разборе](../appendices/script-vm.md#доставка-игровых-часов-в-control).
Статическая цепочка ещё не доказывает исполнение активного Control update на Статическая цепочка ещё не доказывает исполнение активного Control update на
миссии. Rust пока сохраняет начальные provider-снимки и не изменяет их этим путём. миссии. Rust сохраняет исходные и текущие provider-поля отдельно. Прямой delta
API меняет строку и возвращает публикацию узла, но описанный clock/budget-путь
пока не подключён.
В текущем runtime `step_headless` и `frame` получают время очереди явным В текущем runtime `step_headless` и `frame` получают время очереди явным
аргументом `u32`. По нему запускается представленный ресурсный планировщик аргументом `u32`. По нему запускается представленный ресурсный планировщик
+42 -10
View File
@@ -891,8 +891,9 @@ TMA и компоненты, но во временном VFS заменяет
оригинальный профиль генератора — 10.0 и 0.0 соответственно. Для исходного оригинальный профиль генератора — 10.0 и 0.0 соответственно. Для исходного
tag-26 ряда получается `1.0` provider × `1.0` coefficient × `1.0` multiplier; tag-26 ряда получается `1.0` provider × `1.0` coefficient × `1.0` multiplier;
профиль mine умножает эту сумму на `1.0`, поэтому selector `0x1002` возвращает профиль mine умножает эту сумму на `1.0`, поэтому selector `0x1002` возвращает
`1.0`. Эти запросы используют начальные provider-значения после конструкторов, `1.0`. Эти числа описывают начальное состояние после конструкторов.
а не результаты живого игрового тика. Сами запросы читают текущую долю provider; отдельное прямое изменение строки
уже может изменить ответ, хотя полный игровой Control tick ещё не исполняется.
У Control есть и начальные сведения о модели узла, но их таблица не совпадает У Control есть и начальные сведения о модели узла, но их таблица не совпадает
с NDP. Sidecar задаёт состав и порядок provider-строк, а поле type-1 `+0x14` с NDP. Sidecar задаёт состав и порядок provider-строк, а поле type-1 `+0x14`
@@ -920,12 +921,42 @@ initial_mesh_state: Option<MissionControlNodeInitialMeshState>
На той же начальной стадии Control вычисляет количество строки по NDP На той же начальной стадии Control вычисляет количество строки по NDP
`max_volume`. Для загрузки Unit DAT проверенные множители равны `1.0`, поэтому `max_volume`. Для загрузки Unit DAT проверенные множители равны `1.0`, поэтому
количество совпадает с исходным значением NDP. Оригинал начинает с одинакового количество совпадает с исходным значением NDP. Оригинал начинает с одинакового
текущего и последнего обработанного количества; Rust сохраняет его один раз как текущего и последнего обработанного количества. Rust оставляет исходное число
`initial_amount_bits`. Это исходный снимок, а не неизменяемая вместимость: в `initial_amount_bits`, а текущее и обработанное количество хранит отдельно.
позднее Control меняет оба количества. В лицензированном `min_l_n1.dat` Вместимость при этом берётся из NDP; исходный снимок не заменяет её. В лицензированном `min_l_n1.dat`
проверены, например, root row `0` со значением `1.0` и строка `EXTO` с local проверены, например, root row `0` со значением `1.0` и строка `EXTO` с local
row `1` в общей позиции `22` со значением `6000.0`. row `1` в общей позиции `22` со значением `6000.0`.
Для одной строки перенесён прямой путь `Control.dll+0x10f30` с выключенным
флагом накопления анимационного изменения. API
`apply_loaded_mission_control_node_delta` принимает живой объект, глобальный
ключ строки и добавку. Например, у строки с максимумом `100` и количеством
`40` добавка `20` даёт количество `60` и долю `0.6`. Переполнение ограничивается
максимумом, отрицательный результат — нулём; при нуле ставится флаг истощения
`0x10`, а пополнение снимает его. Общий итог изменяется именно на разницу
нового и прежнего количества:
```rust
let amount = (previous + delta).clamp(0.0, maximum);
let total = (total + (amount - previous)).max(0.0);
let ratio = amount / maximum;
```
Это три отдельные операции над `f32`, с проверкой поддерживаемого численного
диапазона до записи состояния. Исходные `initial_*` поля сохраняются;
`processed_amount_bits` тоже не меняется, поскольку его обновляет другой
нативный проход — пересчёт стадий. API возвращает обязательную публикацию
`ControlNodeProviderPublication` для узла и selector `0x200`; сам callback
AniMesh и пересчёт стадий этим API не исполняются. Истощение корня либо узла
с критическим флагом `1` пока возвращает ошибку без изменения состояния:
эта ветвь требует полного lifecycle объекта, а не только нового количества.
На исходном `min_l_n1.dat` прямое уменьшение количества строки ресурсного
устройства вдвое меняет ответы `0xe00` и Behavior `0x1002` с `1.0` на `0.5`.
Обычный последующий шаг runtime сохраняет это состояние. Это воспроизводимый
тест связки загрузки и запросов, но пока не автоматическое изменение через
clock Control.
После загрузки количество в строках может меняться. Контроллер распределяет После загрузки количество в строках может меняться. Контроллер распределяет
добавку либо пропорционально свободному месту, либо по очереди: при максимумах добавку либо пропорционально свободному месту, либо по очереди: при максимумах
`[30, 60, 50]`, текущих значениях `[10, 20, 0]` и добавке `22` первый способ `[30, 60, 50]`, текущих значениях `[10, 20, 0]` и добавке `22` первый способ
@@ -946,14 +977,15 @@ Control пересчитывает дочерние строки в глубин
`[102, 570]`. В проверенных векторах одинаковые данные при скрытии и повторном `[102, 570]`. В проверенных векторах одинаковые данные при скрытии и повторном
включении восстановили суммы; переходы с разными слотами — нет. Эти проверки включении восстановили суммы; переходы с разными слотами — нет. Эти проверки
исполняют исходные DLL на тестовых строках, но не являются запуском Control на исполняют исходные DLL на тестовых строках, но не являются запуском Control на
полной миссии; Rust пока сохраняет только начальное состояние provider-строк. полной миссии; Rust пока не выполняет эти переходы стадий и видимости.
Прямой путь изменения количества остаётся отдельной операцией.
Подробные границы приведены в [техническом приложении](../appendices/script-vm.md#родители-стадии-и-видимость-provider-узлов). Подробные границы приведены в [техническом приложении](../appendices/script-vm.md#родители-стадии-и-видимость-provider-узлов).
Для точного повтора важен порядок округления. При исходных значениях `[2^24, 2]` Для точного повтора важен порядок округления. При исходных значениях `[2^24, 2]`
и запросе `-1` оригинал сначала складывает общий итог с изменением, затем и запросе `-1` оригинал сначала складывает общий итог с изменением, затем
вычитает прежний итог. Из-за округления фактическое изменение получается `-2`. вычитает прежний итог. Из-за округления фактическое изменение получается `-2`.
Нативная проверка подтвердила это поведение; Rust пока использует начальный Нативная проверка подтвердила это поведение; общий распределитель
снимок provider-строк и не исполняет их дальнейшее распределение. `0x10ba0` в Rust ещё не перенесён, хотя прямая добавка к одной строке уже доступна.
Отдельный путь объекта класса `4` умножает интервал на контактный коэффициент и Отдельный путь объекта класса `4` умножает интервал на контактный коэффициент и
передаёт изменение строке. В проверенном примере коэффициент `1`, интервал `2`, передаёт изменение строке. В проверенном примере коэффициент `1`, интервал `2`,
@@ -969,8 +1001,8 @@ Control заново разыгрывает срок обновления, да
на `502`. Выбрать случайный срок один раз и ждать его было бы неверно. на `502`. Выбрать случайный срок один раз и ждать его было бы неверно.
Принудительный шаг тоже расходует RNG; неактивный объект синхронизирует часы Принудительный шаг тоже расходует RNG; неактивный объект синхронизирует часы
без такого расхода. Это локальные native проверки, а не работающий Rust без такого расхода. Это локальные native проверки, а не работающий Rust
Control: запросы Rust `0xe00` и `0x1002` по-прежнему используют начальные Control: запросы Rust `0xe00` и `0x1002` читают текущее состояние строк,
снимки provider-строк, а `0x1005` — загруженную ставку профиля. которое пока меняет только прямой API, а `0x1005` — загруженную ставку профиля.
Точные нативные векторы и открытые границы описаны в [приложении](../appendices/script-vm.md#изменение-provider-состояния-control). Точные нативные векторы и открытые границы описаны в [приложении](../appendices/script-vm.md#изменение-provider-состояния-control).
На x87 control word `0x007f` каждое умножение округляет мантиссу до 24 бит, На x87 control word `0x007f` каждое умножение округляет мантиссу до 24 бит,