aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/fparkan-runtime/src/lib.rs870
-rw-r--r--crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs66
-rw-r--r--docs/appendices/knowledge-boundaries.md42
-rw-r--r--docs/appendices/script-vm.md65
-rw-r--r--docs/tomes/06-behavior.md17
5 files changed, 1022 insertions, 38 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 447e024..923617d 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -257,6 +257,8 @@ pub struct MissionControlNodeProviderRow {
pub initial_type1_value_bits: u32,
/// Current amount at native `+0x08`.
pub current_amount_bits: u32,
+ /// Accumulated distribution amount changes at native `+0x10`.
+ pub current_accumulated_delta_bits: u32,
/// Amount last processed by the stage updater, native `+0x0c`.
pub processed_amount_bits: u32,
/// Current normalized provider value at native `+0x14`.
@@ -283,6 +285,15 @@ pub struct ControlNodeProviderPublication {
pub type1_value_bits: u32,
}
+/// Allocation rule used by native Control provider amount distribution.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ControlNodeDistributionMode {
+ /// Add by free capacity and withdraw in proportion to current row amounts.
+ Proportional,
+ /// Fill from the first row, or withdraw from the last row backwards.
+ Sequential,
+}
+
/// Availability of the global Control node-data provider projection.
#[derive(Clone, Debug, PartialEq)]
pub enum MissionControlNodeProviderTableStatus {
@@ -437,10 +448,14 @@ pub struct MissionControlDeviceTableState {
pub status: MissionControlDeviceTableStatus,
/// Global NDP provider rows used by indexed Control selectors.
pub node_provider_status: MissionControlNodeProviderTableStatus,
+ /// Native `+0x58c`: total provider capacity in the supported unit-scale domain.
+ pub node_provider_maximum_amount_bits: Option<u32>,
/// 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,
+ /// Native `+0x618`: distribution returns without changing rows while set.
+ pub node_provider_distribution_suspended: bool,
}
struct ControlDeviceProjection {
@@ -1487,16 +1502,22 @@ fn load_mission_with_options(
original_id: draft.original_id,
handle: *handle,
status: MissionControlDeviceTableStatus::Available(projection.devices),
+ node_provider_maximum_amount_bits: initial_node_provider_maximum_amount(
+ &projection.node_provider_status,
+ ),
node_provider_amount_bits: initial_node_provider_amount(&projection.node_provider_status),
node_provider_dirty: true,
+ node_provider_distribution_suspended: false,
node_provider_status: projection.node_provider_status,
},
Err(reason) => MissionControlDeviceTableState {
original_id: draft.original_id,
handle: *handle,
status: MissionControlDeviceTableStatus::Unavailable(reason.clone()),
+ node_provider_maximum_amount_bits: None,
node_provider_amount_bits: None,
node_provider_dirty: false,
+ node_provider_distribution_suspended: false,
node_provider_status:
MissionControlNodeProviderTableStatus::Unavailable(
MissionControlNodeProviderTableUnavailableReason::DeviceTableUnavailable(
@@ -2210,6 +2231,41 @@ pub fn apply_loaded_mission_control_node_delta(
apply_control_node_delta(table, key, delta)
}
+/// Distributes an amount change through one loaded Control object's provider rows.
+///
+/// This follows native `Control.dll+0x10ba0`: proportional mode distributes
+/// additions by free capacity and withdrawals by current amount; sequential
+/// mode fills in row order and withdraws in reverse order. `revive_exhausted`
+/// opts into the native replenishment path. Each row also
+/// accumulates its actual amount change at `+0x10`. The final native stage walk
+/// is required and keeps `update_effects = true`; transitions needing effects or
+/// host callbacks remain explicit errors.
+///
+/// # Errors
+/// Rejects invalid handles, unavailable state, unsupported numeric ranges,
+/// lifecycle changes, and stage/FX callbacks not yet modeled. All row amounts,
+/// accumulators, aggregates, and stages remain unchanged on error. This changes
+/// the CPU projection, not GPU uploads or the windowed game path.
+pub fn distribute_loaded_mission_control_provider_amount(
+ engine: &mut Engine,
+ handle: ObjectHandle,
+ delta: f32,
+ mode: ControlNodeDistributionMode,
+ revive_exhausted: bool,
+) -> 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 })?;
+ distribute_control_node_amount(table, delta, mode, revive_exhausted)
+}
+
/// Applies the confirmed single-node stage path of native Control `0x11220`.
///
/// Copies parent ratio/stage when requested, updates the processed amount and
@@ -2515,11 +2571,313 @@ fn initial_node_provider_amount(status: &MissionControlNodeProviderTableStatus)
.map(f32::to_bits)
}
+fn initial_node_provider_maximum_amount(
+ status: &MissionControlNodeProviderTableStatus,
+) -> Option<u32> {
+ let MissionControlNodeProviderTableStatus::Available(rows) = status else {
+ return None;
+ };
+ rows.iter()
+ .try_fold(0.0_f32, |total, row| {
+ let maximum =
+ checked_control_float(0, "initial provider maximum", row.source_row.max_volume)?;
+ checked_control_sum(0, "initial provider maximum aggregate", total, maximum)
+ })
+ .ok()
+ .map(f32::to_bits)
+}
+
+fn control_node_provider_row(
+ table: &MissionControlDeviceTableState,
+ key: u32,
+) -> Result<&MissionControlNodeProviderRow, ControlResourceAccessError> {
+ let rows = match &table.node_provider_status {
+ MissionControlNodeProviderTableStatus::Available(rows) => rows,
+ MissionControlNodeProviderTableStatus::Unavailable(reason) => {
+ return Err(ControlResourceAccessError::NodeProviderTableUnavailable(
+ reason.clone(),
+ ));
+ }
+ };
+ let row_count = rows.len();
+ usize::try_from(key)
+ .ok()
+ .and_then(|index| rows.get(index))
+ .ok_or(ControlResourceAccessError::NodeProviderKeyOutOfRange { key, row_count })
+}
+
+fn checked_control_node_provider_amounts(
+ row: &MissionControlNodeProviderRow,
+ key: u32,
+) -> Result<(f32, f32), ControlResourceAccessError> {
+ let amount = 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 amount < 0.0 || maximum < amount {
+ return Err(ControlResourceAccessError::InvalidNodeProviderAmount { key });
+ }
+ Ok((amount, maximum))
+}
+
+fn distribute_control_node_amount(
+ table: &mut MissionControlDeviceTableState,
+ delta: f32,
+ mode: ControlNodeDistributionMode,
+ revive_exhausted: bool,
+) -> Result<(), ControlResourceAccessError> {
+ if table.node_provider_distribution_suspended || delta == 0.0 {
+ return Ok(());
+ }
+ let delta = checked_control_float(0, "provider distribution delta", delta)?;
+ let mut updated = table.clone();
+ distribute_control_node_amount_inner(&mut updated, delta, mode, revive_exhausted)?;
+ *table = updated;
+ Ok(())
+}
+
+fn distribute_control_node_amount_inner(
+ table: &mut MissionControlDeviceTableState,
+ delta: f32,
+ mode: ControlNodeDistributionMode,
+ revive_exhausted: bool,
+) -> Result<(), ControlResourceAccessError> {
+ let row_count = match &table.node_provider_status {
+ MissionControlNodeProviderTableStatus::Available(rows) => rows.len(),
+ MissionControlNodeProviderTableStatus::Unavailable(reason) => {
+ return Err(ControlResourceAccessError::NodeProviderTableUnavailable(
+ reason.clone(),
+ ));
+ }
+ };
+ let aggregate_bits =
+ table
+ .node_provider_amount_bits
+ .ok_or(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 0,
+ field: "provider aggregate",
+ })?;
+ let aggregate = checked_control_float(0, "provider aggregate", f32::from_bits(aggregate_bits))?;
+ if aggregate < 0.0 {
+ return Err(ControlResourceAccessError::InvalidNodeProviderAmount { key: 0 });
+ }
+
+ let capacity = if delta > 0.0 {
+ if revive_exhausted {
+ let maximum_bits = table.node_provider_maximum_amount_bits.ok_or(
+ ControlResourceAccessError::UnsupportedField {
+ factory_tag: 0,
+ field: "provider maximum aggregate",
+ },
+ )?;
+ let maximum = checked_control_float(
+ 0,
+ "provider maximum aggregate",
+ f32::from_bits(maximum_bits),
+ )?;
+ checked_control_sum(0, "provider remaining total capacity", maximum, -aggregate)?
+ } else {
+ let rows = match &table.node_provider_status {
+ MissionControlNodeProviderTableStatus::Available(rows) => rows,
+ MissionControlNodeProviderTableStatus::Unavailable(reason) => {
+ return Err(ControlResourceAccessError::NodeProviderTableUnavailable(
+ reason.clone(),
+ ));
+ }
+ };
+ let mut capacity = 0.0_f32;
+ for (index, row) in rows.iter().enumerate() {
+ let key = u32::try_from(index).map_err(|_| {
+ ControlResourceAccessError::NodeProviderKeyOutOfRange {
+ key: u32::MAX,
+ row_count,
+ }
+ })?;
+ let flags =
+ row.current_flags
+ .ok_or(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 0,
+ field: "provider flags",
+ })?;
+ if flags & 0x10 != 0 {
+ continue;
+ }
+ let (amount, maximum) = checked_control_node_provider_amounts(row, key)?;
+ let free = checked_control_sum(0, "provider free capacity", maximum, -amount)?;
+ capacity = checked_control_sum(0, "provider total free capacity", capacity, free)?;
+ }
+ capacity
+ }
+ } else {
+ -aggregate
+ };
+ if capacity == 0.0 {
+ return Ok(());
+ }
+
+ let bounded_delta = if delta > 0.0 {
+ if delta > capacity {
+ capacity
+ } else {
+ delta
+ }
+ } else if delta < capacity {
+ capacity
+ } else {
+ delta
+ };
+
+ match mode {
+ ControlNodeDistributionMode::Proportional => {
+ let adjusted_aggregate = checked_control_sum(
+ 0,
+ "provider aggregate after distribution",
+ aggregate,
+ bounded_delta,
+ )?;
+ let applied_delta = checked_control_sum(
+ 0,
+ "provider applied distribution delta",
+ adjusted_aggregate,
+ -aggregate,
+ )?;
+ let ratio =
+ checked_control_ratio("provider distribution ratio", applied_delta, capacity)?;
+ for index in 0..row_count {
+ let key = u32::try_from(index).map_err(|_| {
+ ControlResourceAccessError::NodeProviderKeyOutOfRange {
+ key: u32::MAX,
+ row_count,
+ }
+ })?;
+ if delta > 0.0 && !revive_exhausted {
+ let flags = control_node_provider_row(table, key)?.current_flags.ok_or(
+ ControlResourceAccessError::UnsupportedField {
+ factory_tag: 0,
+ field: "provider flags",
+ },
+ )?;
+ if flags & 0x10 != 0 {
+ continue;
+ }
+ }
+ let (amount, maximum) = checked_control_node_provider_amounts(
+ control_node_provider_row(table, key)?,
+ key,
+ )?;
+ let row_delta = if delta > 0.0 {
+ let free = checked_control_sum(0, "provider free capacity", maximum, -amount)?;
+ checked_control_product(0, "provider proportional addition", free, ratio)?
+ } else {
+ -checked_control_product(0, "provider proportional withdrawal", ratio, amount)?
+ };
+ apply_control_node_distribution_delta(table, key, row_delta)?;
+ }
+ }
+ ControlNodeDistributionMode::Sequential => {
+ let mut remaining = delta;
+ if delta > 0.0 {
+ for index in 0..row_count {
+ if remaining <= 0.0 {
+ break;
+ }
+ let key = u32::try_from(index).map_err(|_| {
+ ControlResourceAccessError::NodeProviderKeyOutOfRange {
+ key: u32::MAX,
+ row_count,
+ }
+ })?;
+ if !revive_exhausted {
+ let flags = control_node_provider_row(table, key)?.current_flags.ok_or(
+ ControlResourceAccessError::UnsupportedField {
+ factory_tag: 0,
+ field: "provider flags",
+ },
+ )?;
+ if flags & 0x10 != 0 {
+ continue;
+ }
+ }
+ let (amount, maximum) = checked_control_node_provider_amounts(
+ control_node_provider_row(table, key)?,
+ key,
+ )?;
+ let free = checked_control_sum(0, "provider free capacity", maximum, -amount)?;
+ let row_delta = if remaining <= free { remaining } else { free };
+ if row_delta != 0.0 {
+ apply_control_node_distribution_delta(table, key, row_delta)?;
+ remaining = checked_control_sum(
+ 0,
+ "provider remaining addition",
+ remaining,
+ -row_delta,
+ )?;
+ }
+ }
+ } else {
+ for index in (0..row_count).rev() {
+ if remaining >= 0.0 {
+ break;
+ }
+ let key = u32::try_from(index).map_err(|_| {
+ ControlResourceAccessError::NodeProviderKeyOutOfRange {
+ key: u32::MAX,
+ row_count,
+ }
+ })?;
+ let (amount, _) = checked_control_node_provider_amounts(
+ control_node_provider_row(table, key)?,
+ key,
+ )?;
+ let requested = -remaining;
+ let removed = if amount < requested {
+ amount
+ } else {
+ requested
+ };
+ if removed != 0.0 {
+ let row_delta = -removed;
+ apply_control_node_distribution_delta(table, key, row_delta)?;
+ remaining = checked_control_sum(
+ 0,
+ "provider remaining withdrawal",
+ removed,
+ remaining,
+ )?;
+ }
+ }
+ }
+ }
+ }
+
+ // Native 0x10ba0 always finishes with 0x11130(root, update_effects=true).
+ propagate_control_node_stage_inner(table, 0, true)
+}
+
fn apply_control_node_delta(
table: &mut MissionControlDeviceTableState,
key: u32,
delta: f32,
) -> Result<Option<ControlNodeProviderPublication>, ControlResourceAccessError> {
+ apply_control_node_delta_inner(table, key, delta, false)
+}
+
+fn apply_control_node_distribution_delta(
+ table: &mut MissionControlDeviceTableState,
+ key: u32,
+ delta: f32,
+) -> Result<Option<ControlNodeProviderPublication>, ControlResourceAccessError> {
+ apply_control_node_delta_inner(table, key, delta, true)
+}
+
+fn apply_control_node_delta_inner(
+ table: &mut MissionControlDeviceTableState,
+ key: u32,
+ delta: f32,
+ accumulate_delta: bool,
+) -> Result<Option<ControlNodeProviderPublication>, ControlResourceAccessError> {
let rows = match &mut table.node_provider_status {
MissionControlNodeProviderTableStatus::Available(rows) => rows,
MissionControlNodeProviderTableStatus::Unavailable(reason) => {
@@ -2575,6 +2933,21 @@ fn apply_control_node_delta(
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)?;
+ let accumulated_delta = if accumulate_delta {
+ let previous = checked_control_float(
+ 0,
+ "provider accumulated delta",
+ f32::from_bits(row.current_accumulated_delta_bits),
+ )?;
+ Some(checked_control_sum(
+ 0,
+ "provider accumulated delta",
+ change,
+ previous,
+ )?)
+ } else {
+ None
+ };
let mesh = row
.mesh_state
.as_mut()
@@ -2583,6 +2956,9 @@ fn apply_control_node_delta(
field: "model node state",
})?;
row.current_amount_bits = amount.to_bits();
+ if let Some(accumulated_delta) = accumulated_delta {
+ row.current_accumulated_delta_bits = accumulated_delta.to_bits();
+ }
row.current_type1_value_bits = ratio.to_bits();
mesh.type1_value_bits = ratio.to_bits();
row.current_flags = Some(if amount == 0.0 {
@@ -4072,6 +4448,7 @@ fn append_control_node_provider_rows(
initial_amount_bits: source_row.max_volume.to_bits(),
initial_type1_value_bits: 0x3f80_0000,
current_amount_bits: source_row.max_volume.to_bits(),
+ current_accumulated_delta_bits: 0,
processed_amount_bits: source_row.max_volume.to_bits(),
current_type1_value_bits: 0x3f80_0000,
current_flags: None,
@@ -4644,6 +5021,7 @@ mod tests {
initial_amount_bits: 100.0_f32.to_bits(),
initial_type1_value_bits,
current_amount_bits: 100.0_f32.to_bits(),
+ current_accumulated_delta_bits: 0,
processed_amount_bits: 100.0_f32.to_bits(),
current_type1_value_bits: initial_type1_value_bits,
current_flags: None,
@@ -4933,8 +5311,12 @@ mod tests {
slot: 7,
},
status,
+ node_provider_maximum_amount_bits: initial_node_provider_maximum_amount(
+ &node_provider_status,
+ ),
node_provider_amount_bits: initial_node_provider_amount(&node_provider_status),
node_provider_dirty: false,
+ node_provider_distribution_suspended: false,
node_provider_status,
}
}
@@ -5413,6 +5795,16 @@ mod tests {
apply_loaded_mission_control_node_delta(&mut engine, handle, 1, -1.0),
Err(ControlResourceAccessError::MissionUnavailable)
);
+ assert_eq!(
+ distribute_loaded_mission_control_provider_amount(
+ &mut engine,
+ handle,
+ -1.0,
+ ControlNodeDistributionMode::Proportional,
+ false,
+ ),
+ Err(ControlResourceAccessError::MissionUnavailable)
+ );
assert_eq!(
update_loaded_mission_control_node_stage(&mut engine, handle, 1, false),
@@ -5427,6 +5819,22 @@ mod tests {
generation: handle.generation.wrapping_add(1),
slot: handle.slot,
};
+ let invalid = ObjectHandle {
+ generation: 0,
+ slot: u32::MAX,
+ };
+ assert_eq!(
+ distribute_loaded_mission_control_provider_amount(
+ &mut engine,
+ invalid,
+ -1.0,
+ ControlNodeDistributionMode::Proportional,
+ false,
+ ),
+ Err(ControlResourceAccessError::World(
+ fparkan_world::WorldError::InvalidHandle
+ ))
+ );
assert_eq!(
query_loaded_mission_control_resource(&engine, stale, 1),
Err(ControlResourceAccessError::World(
@@ -5458,6 +5866,18 @@ mod tests {
fparkan_world::WorldError::StaleHandle
))
);
+ assert_eq!(
+ distribute_loaded_mission_control_provider_amount(
+ &mut engine,
+ stale,
+ -1.0,
+ ControlNodeDistributionMode::Proportional,
+ false,
+ ),
+ Err(ControlResourceAccessError::World(
+ fparkan_world::WorldError::StaleHandle
+ ))
+ );
assert_eq!(
update_loaded_mission_control_node_stage(&mut engine, stale, 1, false),
@@ -5688,6 +6108,350 @@ mod tests {
}
#[test]
+ fn control_provider_distribution_matches_native_and_rolls_back_unsupported_updates() {
+ let make_row = |index: usize, maximum: f32, amount: f32, flags: u32, parent: i32| {
+ let ratio = amount / maximum;
+ let mut row = synthetic_node_provider_row(0, index, ratio.to_bits());
+ row.source_row.max_volume = maximum;
+ row.initial_amount_bits = maximum.to_bits();
+ row.current_amount_bits = amount.to_bits();
+ row.processed_amount_bits = amount.to_bits();
+ row.current_flags = Some(flags);
+ row.initial_mesh_state = Some(MissionControlNodeInitialMeshState {
+ parent_index: parent,
+ flags,
+ stage_count: 1,
+ });
+ row.mesh_state = Some(MissionControlNodeMeshState {
+ type1_value_bits: ratio.to_bits(),
+ visible_stage: 0,
+ });
+ row
+ };
+
+ let mut proportional = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ make_row(0, 30.0, 10.0, 0x02, -1),
+ make_row(1, 60.0, 20.0, 0x02, -1),
+ make_row(2, 50.0, 0.0, 0x02, -1),
+ ]),
+ );
+ distribute_control_node_amount(
+ &mut proportional,
+ 22.0,
+ ControlNodeDistributionMode::Proportional,
+ false,
+ )
+ .expect("native proportional distribution");
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &proportional.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ assert_eq!(
+ rows.iter()
+ .map(|row| f32::from_bits(row.current_amount_bits))
+ .collect::<Vec<_>>(),
+ [14.0, 28.0, 10.0]
+ );
+ assert_eq!(
+ rows.iter()
+ .map(|row| f32::from_bits(row.current_accumulated_delta_bits))
+ .collect::<Vec<_>>(),
+ [4.0, 8.0, 10.0]
+ );
+ assert_eq!(
+ proportional.node_provider_amount_bits,
+ Some(52.0_f32.to_bits())
+ );
+
+ let mut accumulated = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ MissionControlNodeProviderTableStatus::Available(vec![make_row(
+ 0, 100.0, 40.0, 0x02, -1,
+ )]),
+ );
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &mut accumulated.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ rows[0].current_accumulated_delta_bits = 7.0_f32.to_bits();
+ let _publication = apply_control_node_delta(&mut accumulated, 0, 10.0)
+ .expect("single-node zero-flag update")
+ .expect("amount changed");
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &accumulated.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ assert_eq!(rows[0].current_accumulated_delta_bits, 7.0_f32.to_bits());
+ distribute_control_node_amount(
+ &mut accumulated,
+ 10.0,
+ ControlNodeDistributionMode::Proportional,
+ false,
+ )
+ .expect("distribution adds to the existing accumulator");
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &accumulated.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ assert_eq!(rows[0].current_accumulated_delta_bits, 17.0_f32.to_bits());
+
+ let mut sequential = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ make_row(0, 30.0, 10.0, 0x02, -1),
+ make_row(1, 60.0, 20.0, 0x02, -1),
+ make_row(2, 50.0, 30.0, 0x02, -1),
+ ]),
+ );
+ distribute_control_node_amount(
+ &mut sequential,
+ -35.0,
+ ControlNodeDistributionMode::Sequential,
+ false,
+ )
+ .expect("native reverse sequential withdrawal");
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &sequential.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ assert_eq!(
+ rows.iter()
+ .map(|row| f32::from_bits(row.current_amount_bits))
+ .collect::<Vec<_>>(),
+ [10.0, 15.0, 0.0]
+ );
+ assert_eq!(
+ rows.iter()
+ .map(|row| f32::from_bits(row.current_accumulated_delta_bits))
+ .collect::<Vec<_>>(),
+ [0.0, -5.0, -30.0]
+ );
+
+ let mut rounded_capacity = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ make_row(0, 33_554_432.0, 16_777_216.0, 0x02, -1),
+ make_row(1, 3.0, 2.0, 0x02, -1),
+ ]),
+ );
+ distribute_control_node_amount(
+ &mut rounded_capacity,
+ 33_554_432.0,
+ ControlNodeDistributionMode::Sequential,
+ false,
+ )
+ .expect("sequential mode carries the original request across rows");
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &rounded_capacity.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ assert_eq!(
+ rows.iter()
+ .map(|row| f32::from_bits(row.current_amount_bits))
+ .collect::<Vec<_>>(),
+ [33_554_432.0, 3.0]
+ );
+ assert_eq!(
+ rows.iter()
+ .map(|row| f32::from_bits(row.current_accumulated_delta_bits))
+ .collect::<Vec<_>>(),
+ [16_777_216.0, 1.0]
+ );
+
+ let mut rounded_aggregate = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ make_row(0, 33_554_432.0, 33_554_432.0, 0x02, -1),
+ make_row(1, 3.0, 3.0, 0x02, -1),
+ ]),
+ );
+ for _ in 0..3 {
+ let _publication = apply_control_node_delta(&mut rounded_aggregate, 1, -1.0)
+ .expect("direct amount update")
+ .expect("row amount changed");
+ }
+ let _publication = apply_control_node_delta(&mut rounded_aggregate, 1, 3.0)
+ .expect("direct refill")
+ .expect("row amount changed");
+ assert_eq!(
+ rounded_aggregate.node_provider_maximum_amount_bits,
+ Some(33_554_436.0_f32.to_bits())
+ );
+ assert_eq!(
+ rounded_aggregate.node_provider_amount_bits,
+ Some(33_554_440.0_f32.to_bits())
+ );
+ distribute_control_node_amount(
+ &mut rounded_aggregate,
+ 1.0,
+ ControlNodeDistributionMode::Proportional,
+ true,
+ )
+ .expect("signed capacity after native aggregate rounding");
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &rounded_aggregate.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ assert_eq!(rows[0].current_amount_bits, 33_554_432.0_f32.to_bits());
+ assert_eq!(rows[1].current_amount_bits, 3.0_f32.to_bits());
+ assert_eq!(
+ rounded_aggregate.node_provider_amount_bits,
+ Some(33_554_440.0_f32.to_bits())
+ );
+
+ let mut rounding = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ make_row(0, 33_554_432.0, 16_777_216.0, 0x02, -1),
+ make_row(1, 10.0, 2.0, 0x02, -1),
+ ]),
+ );
+ distribute_control_node_amount(
+ &mut rounding,
+ -1.0,
+ ControlNodeDistributionMode::Proportional,
+ false,
+ )
+ .expect("native x87 cancellation rounding");
+ let MissionControlNodeProviderTableStatus::Available(rows) = &rounding.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ assert_eq!(rows[0].current_amount_bits, 16_777_214.0_f32.to_bits());
+ assert_eq!(rows[0].current_accumulated_delta_bits, (-2.0_f32).to_bits());
+ assert_eq!(rows[1].current_amount_bits, 0x3fff_fffe);
+ assert_eq!(rows[1].current_accumulated_delta_bits, 0xb480_0000);
+ assert_eq!(
+ rounding.node_provider_amount_bits,
+ Some(16_777_216.0_f32.to_bits())
+ );
+
+ let mut exhausted = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ make_row(0, 10.0, 9.0, 0x02, -1),
+ make_row(1, 10.0, 0.0, 0x12, 0),
+ ]),
+ );
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &mut exhausted.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ rows[1].current_stage = 1;
+ rows[1].mesh_state.as_mut().unwrap().visible_stage = 0;
+ distribute_control_node_amount(
+ &mut exhausted,
+ 10.0,
+ ControlNodeDistributionMode::Sequential,
+ false,
+ )
+ .expect("ordinary distribution skips an exhausted row");
+ let before = exhausted.clone();
+ assert_eq!(
+ distribute_control_node_amount(
+ &mut exhausted,
+ 10.0,
+ ControlNodeDistributionMode::Sequential,
+ true,
+ ),
+ Err(ControlResourceAccessError::UnsupportedNodeEffects { key: 1 })
+ );
+ assert_eq!(
+ exhausted, before,
+ "revival with an FX stage transition rolls back"
+ );
+
+ let mut lifecycle = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ make_row(0, 100.0, 20.0, 0x02, -1),
+ make_row(1, 100.0, 40.0, 0x02, 0),
+ ]),
+ );
+ let before = lifecycle.clone();
+ assert_eq!(
+ distribute_control_node_amount(
+ &mut lifecycle,
+ -80.0,
+ ControlNodeDistributionMode::Sequential,
+ false,
+ ),
+ Err(ControlResourceAccessError::UnsupportedNodeLifecycle { key: 0 })
+ );
+ assert_eq!(
+ lifecycle, before,
+ "root lifecycle failure rolls back earlier row updates"
+ );
+
+ for (accumulated_delta, overflow) in [(f32::NAN, false), (f32::MAX, true)] {
+ let second_maximum = if overflow { f32::MAX } else { 100.0 };
+ let second_amount = if overflow { 0.0 } else { 40.0 };
+ let mut malformed = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ make_row(0, 100.0, 20.0, 0x02, -1),
+ make_row(1, second_maximum, second_amount, 0x02, -1),
+ ]),
+ );
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &mut malformed.node_provider_status
+ else {
+ panic!("provider rows should be available");
+ };
+ rows[1].current_accumulated_delta_bits = accumulated_delta.to_bits();
+ let before = malformed.clone();
+ let delta = if overflow { f32::MAX } else { 10.0 };
+ let error = distribute_control_node_amount(
+ &mut malformed,
+ delta,
+ ControlNodeDistributionMode::Proportional,
+ false,
+ )
+ .expect_err("an invalid late-row accumulator must abort the transaction");
+ if overflow {
+ assert_eq!(
+ error,
+ ControlResourceAccessError::UnsupportedFloatRange {
+ factory_tag: Some(0),
+ field: "provider accumulated delta",
+ }
+ );
+ } else {
+ assert_eq!(
+ error,
+ ControlResourceAccessError::NonFiniteValue {
+ factory_tag: 0,
+ field: "provider accumulated delta",
+ }
+ );
+ }
+ assert_eq!(malformed, before);
+ }
+
+ let mut suspended = proportional.clone();
+ suspended.node_provider_distribution_suspended = true;
+ let before = suspended.clone();
+ distribute_control_node_amount(
+ &mut suspended,
+ -1.0,
+ ControlNodeDistributionMode::Proportional,
+ false,
+ )
+ .expect("native distribution suspension is a no-op");
+ assert_eq!(suspended, before);
+ }
+
+ #[test]
fn control_node_stage_matches_native_inheritance_and_visible_sentinel() {
let descriptors = [
(-1, 0.25_f32, 4, 0x02),
@@ -7448,24 +8212,88 @@ mod tests {
.map(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, initial, initial_amount) = {
+ 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 initial_amount = f32::from_bits(initial);
+ assert!(initial_amount > 0.0);
+ let initial_processed = mine_rows[key as usize].processed_amount_bits;
+ assert_eq!(mine_rows[key as usize].current_amount_bits, initial);
+ assert_eq!(initial_processed, initial);
+ (key, initial, initial_amount)
};
- let key = mine_devices[8].key;
- let MissionControlNodeProviderTableStatus::Available(mine_rows) =
- &mine_table.node_provider_status
- else {
- panic!()
+ distribute_loaded_mission_control_provider_amount(
+ &mut mine_engine,
+ mine_profile.handle,
+ -1.0,
+ ControlNodeDistributionMode::Proportional,
+ false,
+ )
+ .expect("licensed proportional provider withdrawal");
+ let (distributed_amount, processed_before_direct_delta, accumulated_before_direct_delta) = {
+ let MissionControlNodeProviderTableStatus::Available(mine_rows) =
+ &loaded_mission_control_device_tables(&mine_engine).unwrap()[0]
+ .node_provider_status
+ else {
+ panic!()
+ };
+ let distributed_row = &mine_rows[key as usize];
+ let distributed_amount = f32::from_bits(distributed_row.current_amount_bits);
+ let distributed_change = distributed_amount - initial_amount;
+ assert_ne!(distributed_row.current_amount_bits, initial);
+ assert_ne!(
+ distributed_row.current_accumulated_delta_bits,
+ 0.0_f32.to_bits()
+ );
+ assert_eq!(
+ distributed_row.current_accumulated_delta_bits,
+ distributed_change.to_bits()
+ );
+ assert_eq!(distributed_row.initial_amount_bits, initial);
+ assert_eq!(
+ distributed_row.processed_amount_bits,
+ distributed_row.current_amount_bits
+ );
+ assert_eq!(
+ query_loaded_mission_control_device_selector(
+ &mine_engine,
+ mine_profile.handle,
+ 8,
+ 0xe00
+ )
+ .unwrap()
+ .unwrap()
+ .to_bits(),
+ distributed_row.current_type1_value_bits
+ );
+ assert_eq!(
+ query_loaded_mission_behavior_resource(&mine_engine, mine_profile.handle, 0x1002)
+ .unwrap()
+ .to_bits(),
+ distributed_row.current_type1_value_bits
+ );
+ (
+ distributed_amount,
+ distributed_row.processed_amount_bits,
+ distributed_row.current_accumulated_delta_bits,
+ )
};
- let initial = mine_rows[key as usize].initial_amount_bits;
- let amount = f32::from_bits(initial);
- assert!(amount > 0.0);
+ let amount = distributed_amount;
let publication = apply_loaded_mission_control_node_delta(
&mut mine_engine,
mine_profile.handle,
key,
- -amount * 0.5,
+ initial_amount * 0.5 - amount,
)
.unwrap()
.unwrap();
@@ -7507,7 +8335,14 @@ mod tests {
panic!()
};
assert_eq!(mine_rows[key as usize].initial_amount_bits, initial);
- assert_eq!(mine_rows[key as usize].processed_amount_bits, initial);
+ assert_eq!(
+ mine_rows[key as usize].processed_amount_bits,
+ processed_before_direct_delta
+ );
+ assert_eq!(
+ mine_rows[key as usize].current_accumulated_delta_bits,
+ accumulated_before_direct_delta
+ );
assert_eq!(
mine_rows[key as usize].mesh_state,
Some(MissionControlNodeMeshState {
@@ -7520,11 +8355,12 @@ mod tests {
assert_eq!(stage_row.initial_mesh_state.unwrap().stage_count, 2);
assert_eq!(stage_row.current_flags, Some(2));
let stage_initial = stage_row.initial_amount_bits;
+ let stage_current = stage_row.current_amount_bits;
let _publication = apply_loaded_mission_control_node_delta(
&mut mine_engine,
mine_profile.handle,
stage_key,
- -f32::from_bits(stage_initial) * 0.5,
+ f32::from_bits(stage_initial) * 0.5 - f32::from_bits(stage_current),
)
.unwrap()
.unwrap();
@@ -8872,8 +9708,10 @@ mod tests {
handle,
status: MissionControlDeviceTableStatus::Available(vec![device]),
node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()),
+ node_provider_maximum_amount_bits: Some(0),
node_provider_amount_bits: Some(0),
node_provider_dirty: false,
+ node_provider_distribution_suspended: false,
}
}
@@ -8978,8 +9816,10 @@ mod tests {
node_provider_status: MissionControlNodeProviderTableStatus::Available(vec![
synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()),
]),
+ node_provider_maximum_amount_bits: Some(100.0_f32.to_bits()),
node_provider_amount_bits: Some(100.0_f32.to_bits()),
node_provider_dirty: false,
+ node_provider_distribution_suspended: false,
};
let mut control_tables = vec![producer_table];
@@ -9026,8 +9866,10 @@ mod tests {
handle: object.handle,
status: MissionControlDeviceTableStatus::Available(Vec::new()),
node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()),
+ node_provider_maximum_amount_bits: Some(0),
node_provider_amount_bits: Some(0),
node_provider_dirty: false,
+ node_provider_distribution_suspended: false,
}];
advance_behavior_distributor_tick(
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 7b82ad3..500cc0d 100644
--- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs
+++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs
@@ -1464,6 +1464,10 @@ internal static class BehaviorDistributorTickOracle {
return ReadFloat(table.Rows, index * 0x2c + 0x08);
}
+ private static float ReadNativeProviderAccumulatedDelta(NativeProviderTable table, int index) {
+ return ReadFloat(table.Rows, index * 0x2c + 0x10);
+ }
+
private static byte ReadNativeProviderFlags(NativeProviderTable table, int index) {
return Marshal.ReadByte(table.Rows, index * 0x2c + 0x28);
}
@@ -1489,6 +1493,10 @@ internal static class BehaviorDistributorTickOracle {
"Control proportional distribution publishes each row update through the existing native node callback");
Expect(String.Join(",", controlNodeUpdateIndices.ConvertAll(index => index.ToString()).ToArray()) == "0,1,2",
"Control proportional distribution visits rows in ascending order");
+ Expect(ReadNativeProviderAccumulatedDelta(table, 0) == 4.0f &&
+ ReadNativeProviderAccumulatedDelta(table, 1) == 8.0f &&
+ ReadNativeProviderAccumulatedDelta(table, 2) == 10.0f,
+ "Control distribution accumulates each actual amount change in provider row +0x10");
}
using (Fixture fixture = NewFixture()) {
@@ -1503,6 +1511,58 @@ internal static class BehaviorDistributorTickOracle {
"Control native sequential withdrawal consumes rows in reverse order");
Expect(String.Join(",", controlNodeUpdateIndices.ConvertAll(index => index.ToString()).ToArray()) == "2,1",
"Control sequential withdrawal calls the node updater from the last row backwards");
+ Expect(ReadNativeProviderAccumulatedDelta(table, 0) == 0.0f &&
+ ReadNativeProviderAccumulatedDelta(table, 1) == -5.0f &&
+ ReadNativeProviderAccumulatedDelta(table, 2) == -30.0f,
+ "Control reverse withdrawal accumulates the applied changes in provider row +0x10");
+ }
+
+ using (Fixture fixture = NewFixture()) {
+ NativeProviderTable table = CreateNativeProviderTable(fixture, module,
+ new float[] { 33554432.0f, 3.0f }, new float[] { 16777216.0f, 2.0f },
+ new byte[] { 0, 0 });
+ for (int i = 0; i < 2; i++) {
+ IntPtr row = IntPtr.Add(table.Rows, i * 0x2c);
+ Marshal.WriteInt32(row, 0x1c, 1); // Avoid the zero-count synthetic stage boundary.
+ Marshal.WriteByte(row, 0x28, 0x02);
+ }
+ distribute(table.Owner, 33554432.0f, 1, 0);
+ Expect(ReadNativeProviderAmount(table, 0) == 33554432.0f &&
+ ReadNativeProviderAmount(table, 1) == 3.0f,
+ "Control sequential mode keeps the original request after summed free capacity rounds down");
+ Expect(ReadNativeProviderAccumulatedDelta(table, 0) == 16777216.0f &&
+ ReadNativeProviderAccumulatedDelta(table, 1) == 1.0f,
+ "Control sequential mode accumulates each row's applied amount at the capacity-rounding boundary");
+ }
+
+ using (Fixture fixture = NewFixture()) {
+ NativeProviderTable table = CreateNativeProviderTable(fixture, module,
+ new float[] { 33554432.0f, 3.0f }, new float[] { 33554432.0f, 3.0f },
+ new byte[] { 0, 0 });
+ for (int i = 0; i < 2; i++) {
+ IntPtr row = IntPtr.Add(table.Rows, i * 0x2c);
+ Marshal.WriteInt32(row, 0x1c, 1);
+ Marshal.WriteByte(row, 0x28, 0x02);
+ }
+ NativeControlProviderAmountUpdate update = (NativeControlProviderAmountUpdate)
+ Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, ControlProviderAmountUpdateRva),
+ typeof(NativeControlProviderAmountUpdate));
+ update(table.Owner, 1, -1.0f, 0);
+ update(table.Owner, 1, -1.0f, 0);
+ update(table.Owner, 1, -1.0f, 0);
+ update(table.Owner, 1, 3.0f, 0);
+ int roundedAmount = FloatBits(ReadFloat(table.Owner, 0x590));
+ Expect(ReadNativeProviderAmount(table, 1) == 3.0f &&
+ ReadFloat(table.Owner, 0x58c) == 33554436.0f &&
+ roundedAmount == FloatBits(33554440.0f),
+ "Control ordered aggregate updates can round the current amount above its initial capacity");
+ distribute(table.Owner, 1.0f, 0, 1);
+ Expect(ReadNativeProviderAmount(table, 0) == 33554432.0f &&
+ ReadNativeProviderAmount(table, 1) == 3.0f &&
+ ReadFloat(table.Owner, 0x58c) == 33554436.0f &&
+ ReadFloat(table.Owner, 0x590) == 33554440.0f,
+ "Control proportional revive path preserves signed negative capacity after native aggregate rounding");
+ GC.KeepAlive(update);
}
using (Fixture fixture = NewFixture()) {
@@ -1524,6 +1584,9 @@ internal static class BehaviorDistributorTickOracle {
(ReadNativeProviderFlags(table, 0) & 0x10) == 0 &&
(ReadNativeProviderFlags(table, 1) & 0x10) == 0,
"Control native revive flag admits exhausted rows and clears their exhausted flags when refilled");
+ Expect(ReadNativeProviderAccumulatedDelta(table, 0) == 1.0f &&
+ ReadNativeProviderAccumulatedDelta(table, 1) == 5.0f,
+ "Control refill adds to the existing provider row +0x10 accumulator");
}
using (Fixture fixture = NewFixture()) {
@@ -1539,6 +1602,9 @@ internal static class BehaviorDistributorTickOracle {
secondBits == 0x3ffffffe && totalBits == unchecked((uint)FloatBits(16777216.0f)),
"Control proportional distribution preserves the native x87 add-then-subtract rounding at a 2^24 cancellation boundary; actual=[" +
Hex(firstBits) + "," + Hex(secondBits) + "] total=" + Hex(totalBits));
+ Expect(FloatBits(ReadNativeProviderAccumulatedDelta(table, 0)) == FloatBits(-2.0f) &&
+ FloatBits(ReadNativeProviderAccumulatedDelta(table, 1)) == unchecked((int)0xb4800000),
+ "Control distribution accumulates the rounded applied change, not the requested delta");
}
Console.WriteLine("native-control-provider-distribution: controlHash=" + ExpectedControlSha256 +
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index e8bdb42..9869581 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -338,17 +338,37 @@ MSH-часть проверки подтверждает начальный сн
остаётся статическим выводом по RVA `AniMesh.dll+0xa460`, а последующие
изменения в полном Control/animation tick-е не проверялись.
-Для provider-строки Rust сохраняет исходные и текущие amount/type-1 поля
-отдельно, а `processed_amount_bits` соответствует native `+0x0c`. Инициализатор
-`Control.dll+0xf940` кладёт результат selector `3` в текущее и обработанное
-количество; при подтверждённых единичных множителях это raw `max_volume` NDP.
-Прямой API `apply_loaded_mission_control_node_delta` меняет amount, долю, флаг
-истощения, aggregate и dirty, применяя публикацию `0x200` к CPU-проекции
-соответствующего узла модели. Исходный `AniMesh.dll+0x5750` подтверждён в
-связке с `Control+0x10f30`: меняется только поле узла `+0x124`, а стадия и
-соседние узлы сохраняются. Эта проекция ещё не подключена к GPU/window path
-и сама delta-операция не выполняет пересчёт стадий, видимости, FX или время
-Control. Обработанное количество при ней остаётся прежним. Отдельный
+Для provider-строки Rust хранит amount `+0x08`, накопитель `+0x10`, обработанное
+количество `+0x0c`, долю `+0x14`, стадию `+0x18` и flags `+0x28` раздельно.
+Инициализатор `Control.dll+0xf940` кладёт результат selector `3` в текущее и
+обработанное количество; при подтверждённых единичных множителях это raw
+`max_volume` NDP. Таблица также хранит capacity aggregate `+0x58c`, текущий
+aggregate `+0x590`, dirty `+0x598` и начальный gate `+0x618`.
+
+Прямой API `apply_loaded_mission_control_node_delta` сохраняет native четвёртый
+аргумент `0`: меняет amount, долю, флаг истощения, aggregate и dirty, оставляя
+`+0x10` нетронутым. Публикация `0x200` отражается в CPU-проекции соответствующего
+узла модели по правилу исходного обработчика; стадия и соседние узлы не меняются.
+Обработанное количество при этой прямой операции остаётся прежним.
+API `distribute_loaded_mission_control_provider_amount` переносит
+`Control+0x10ba0`: proportional mode делит добавку по свободной ёмкости, а
+вычитание распределяет по текущим количествам; sequential заполняет строки
+вперёд, снимает с конца и использует исходный запрос даже если общая ёмкость
+округлилась вниз. Row updater вызывается с четвёртым аргументом `1`, поэтому
+`+0x10` получает фактическую разницу amount. После распределения обязателен
+`0x11130(root, true)`.
+
+Оба API работают только с CPU-проекцией и не подключены к GPU/window path или
+игровому Control clock. Gate `+0x618` изначально выключен; событие `7` задаёт его
+по правилу `gate = (arg == 2)` и вызывает `0x2dc20`, только если gate изменился.
+События Control в Rust пока не исполняются.
+Capacity aggregate выведен при подтверждённых единичных множителях Unit DAT;
+изменение масштабов `0x9ee0`/`0x9f90` не реализовано. Из-за округления `+0x590`
+может стать больше сохранённого `+0x58c`; распределение сохраняет этот signed
+capacity по native формуле, не вводя отдельного запрета. Если итоговый stage walk
+нуждается в неизвестном состоянии, depletion lifecycle, FX или host callback,
+распределение сообщает ошибку и не публикует ни одной частичной записи.
+Отдельный
`update_loaded_mission_control_node_stage` реализует наследование parent ratio
и stage, ceiling-арифметику, CPU visible stage и копию processed amount.
Отдельная `propagate_loaded_mission_control_node_stage` переносит `0x11130`:
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index 60d15e1..b658348 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -624,12 +624,25 @@ oracle вызывает оригинальные `FUN_10010ba0` (RVA `0x10ba0`),
`0xdc40`. Пропорциональный режим проверен на максимумах `[30, 60, 50]`, текущих
значениях `[10, 20, 0]` и добавке `22`, результат `[14, 28, 10]`; отдельный
вектор подтверждает последовательное заполнение вперёд и вычитание с конца.
-Проверены ограничение строк, снятие признака истощения при пополнении и порядок
-обратных вызовов. При x87 control word `0x007f` для `[2^24, 2]` с запросом `-1`
-получены значения `[16777214, 1.99999976]` и итог `16777216`: округление
-вычисленного изменения даёт `-2`. Oracle задаёт внешнюю публикацию результата
-обратным вызовом; provider строки не имеют родительских узлов и не проходят
-отдельный жизненный цикл истощения корневого объекта.
+Проверены ограничение строк, пропуск истощённых записей, их пополнение с флагом
+revive и порядок обратных вызовов. Все распределительные вызовы используют
+четвёртый аргумент `1` в `0x10f30`: фактическое изменение строки прибавляется к
+её полю `+0x10`. Для исходных значений `[2^24, 2]` и запроса `-1` получены
+`[16777214, 1.99999976]`, общий итог `16777216` и накопленные разницы
+`[-2, -2.3841858e-7]`. Проверка также подтверждает, что последовательный режим
+несёт исходный запрос между строками: сумма свободных мест `[16777216, 1]`
+округляется до `16777216`, однако запрос `33554432` заполняет обе строки.
+Oracle задаёт внешнюю публикацию результата обратным вызовом; provider-строки
+не имеют родительских узлов и не проходят отдельный жизненный цикл истощения
+корневого объекта.
+
+Агрегаты тоже округляются независимо. Начальная ёмкость `+0x58c = 33554436`
+для максимумов `[33554432, 3]` не меняется при обновлениях одной строки
+`-1, -1, -1, +3`, но текущий итог `+0x590` округляется до `33554440`.
+Следующая добавка `1` с revive использует signed capacity `-4`, выдаёт ratio `1`
+и сохраняет уже полные строки. Native oracle и Rust повторяют эту достижимую
+последовательность; отрицательный результат разности сам по себе не является
+ошибкой состояния.
Для `FUN_10012a40` (RVA `0x12a40`) нативный вектор задаёт класс `4`, интервал
`2`, коэффициент `+0x1a4` равный `1` и текущее значение `10.000001`; результат
@@ -654,14 +667,16 @@ oracle вызывает оригинальные `FUN_10010ba0` (RVA `0x10ba0`),
хосте класса `10`, как описано ниже. Источник входного времени и порядок его
доставки до обработчика установлены статически;
Rust ещё не вызывает этот бюджетный цикл; отдельно перенесён прямой путь
-изменения одной строки `0x10f30`, описанный ниже.
+изменения одной строки `0x10f30` и явный шаг распределения `0x10ba0`, описанные
+ниже. Ни один из них пока не подключён к игровому Control clock.
#### Прямое изменение одной строки в Rust
`apply_loaded_mission_control_node_delta(engine, handle, key, delta)` моделирует
`0x10f30` с нулевым четвёртым аргументом: накопление анимационного изменения
-`+0x10` и detached-ветвь сюда не входят. Максимум читается из исходной NDP
-строки при подтверждённых единичных множителях Unit DAT. Сначала складываются
+`+0x10` не выполняется, а текущее значение поля сохраняется; detached-ветвь
+объекта тоже не моделируется. Максимум читается из исходной NDP строки при
+подтверждённых единичных множителях Unit DAT. Сначала складываются
`f32` amount и delta, результат ограничивается `0..maximum`; при изменении
native `+0x590` получает `max(0, total + (new - old))`, `+0x14` — долю
`new / maximum`, а `+0x598` становится dirty. Флаг `+0x28 & 0x10` отмечает
@@ -711,6 +726,38 @@ Native oracle отдельно подтверждает сохранение `+0
native записывает биты `+0.0`, но сравнение количеств считает их равными:
доля, флаги, итог и callback остаются прежними. Этот случай сохранён и в Rust.
+#### Распределение provider-таблицы в Rust
+
+`distribute_loaded_mission_control_provider_amount` переносит общий шаг
+`Control.dll+0x10ba0`. Режим proportional распределяет добавку по свободной
+ёмкости, а снятие — по текущему количеству строк; sequential заполняет их с
+первой и снимает ресурс с последней. Для sequential native сохраняет исходную
+величину запроса, даже когда сумма свободных мест округляется вниз.
+`revive_exhausted` включает строки с флагом истощения `+0x28 & 0x10` при добавке.
+Rust хранит максимум общей таблицы `+0x58c` отдельно от текущей суммы `+0x590`;
+для загруженных Unit DAT эти значения рассчитаны при подтверждённых единичных
+масштабах. Их разность может стать отрицательной из-за независимого округления
+изменений текущего агрегата; распределитель сохраняет такой signed capacity по
+формуле оригинала. Обновления масштабов `0x9ee0` и `0x9f90` в Rust не реализованы.
+
+Native row updater вызывается здесь с четвёртым аргументом `1`, поэтому
+изменение `(new amount - old amount)` накапливается в provider `+0x10`.
+`apply_loaded_mission_control_node_delta` сохраняет прежний четвёртый аргумент
+`0` и оставляет этот накопитель нетронутым. После всех строк `0x10ba0`
+обязательно запускает `0x11130(root, update_effects=true)`. Rust переносит этот
+проход на копии таблицы: если ему нужны неизвестное состояние, depletion
+lifecycle, FX или host callback, весь шаг завершается ошибкой без частичных
+amount, accumulator, aggregate или stage изменений.
+
+Поле `+0x618` изначально выключено; обработчик события `7` задаёт gate по
+правилу `gate = (arg == 2)` и вызывает пересчёт `0x2dc20`, только если gate
+изменился. Проекция сохраняет начальное состояние и уважает установленную
+suspension-метку. События Control пока не исполняются,
+поэтому это API остаётся явным CPU-шагом и не подключено к игровому clock.
+Тесты Rust повторяют пропорциональный вектор, накопитель `+0x10`, округление
+`2^24`, sequential capacity boundary, достижимый negative capacity и откат при
+unsupported FX/lifecycle.
+
#### Бюджет и обновление устройства tag 15
Для той же сборки `Control.dll` oracle исполняет оригинальный диспетчер
diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md
index 777caf1..452f577 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -995,7 +995,13 @@ clock Control.
добавку либо пропорционально свободному месту, либо по очереди: при максимумах
`[30, 60, 50]`, текущих значениях `[10, 20, 0]` и добавке `22` первый способ
даёт `[14, 28, 10]`. При вычитании последовательный способ начинает с последней
-строки. Так одна таблица описывает и текущее значение, и то, как оно меняется.
+строки. Пропорциональное вычитание делит запрос по текущим количествам.
+У каждой изменившейся строки есть отдельный накопитель изменений количества: он
+получает фактически применённую разницу, а не исходный запрос.
+Большие суммы округляются в двоичном формате. Поэтому после нескольких
+маленьких изменений общий запас иногда оказывается чуть больше первоначальной
+ёмкости; оригинал продолжает считать с этой отрицательной разностью, а не
+исправляет её отдельным правилом.
У строк есть и связи с родительскими узлами. Нативный `Control.dll` сначала
обновляет запрошенную строку через `0x11220`, затем `0x11130` ищет её детей
@@ -1040,8 +1046,10 @@ clock Control.
Для точного повтора важен порядок округления. При исходных значениях `[2^24, 2]`
и запросе `-1` оригинал сначала складывает общий итог с изменением, затем
вычитает прежний итог. Из-за округления фактическое изменение получается `-2`.
-Нативная проверка подтвердила это поведение; общий распределитель
-`0x10ba0` в Rust ещё не перенесён, хотя прямая добавка к одной строке уже доступна.
+Rust переносит этот порядок, сохраняет native накопитель каждой строки и
+запускает обязательный проход стадий с effects. Если ему нужны ещё не
+реализованные FX или lifecycle-вызовы, таблица остаётся без изменений. Эти
+операции обновляют только CPU-снимок и пока не подключены к игровым часам Control.
Отдельный путь объекта класса `4` умножает интервал на контактный коэффициент и
передаёт изменение строке. В проверенном примере коэффициент `1`, интервал `2`,
@@ -1058,7 +1066,8 @@ Control заново разыгрывает срок обновления, даÐ
Принудительный шаг тоже расходует RNG; неактивный объект синхронизирует часы
без такого расхода. Это локальные native проверки, а не работающий Rust
Control: запросы Rust `0xe00` и `0x1002` читают текущее состояние строк,
-которое пока меняет только прямой API, а `0x1005` — загруженную ставку профиля.
+которое меняют явные API одной строки и распределения, а `0x1005` — загруженную
+ставку профиля.
Точные нативные векторы и открытые границы описаны в [приложении](../appendices/script-vm.md#изменение-provider-состояния-control).
На x87 control word `0x007f` каждое умножение округляет мантиссу до 24 бит,