From d3a0f8c7ffafc9ae2d8f9efe5d7a730c1fa0173d Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 29 Oct 2026 19:05:13 +0400 Subject: [PATCH] feat(runtime): run tag-15 and tag-19 Control budgets --- crates/fparkan-runtime/src/lib.rs | 968 +++++++++++++++++- ...behavior_distributor_tick_native_oracle.cs | 85 +- docs/appendices/knowledge-boundaries.md | 43 +- docs/appendices/script-vm.md | 49 +- docs/tomes/06-behavior.md | 28 +- 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, /// Current native word at `+0x50` when it is known. pub native_word_50_bits: Option, + /// Current native tag-15 byte at `+0x94`; constructors currently set zero. + pub native_byte_94: Option, /// 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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, Option (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 { + (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), @@ -5835,12 +6724,24 @@ mod tests { fparkan_world::WorldError::InvalidHandle )) ); + 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( @@ -5919,6 +6820,12 @@ mod tests { fparkan_world::WorldError::StaleHandle )) ); + 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( @@ -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::>(), + [ + (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 бит,