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