aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/fparkan-runtime/src/lib.rs968
-rw-r--r--crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs85
-rw-r--r--docs/appendices/knowledge-boundaries.md43
-rw-r--r--docs/appendices/script-vm.md49
-rw-r--r--docs/tomes/06-behavior.md28
5 files changed, 1143 insertions, 30 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 923617d..6b037aa 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -203,6 +203,8 @@ pub struct EffectiveControlDevice {
pub class9_selector0_scale_bits: Option<u32>,
/// Current native word at `+0x50` when it is known.
pub native_word_50_bits: Option<u32>,
+ /// Current native tag-15 byte at `+0x94`; constructors currently set zero.
+ pub native_byte_94: Option<u8>,
/// Current raw descriptor, including typed values and string bytes.
pub descriptor: PreparedControlTypedRecord,
/// Source location that created the original factory instance.
@@ -456,6 +458,10 @@ pub struct MissionControlDeviceTableState {
pub node_provider_dirty: bool,
/// Native `+0x618`: distribution returns without changing rows while set.
pub node_provider_distribution_suspended: bool,
+ /// Native `+0x5dc`, last output rate written by the Control budget pass.
+ pub current_output_rate_5dc_bits: u32,
+ /// Native `+0x5e0`, last output rate written by the Control budget pass.
+ pub current_output_rate_5e0_bits: u32,
}
struct ControlDeviceProjection {
@@ -1508,6 +1514,8 @@ fn load_mission_with_options(
node_provider_amount_bits: initial_node_provider_amount(&projection.node_provider_status),
node_provider_dirty: true,
node_provider_distribution_suspended: false,
+ current_output_rate_5dc_bits: 0,
+ current_output_rate_5e0_bits: 0,
node_provider_status: projection.node_provider_status,
},
Err(reason) => MissionControlDeviceTableState {
@@ -1518,6 +1526,8 @@ fn load_mission_with_options(
node_provider_amount_bits: None,
node_provider_dirty: false,
node_provider_distribution_suspended: false,
+ current_output_rate_5dc_bits: 0,
+ current_output_rate_5e0_bits: 0,
node_provider_status:
MissionControlNodeProviderTableStatus::Unavailable(
MissionControlNodeProviderTableUnavailableReason::DeviceTableUnavailable(
@@ -2266,6 +2276,35 @@ pub fn distribute_loaded_mission_control_provider_amount(
distribute_control_node_amount(table, delta, mode, revive_exhausted)
}
+/// Runs the supported loaded Control resource-budget pass (`Control.dll+0x2d340`).
+///
+/// The pass currently supports original factory callbacks for tags 15 and 19,
+/// with current descriptor tags 15 and 19 (native categories 0 and 1). It is an explicit CPU update;
+/// the mission clock does not call it yet. Rate outputs at `+0x5dc/+0x5e0` are
+/// stored on the table after a successful pass.
+///
+/// # Errors
+/// Rejects unavailable objects or tables, unknown current categories or
+/// callbacks, and unsupported provider or floating-point state. The complete
+/// table is updated only when every category and callback succeeds.
+pub fn advance_loaded_mission_control_budget(
+ engine: &mut Engine,
+ handle: ObjectHandle,
+ delta_time_seconds: f32,
+) -> Result<(), ControlResourceAccessError> {
+ transform_state(&engine.world, handle).map_err(ControlResourceAccessError::World)?;
+ let loaded = engine
+ .loaded
+ .as_mut()
+ .ok_or(ControlResourceAccessError::MissionUnavailable)?;
+ let table = loaded
+ .control_device_tables
+ .iter_mut()
+ .find(|table| table.handle == handle)
+ .ok_or(ControlResourceAccessError::ObjectUnavailable { handle })?;
+ control_resource_budget_pass(table, delta_time_seconds)
+}
+
/// Applies the confirmed single-node stage path of native Control `0x11220`.
///
/// Copies parent ratio/stage when requested, updates the processed amount and
@@ -2856,6 +2895,602 @@ fn distribute_control_node_amount_inner(
propagate_control_node_stage_inner(table, 0, true)
}
+fn control_resource_budget_pass(
+ table: &mut MissionControlDeviceTableState,
+ delta_time_seconds: f32,
+) -> Result<(), ControlResourceAccessError> {
+ let delta_time_seconds =
+ checked_control_float(0, "Control resource interval", delta_time_seconds)?;
+ let mut updated = table.clone();
+ control_resource_budget_pass_inner(&mut updated, delta_time_seconds)?;
+ *table = updated;
+ Ok(())
+}
+
+fn control_resource_budget_pass_inner(
+ table: &mut MissionControlDeviceTableState,
+ delta_time_seconds: f32,
+) -> Result<(), ControlResourceAccessError> {
+ let device_count = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => devices.len(),
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ return Err(ControlResourceAccessError::TableUnavailable(reason.clone()))
+ }
+ };
+
+ // Category selection reads the current descriptor, while virtual callbacks
+ // still belong to the original factory. Reject either unsupported side up
+ // front so no unmodeled rate can affect another device's allocation.
+ for index in 0..device_count {
+ let device = control_budget_device(table, index)?;
+ control_budget_category(device.descriptor.tag)?;
+ if !matches!(device.factory_tag, 15 | 19) {
+ return Err(ControlResourceAccessError::UnsupportedFactoryTag {
+ factory_tag: device.factory_tag,
+ });
+ }
+ }
+
+ let mut category_rates = [0.0_f32; 6];
+ let mut positive_rate_sum = 0.0_f32;
+ for index in 0..device_count {
+ let device = control_budget_device(table, index)?;
+ let rate = control_device_resource_rate(table, &device, delta_time_seconds)?;
+ if rate > 0.0 {
+ positive_rate_sum = checked_control_sum(
+ device.factory_tag,
+ "positive Control resource rate",
+ positive_rate_sum,
+ rate,
+ )?;
+ }
+ let category = control_budget_category(device.descriptor.tag)?;
+ category_rates[category] = checked_control_sum(
+ device.factory_tag,
+ "Control category rate",
+ category_rates[category],
+ rate,
+ )?;
+ }
+
+ let mut budget = positive_rate_sum;
+ for mask in [8, 1, 0x24, 0x10] {
+ budget = control_budget_category_pass(
+ table,
+ &mut category_rates,
+ mask,
+ budget,
+ delta_time_seconds,
+ )?;
+ }
+ budget = checked_control_sum(
+ 0,
+ "final Control category budget",
+ budget,
+ -positive_rate_sum,
+ )?;
+ let _remaining =
+ control_budget_category_pass(table, &mut category_rates, 2, budget, delta_time_seconds)?;
+
+ let inverse_interval = if delta_time_seconds > 0.0001 {
+ checked_control_ratio("inverse Control resource interval", 1.0, delta_time_seconds)?
+ } else {
+ 0.0
+ };
+ let first_output_sum = checked_control_sum(
+ 0,
+ "Control output rate 5dc categories",
+ category_rates[2],
+ category_rates[5],
+ )?;
+ let first_output_sum = checked_control_sum(
+ 0,
+ "Control output rate 5dc total",
+ first_output_sum,
+ category_rates[0],
+ )?;
+ let second_output_sum = checked_control_sum(
+ 0,
+ "Control output rate 5e0 categories",
+ category_rates[4],
+ category_rates[3],
+ )?;
+ let second_output_sum = checked_control_sum(
+ 0,
+ "Control output rate 5e0 total",
+ second_output_sum,
+ category_rates[0],
+ )?;
+ table.current_output_rate_5dc_bits = (-checked_control_product(
+ 0,
+ "Control output rate 5dc",
+ first_output_sum,
+ inverse_interval,
+ )?)
+ .to_bits();
+ table.current_output_rate_5e0_bits = (-checked_control_product(
+ 0,
+ "Control output rate 5e0",
+ second_output_sum,
+ inverse_interval,
+ )?)
+ .to_bits();
+ Ok(())
+}
+
+fn control_budget_category(tag: u32) -> Result<usize, ControlResourceAccessError> {
+ match tag {
+ 15 => Ok(0),
+ 19 => Ok(1),
+ _ => Err(ControlResourceAccessError::UnsupportedCurrentTag { tag }),
+ }
+}
+
+fn control_budget_device(
+ table: &MissionControlDeviceTableState,
+ device_index: usize,
+) -> Result<&EffectiveControlDevice, ControlResourceAccessError> {
+ let devices = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => devices,
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ return Err(ControlResourceAccessError::TableUnavailable(reason.clone()))
+ }
+ };
+ devices
+ .get(device_index)
+ .ok_or(ControlResourceAccessError::DeviceIndexOutOfRange {
+ device_index,
+ device_count: devices.len(),
+ })
+}
+
+fn control_device_provider_value(
+ table: &MissionControlDeviceTableState,
+ device: &EffectiveControlDevice,
+) -> Result<f32, ControlResourceAccessError> {
+ let provider = control_node_provider_row(table, device.key)?;
+ checked_control_float(
+ device.factory_tag,
+ "node provider value",
+ f32::from_bits(provider.current_type1_value_bits),
+ )
+}
+
+fn control_device_budget_active_provider_value(
+ table: &MissionControlDeviceTableState,
+ device: &EffectiveControlDevice,
+) -> Result<Option<f32>, ControlResourceAccessError> {
+ let word_50 =
+ device
+ .native_word_50_bits
+ .ok_or(ControlResourceAccessError::UnsupportedField {
+ factory_tag: device.factory_tag,
+ field: "budget callback word +0x50",
+ })?;
+ if word_50 == 0 {
+ return Ok(None);
+ }
+ let provider_value = control_device_provider_value(table, device)?;
+ if provider_value == 0.0 {
+ return Ok(None);
+ }
+ Ok(Some(provider_value))
+}
+
+fn control_device_budget_coefficient(
+ device: &EffectiveControlDevice,
+ index: usize,
+) -> Result<f32, ControlResourceAccessError> {
+ let coefficients =
+ device
+ .coefficient_bits
+ .ok_or(ControlResourceAccessError::UnsupportedField {
+ factory_tag: device.factory_tag,
+ field: "coefficient table",
+ })?;
+ let bits =
+ coefficients
+ .get(index)
+ .copied()
+ .ok_or(ControlResourceAccessError::UnsupportedField {
+ factory_tag: device.factory_tag,
+ field: "coefficient index",
+ })?;
+ control_device_float(device, Some(bits), "coefficient")
+}
+
+fn control_device_budget_descriptor_float(
+ device: &EffectiveControlDevice,
+ word_index: usize,
+ field: &'static str,
+) -> Result<f32, ControlResourceAccessError> {
+ let bits = device
+ .descriptor
+ .fixed_words
+ .get(word_index)
+ .copied()
+ .ok_or(ControlResourceAccessError::UnsupportedField {
+ factory_tag: device.factory_tag,
+ field,
+ })?;
+ checked_control_float(device.factory_tag, field, f32::from_bits(bits))
+}
+
+fn control_device_resource_availability(
+ table: &MissionControlDeviceTableState,
+ factory_tag: u32,
+ revive_exhausted: bool,
+) -> Result<f32, ControlResourceAccessError> {
+ if revive_exhausted {
+ let maximum = table.node_provider_maximum_amount_bits.ok_or(
+ ControlResourceAccessError::UnsupportedField {
+ factory_tag,
+ field: "provider maximum aggregate",
+ },
+ )?;
+ let amount = table.node_provider_amount_bits.ok_or(
+ ControlResourceAccessError::UnsupportedField {
+ factory_tag,
+ field: "provider amount aggregate",
+ },
+ )?;
+ return checked_control_sum(
+ factory_tag,
+ "revive provider availability",
+ checked_control_float(
+ factory_tag,
+ "provider maximum aggregate",
+ f32::from_bits(maximum),
+ )?,
+ -checked_control_float(
+ factory_tag,
+ "provider amount aggregate",
+ f32::from_bits(amount),
+ )?,
+ );
+ }
+
+ let rows = match &table.node_provider_status {
+ MissionControlNodeProviderTableStatus::Available(rows) => rows,
+ MissionControlNodeProviderTableStatus::Unavailable(reason) => {
+ return Err(ControlResourceAccessError::NodeProviderTableUnavailable(
+ reason.clone(),
+ ))
+ }
+ };
+ let mut availability = 0.0_f32;
+ for row in rows {
+ let amount = checked_control_float(
+ factory_tag,
+ "provider amount for availability",
+ f32::from_bits(row.current_amount_bits),
+ )?;
+ if amount > 0.0 {
+ let maximum = checked_control_float(
+ factory_tag,
+ "provider maximum for availability",
+ row.source_row.max_volume,
+ )?;
+ let free =
+ checked_control_sum(factory_tag, "provider free availability", maximum, -amount)?;
+ availability = checked_control_sum(
+ factory_tag,
+ "total provider availability",
+ availability,
+ free,
+ )?;
+ }
+ }
+ Ok(availability)
+}
+
+fn control_device_resource_rate(
+ table: &MissionControlDeviceTableState,
+ device: &EffectiveControlDevice,
+ delta_time_seconds: f32,
+) -> Result<f32, ControlResourceAccessError> {
+ let Some(provider_value) = control_device_budget_active_provider_value(table, device)? else {
+ return Ok(0.0);
+ };
+ let coefficient_0 = control_device_budget_coefficient(device, 0)?;
+ let descriptor_rate =
+ control_device_budget_descriptor_float(device, 8, "descriptor resource rate +0x20")?;
+
+ match device.factory_tag {
+ 19 => {
+ let multiplier = control_device_float(device, device.multiplier_bits, "multiplier")?;
+ let mut raw = checked_control_product(
+ device.factory_tag,
+ "generator descriptor rate times multiplier",
+ descriptor_rate,
+ multiplier,
+ )?;
+ raw = checked_control_product(
+ device.factory_tag,
+ "generator rate times provider value",
+ raw,
+ provider_value,
+ )?;
+ raw = checked_control_product(
+ device.factory_tag,
+ "generator rate times interval",
+ raw,
+ delta_time_seconds,
+ )?;
+ if coefficient_0 > 0.0 {
+ let cap = checked_control_product(
+ device.factory_tag,
+ "generator coefficient times multiplier",
+ coefficient_0,
+ multiplier,
+ )?;
+ let cap = checked_control_product(
+ device.factory_tag,
+ "generator cap times provider value",
+ cap,
+ provider_value,
+ )?;
+ Ok(if raw < cap { raw } else { cap })
+ } else {
+ Ok(raw)
+ }
+ }
+ 15 => {
+ let coefficient_1 = control_device_budget_coefficient(device, 1)?;
+ let interval_rate = checked_control_product(
+ device.factory_tag,
+ "tag-15 interval times descriptor rate",
+ delta_time_seconds,
+ descriptor_rate,
+ )?;
+ let base = -interval_rate;
+ let query = checked_control_product(
+ device.factory_tag,
+ "tag-15 provider coefficient",
+ coefficient_0,
+ provider_value,
+ )?;
+ let query_interval = checked_control_product(
+ device.factory_tag,
+ "tag-15 provider rate times interval",
+ query,
+ delta_time_seconds,
+ )?;
+ let revive_exhausted =
+ device
+ .native_byte_94
+ .ok_or(ControlResourceAccessError::UnsupportedField {
+ factory_tag: device.factory_tag,
+ field: "tag-15 byte +0x94",
+ })?
+ != 0;
+ let available =
+ control_device_resource_availability(table, device.factory_tag, revive_exhausted)?;
+ let selected = if available < query_interval {
+ available
+ } else {
+ query_interval
+ };
+ let coefficient_cost = checked_control_product(
+ device.factory_tag,
+ "tag-15 coefficient cost",
+ coefficient_1,
+ selected,
+ )?;
+ checked_control_sum(
+ device.factory_tag,
+ "tag-15 resource rate",
+ base,
+ -coefficient_cost,
+ )
+ }
+ factory_tag => Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag }),
+ }
+}
+
+fn control_budget_category_pass(
+ table: &mut MissionControlDeviceTableState,
+ category_rates: &mut [f32; 6],
+ category_mask: u32,
+ budget: f32,
+ delta_time_seconds: f32,
+) -> Result<f32, ControlResourceAccessError> {
+ const CATEGORY_MASKS: [u32; 6] = [1, 2, 4, 8, 16, 32];
+
+ let mut demand = 0.0_f32;
+ for (index, mask) in CATEGORY_MASKS.into_iter().enumerate() {
+ if category_mask & mask != 0 {
+ demand =
+ checked_control_sum(0, "Control category demand", demand, -category_rates[index])?;
+ }
+ }
+ if demand == 0.0 {
+ return Ok(budget);
+ }
+ let requested_allocation =
+ checked_control_ratio("Control category allocation", budget, demand)?;
+ let allocation = if requested_allocation <= 1.0 {
+ requested_allocation
+ } else {
+ 1.0
+ };
+ let spent = checked_control_product(0, "Control category budget spent", allocation, demand)?;
+
+ for (index, mask) in CATEGORY_MASKS.into_iter().enumerate() {
+ if category_mask & mask != 0 {
+ category_rates[index] = checked_control_product(
+ 0,
+ "scaled Control category rate",
+ allocation,
+ category_rates[index],
+ )?;
+ }
+ }
+
+ let device_count = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => devices.len(),
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ return Err(ControlResourceAccessError::TableUnavailable(reason.clone()))
+ }
+ };
+ for index in 0..device_count {
+ let device = control_budget_device(table, index)?;
+ if category_mask & CATEGORY_MASKS[control_budget_category(device.descriptor.tag)?] != 0 {
+ control_device_resource_update(table, index, delta_time_seconds, allocation)?;
+ }
+ }
+
+ checked_control_sum(0, "remaining Control category budget", budget, -spent)
+}
+
+fn control_device_resource_update(
+ table: &mut MissionControlDeviceTableState,
+ device_index: usize,
+ delta_time_seconds: f32,
+ allocation: f32,
+) -> Result<(), ControlResourceAccessError> {
+ let device = control_budget_device(table, device_index)?;
+ let Some(provider_value) = control_device_budget_active_provider_value(table, &device)? else {
+ return Ok(());
+ };
+ match device.factory_tag {
+ 19 => {
+ let coefficient_0 = control_device_budget_coefficient(&device, 0)?;
+ if coefficient_0 < 0.0 {
+ return Ok(());
+ }
+ let multiplier = control_device_float(&device, device.multiplier_bits, "multiplier")?;
+ let rate = control_device_resource_rate(table, &device, delta_time_seconds)?;
+ let consumed = checked_control_product(
+ device.factory_tag,
+ "generator allocated rate",
+ rate,
+ allocation,
+ )?;
+ let divisor = checked_control_product(
+ device.factory_tag,
+ "generator reserve divisor",
+ coefficient_0,
+ provider_value,
+ )?;
+ let reserve_change =
+ checked_control_ratio("generator reserve change", consumed, divisor)?;
+ let new_multiplier = checked_control_sum(
+ device.factory_tag,
+ "generator multiplier after budget",
+ multiplier,
+ -reserve_change,
+ )?;
+ match &mut table.status {
+ MissionControlDeviceTableStatus::Available(devices) => {
+ let Some(device) = devices.get_mut(device_index) else {
+ return Err(ControlResourceAccessError::DeviceIndexOutOfRange {
+ device_index,
+ device_count: devices.len(),
+ });
+ };
+ device.multiplier_bits = Some(new_multiplier.to_bits());
+ }
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ return Err(ControlResourceAccessError::TableUnavailable(reason.clone()))
+ }
+ }
+ }
+ 15 => {
+ match &mut table.status {
+ MissionControlDeviceTableStatus::Available(devices) => {
+ let Some(device) = devices.get_mut(device_index) else {
+ return Err(ControlResourceAccessError::DeviceIndexOutOfRange {
+ device_index,
+ device_count: devices.len(),
+ });
+ };
+ device.multiplier_bits = Some(allocation.to_bits());
+ }
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ return Err(ControlResourceAccessError::TableUnavailable(reason.clone()))
+ }
+ };
+ let device = control_budget_device(table, device_index)?;
+ let coefficient_1 = control_device_budget_coefficient(&device, 1)?;
+ let rate = control_device_resource_rate(table, &device, delta_time_seconds)?;
+ let allocated_rate = checked_control_product(
+ device.factory_tag,
+ "tag-15 allocated rate",
+ rate,
+ allocation,
+ )?;
+ let interval_rate = checked_control_product(
+ device.factory_tag,
+ "tag-15 interval descriptor rate",
+ delta_time_seconds,
+ control_device_budget_descriptor_float(
+ &device,
+ 8,
+ "descriptor resource rate +0x20",
+ )?,
+ )?;
+ let remaining = checked_control_sum(
+ device.factory_tag,
+ "tag-15 remaining amount",
+ -allocated_rate,
+ -interval_rate,
+ )?;
+ if remaining < 0.0 {
+ return Ok(());
+ }
+ let revive_exhausted =
+ device
+ .native_byte_94
+ .ok_or(ControlResourceAccessError::UnsupportedField {
+ factory_tag: device.factory_tag,
+ field: "tag-15 byte +0x94",
+ })?
+ != 0;
+ let delta = if coefficient_1 > 0.0 {
+ checked_control_ratio("tag-15 provider amount change", remaining, coefficient_1)?
+ } else {
+ let available = control_device_resource_availability(
+ table,
+ device.factory_tag,
+ revive_exhausted,
+ )?;
+ let provider_value = control_device_provider_value(table, &device)?;
+ let coefficient_0 = control_device_budget_coefficient(&device, 0)?;
+ let query = checked_control_product(
+ device.factory_tag,
+ "tag-15 provider coefficient",
+ coefficient_0,
+ provider_value,
+ )?;
+ let query_interval = checked_control_product(
+ device.factory_tag,
+ "tag-15 provider rate times interval",
+ query,
+ delta_time_seconds,
+ )?;
+ if available < query_interval {
+ available
+ } else {
+ query_interval
+ }
+ };
+ if !table.node_provider_distribution_suspended && delta != 0.0 {
+ distribute_control_node_amount_inner(
+ table,
+ delta,
+ ControlNodeDistributionMode::Sequential,
+ revive_exhausted,
+ )?;
+ }
+ }
+ factory_tag => {
+ return Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag })
+ }
+ }
+ Ok(())
+}
+
fn apply_control_node_delta(
table: &mut MissionControlDeviceTableState,
key: u32,
@@ -3966,6 +4601,7 @@ fn append_effective_control_device(
let coefficient_bits =
control_factory_uses_common_setter(descriptor.tag).then_some(coefficient_words);
let (multiplier_bits, default_word_50) = native_control_factory_defaults(descriptor.tag);
+ let native_byte_94 = native_control_factory_byte_94(descriptor.tag);
let native_word_50_bits = if descriptor.fixed_words[6] == u32::MAX {
default_word_50
} else {
@@ -3979,6 +4615,7 @@ fn append_effective_control_device(
multiplier_bits,
class9_selector0_scale_bits: (descriptor.tag == 9).then_some(0x3f80_0000),
native_word_50_bits,
+ native_byte_94,
descriptor: descriptor.clone(),
factory_provenance: provenance.clone(),
descriptor_provenance: provenance,
@@ -4017,11 +4654,16 @@ fn native_control_factory_defaults(factory_tag: u32) -> (Option<u32>, Option<u32
10 => (Some(0x3f80_0000), Some(0x2000)),
17 | 19 => (Some(0x3f80_0000), Some(0x20)),
2 | 27 => (Some(0x3f80_0000), Some(0)),
- 9 | 15 | 25 => (Some(0x3f80_0000), None),
+ 9 | 25 => (Some(0x3f80_0000), None),
+ 15 => (Some(0x3f80_0000), Some(0)),
_ => (None, None),
}
}
+fn native_control_factory_byte_94(factory_tag: u32) -> Option<u8> {
+ (factory_tag == 15).then_some(0)
+}
+
#[cfg(test)]
fn build_control_device_table(
object_index: usize,
@@ -5277,6 +5919,7 @@ mod tests {
let provenance = control_device_record_provenance(0, 0, &control);
let mut coefficients = [0; 16];
coefficients[0] = coefficient_bits;
+ let (_, native_word_50_bits) = native_control_factory_defaults(factory_tag);
EffectiveControlDevice {
factory_tag,
component_id: 3,
@@ -5284,7 +5927,8 @@ mod tests {
coefficient_bits: Some(coefficients),
multiplier_bits,
class9_selector0_scale_bits: class9_scale_bits,
- native_word_50_bits: None,
+ native_word_50_bits,
+ native_byte_94: native_control_factory_byte_94(factory_tag),
descriptor,
factory_provenance: provenance.clone(),
descriptor_provenance: provenance,
@@ -5317,10 +5961,251 @@ mod tests {
node_provider_amount_bits: initial_node_provider_amount(&node_provider_status),
node_provider_dirty: false,
node_provider_distribution_suspended: false,
+ current_output_rate_5dc_bits: 0,
+ current_output_rate_5e0_bits: 0,
node_provider_status,
}
}
+ fn control_budget_test_table(generator_coefficient: f32) -> MissionControlDeviceTableState {
+ let mut generator = synthetic_effective_control_device(
+ 19,
+ 19,
+ generator_coefficient.to_bits(),
+ Some(1.0_f32.to_bits()),
+ None,
+ );
+ generator.key = 0;
+ generator.coefficient_bits.as_mut().unwrap()[1] = 1.0_f32.to_bits();
+ generator.descriptor.fixed_words[8] = 0.5_f32.to_bits();
+
+ let mut consumer = synthetic_effective_control_device(
+ 15,
+ 15,
+ 1.0_f32.to_bits(),
+ Some(1.0_f32.to_bits()),
+ None,
+ );
+ consumer.key = 0;
+ consumer.coefficient_bits.as_mut().unwrap()[1] = 1.0_f32.to_bits();
+ consumer.native_word_50_bits = Some(1);
+
+ let mut provider = synthetic_node_provider_row(0, 0, 0.4_f32.to_bits());
+ provider.source_row.max_volume = 100.0;
+ provider.initial_amount_bits = 40.0_f32.to_bits();
+ provider.current_amount_bits = 40.0_f32.to_bits();
+ provider.current_accumulated_delta_bits = 0.0_f32.to_bits();
+ provider.processed_amount_bits = 40.0_f32.to_bits();
+ provider.initial_type1_value_bits = 0.4_f32.to_bits();
+ provider.current_type1_value_bits = 0.4_f32.to_bits();
+ provider.current_flags = Some(0);
+ provider.current_stage = 0;
+ provider.initial_mesh_state = Some(MissionControlNodeInitialMeshState {
+ parent_index: -1,
+ flags: 0,
+ stage_count: 1,
+ });
+ provider.mesh_state = Some(MissionControlNodeMeshState {
+ type1_value_bits: 0.4_f32.to_bits(),
+ visible_stage: 0,
+ });
+
+ synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(vec![generator, consumer]),
+ MissionControlNodeProviderTableStatus::Available(vec![provider]),
+ )
+ }
+
+ #[test]
+ fn control_budget_dispatch_writes_negative_zero_for_empty_tables() {
+ for dt in [0.0, 0.5] {
+ let mut table = synthetic_control_device_table(
+ MissionControlDeviceTableStatus::Available(Vec::new()),
+ );
+ control_resource_budget_pass(&mut table, dt).expect("empty budget pass");
+ assert_eq!(table.current_output_rate_5dc_bits, 0x8000_0000);
+ assert_eq!(table.current_output_rate_5e0_bits, 0x8000_0000);
+ }
+ }
+
+ #[test]
+ fn control_budget_dispatch_matches_tag15_tag19_native_vectors() {
+ for (generator_coefficient, amount_bits, ratio_bits, generator_multiplier, scale, rate) in [
+ (
+ 1.0_f32,
+ 0x4220_6666,
+ 0x3ecd_4fdf,
+ 0x3f40_0000,
+ 0x3f00_0000,
+ 0x3e4c_cccd,
+ ),
+ (
+ 0.125_f32,
+ 0x4220_3333,
+ 0x3ecd_0e56,
+ 0,
+ 0x3e80_0000,
+ 0x3dcc_cccd,
+ ),
+ ] {
+ let mut table = control_budget_test_table(generator_coefficient);
+ control_resource_budget_pass(&mut table, 0.5).expect("supported tag-15/tag-19 pass");
+
+ let MissionControlDeviceTableStatus::Available(devices) = &table.status else {
+ unreachable!();
+ };
+ assert_eq!(devices[0].multiplier_bits, Some(generator_multiplier));
+ assert_eq!(devices[1].multiplier_bits, Some(scale));
+ assert_eq!(table.current_output_rate_5dc_bits, rate);
+ assert_eq!(table.current_output_rate_5e0_bits, rate);
+
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &table.node_provider_status
+ else {
+ unreachable!();
+ };
+ assert_eq!(rows[0].current_amount_bits, amount_bits);
+ assert_eq!(rows[0].processed_amount_bits, amount_bits);
+ assert_eq!(rows[0].current_type1_value_bits, ratio_bits);
+ assert_eq!(
+ rows[0].current_accumulated_delta_bits,
+ if amount_bits == 0x4220_6666 {
+ 0x3dcc_cc00
+ } else {
+ 0x3d4c_cc00
+ }
+ );
+ assert!(table.node_provider_dirty);
+ }
+ }
+
+ #[test]
+ fn control_budget_dispatch_matches_non_degenerate_native_pair() {
+ let mut table = control_budget_test_table(0.25);
+ let MissionControlDeviceTableStatus::Available(devices) = &mut table.status else {
+ unreachable!();
+ };
+ devices[0].descriptor.fixed_words[8] = 1.0_f32.to_bits();
+ devices[1].descriptor.fixed_words[8] = 0.125_f32.to_bits();
+ devices[1].coefficient_bits.as_mut().unwrap()[0] = 0.75_f32.to_bits();
+ devices[1].coefficient_bits.as_mut().unwrap()[1] = 1.25_f32.to_bits();
+ devices[1].multiplier_bits = Some(0.8_f32.to_bits());
+
+ control_resource_budget_pass(&mut table, 0.375).expect("non-degenerate native vector");
+
+ let MissionControlDeviceTableStatus::Available(devices) = &table.status else {
+ unreachable!();
+ };
+ assert_eq!(devices[0].multiplier_bits, Some(0));
+ assert_eq!(devices[1].multiplier_bits, Some(0x3f08_8889));
+ assert_eq!(table.current_output_rate_5dc_bits, 0x3e88_888a);
+ assert_eq!(table.current_output_rate_5e0_bits, 0x3e88_888a);
+ let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status
+ else {
+ unreachable!();
+ };
+ assert_eq!(rows[0].current_amount_bits, 0x4220_2b85);
+ assert_eq!(rows[0].processed_amount_bits, 0x4220_2b85);
+ assert_eq!(rows[0].current_type1_value_bits, 0x3ecd_0481);
+ assert_eq!(rows[0].current_accumulated_delta_bits, 0x3d2e_1400);
+ }
+
+ #[test]
+ fn control_budget_dispatch_keeps_callbacks_but_honors_owner_suspension() {
+ let mut table = control_budget_test_table(1.0);
+ table.node_provider_distribution_suspended = true;
+
+ control_resource_budget_pass(&mut table, 0.5).expect("suspended provider distribution");
+
+ let MissionControlDeviceTableStatus::Available(devices) = &table.status else {
+ unreachable!();
+ };
+ assert_eq!(devices[0].multiplier_bits, Some(0x3f40_0000));
+ assert_eq!(devices[1].multiplier_bits, Some(0x3f00_0000));
+ let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status
+ else {
+ unreachable!();
+ };
+ assert_eq!(rows[0].current_amount_bits, 40.0_f32.to_bits());
+ assert!(!table.node_provider_dirty);
+ }
+
+ #[test]
+ fn control_budget_dispatch_skips_provider_lookup_for_disabled_device() {
+ let mut device = synthetic_effective_control_device(
+ 15,
+ 15,
+ 1.0_f32.to_bits(),
+ Some(1.0_f32.to_bits()),
+ None,
+ );
+ device.native_word_50_bits = Some(0);
+ device.key = u32::MAX;
+ let mut table = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(vec![device]),
+ MissionControlNodeProviderTableStatus::Unavailable(
+ MissionControlNodeProviderTableUnavailableReason::MissingNodeData {
+ unit_component_index: 0,
+ source: None,
+ },
+ ),
+ );
+
+ control_resource_budget_pass(&mut table, 0.5)
+ .expect("disabled callback returns before provider lookup");
+ assert_eq!(table.current_output_rate_5dc_bits, 0x8000_0000);
+ assert_eq!(table.current_output_rate_5e0_bits, 0x8000_0000);
+ }
+
+ #[test]
+ fn control_budget_dispatch_rolls_back_unsupported_current_tag() {
+ let mut table = control_budget_test_table(1.0);
+ let unsupported = synthetic_effective_control_device(
+ 26,
+ 26,
+ 1.0_f32.to_bits(),
+ Some(1.0_f32.to_bits()),
+ None,
+ );
+ let MissionControlDeviceTableStatus::Available(devices) = &mut table.status else {
+ unreachable!();
+ };
+ devices.push(unsupported);
+ let before = table.clone();
+
+ assert_eq!(
+ control_resource_budget_pass(&mut table, 0.5),
+ Err(ControlResourceAccessError::UnsupportedCurrentTag { tag: 26 })
+ );
+ assert_eq!(table, before);
+ }
+
+ #[test]
+ fn control_budget_dispatch_rolls_back_late_generator_division_error() {
+ let mut table = control_budget_test_table(0.0);
+ let before = table.clone();
+
+ assert!(matches!(
+ control_resource_budget_pass(&mut table, 0.5),
+ Err(ControlResourceAccessError::UnsupportedFloatRange {
+ field: "generator reserve change",
+ ..
+ })
+ ));
+ assert_eq!(table, before);
+ }
+
+ #[test]
+ fn control_budget_dispatch_keeps_negative_tag19_coefficient_supported() {
+ let mut table = control_budget_test_table(-0.125);
+ control_resource_budget_pass(&mut table, 0.5)
+ .expect("native tag-19 update skips reserve consumption for negative coefficient");
+ let MissionControlDeviceTableStatus::Available(devices) = &table.status else {
+ unreachable!();
+ };
+ assert_eq!(devices[0].multiplier_bits, Some(1.0_f32.to_bits()));
+ }
+
#[test]
fn behavior_profile_selector_1002_sums_control_e00_rows_then_multiplies_profile_rate() {
let table = synthetic_control_device_table_with_providers(
@@ -5805,6 +6690,10 @@ mod tests {
),
Err(ControlResourceAccessError::MissionUnavailable)
);
+ assert_eq!(
+ advance_loaded_mission_control_budget(&mut engine, handle, 0.5),
+ Err(ControlResourceAccessError::MissionUnavailable)
+ );
assert_eq!(
update_loaded_mission_control_node_stage(&mut engine, handle, 1, false),
@@ -5836,12 +6725,24 @@ mod tests {
))
);
assert_eq!(
+ advance_loaded_mission_control_budget(&mut engine, invalid, 0.5),
+ Err(ControlResourceAccessError::World(
+ fparkan_world::WorldError::InvalidHandle
+ ))
+ );
+ assert_eq!(
query_loaded_mission_control_resource(&engine, stale, 1),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
))
);
assert_eq!(
+ advance_loaded_mission_control_budget(&mut engine, stale, 0.5),
+ Err(ControlResourceAccessError::World(
+ fparkan_world::WorldError::StaleHandle
+ ))
+ );
+ assert_eq!(
query_loaded_mission_control_device_selector(&engine, stale, 0, 0xe00),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
@@ -5920,6 +6821,12 @@ mod tests {
))
);
assert_eq!(
+ advance_loaded_mission_control_budget(&mut engine, handle, 0.5),
+ Err(ControlResourceAccessError::World(
+ fparkan_world::WorldError::StaleHandle
+ ))
+ );
+ assert_eq!(
query_loaded_mission_control_device_selector(&engine, handle, 0, 0xe00),
Err(ControlResourceAccessError::World(
fparkan_world::WorldError::StaleHandle
@@ -6795,12 +7702,17 @@ mod tests {
#[test]
fn control_factory_multiplier_defaults_preserve_unknown_word_50() {
- for factory_tag in [9, 15, 25] {
+ for factory_tag in [9, 25] {
assert_eq!(
native_control_factory_defaults(factory_tag),
(Some(0x3f80_0000), None)
);
}
+ assert_eq!(
+ native_control_factory_defaults(15),
+ (Some(0x3f80_0000), Some(0))
+ );
+ assert_eq!(native_control_factory_byte_94(15), Some(0));
}
#[test]
@@ -7891,6 +8803,50 @@ mod tests {
let mut engine = load_full_fixture(0x8000_0002);
let mut mine_engine = load_full_fixture(0x8000_0004);
+ let mine_control_before = loaded_mission_control_device_tables(&mine_engine)
+ .expect("licensed mine Control table")
+ .to_vec();
+ let mine_control_handle = mine_control_before[0].handle;
+ let MissionControlDeviceTableStatus::Available(mine_control_devices) =
+ &mine_control_before[0].status
+ else {
+ panic!("licensed mine device table should be available");
+ };
+ assert_eq!(
+ mine_control_devices
+ .iter()
+ .map(|device| (device.factory_tag, device.descriptor.tag))
+ .collect::<Vec<_>>(),
+ [
+ (19, 19),
+ (19, 19),
+ (9, 9),
+ (15, 15),
+ (12, 12),
+ (12, 12),
+ (13, 13),
+ (13, 13),
+ (26, 26),
+ (25, 25),
+ (21, 21),
+ ]
+ );
+ assert_eq!(
+ advance_loaded_mission_control_budget(&mut mine_engine, mine_control_handle, f32::NAN),
+ Err(ControlResourceAccessError::NonFiniteValue {
+ factory_tag: 0,
+ field: "Control resource interval",
+ })
+ );
+ assert_eq!(
+ advance_loaded_mission_control_budget(&mut mine_engine, mine_control_handle, 0.5),
+ Err(ControlResourceAccessError::UnsupportedCurrentTag { tag: 9 })
+ );
+ assert_eq!(
+ loaded_mission_control_device_tables(&mine_engine),
+ Some(mine_control_before.as_slice())
+ );
+
let draft = &loaded_mission_object_drafts(&engine).expect("object drafts")[0];
assert_eq!(draft.unit_components.len(), 6);
assert_eq!(draft.unit_components[0].immediate_child_count, 5);
@@ -9712,6 +10668,8 @@ mod tests {
node_provider_amount_bits: Some(0),
node_provider_dirty: false,
node_provider_distribution_suspended: false,
+ current_output_rate_5dc_bits: 0,
+ current_output_rate_5e0_bits: 0,
}
}
@@ -9820,6 +10778,8 @@ mod tests {
node_provider_amount_bits: Some(100.0_f32.to_bits()),
node_provider_dirty: false,
node_provider_distribution_suspended: false,
+ current_output_rate_5dc_bits: 0,
+ current_output_rate_5e0_bits: 0,
};
let mut control_tables = vec![producer_table];
@@ -9870,6 +10830,8 @@ mod tests {
node_provider_amount_bits: Some(0),
node_provider_dirty: false,
node_provider_distribution_suspended: false,
+ current_output_rate_5dc_bits: 0,
+ current_output_rate_5e0_bits: 0,
}];
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 500cc0d..457a6d6 100644
--- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs
+++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs
@@ -1791,6 +1791,7 @@ internal static class BehaviorDistributorTickOracle {
}
private static void RunNativeControlResourceBudgetTag15Case(IntPtr module) {
+ RunNativeControlResourceBudgetEmptyCases(module);
NativeControlOwnerConstructor constructOwner = (NativeControlOwnerConstructor)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, ControlOwnerConstructorRva), typeof(NativeControlOwnerConstructor));
NativeControlProviderRowConstructor constructProviderRow = (NativeControlProviderRowConstructor)
@@ -1889,6 +1890,7 @@ internal static class BehaviorDistributorTickOracle {
Marshal.WriteInt32(generator, 0x1c, 0);
Marshal.WriteInt32(providerRow, 8, FloatBits(40.0f));
Marshal.WriteInt32(providerRow, 0x0c, FloatBits(40.0f));
+ Marshal.WriteInt32(providerRow, 0x10, 0);
Marshal.WriteInt32(providerRow, 0x14, FloatBits(0.4f));
Marshal.WriteInt32(owner, 0x590, FloatBits(40.0f));
const float dt = 0.5f;
@@ -1941,6 +1943,7 @@ internal static class BehaviorDistributorTickOracle {
Marshal.WriteInt32(nativeGenerator, 0x4c, FloatBits(1.0f));
Marshal.WriteInt32(providerRow, 8, FloatBits(40.0f));
Marshal.WriteInt32(providerRow, 0x0c, FloatBits(40.0f));
+ Marshal.WriteInt32(providerRow, 0x10, 0);
Marshal.WriteInt32(providerRow, 0x14, FloatBits(0.4f));
Marshal.WriteInt32(owner, 0x590, FloatBits(40.0f));
NativeControlResourceDeviceRate generatorRate = (NativeControlResourceDeviceRate)
@@ -1951,17 +1954,74 @@ internal static class BehaviorDistributorTickOracle {
update(owner, 0.5f);
uint amount = unchecked((uint)Marshal.ReadInt32(providerRow, 8));
uint fraction = unchecked((uint)Marshal.ReadInt32(providerRow, 0x14));
+ uint accumulated = unchecked((uint)Marshal.ReadInt32(providerRow, 0x10));
uint remaining = unchecked((uint)Marshal.ReadInt32(nativeGenerator, 0x4c));
Console.WriteLine("native-control-real-generator-budget: ctor=0x22980 rate=0x229a0 update=0x22a60" +
" cap=" + capped + " produced=" + Float(produced) + " amount=" + Hex(amount) +
- " ratio=" + Hex(fraction) + " remaining=" + Hex(remaining));
+ " accumulated=" + Hex(accumulated) + " ratio=" + Hex(fraction) + " remaining=" + Hex(remaining));
Expect(amount == (capped == 0 ? 0x42206666u : 0x42203333u) &&
fraction == (capped == 0 ? 0x3ecd4fdfu : 0x3ecd0e56u) &&
+ accumulated == (capped == 0 ? 0x3dcccc00u : 0x3d4ccc00u) &&
remaining == unchecked((uint)FloatBits(capped == 0 ? 0.75f : 0.0f)),
- "real generator and consumer update exact provider and reserve values in one budget pass");
+ "real generator and consumer update exact provider amount, delta, ratio, and reserve in one budget pass");
GC.KeepAlive(generatorRate);
}
+
+ Marshal.WriteInt32(typedRecord, 0x20, FloatBits(0.125f));
+ Marshal.WriteInt32(typedRecord, 0x2c, FloatBits(0.75f));
+ Marshal.WriteInt32(typedRecord, 0x30, FloatBits(1.25f));
+ Marshal.WriteInt32(nativeGeneratorRecord, 0x20, FloatBits(1.0f));
+ Marshal.WriteInt32(nativeGeneratorRecord, 0x2c, FloatBits(0.25f));
+ Expect(applyInto(owner, 1, typedRecord) == IntPtr.Add(typedRecord, 0xb4) &&
+ applyInto(owner, 0, nativeGeneratorRecord) == IntPtr.Add(nativeGeneratorRecord, 0xb4),
+ "native non-degenerate tag-19/tag-15 descriptor initializers");
+ Marshal.WriteInt32(device, 0x4c, FloatBits(0.8f));
+ Marshal.WriteInt32(nativeGenerator, 0x4c, FloatBits(1.0f));
+ Marshal.WriteInt32(providerRow, 8, FloatBits(40.0f));
+ Marshal.WriteInt32(providerRow, 0x0c, FloatBits(40.0f));
+ Marshal.WriteInt32(providerRow, 0x10, 0);
+ Marshal.WriteInt32(providerRow, 0x14, FloatBits(0.4f));
+ Marshal.WriteInt32(owner, 0x590, FloatBits(40.0f));
+ NativeControlResourceDeviceRate nativeGeneratorRate = (NativeControlResourceDeviceRate)
+ Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, 0x229a0), typeof(NativeControlResourceDeviceRate));
+ float nonDegenerateProduced = nativeGeneratorRate(nativeGenerator, 0.375f);
+ float nonDegenerateDemand = rate(device, 0.375f);
+ update(owner, 0.375f);
+ uint nonDegenerateAmount = unchecked((uint)Marshal.ReadInt32(providerRow, 8));
+ uint nonDegenerateProcessed = unchecked((uint)Marshal.ReadInt32(providerRow, 0x0c));
+ uint nonDegenerateAccumulated = unchecked((uint)Marshal.ReadInt32(providerRow, 0x10));
+ uint nonDegenerateRatio = unchecked((uint)Marshal.ReadInt32(providerRow, 0x14));
+ uint nonDegenerateConsumerMultiplier = unchecked((uint)Marshal.ReadInt32(device, 0x4c));
+ uint nonDegenerateGeneratorMultiplier = unchecked((uint)Marshal.ReadInt32(nativeGenerator, 0x4c));
+ uint nonDegenerateRate5dc = unchecked((uint)Marshal.ReadInt32(owner, 0x5dc));
+ uint nonDegenerateRate5e0 = unchecked((uint)Marshal.ReadInt32(owner, 0x5e0));
+ Expect(FloatBits(nonDegenerateProduced) == 0x3dcccccd && FloatBits(nonDegenerateDemand) == unchecked((int)0xbe400000) &&
+ nonDegenerateAmount == 0x42202b85 && nonDegenerateProcessed == 0x42202b85 &&
+ nonDegenerateAccumulated == 0x3d2e1400 && nonDegenerateRatio == 0x3ecd0481 &&
+ nonDegenerateConsumerMultiplier == 0x3f088889 && nonDegenerateGeneratorMultiplier == 0 &&
+ nonDegenerateRate5dc == 0x3e88888a && nonDegenerateRate5e0 == 0x3e88888a,
+ "native non-degenerate tag-19/tag-15 budget vector matches amount, delta, rates, and multipliers");
+ Console.WriteLine("native-control-real-complex-budget: dt=0.375 produced=" + Hex(unchecked((uint)FloatBits(nonDegenerateProduced))) +
+ " demand=" + Hex(unchecked((uint)FloatBits(nonDegenerateDemand))) +
+ " amount=" + Hex(nonDegenerateAmount) + " processed=" + Hex(nonDegenerateProcessed) +
+ " accumulated=" + Hex(nonDegenerateAccumulated) + " ratio=" + Hex(nonDegenerateRatio) +
+ " multipliers=" + Hex(nonDegenerateConsumerMultiplier) + "," + Hex(nonDegenerateGeneratorMultiplier) +
+ " rates=" + Hex(nonDegenerateRate5dc) + "," + Hex(nonDegenerateRate5e0));
+ GC.KeepAlive(nativeGeneratorRate);
+
+ // Restore the fixture that the subsequent native clock test uses.
+ Marshal.WriteInt32(typedRecord, 0x20, FloatBits(0.0f));
+ Marshal.WriteInt32(typedRecord, 0x2c, FloatBits(1.0f));
+ Marshal.WriteInt32(typedRecord, 0x30, FloatBits(1.0f));
+ Expect(applyInto(owner, 1, typedRecord) == IntPtr.Add(typedRecord, 0xb4),
+ "restore native tag-15 clock-test descriptor");
+ Marshal.WriteInt32(device, 0x4c, FloatBits(1.0f));
Marshal.WriteIntPtr(devices, 0, generator);
+ Marshal.WriteInt32(providerRow, 8, FloatBits(40.0f));
+ Marshal.WriteInt32(providerRow, 0x0c, FloatBits(40.0f));
+ Marshal.WriteInt32(providerRow, 0x10, 0);
+ Marshal.WriteInt32(providerRow, 0x14, FloatBits(0.4f));
+ Marshal.WriteInt32(owner, 0x590, FloatBits(40.0f));
GC.KeepAlive(constructGenerator);
// Bounded active class-10 host: no motion object, no deferred packets.
@@ -2081,6 +2141,27 @@ internal static class BehaviorDistributorTickOracle {
}
}
+ private static void RunNativeControlResourceBudgetEmptyCases(IntPtr module) {
+ NativeControlOwnerConstructor constructOwner = (NativeControlOwnerConstructor)
+ Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, ControlOwnerConstructorRva),
+ typeof(NativeControlOwnerConstructor));
+ NativeControlResourceBudgetUpdate update = (NativeControlResourceBudgetUpdate)
+ Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, ControlResourceBudgetUpdateRva),
+ typeof(NativeControlResourceBudgetUpdate));
+ using (Fixture fixture = NewFixture()) {
+ IntPtr owner = fixture.Allocate(0x664);
+ Expect(constructOwner(owner, IntPtr.Zero, 4) == owner, "native empty-budget owner constructor");
+ foreach (float dt in new[] { 0.0f, 0.5f }) {
+ update(owner, dt);
+ Expect(Marshal.ReadInt32(owner, 0x5dc) == unchecked((int)0x80000000) &&
+ Marshal.ReadInt32(owner, 0x5e0) == unchecked((int)0x80000000),
+ "native empty budget writes negative-zero output rates at dt=" + Float(dt));
+ }
+ Console.WriteLine("native-control-empty-budget: dt=0/0.5 rates=0x80000000,0x80000000");
+ }
+ GC.KeepAlive(update);
+ }
+
private static void RunNativeControlActivationCase(IntPtr module) {
NativeControlOwnerConstructor construct = (NativeControlOwnerConstructor)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, 0x31490), typeof(NativeControlOwnerConstructor));
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index 9869581..dcc14fe 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -358,10 +358,18 @@ API `distribute_loaded_mission_control_provider_amount` переносит
`+0x10` получает фактическую разницу amount. После распределения обязателен
`0x11130(root, true)`.
-Оба API работают только с CPU-проекцией и не подключены к GPU/window path или
-игровому Control clock. Gate `+0x618` изначально выключен; событие `7` задаёт его
-по правилу `gate = (arg == 2)` и вызывает `0x2dc20`, только если gate изменился.
-События Control в Rust пока не исполняются.
+Три API работают только с CPU-проекцией и не подключены к GPU/window path или
+игровому Control clock. Отдельный
+`advance_loaded_mission_control_budget` переносит `0x2d340` для factory
+callbacks `15/19` и current descriptor tags `15/19` (категории `0/1`); ставки сохраняются
+в текущих полях `+0x5dc/+0x5e0`. Он выполняется транзакционно, а неизвестный
+callback, provider или диапазон числа возвращает ошибку без частичного
+изменения. Тест с нулевым знаменателем генератора подтверждает откат после уже
+выполненного изменения потребителем. Пустая таблица всё равно записывает в оба поля отрицательный ноль;
+неактивный callback не требует provider lookup. Gate `+0x618` изначально
+выключен; событие `7` задаёт его по правилу `gate = (arg == 2)` и вызывает
+`0x2dc20`, только если gate изменился. События Control в Rust пока не
+исполняются, поэтому ресурсный шаг вызывается явно и не подключён к часам.
Capacity aggregate выведен при подтверждённых единичных множителях Unit DAT;
изменение масштабов `0x9ee0`/`0x9f90` не реализовано. Из-за округления `+0x590`
может стать больше сохранённого `+0x58c`; распределение сохраняет этот signed
@@ -394,8 +402,8 @@ Rust query обращается к загруженному объекту и п
текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и
factory paths остаются явными границами. Запрос читает текущее type-1 значение.
Licensed `min_l_n1.dat` подтверждает изменение `0xe00` и Behavior `0x1002` с
-`1.0` до `0.5` после явного уменьшения количества вдвое; это ещё не
-автоматический бюджетный цикл.
+`1.0` до `0.5` после явного уменьшения количества вдвое; чтение получает
+текущее значение provider, который также меняет явный бюджетный шаг.
Native oracle проверил изолированные пути распределения, обновления класса `4`,
стадийную арифметику `0x11220`, обход дочерних provider-строк `0x11130` на
interleaved parents `[-1, 0, 0, 1, 3]` с callback-порядком `1, 3, 4, 2` и
@@ -406,15 +414,28 @@ visibility transition `0x11920` с исходными AniMesh callbacks. ОтдÐ
в этом векторе подставлен mock-ом. Отдельный вектор с исходным генератором
tag `19` (`0x22980`, `0x229a0`, `0x22a60`) проверяет полную и ограниченную
rate, изменение provider и собственного множителя генератора; отрицательные
-параметры и нулевой знаменатель не проверены. Эти сценарии
-используют синтетические NDP/Node38-строки; они подтверждают локальные переходы
-и порядок вызовов, но не полную загрузку или выполнение Control на миссии. Вызовы
-`0x11220` используют `updateEffects = false`, что исключает ветвь Control FX и
+параметры и нулевой знаменатель не проверены. Native-векторы бюджетного шага
+подтверждают текущие ставки `+0x5dc/+0x5e0`, включая значения `0.2`/`0.4` у
+потребителя tag 15, пустой результат `-0.0` и невырожденную пару исходных
+устройств tag 19/tag 15 при `dt = 0.375`; Rust-тест повторяет поля этой пары
+на синтетической таблице. Реализация принимает только описанные factory и
+category tags; остальные callbacks и состояния остаются явными ошибками.
+Лицензированный `min_l_n1.dat` содержит исходные factory/current tags
+`[19, 19, 9, 15, 12, 12, 13, 13, 26, 25, 21]`. Публичный API оставляет всю
+таблицу без изменений при не конечном интервале и при первом неподдержанном
+current tag `9`; полный бюджетный проход для этой реальной таблицы поэтому не
+проверен. Успешные бюджетные native-векторы используют синтетические
+NDP/Node38-строки и подтверждают локальные переходы и порядок вызовов, а не
+полный Control tick на миссии. Прямые oracle-вызовы `0x11220` для одиночной
+строки используют `updateEffects = false`, что исключает ветвь Control FX и
`0x11af0`; в отдельном сценарии класса `9` `0xfca0` возвращается сразу после
смены видимости, поэтому связанное с ним движение не проверено. Хотя у строки
Control выставлен flag `0x102`, слово флагов ANI `+0x14` и производные байты
`+0x111..+0x113` изначально равны нулю. Вызовы AniMesh `0x93c0` и `0xa2a0` обновляют производную геометрию и
-видимость, это отдельная непроверенная граница, а не FX callbacks. Бюджетная
+видимость, это отдельная непроверенная граница, а не FX callbacks. Распределение
+и бюджетный шаг вызывают обязательный `0x11130(root, true)`, но их проверенные
+векторы имеют одну строку без смены стадии; они не доказывают исполнение FX или
+`0x11af0`. Бюджетная
часть активного `0xbcf0` исполнена на синтетическом хосте класса `10`:
при периоде `500` и нулевом RNG проверены пропуск до deadline и на равенстве
`437.5`, затем ресурсный `dt = 0.5` при времени `500`. На том же ограниченном
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index b658348..a362b73 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -752,8 +752,8 @@ amount, accumulator, aggregate или stage изменений.
Поле `+0x618` изначально выключено; обработчик события `7` задаёт gate по
правилу `gate = (arg == 2)` и вызывает пересчёт `0x2dc20`, только если gate
изменился. Проекция сохраняет начальное состояние и уважает установленную
-suspension-метку. События Control пока не исполняются,
-поэтому это API остаётся явным CPU-шагом и не подключено к игровому clock.
+suspension-метку. События Control пока не исполняются, а ресурсный budget API
+вызывается явно и пока не подключён к игровому clock.
Тесты Rust повторяют пропорциональный вектор, накопитель `+0x10`, округление
`2^24`, sequential capacity boundary, достижимый negative capacity и откат при
unsupported FX/lifecycle.
@@ -776,18 +776,37 @@ descriptor `+0x20`. Исходная rate-функция возвращает `-
вклад `0.1` или `0.5` и записывает аргументы своего update. Функции tag 15,
provider getter и изменение количества не подменены.
-| Бюджет | Коэффициент tag 15 | Amount / processed / total, биты | Доля, биты |
-|---|---|---|---|
-| `0.1` | `0.5` | `0x42206666` (`40.1`) | `0x3ecd4fdf` |
-| `0.5` | `1` | `0x4220cccd` (`40.2`) | `0x3ecdd2f2` |
+| Бюджет | Коэффициент tag 15 | Amount / processed / total, биты | Доля, биты | `+0x5dc` / `+0x5e0`, биты |
+|---|---|---|---|---|
+| `0.1` | `0.5` | `0x42206666` (`40.1`) | `0x3ecd4fdf` | `0x3e4ccccd` / `0x3e4ccccd` (`0.2`) |
+| `0.5` | `1` | `0x4220cccd` (`40.2`) | `0x3ecdd2f2` | `0x3ecccccd` / `0x3ecccccd` (`0.4`) |
Сравнение идёт по битам при x87 control word `0x007f`. Реальный update
вызывает RVA `0x10ba0` и публикует новую долю одним callback selector `0x200`.
Хотя генератор стоит первым в массиве, его update уже видит изменённое
количество: категория tag 15 обслуживается раньше. Проверены также интервал
и коэффициент, переданные mock-генератору. Его собственная генерация, другие
-категории и выполнение миссии этим не проверены.
-Rust пока не переносит этот изменяемый бюджетный цикл.
+категории и выполнение миссии этим не проверены. Исходные выходные ставки
+`+0x5dc` и `+0x5e0` также записываются на каждом вызове; при пустой таблице
+оба поля получают битовый отрицательный ноль.
+
+Rust предоставляет явный CPU-вызов
+`advance_loaded_mission_control_budget(engine, handle, dt)`. Он переносит
+диспетчер и callbacks исходных factory tags `15` и `19`, если текущий
+descriptor tag равен `15` или `19` (native categories `0` или `1`). Rates `+0x5dc/+0x5e0`
+сохраняются отдельно. Вызов работает на копии полного состояния: неизвестный
+descriptor, factory callback, provider или численный диапазон возвращает
+ошибку без публикации частичных изменений. Вызов ещё не включён в Control clock;
+нативный oracle также проверяет пустую таблицу с отрицательным нулём в обеих
+выходных ставках и невырожденную пару исходных устройств tag 19/tag 15 при
+`dt = 0.375`. Этот парный вектор проверяет изменения amount, processed amount,
+накопителя, доли, обоих множителей и ставок `+0x5dc/+0x5e0` по битам. Rust-тест
+повторяет его на синтетической provider-таблице. Для лицензированного
+`min_l_n1.dat` отдельный тест подтверждает состав factory/current tags и то, что
+первый неподдержанный current tag `9` отклоняет весь шаг без изменений; полного
+успешного бюджетного прохода для реальной миссионной таблицы пока нет.
+Rust также отклоняет нулевой знаменатель обновления tag 19; тест вызывает эту
+ошибку после изменения provider потребителем и подтверждает откат всей таблицы.
#### Генератор tag 19 в том же бюджетном проходе
@@ -814,8 +833,18 @@ provider уже после потребителя tag `15`, повторно вÑ
собственный множитель. Это не запись коэффициента выделения в `+0x4c`, как у
tag `15`: смысл изменения этого поля зависит от фабричного класса.
Векторы проверяют конечные положительные значения при x87 CW `0x007f`;
-нулевой знаменатель, отрицательные параметры и другие классы устройств ими
-не покрыты. Перенос общего бюджетного цикла в Rust ещё не завершён.
+нативный oracle дополнительно фиксирует невырожденный случай, в котором
+коэффициенты различаются и поле `+0x20` ненулевое. Нулевой знаменатель,
+отрицательные параметры и другие классы устройств остаются непокрытыми.
+В этом дополнительном native-векторе `dt = 0.375`, provider `40/100`, tag 19
+имеет `B = 1`, `C0 = 0.25`, а tag 15 — `B = 0.125`, `C0 = 0.75`, `C1 = 1.25`
+и начальный множитель `0.8`. Получены rate генератора `0x3dcccccd`, rate
+потребителя `0xbe400000`, amount `0x42202b85`, ratio `0x3ecd0481`, накопитель
+`+0x10 = 0x3d2e1400`, множитель tag 15 `0x3f088889`, множитель tag 19 `0`, а
+обе выходные ставки равны `0x3e88888a`. Это проверяет раздельное чтение
+коэффициента `C1` и множителя `+0x4c`, а также ненулевую базовую стоимость.
+Поддержанные Rust-векторы не означают, что любой Control descriptor или
+полный миссионный цикл уже работает.
#### Активация и начальные часы
diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md
index 452f577..6406307 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -1051,6 +1051,26 @@ Rust переносит этот порядок, сохраняет native наÐ
реализованные FX или lifecycle-вызовы, таблица остаётся без изменений. Эти
операции обновляют только CPU-снимок и пока не подключены к игровым часам Control.
+Распределение по бюджету тоже доступно как отдельный CPU-вызов
+`advance_loaded_mission_control_budget`. Он сначала собирает положительные
+ставки, затем обслуживает категории в исходном порядке и передаёт каждому
+устройству его долю. Например, при запасе `40/100`, доле `0.4` и интервале
+`0.5` секунды tag 19 может принести `0.1`, а tag 15 запросить `0.2`.
+Потребитель получает половину запроса: запас становится `40.1`, доля — `0.401`,
+а множитель генератора уменьшается до `0.75`. Хотя генератор может стоять раньше
+в массиве, его обновление перечитывает уже изменённую долю после обслуживания
+категории tag 15. Этот явный вызов хранит также две выходные ставки `+0x5dc` и
+`+0x5e0`, но сам по себе ещё не запускается игровыми часами.
+Поддержаны factory callbacks `15/19` и текущие descriptor tags `15/19`
+(категории `0/1`); неизвестное устройство,
+недоступные данные provider или нулевой знаменатель останавливают весь шаг без
+частичных изменений.
+Другие категории и полный миссионный цикл пока не подтверждены. В лицензированном
+`min_l_n1.dat` рядом с тегами `19` и `15` есть также factory tags `9`, `12`,
+`13`, `21`, `25` и `26`; поэтому его полный проход пока явно отклоняется на
+первой неподдержанной категории `9`. Проверка сохраняет всю таблицу без
+изменений и не доказывает успешный бюджетный шаг на реальной миссии.
+
Отдельный путь объекта класса `4` умножает интервал на контактный коэффициент и
передаёт изменение строке. В проверенном примере коэффициент `1`, интервал `2`,
и значение строки уменьшается на `2`; этот коэффициент не является ставкой
@@ -1064,10 +1084,10 @@ Control заново разыгрывает срок обновления, даÐ
исходная DLL пропустила времена `500` и `501`, затем выполнила ресурсный шаг
на `502`. Выбрать случайный срок один раз и ждать его было бы неверно.
Принудительный шаг тоже расходует RNG; неактивный объект синхронизирует часы
-без такого расхода. Это локальные native проверки, а не работающий Rust
-Control: запросы Rust `0xe00` и `0x1002` читают текущее состояние строк,
-которое меняют явные API одной строки и распределения, а `0x1005` — загруженную
-ставку профиля.
+без такого расхода. Это локальные native проверки, а не автоматический Rust
+Control tick: запросы Rust `0xe00` и `0x1002` читают текущее состояние строк,
+которое меняют явные API одной строки, распределения и бюджетного шага, а
+`0x1005` — загруженную ставку профиля.
Точные нативные векторы и открытые границы описаны в [приложении](../appendices/script-vm.md#изменение-provider-состояния-control).
На x87 control word `0x007f` каждое умножение округляет мантиссу до 24 бит,