From d8e86e4f33241d4c0cbb236ecbd32f71798ce9ed Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 29 Oct 2026 18:24:43 +0400 Subject: [PATCH] feat(runtime): distribute Control provider amounts --- crates/fparkan-runtime/src/lib.rs | 870 +++++++++++++++++- ...behavior_distributor_tick_native_oracle.cs | 66 ++ docs/appendices/knowledge-boundaries.md | 42 +- docs/appendices/script-vm.md | 65 +- docs/tomes/06-behavior.md | 17 +- 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, /// Native `+0x590` amount aggregate; absent if it cannot be represented. pub node_provider_amount_bits: Option, /// 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,10 +2571,312 @@ fn initial_node_provider_amount(status: &MissionControlNodeProviderTableStatus) .map(f32::to_bits) } +fn initial_node_provider_maximum_amount( + status: &MissionControlNodeProviderTableStatus, +) -> Option { + 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, ControlResourceAccessError> { + apply_control_node_delta_inner(table, key, delta, false) +} + +fn apply_control_node_distribution_delta( + table: &mut MissionControlDeviceTableState, + key: u32, + delta: f32, +) -> Result, 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, ControlResourceAccessError> { let rows = match &mut table.node_provider_status { MissionControlNodeProviderTableStatus::Available(rows) => rows, @@ -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), @@ -5687,6 +6107,350 @@ mod tests { assert_eq!(table.node_provider_amount_bits, total); } + #[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::>(), + [14.0, 28.0, 10.0] + ); + assert_eq!( + rows.iter() + .map(|row| f32::from_bits(row.current_accumulated_delta_bits)) + .collect::>(), + [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::>(), + [10.0, 15.0, 0.0] + ); + assert_eq!( + rows.iter() + .map(|row| f32::from_bits(row.current_accumulated_delta_bits)) + .collect::>(), + [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::>(), + [33_554_432.0, 3.0] + ); + assert_eq!( + rows.iter() + .map(|row| f32::from_bits(row.current_accumulated_delta_bits)) + .collect::>(), + [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 = [ @@ -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 бит,